Make mypy happy (#1831)
* Helper file for error progress tracking. * Fix typing of Keys.???.value. * Assert that we have frame information. @willmcgugan I went with the asserts because the tone of the code tells me that we are kind of certain that the current and previous frames always exist. Should the function be refactored to handle None for the previous frame (or the current & previous frames) by writing to the buffer without caller information, for example? * Use inspect.Traceback instead of inspect.FrameInfo. Looks like the two shared a lot of attributes, so we were trying to use FrameInfo but inspect.getframeinfo returns a Traceback, so that felt like the correct type to use. * Update after installing msgpack-types. * Assert we have frame info. Same fix as in b709219. * Fix MapGeometry order information typing. There was another alternative solution, which was to just flatten everything entirely, so that the code actually obeyed the comments. After all, the comments for the attribute `order` in the definition of `MapGeometry` said that `order` was a tuple of integers defining the painting order, which was not true because the very first widget was having its order defined as `order = ((0,),)` instead of `order = (0,)`. Thus, another possible solution would be to retype `order` as `tuple[int, ...]` and make everything flat, but I opted for the “tuple of tuples” approach because the sub-tuples will highlight the nested structure of the DOM hierarchy. * Fix import and typing for fromkeys. * Assert app is not None. [skip cli] * Import missing type. * Use CallbackType for event Callback. * Remove variable name clash. * Ensure ScalarAnimation only receives widgets. Two other alternatives would be: - leave typing of 'widget: DOMNode' and then assert 'widget' is actually of type 'Widget', which works just fine but looks weird in the sense that we type a parameter in one way but then only manage to do any work if it is of another type; - type it as 'widget: DOMNode | Widget' and set 'size' to something other than 'widget.outer_size' if 'widget' is a 'DOMNode'. * Count spacing values as 1 for int instance. Adding an 'assert not isinstance(spacing, int)' before raising the error sounds reasonable, because 'Spacing.unpack' seems to be able to handle a lone integer just fine, but I decided against it because I would not like to raise an assertion error from within an exception handling inside Textual. So, to keep it on the safer side, I went with the conditional expression that checks if we have an integer or a tuple. * Use correct default. The obvious fix would be to do 'default_factory=RulesMap' but mypy will infer that the type of 'RulesMap' is 'type[RulesMap]' and will not see it as a 'Callable[[], RulesMap]'. That could be fixed by using 'cast'. I decided to use 'RulesMap.__call__'. [skip ci] * Use correct abstract methods. We fix the LSP violation by using the abstract methods that the ABC already provides... Which is a shame, because I thought I was about to write my first Protocol. * Add missing annotation. * Fix type inference. * Check token is not None. * Revert "Check token is not None." This reverts commit 0ae463366e53403aa8cacf7559af889571f382e1. Upon closer look, this is not the correct fix. * Check that token is not None. Checking if the token is not 'None' brings us a tiny step closer to fixing #1849, which still needs to ensure the variable definition is complete and valid, even if empty. * Type DOMQuery instantiation correctly. After some fiddling, some crying, and talking to Dave and Will, we got to a partial solution. I cried a bit more and came up with the fix that entailed lifting 'ExpectType' to outside of 'DOMNode'. Then, I realised 'ExpectType' and 'QueryType' from 'query.py' were essentially the same thing so I decided to only make use of 'QueryType'. * Infer correct type while walking. * Cast to remove doubt about None. mypy can't infer that if after is None, then before won't. * Explicitly type variable. * Cast to console renderable. @willmcgugan did I understand correctly that this 'cast' is exactly what renderable being possibly a 'RichCast' asks me to do..? To be honest, I was not 100% sure after reading rich's documentation for 'RichCast' and after reading the source of 'rich_cast'. * Type variable to remove literal ambiguity. mypy was inferring the type of the empty string as a literal and thus was not type checking the fact that 'render' would either be a string or Text. * Assert scrollbars always have parents. * Make scrollbar scroll actions synchronous. The scrollbars were posting the messages and awaiting for them but that's not what Widget does, which handles scrolling synchronously. Thus, I made them synchronous methods by using 'post_message_no_wait'. * Use link only when available. * Update errors. * Relax type inference. * Ignore missing imports for uvloop. 'uvloop' is completely optional and this code only exists to cater for the case where the user _already_ has uvloop installed. With that in mind, it makes sense to silence errors about uvloop not being available because we don't need to know about that. We only care if uvloop happens to be installed. * Enable variable reuse. * Fix type issues in easing.py. * Fix typing issues in demo. Fixing the typing issues here involved making use of the messaging features to replace a method (wasn't exactly wrong, but this is more in line with the Textual way of doing things, which should be prevalent in the demo of the framework). We also dealt with a typing issue in DarkSwitch.on_dark_change by deleting an unnecessary parameter, but the underlying problem is unsolved and was logged as issue #1865. Related issues: #1865. * Fix typing issues in _doc.py. * Type actions with a type alias. * Make return values optional. As per discussion with @darrenburns. * Make StringKey idempotent. This set up is so that we can easily make sure that a variable of the type str | SK (where SK is a subclass of StringKey) becomes of the type SK. Instead of having to write a conditional expression to only convert in case of a string, we make SK idempotent so that we can just call it on the value that may still be a string. * Make sorting type safe. * Fix typing of StringKey.__new__. This is needed to ensure the type checkers know the exact type of the instances returned when instantiating subclasses of StringKey. * Add explicit type variables. * Type-safe line rendering. * Type safe _render_line_in_row. * Type safe _render_cell. * Type safe ordered_rows property. * Type safe ordered_columns property. * Simplify handling of Nones in TwoWayDict. In the beginning of the work on this PR, mypy flagged two issues in the implementation of TwoWayDict, which would return None when the keys/values were not available but the signatures of get/get_key did not have the missing '| None'. When I added the '| None' to the return values of TwoWayDict.get and TwoWayDict.get_key, many new errors popped up because the implementation of DataTable assumes, in many different places, that those methods return the exact thing that we asked for, and not None. To fix this, I started going over the call sites for get/get_key and started adding checks for 'None' and raising the appropriate RowDoesNotExist/ColumnDoesNotExist error. At some point, I realised that went a little bit against the semantics of the code, which pretty much assumes those accesses WILL work. So, I subclassed TwoWayDict to specialise it even further to this use case. The subclass is meant to work with RowKey/ColumnKey as the underlying keys and it will automatically raise an error if the access fails. CC: @darrenburns. * Make use of idempotency of StringKey subclasses. * Make message aware of type of sender. * Only select when possible. * Fix typing of reactive attribute. * Reset cursor upon moving action if needed. * Assure mypy we have ListItems. This could be improved (as in, cast wouldn't be needed) if #1871 is resolved favourably. * Add explicit return. * Ignore argument types to watch. Related issues: #1865. * Type safe App._register. * Redirect output to void. * Remove helper file. * Fix Python compat. issues. * Button can only accept str/Text as label. Fixes: #1870. * Add runtime type check for list items. This change follows from a discussion in issue #1871. * Address review comments. * Revert "Fix typing issues in demo." This reverts commit f36678392046ae6ffcf1e3e5f8354e91ec5f50f2. * Address review comments. Related comments: https://github.com/Textualize/textual/pull/1831\#discussion_r1118155296 * Add clarifying comment. See: https://github.com/Textualize/textual/pull/1831\#discussion_r1118154777 * Revert changes to data table.
This commit is contained in:
parent
a283689d1d
commit
ed28a7019c
32 changed files with 199 additions and 143 deletions
|
|
@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Widget scrolling methods (such as `Widget.scroll_home` and `Widget.scroll_end`) now perform the scroll after the next refresh https://github.com/Textualize/textual/issues/1774
|
- Widget scrolling methods (such as `Widget.scroll_home` and `Widget.scroll_end`) now perform the scroll after the next refresh https://github.com/Textualize/textual/issues/1774
|
||||||
|
- Buttons no longer accept arbitrary renderables https://github.com/Textualize/textual/issues/1870
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|
|
||||||
4
mypy.ini
4
mypy.ini
|
|
@ -14,3 +14,7 @@ ignore_missing_imports = True
|
||||||
|
|
||||||
[mypy-ipywidgets.*]
|
[mypy-ipywidgets.*]
|
||||||
ignore_missing_imports = True
|
ignore_missing_imports = True
|
||||||
|
|
||||||
|
[mypy-uvloop.*]
|
||||||
|
# Ignore missing imports for optional library that isn't listed as a dependency.
|
||||||
|
ignore_missing_imports = True
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,10 @@ class Logger:
|
||||||
if app.devtools is None or not app.devtools.is_connected:
|
if app.devtools is None or not app.devtools.is_connected:
|
||||||
return
|
return
|
||||||
|
|
||||||
previous_frame = inspect.currentframe().f_back
|
current_frame = inspect.currentframe()
|
||||||
|
assert current_frame is not None
|
||||||
|
previous_frame = current_frame.f_back
|
||||||
|
assert previous_frame is not None
|
||||||
caller = inspect.getframeinfo(previous_frame)
|
caller = inspect.getframeinfo(previous_frame)
|
||||||
|
|
||||||
_log = self._log or app._log
|
_log = self._log or app._log
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ async def invoke(callback: Callable, *params: object) -> Any:
|
||||||
# In debug mode we will warn about callbacks that may be stuck
|
# In debug mode we will warn about callbacks that may be stuck
|
||||||
def log_slow() -> None:
|
def log_slow() -> None:
|
||||||
"""Log a message regarding a slow callback."""
|
"""Log a message regarding a slow callback."""
|
||||||
|
assert app is not None
|
||||||
app.log.warning(
|
app.log.warning(
|
||||||
f"Callback {callback} is still pending after {INVOKE_TIMEOUT_WARNING} seconds"
|
f"Callback {callback} is still pending after {INVOKE_TIMEOUT_WARNING} seconds"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ without having to render the entire screen.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from operator import itemgetter
|
from operator import itemgetter
|
||||||
from typing import TYPE_CHECKING, Iterable, NamedTuple, cast
|
from typing import TYPE_CHECKING, Callable, Iterable, NamedTuple, cast
|
||||||
|
|
||||||
import rich.repr
|
import rich.repr
|
||||||
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
|
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
|
||||||
|
|
@ -45,12 +45,23 @@ class ReflowResult(NamedTuple):
|
||||||
class MapGeometry(NamedTuple):
|
class MapGeometry(NamedTuple):
|
||||||
"""Defines the absolute location of a Widget."""
|
"""Defines the absolute location of a Widget."""
|
||||||
|
|
||||||
region: Region # The (screen) region occupied by the widget
|
region: Region
|
||||||
order: tuple[tuple[int, ...], ...] # A tuple of ints defining the painting order
|
"""The (screen) region occupied by the widget."""
|
||||||
clip: Region # A region to clip the widget by (if a Widget is within a container)
|
order: tuple[tuple[int, int, int], ...]
|
||||||
virtual_size: Size # The virtual size (scrollable region) of a widget if it is a container
|
"""Tuple of tuples defining the painting order of the widget.
|
||||||
container_size: Size # The container size (area not occupied by scrollbars)
|
|
||||||
virtual_region: Region # The region relative to the container (but not necessarily visible)
|
Each successive triple represents painting order information with regards to
|
||||||
|
ancestors in the DOM hierarchy and the last triple provides painting order
|
||||||
|
information for this specific widget.
|
||||||
|
"""
|
||||||
|
clip: Region
|
||||||
|
"""A region to clip the widget by (if a Widget is within a container)."""
|
||||||
|
virtual_size: Size
|
||||||
|
"""The virtual size (scrollable region) of a widget if it is a container."""
|
||||||
|
container_size: Size
|
||||||
|
"""The container size (area not occupied by scrollbars)."""
|
||||||
|
virtual_region: Region
|
||||||
|
"""The region relative to the container (but not necessarily visible)."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def visible_region(self) -> Region:
|
def visible_region(self) -> Region:
|
||||||
|
|
@ -419,19 +430,23 @@ class Compositor:
|
||||||
widget: Widget,
|
widget: Widget,
|
||||||
virtual_region: Region,
|
virtual_region: Region,
|
||||||
region: Region,
|
region: Region,
|
||||||
order: tuple[tuple[int, ...], ...],
|
order: tuple[tuple[int, int, int], ...],
|
||||||
layer_order: int,
|
layer_order: int,
|
||||||
clip: Region,
|
clip: Region,
|
||||||
visible: bool,
|
visible: bool,
|
||||||
_MapGeometry=MapGeometry,
|
_MapGeometry: type[MapGeometry] = MapGeometry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Called recursively to place a widget and its children in the map.
|
"""Called recursively to place a widget and its children in the map.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
widget: The widget to add.
|
widget: The widget to add.
|
||||||
|
virtual_region: The Widget region relative to it's container.
|
||||||
region: The region the widget will occupy.
|
region: The region the widget will occupy.
|
||||||
order: A tuple of ints to define the order.
|
order: Painting order information.
|
||||||
|
layer_order: The order of the widget in its layer.
|
||||||
clip: The clipping region (i.e. the viewport which contains it).
|
clip: The clipping region (i.e. the viewport which contains it).
|
||||||
|
visible: Whether the widget should be visible by default.
|
||||||
|
This may be overriden by the CSS rule `visibility`.
|
||||||
"""
|
"""
|
||||||
visibility = widget.styles.get_rule("visibility")
|
visibility = widget.styles.get_rule("visibility")
|
||||||
if visibility is not None:
|
if visibility is not None:
|
||||||
|
|
@ -501,11 +516,12 @@ class Compositor:
|
||||||
)
|
)
|
||||||
widget_region = sub_region + placement_scroll_offset
|
widget_region = sub_region + placement_scroll_offset
|
||||||
|
|
||||||
widget_order = (
|
widget_order = order + (
|
||||||
*order,
|
(
|
||||||
get_layer_index(sub_widget.layer, 0),
|
get_layer_index(sub_widget.layer, 0),
|
||||||
z,
|
z,
|
||||||
layer_order,
|
layer_order,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
add_widget(
|
add_widget(
|
||||||
|
|
@ -560,7 +576,7 @@ class Compositor:
|
||||||
root,
|
root,
|
||||||
size.region,
|
size.region,
|
||||||
size.region,
|
size.region,
|
||||||
((0,),),
|
((0, 0, 0),),
|
||||||
layer_order,
|
layer_order,
|
||||||
size.region,
|
size.region,
|
||||||
True,
|
True,
|
||||||
|
|
@ -818,11 +834,8 @@ class Compositor:
|
||||||
# Maps each cut on to a list of segments
|
# Maps each cut on to a list of segments
|
||||||
cuts = self.cuts
|
cuts = self.cuts
|
||||||
|
|
||||||
# dict.fromkeys is a callable which takes a list of ints returns a dict which maps ints on to a list of Segments or None.
|
# dict.fromkeys is a callable which takes a list of ints returns a dict which maps ints onto a Segment or None.
|
||||||
fromkeys = cast(
|
fromkeys = cast("Callable[[list[int]], dict[int, Strip | None]]", dict.fromkeys)
|
||||||
"Callable[[list[int]], dict[int, list[Segment] | None]]", dict.fromkeys
|
|
||||||
)
|
|
||||||
# A mapping of cut index to a list of segments for each line
|
|
||||||
chops: list[dict[int, Strip | None]]
|
chops: list[dict[int, Strip | None]]
|
||||||
chops = [fromkeys(cut_set[:-1]) for cut_set in cuts]
|
chops = [fromkeys(cut_set[:-1]) for cut_set in cuts]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import hashlib
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
from typing import Iterable, cast
|
||||||
|
|
||||||
from textual._import_app import import_app
|
from textual._import_app import import_app
|
||||||
from textual.app import App
|
from textual.app import App
|
||||||
|
|
@ -45,6 +45,7 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
traceback.print_exception(error)
|
traceback.print_exception(error)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def take_svg_screenshot(
|
def take_svg_screenshot(
|
||||||
|
|
@ -82,6 +83,7 @@ def take_svg_screenshot(
|
||||||
hash = hashlib.md5()
|
hash = hashlib.md5()
|
||||||
file_paths = [app_path] + app.css_path
|
file_paths = [app_path] + app.css_path
|
||||||
for path in file_paths:
|
for path in file_paths:
|
||||||
|
assert path is not None
|
||||||
with open(path, "rb") as source_file:
|
with open(path, "rb") as source_file:
|
||||||
hash.update(source_file.read())
|
hash.update(source_file.read())
|
||||||
hash.update(f"{press}-{title}-{terminal_size}".encode("utf-8"))
|
hash.update(f"{press}-{title}-{terminal_size}".encode("utf-8"))
|
||||||
|
|
@ -105,10 +107,13 @@ def take_svg_screenshot(
|
||||||
|
|
||||||
app.exit(svg)
|
app.exit(svg)
|
||||||
|
|
||||||
svg = app.run(
|
svg = cast(
|
||||||
headless=True,
|
str,
|
||||||
auto_pilot=auto_pilot,
|
app.run(
|
||||||
size=terminal_size,
|
headless=True,
|
||||||
|
auto_pilot=auto_pilot,
|
||||||
|
size=terminal_size,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if app_path is not None:
|
if app_path is not None:
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ from .renderables.tint import Tint
|
||||||
from .strip import Strip
|
from .strip import Strip
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from typing import TypeAlias
|
from typing_extensions import TypeAlias
|
||||||
|
|
||||||
from .css.styles import StylesBase
|
from .css.styles import StylesBase
|
||||||
from .widget import Widget
|
from .widget import Widget
|
||||||
|
|
|
||||||
|
|
@ -200,8 +200,8 @@ class XTermParser(Parser[events.Event]):
|
||||||
if not bracketed_paste:
|
if not bracketed_paste:
|
||||||
# Was it a pressed key event that we received?
|
# Was it a pressed key event that we received?
|
||||||
key_events = list(sequence_to_key_events(sequence))
|
key_events = list(sequence_to_key_events(sequence))
|
||||||
for event in key_events:
|
for key_event in key_events:
|
||||||
on_token(event)
|
on_token(key_event)
|
||||||
if key_events:
|
if key_events:
|
||||||
break
|
break
|
||||||
# Or a mouse event?
|
# Or a mouse event?
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,11 @@ from __future__ import annotations
|
||||||
import ast
|
import ast
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from typing_extensions import Any, TypeAlias
|
||||||
|
|
||||||
|
ActionParseResult: TypeAlias = "tuple[str, tuple[Any, ...]]"
|
||||||
|
"""An action is its name and the arbitrary tuple of its parameters."""
|
||||||
|
|
||||||
|
|
||||||
class SkipAction(Exception):
|
class SkipAction(Exception):
|
||||||
"""Raise in an action to skip the action (and allow any parent bindings to run)."""
|
"""Raise in an action to skip the action (and allow any parent bindings to run)."""
|
||||||
|
|
@ -15,7 +20,7 @@ class ActionError(Exception):
|
||||||
re_action_params = re.compile(r"([\w\.]+)(\(.*?\))")
|
re_action_params = re.compile(r"([\w\.]+)(\(.*?\))")
|
||||||
|
|
||||||
|
|
||||||
def parse(action: str) -> tuple[str, tuple[object, ...]]:
|
def parse(action: str) -> ActionParseResult:
|
||||||
"""Parses an action string.
|
"""Parses an action string.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ from ._context import active_app
|
||||||
from ._event_broker import NoHandler, extract_handler_actions
|
from ._event_broker import NoHandler, extract_handler_actions
|
||||||
from ._path import _make_path_object_relative
|
from ._path import _make_path_object_relative
|
||||||
from ._wait import wait_for_idle
|
from ._wait import wait_for_idle
|
||||||
from .actions import SkipAction
|
from .actions import ActionParseResult, SkipAction
|
||||||
from .await_remove import AwaitRemove
|
from .await_remove import AwaitRemove
|
||||||
from .binding import Binding, Bindings
|
from .binding import Binding, Bindings
|
||||||
from .css.query import NoMatches
|
from .css.query import NoMatches
|
||||||
|
|
@ -645,7 +645,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||||
self,
|
self,
|
||||||
group: LogGroup,
|
group: LogGroup,
|
||||||
verbosity: LogVerbosity,
|
verbosity: LogVerbosity,
|
||||||
_textual_calling_frame: inspect.FrameInfo,
|
_textual_calling_frame: inspect.Traceback,
|
||||||
*objects: Any,
|
*objects: Any,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -1605,9 +1605,8 @@ class App(Generic[ReturnType], DOMNode):
|
||||||
with redirect_stdout(redirector): # type: ignore
|
with redirect_stdout(redirector): # type: ignore
|
||||||
await run_process_messages()
|
await run_process_messages()
|
||||||
else:
|
else:
|
||||||
null_file = _NullFile()
|
with redirect_stderr(None):
|
||||||
with redirect_stderr(null_file):
|
with redirect_stdout(None):
|
||||||
with redirect_stdout(null_file):
|
|
||||||
await run_process_messages()
|
await run_process_messages()
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -1732,16 +1731,17 @@ class App(Generic[ReturnType], DOMNode):
|
||||||
if not widgets:
|
if not widgets:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
new_widgets = list(widgets)
|
widget_list: Iterable[Widget]
|
||||||
|
|
||||||
if before is not None or after is not None:
|
if before is not None or after is not None:
|
||||||
# There's a before or after, which means there's going to be an
|
# There's a before or after, which means there's going to be an
|
||||||
# insertion, so make it easier to get the new things in the
|
# insertion, so make it easier to get the new things in the
|
||||||
# correct order.
|
# correct order.
|
||||||
new_widgets = reversed(new_widgets)
|
widget_list = reversed(widgets)
|
||||||
|
else:
|
||||||
|
widget_list = widgets
|
||||||
|
|
||||||
apply_stylesheet = self.stylesheet.apply
|
apply_stylesheet = self.stylesheet.apply
|
||||||
for widget in new_widgets:
|
for widget in widget_list:
|
||||||
if not isinstance(widget, Widget):
|
if not isinstance(widget, Widget):
|
||||||
raise AppError(f"Can't register {widget!r}; expected a Widget instance")
|
raise AppError(f"Can't register {widget!r}; expected a Widget instance")
|
||||||
if widget not in self._registry:
|
if widget not in self._registry:
|
||||||
|
|
@ -1798,14 +1798,14 @@ class App(Generic[ReturnType], DOMNode):
|
||||||
async def _close_all(self) -> None:
|
async def _close_all(self) -> None:
|
||||||
"""Close all message pumps."""
|
"""Close all message pumps."""
|
||||||
|
|
||||||
# Close all screens on the stack
|
# Close all screens on the stack.
|
||||||
for screen in reversed(self._screen_stack):
|
for stack_screen in reversed(self._screen_stack):
|
||||||
if screen._running:
|
if stack_screen._running:
|
||||||
await self._prune_node(screen)
|
await self._prune_node(stack_screen)
|
||||||
|
|
||||||
self._screen_stack.clear()
|
self._screen_stack.clear()
|
||||||
|
|
||||||
# Close pre-defined screens
|
# Close pre-defined screens.
|
||||||
for screen in self.SCREENS.values():
|
for screen in self.SCREENS.values():
|
||||||
if isinstance(screen, Screen) and screen._running:
|
if isinstance(screen, Screen) and screen._running:
|
||||||
await self._prune_node(screen)
|
await self._prune_node(screen)
|
||||||
|
|
@ -1971,7 +1971,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||||
|
|
||||||
async def action(
|
async def action(
|
||||||
self,
|
self,
|
||||||
action: str | tuple[str, tuple[str, ...]],
|
action: str | ActionParseResult,
|
||||||
default_namespace: object | None = None,
|
default_namespace: object | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Perform an action.
|
"""Perform an action.
|
||||||
|
|
@ -2069,7 +2069,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||||
else:
|
else:
|
||||||
event.stop()
|
event.stop()
|
||||||
if isinstance(action, (str, tuple)):
|
if isinstance(action, (str, tuple)):
|
||||||
await self.action(action, default_namespace=default_namespace)
|
await self.action(action, default_namespace=default_namespace) # type: ignore[arg-type]
|
||||||
elif callable(action):
|
elif callable(action):
|
||||||
await action()
|
await action()
|
||||||
else:
|
else:
|
||||||
|
|
@ -2339,9 +2339,12 @@ _uvloop_init_done: bool = False
|
||||||
|
|
||||||
|
|
||||||
def _init_uvloop() -> None:
|
def _init_uvloop() -> None:
|
||||||
"""
|
"""Import and install the `uvloop` asyncio policy, if available.
|
||||||
Import and install the `uvloop` asyncio policy, if available.
|
|
||||||
This is done only once, even if the function is called multiple times.
|
This is done only once, even if the function is called multiple times.
|
||||||
|
|
||||||
|
This is provided as a nicety for users that have `uvloop` installed independently
|
||||||
|
of Textual, as `uvloop` is not listed as a Textual dependency.
|
||||||
"""
|
"""
|
||||||
global _uvloop_init_done
|
global _uvloop_init_done
|
||||||
|
|
||||||
|
|
@ -2349,10 +2352,10 @@ def _init_uvloop() -> None:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import uvloop
|
import uvloop # type: ignore[reportMissingImports]
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
uvloop.install()
|
uvloop.install() # type: ignore[reportUnknownMemberType]
|
||||||
|
|
||||||
_uvloop_init_done = True
|
_uvloop_init_done = True
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ class EasingApp(App):
|
||||||
target_position = (
|
target_position = (
|
||||||
END_POSITION if self.position == START_POSITION else START_POSITION
|
END_POSITION if self.position == START_POSITION else START_POSITION
|
||||||
)
|
)
|
||||||
|
assert event.button.id is not None # Should be set to an easing function str.
|
||||||
self.animate(
|
self.animate(
|
||||||
"position",
|
"position",
|
||||||
value=target_position,
|
value=target_position,
|
||||||
|
|
@ -106,7 +107,7 @@ class EasingApp(App):
|
||||||
self.opacity_widget.styles.opacity = 1 - value / END_POSITION
|
self.opacity_widget.styles.opacity = 1 - value / END_POSITION
|
||||||
|
|
||||||
def on_input_changed(self, event: Input.Changed):
|
def on_input_changed(self, event: Input.Changed):
|
||||||
if event.sender.id == "duration-input":
|
if event.input.id == "duration-input":
|
||||||
new_duration = _try_float(event.value)
|
new_duration = _try_float(event.value)
|
||||||
if new_duration is not None:
|
if new_duration is not None:
|
||||||
self.duration = new_duration
|
self.duration = new_duration
|
||||||
|
|
|
||||||
|
|
@ -528,8 +528,8 @@ class SpacingProperty:
|
||||||
string (e.g. ``"blue on #f0f0f0"``).
|
string (e.g. ``"blue on #f0f0f0"``).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: When the value is malformed, e.g. a ``tuple`` with a length that is
|
ValueError: When the value is malformed,
|
||||||
not 1, 2, or 4.
|
e.g. a ``tuple`` with a length that is not 1, 2, or 4.
|
||||||
"""
|
"""
|
||||||
_rich_traceback_omit = True
|
_rich_traceback_omit = True
|
||||||
if spacing is None:
|
if spacing is None:
|
||||||
|
|
@ -543,7 +543,9 @@ class SpacingProperty:
|
||||||
str(error),
|
str(error),
|
||||||
help_text=spacing_wrong_number_of_values_help_text(
|
help_text=spacing_wrong_number_of_values_help_text(
|
||||||
property_name=self.name,
|
property_name=self.name,
|
||||||
num_values_supplied=len(spacing),
|
num_values_supplied=(
|
||||||
|
1 if isinstance(spacing, int) else len(spacing)
|
||||||
|
),
|
||||||
context="inline",
|
context="inline",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -264,7 +264,7 @@ def substitute_references(
|
||||||
|
|
||||||
iter_tokens = iter(tokens)
|
iter_tokens = iter(tokens)
|
||||||
|
|
||||||
while tokens:
|
while True:
|
||||||
token = next(iter_tokens, None)
|
token = next(iter_tokens, None)
|
||||||
if token is None:
|
if token is None:
|
||||||
break
|
break
|
||||||
|
|
@ -274,8 +274,7 @@ def substitute_references(
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
token = next(iter_tokens, None)
|
token = next(iter_tokens, None)
|
||||||
# TODO: Mypy error looks legit
|
if token is not None and token.name == "whitespace":
|
||||||
if token.name == "whitespace":
|
|
||||||
yield token
|
yield token
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,14 @@ from .._types import CallbackType
|
||||||
from .scalar import Scalar, ScalarOffset
|
from .scalar import Scalar, ScalarOffset
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..dom import DOMNode
|
from ..widget import Widget
|
||||||
from .styles import StylesBase
|
from .styles import StylesBase
|
||||||
|
|
||||||
|
|
||||||
class ScalarAnimation(Animation):
|
class ScalarAnimation(Animation):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
widget: DOMNode,
|
widget: Widget,
|
||||||
styles: StylesBase,
|
styles: StylesBase,
|
||||||
start_time: float,
|
start_time: float,
|
||||||
attribute: str,
|
attribute: str,
|
||||||
|
|
|
||||||
|
|
@ -335,6 +335,9 @@ class StylesBase(ABC):
|
||||||
if not isinstance(value, (Scalar, ScalarOffset)):
|
if not isinstance(value, (Scalar, ScalarOffset)):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
from ..widget import Widget
|
||||||
|
|
||||||
|
assert isinstance(self.node, Widget)
|
||||||
return ScalarAnimation(
|
return ScalarAnimation(
|
||||||
self.node,
|
self.node,
|
||||||
self,
|
self,
|
||||||
|
|
@ -581,7 +584,9 @@ class StylesBase(ABC):
|
||||||
@dataclass
|
@dataclass
|
||||||
class Styles(StylesBase):
|
class Styles(StylesBase):
|
||||||
node: DOMNode | None = None
|
node: DOMNode | None = None
|
||||||
_rules: RulesMap = field(default_factory=dict)
|
_rules: RulesMap = field(
|
||||||
|
default_factory=lambda: RulesMap()
|
||||||
|
) # mypy won't be happy with `default_factory=RulesMap`
|
||||||
_updates: int = 0
|
_updates: int = 0
|
||||||
|
|
||||||
important: set[str] = field(default_factory=set)
|
important: set[str] = field(default_factory=set)
|
||||||
|
|
@ -648,14 +653,14 @@ class Styles(StylesBase):
|
||||||
self._updates += 1
|
self._updates += 1
|
||||||
self._rules.clear() # type: ignore
|
self._rules.clear() # type: ignore
|
||||||
|
|
||||||
def merge(self, other: Styles) -> None:
|
def merge(self, other: StylesBase) -> None:
|
||||||
"""Merge values from another Styles.
|
"""Merge values from another Styles.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
other: A Styles object.
|
other: A Styles object.
|
||||||
"""
|
"""
|
||||||
self._updates += 1
|
self._updates += 1
|
||||||
self._rules.update(other._rules)
|
self._rules.update(other.get_rules())
|
||||||
|
|
||||||
def merge_rules(self, rules: RulesMap) -> None:
|
def merge_rules(self, rules: RulesMap) -> None:
|
||||||
self._updates += 1
|
self._updates += 1
|
||||||
|
|
@ -1066,7 +1071,7 @@ class RenderStyles(StylesBase):
|
||||||
def refresh(self, *, layout: bool = False, children: bool = False) -> None:
|
def refresh(self, *, layout: bool = False, children: bool = False) -> None:
|
||||||
self._inline_styles.refresh(layout=layout, children=children)
|
self._inline_styles.refresh(layout=layout, children=children)
|
||||||
|
|
||||||
def merge(self, other: Styles) -> None:
|
def merge(self, other: StylesBase) -> None:
|
||||||
"""Merge values from another Styles.
|
"""Merge values from another Styles.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ class StylesheetErrors:
|
||||||
f"{path.absolute() if path else filename}:{line_no}:{col_no}"
|
f"{path.absolute() if path else filename}:{line_no}:{col_no}"
|
||||||
)
|
)
|
||||||
link_style = Style(
|
link_style = Style(
|
||||||
link=f"file://{path.absolute()}",
|
link=f"file://{path.absolute()}" if path else None,
|
||||||
color="red",
|
color="red",
|
||||||
bold=True,
|
bold=True,
|
||||||
italic=True,
|
italic=True,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
* {
|
* {
|
||||||
transition: background 500ms in_out_cubic, color 500ms in_out_cubic;
|
transition: background 500ms in_out_cubic, color 500ms in_out_cubic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,7 +125,7 @@ DarkSwitch Switch {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Screen > Container {
|
Screen>Container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
@ -222,7 +222,7 @@ LoginForm {
|
||||||
border: wide $background;
|
border: wide $background;
|
||||||
}
|
}
|
||||||
|
|
||||||
LoginForm Button{
|
LoginForm Button {
|
||||||
margin: 0 1;
|
margin: 0 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
@ -250,7 +250,7 @@ Window {
|
||||||
max-height: 16;
|
max-height: 16;
|
||||||
}
|
}
|
||||||
|
|
||||||
Window > Static {
|
Window>Static {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,7 @@ class DarkSwitch(Horizontal):
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self.watch(self.app, "dark", self.on_dark_change, init=False)
|
self.watch(self.app, "dark", self.on_dark_change, init=False)
|
||||||
|
|
||||||
def on_dark_change(self, dark: bool) -> None:
|
def on_dark_change(self) -> None:
|
||||||
self.query_one(Switch).value = self.app.dark
|
self.query_one(Switch).value = self.app.dark
|
||||||
|
|
||||||
def on_switch_changed(self, event: Switch.Changed) -> None:
|
def on_switch_changed(self, event: Switch.Changed) -> None:
|
||||||
|
|
@ -302,7 +302,7 @@ class Notification(Static):
|
||||||
self.remove()
|
self.remove()
|
||||||
|
|
||||||
|
|
||||||
class DemoApp(App):
|
class DemoApp(App[None]):
|
||||||
CSS_PATH = "demo.css"
|
CSS_PATH = "demo.css"
|
||||||
TITLE = "Textual Demo"
|
TITLE = "Textual Demo"
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ class DevtoolsLog(NamedTuple):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
objects_or_string: tuple[Any, ...] | str
|
objects_or_string: tuple[Any, ...] | str
|
||||||
caller: inspect.FrameInfo
|
caller: inspect.Traceback
|
||||||
|
|
||||||
|
|
||||||
class DevtoolsConsole(Console):
|
class DevtoolsConsole(Console):
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,10 @@ class StdoutRedirector:
|
||||||
if not self.devtools.is_connected:
|
if not self.devtools.is_connected:
|
||||||
return
|
return
|
||||||
|
|
||||||
previous_frame = inspect.currentframe().f_back
|
current_frame = inspect.currentframe()
|
||||||
|
assert current_frame is not None
|
||||||
|
previous_frame = current_frame.f_back
|
||||||
|
assert previous_frame is not None
|
||||||
caller = inspect.getframeinfo(previous_frame)
|
caller = inspect.getframeinfo(previous_frame)
|
||||||
|
|
||||||
self._buffer.append(DevtoolsLog(string, caller=caller))
|
self._buffer.append(DevtoolsLog(string, caller=caller))
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@ import asyncio
|
||||||
import json
|
import json
|
||||||
import pickle
|
import pickle
|
||||||
from json import JSONDecodeError
|
from json import JSONDecodeError
|
||||||
from typing import Any, cast
|
from typing import Any
|
||||||
|
|
||||||
import msgpack
|
import msgpack
|
||||||
from aiohttp import WSMessage, WSMsgType
|
from aiohttp import WSMsgType
|
||||||
from aiohttp.abc import Request
|
from aiohttp.abc import Request
|
||||||
from aiohttp.web_ws import WebSocketResponse
|
from aiohttp.web_ws import WebSocketResponse
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ from rich.tree import Tree
|
||||||
from ._context import NoActiveAppError
|
from ._context import NoActiveAppError
|
||||||
from ._node_list import NodeList
|
from ._node_list import NodeList
|
||||||
from ._types import CallbackType
|
from ._types import CallbackType
|
||||||
from .binding import Bindings, BindingType
|
from .binding import Binding, Bindings, BindingType
|
||||||
from .color import BLACK, WHITE, Color
|
from .color import BLACK, WHITE, Color
|
||||||
from .css._error_tools import friendly_list
|
from .css._error_tools import friendly_list
|
||||||
from .css.constants import VALID_DISPLAY, VALID_VISIBILITY
|
from .css.constants import VALID_DISPLAY, VALID_VISIBILITY
|
||||||
|
|
@ -39,7 +39,7 @@ from .walk import walk_breadth_first, walk_depth_first
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .app import App
|
from .app import App
|
||||||
from .css.query import DOMQuery
|
from .css.query import DOMQuery, QueryType
|
||||||
from .screen import Screen
|
from .screen import Screen
|
||||||
from .widget import Widget
|
from .widget import Widget
|
||||||
from typing_extensions import TypeAlias
|
from typing_extensions import TypeAlias
|
||||||
|
|
@ -276,7 +276,7 @@ class DOMNode(MessagePump):
|
||||||
base.__dict__.get("BINDINGS", []),
|
base.__dict__.get("BINDINGS", []),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
keys = {}
|
keys: dict[str, Binding] = {}
|
||||||
for bindings_ in bindings:
|
for bindings_ in bindings:
|
||||||
keys.update(bindings_.keys)
|
keys.update(bindings_.keys)
|
||||||
return Bindings(keys.values())
|
return Bindings(keys.values())
|
||||||
|
|
@ -357,7 +357,7 @@ class DOMNode(MessagePump):
|
||||||
# Note that self.screen may not be the same as self.app.screen
|
# Note that self.screen may not be the same as self.app.screen
|
||||||
from .screen import Screen
|
from .screen import Screen
|
||||||
|
|
||||||
node = self
|
node: MessagePump | None = self
|
||||||
while node is not None and not isinstance(node, Screen):
|
while node is not None and not isinstance(node, Screen):
|
||||||
node = node._parent
|
node = node._parent
|
||||||
if not isinstance(node, Screen):
|
if not isinstance(node, Screen):
|
||||||
|
|
@ -771,19 +771,17 @@ class DOMNode(MessagePump):
|
||||||
nodes.reverse()
|
nodes.reverse()
|
||||||
return cast("list[DOMNode]", nodes)
|
return cast("list[DOMNode]", nodes)
|
||||||
|
|
||||||
ExpectType = TypeVar("ExpectType", bound="Widget")
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def query(self, selector: str | None) -> DOMQuery[Widget]:
|
def query(self, selector: str | None) -> DOMQuery[Widget]:
|
||||||
...
|
...
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def query(self, selector: type[ExpectType]) -> DOMQuery[ExpectType]:
|
def query(self, selector: type[QueryType]) -> DOMQuery[QueryType]:
|
||||||
...
|
...
|
||||||
|
|
||||||
def query(
|
def query(
|
||||||
self, selector: str | type[ExpectType] | None = None
|
self, selector: str | type[QueryType] | None = None
|
||||||
) -> DOMQuery[Widget] | DOMQuery[ExpectType]:
|
) -> DOMQuery[Widget] | DOMQuery[QueryType]:
|
||||||
"""Get a DOM query matching a selector.
|
"""Get a DOM query matching a selector.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -792,33 +790,31 @@ class DOMNode(MessagePump):
|
||||||
Returns:
|
Returns:
|
||||||
A query object.
|
A query object.
|
||||||
"""
|
"""
|
||||||
from .css.query import DOMQuery
|
from .css.query import DOMQuery, QueryType
|
||||||
|
from .widget import Widget
|
||||||
|
|
||||||
query: str | None
|
|
||||||
if isinstance(selector, str) or selector is None:
|
if isinstance(selector, str) or selector is None:
|
||||||
query = selector
|
return DOMQuery[Widget](self, filter=selector)
|
||||||
else:
|
else:
|
||||||
query = selector.__name__
|
return DOMQuery[QueryType](self, filter=selector.__name__)
|
||||||
|
|
||||||
return DOMQuery(self, filter=query)
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def query_one(self, selector: str) -> Widget:
|
def query_one(self, selector: str) -> Widget:
|
||||||
...
|
...
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def query_one(self, selector: type[ExpectType]) -> ExpectType:
|
def query_one(self, selector: type[QueryType]) -> QueryType:
|
||||||
...
|
...
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def query_one(self, selector: str, expect_type: type[ExpectType]) -> ExpectType:
|
def query_one(self, selector: str, expect_type: type[QueryType]) -> QueryType:
|
||||||
...
|
...
|
||||||
|
|
||||||
def query_one(
|
def query_one(
|
||||||
self,
|
self,
|
||||||
selector: str | type[ExpectType],
|
selector: str | type[QueryType],
|
||||||
expect_type: type[ExpectType] | None = None,
|
expect_type: type[QueryType] | None = None,
|
||||||
) -> ExpectType | Widget:
|
) -> QueryType | Widget:
|
||||||
"""Get a single Widget matching the given selector or selector type.
|
"""Get a single Widget matching the given selector or selector type.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Awaitable, Callable, Type, TypeVar
|
||||||
import rich.repr
|
import rich.repr
|
||||||
from rich.style import Style
|
from rich.style import Style
|
||||||
|
|
||||||
from ._types import MessageTarget
|
from ._types import CallbackType, MessageTarget
|
||||||
from .geometry import Offset, Size
|
from .geometry import Offset, Size
|
||||||
from .keys import _get_key_aliases
|
from .keys import _get_key_aliases
|
||||||
from .message import Message
|
from .message import Message
|
||||||
|
|
@ -28,11 +28,7 @@ class Event(Message):
|
||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class Callback(Event, bubble=False, verbose=True):
|
class Callback(Event, bubble=False, verbose=True):
|
||||||
def __init__(
|
def __init__(self, sender: MessageTarget, callback: CallbackType) -> None:
|
||||||
self,
|
|
||||||
sender: MessageTarget,
|
|
||||||
callback: Callable[[], Awaitable[None]],
|
|
||||||
) -> None:
|
|
||||||
self.callback = callback
|
self.callback = callback
|
||||||
super().__init__(sender)
|
super().__init__(sender)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
# Adapted from prompt toolkit https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/prompt_toolkit/keys.py
|
# Adapted from prompt toolkit https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/prompt_toolkit/keys.py
|
||||||
class Keys(str, Enum):
|
class Keys(str, Enum): # type: ignore[no-redef]
|
||||||
"""
|
"""
|
||||||
List of keys for use in key bindings.
|
List of keys for use in key bindings.
|
||||||
|
|
||||||
|
|
@ -13,7 +13,9 @@ class Keys(str, Enum):
|
||||||
strings.
|
strings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
value: str
|
@property
|
||||||
|
def value(self) -> str:
|
||||||
|
return super().value
|
||||||
|
|
||||||
Escape = "escape" # Also Control-[
|
Escape = "escape" # Also Control-[
|
||||||
ShiftEscape = "shift+escape"
|
ShiftEscape = "shift+escape"
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from ._compositor import Compositor, MapGeometry
|
||||||
from ._types import CallbackType
|
from ._types import CallbackType
|
||||||
from .css.match import match
|
from .css.match import match
|
||||||
from .css.parse import parse_selectors
|
from .css.parse import parse_selectors
|
||||||
from .dom import DOMNode
|
from .css.query import QueryType
|
||||||
from .geometry import Offset, Region, Size
|
from .geometry import Offset, Region, Size
|
||||||
from .reactive import Reactive
|
from .reactive import Reactive
|
||||||
from .renderables.blank import Blank
|
from .renderables.blank import Blank
|
||||||
|
|
@ -169,7 +169,7 @@ class Screen(Widget):
|
||||||
return widgets
|
return widgets
|
||||||
|
|
||||||
def _move_focus(
|
def _move_focus(
|
||||||
self, direction: int = 0, selector: str | type[DOMNode.ExpectType] = "*"
|
self, direction: int = 0, selector: str | type[QueryType] = "*"
|
||||||
) -> Widget | None:
|
) -> Widget | None:
|
||||||
"""Move the focus in the given direction.
|
"""Move the focus in the given direction.
|
||||||
|
|
||||||
|
|
@ -230,9 +230,7 @@ class Screen(Widget):
|
||||||
|
|
||||||
return self.focused
|
return self.focused
|
||||||
|
|
||||||
def focus_next(
|
def focus_next(self, selector: str | type[QueryType] = "*") -> Widget | None:
|
||||||
self, selector: str | type[DOMNode.ExpectType] = "*"
|
|
||||||
) -> Widget | None:
|
|
||||||
"""Focus the next widget, optionally filtered by a CSS selector.
|
"""Focus the next widget, optionally filtered by a CSS selector.
|
||||||
|
|
||||||
If no widget is currently focused, this will focus the first focusable widget.
|
If no widget is currently focused, this will focus the first focusable widget.
|
||||||
|
|
@ -249,9 +247,7 @@ class Screen(Widget):
|
||||||
"""
|
"""
|
||||||
return self._move_focus(1, selector)
|
return self._move_focus(1, selector)
|
||||||
|
|
||||||
def focus_previous(
|
def focus_previous(self, selector: str | type[QueryType] = "*") -> Widget | None:
|
||||||
self, selector: str | type[DOMNode.ExpectType] = "*"
|
|
||||||
) -> Widget | None:
|
|
||||||
"""Focus the previous widget, optionally filtered by a CSS selector.
|
"""Focus the previous widget, optionally filtered by a CSS selector.
|
||||||
|
|
||||||
If no widget is currently focused, this will focus the first focusable widget.
|
If no widget is currently focused, this will focus the first focusable widget.
|
||||||
|
|
|
||||||
|
|
@ -300,13 +300,13 @@ class ScrollBar(Widget):
|
||||||
def _on_leave(self, event: events.Leave) -> None:
|
def _on_leave(self, event: events.Leave) -> None:
|
||||||
self.mouse_over = False
|
self.mouse_over = False
|
||||||
|
|
||||||
async def action_scroll_down(self) -> None:
|
def action_scroll_down(self) -> None:
|
||||||
await self.post_message(
|
self.post_message_no_wait(
|
||||||
ScrollDown(self) if self.vertical else ScrollRight(self)
|
ScrollDown(self) if self.vertical else ScrollRight(self)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def action_scroll_up(self) -> None:
|
def action_scroll_up(self) -> None:
|
||||||
await self.post_message(ScrollUp(self) if self.vertical else ScrollLeft(self))
|
self.post_message_no_wait(ScrollUp(self) if self.vertical else ScrollLeft(self))
|
||||||
|
|
||||||
def action_grab(self) -> None:
|
def action_grab(self) -> None:
|
||||||
self.capture_mouse()
|
self.capture_mouse()
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ from .walk import walk_depth_first
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .app import App, ComposeResult
|
from .app import App, ComposeResult
|
||||||
|
from .message_pump import MessagePump
|
||||||
from .scrollbar import (
|
from .scrollbar import (
|
||||||
ScrollBar,
|
ScrollBar,
|
||||||
ScrollBarCorner,
|
ScrollBarCorner,
|
||||||
|
|
@ -443,23 +444,27 @@ class Widget(DOMNode):
|
||||||
self, id: str, expect_type: type[ExpectType] | None = None
|
self, id: str, expect_type: type[ExpectType] | None = None
|
||||||
) -> ExpectType | Widget:
|
) -> ExpectType | Widget:
|
||||||
"""Return the first descendant widget with the given ID.
|
"""Return the first descendant widget with the given ID.
|
||||||
|
|
||||||
Performs a depth-first search rooted at this widget.
|
Performs a depth-first search rooted at this widget.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
id: The ID to search for in the subtree
|
id: The ID to search for in the subtree.
|
||||||
expect_type: Require the object be of the supplied type, or None for any type.
|
expect_type: Require the object be of the supplied type, or None for any type.
|
||||||
Defaults to None.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The first descendant encountered with this ID.
|
The first descendant encountered with this ID.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
NoMatches: if no children could be found for this ID
|
NoMatches: if no children could be found for this ID.
|
||||||
WrongType: if the wrong type was found.
|
WrongType: if the wrong type was found.
|
||||||
"""
|
"""
|
||||||
for child in walk_depth_first(self):
|
# We use Widget as a filter_type so that the inferred type of child is Widget.
|
||||||
|
for child in walk_depth_first(self, filter_type=Widget):
|
||||||
try:
|
try:
|
||||||
return child.get_child_by_id(id, expect_type=expect_type)
|
if expect_type is None:
|
||||||
|
return child.get_child_by_id(id)
|
||||||
|
else:
|
||||||
|
return child.get_child_by_id(id, expect_type=expect_type)
|
||||||
except NoMatches:
|
except NoMatches:
|
||||||
pass
|
pass
|
||||||
except WrongType as exc:
|
except WrongType as exc:
|
||||||
|
|
@ -729,7 +734,9 @@ class Widget(DOMNode):
|
||||||
|
|
||||||
# Ensure the child and target are widgets.
|
# Ensure the child and target are widgets.
|
||||||
child = _to_widget(child, "move")
|
child = _to_widget(child, "move")
|
||||||
target = _to_widget(before if after is None else after, "move towards")
|
target = _to_widget(
|
||||||
|
cast("int | Widget", before if after is None else after), "move towards"
|
||||||
|
)
|
||||||
|
|
||||||
# At this point we should know what we're moving, and it should be a
|
# At this point we should know what we're moving, and it should be a
|
||||||
# child; where we're moving it to, which should be within the child
|
# child; where we're moving it to, which should be within the child
|
||||||
|
|
@ -2275,7 +2282,7 @@ class Widget(DOMNode):
|
||||||
Names of the pseudo classes.
|
Names of the pseudo classes.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
node = self
|
node: MessagePump | None = self
|
||||||
while isinstance(node, Widget):
|
while isinstance(node, Widget):
|
||||||
if node.disabled:
|
if node.disabled:
|
||||||
yield "disabled"
|
yield "disabled"
|
||||||
|
|
@ -2322,7 +2329,9 @@ class Widget(DOMNode):
|
||||||
renderable.justify = text_justify
|
renderable.justify = text_justify
|
||||||
|
|
||||||
renderable = _Styled(
|
renderable = _Styled(
|
||||||
renderable, self.rich_style, self.link_style if self.auto_links else None
|
cast(ConsoleRenderable, renderable),
|
||||||
|
self.rich_style,
|
||||||
|
self.link_style if self.auto_links else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
return renderable
|
return renderable
|
||||||
|
|
@ -2524,7 +2533,7 @@ class Widget(DOMNode):
|
||||||
self.check_idle()
|
self.check_idle()
|
||||||
|
|
||||||
def remove(self) -> AwaitRemove:
|
def remove(self) -> AwaitRemove:
|
||||||
"""Remove the Widget from the DOM (effectively deleting it)
|
"""Remove the Widget from the DOM (effectively deleting it).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
An awaitable object that waits for the widget to be removed.
|
An awaitable object that waits for the widget to be removed.
|
||||||
|
|
@ -2537,16 +2546,16 @@ class Widget(DOMNode):
|
||||||
"""Get renderable for widget.
|
"""Get renderable for widget.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Any renderable
|
Any renderable.
|
||||||
"""
|
"""
|
||||||
render = "" if self.is_container else self.css_identifier_styled
|
render: Text | str = "" if self.is_container else self.css_identifier_styled
|
||||||
return render
|
return render
|
||||||
|
|
||||||
def _render(self) -> ConsoleRenderable | RichCast:
|
def _render(self) -> ConsoleRenderable | RichCast:
|
||||||
"""Get renderable, promoting str to text as required.
|
"""Get renderable, promoting str to text as required.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A renderable
|
A renderable.
|
||||||
"""
|
"""
|
||||||
renderable = self.render()
|
renderable = self.render()
|
||||||
if isinstance(renderable, str):
|
if isinstance(renderable, str):
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ from functools import partial
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
import rich.repr
|
import rich.repr
|
||||||
from rich.console import RenderableType
|
|
||||||
from rich.text import Text, TextType
|
from rich.text import Text, TextType
|
||||||
from typing_extensions import Literal
|
from typing_extensions import Literal
|
||||||
|
|
||||||
|
|
@ -145,7 +144,7 @@ class Button(Static, can_focus=True):
|
||||||
ACTIVE_EFFECT_DURATION = 0.3
|
ACTIVE_EFFECT_DURATION = 0.3
|
||||||
"""When buttons are clicked they get the `-active` class for this duration (in seconds)"""
|
"""When buttons are clicked they get the `-active` class for this duration (in seconds)"""
|
||||||
|
|
||||||
label: reactive[RenderableType] = reactive[RenderableType]("")
|
label: reactive[TextType] = reactive[TextType]("")
|
||||||
"""The text label that appears within the button."""
|
"""The text label that appears within the button."""
|
||||||
|
|
||||||
variant = reactive("default")
|
variant = reactive("default")
|
||||||
|
|
@ -209,15 +208,14 @@ class Button(Static, can_focus=True):
|
||||||
self.remove_class(f"-{old_variant}")
|
self.remove_class(f"-{old_variant}")
|
||||||
self.add_class(f"-{variant}")
|
self.add_class(f"-{variant}")
|
||||||
|
|
||||||
def validate_label(self, label: RenderableType) -> RenderableType:
|
def validate_label(self, label: TextType) -> TextType:
|
||||||
"""Parse markup for self.label"""
|
"""Parse markup for self.label"""
|
||||||
if isinstance(label, str):
|
if isinstance(label, str):
|
||||||
return Text.from_markup(label)
|
return Text.from_markup(label)
|
||||||
return label
|
return label
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> TextType:
|
||||||
label = self.label.copy()
|
label = Text.assemble(" ", self.label, " ")
|
||||||
label = Text.assemble(" ", label, " ")
|
|
||||||
label.stylize(self.text_style)
|
label.stylize(self.text_style)
|
||||||
return label
|
return label
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class Footer(Widget):
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self.watch(self.screen, "focused", self._focus_changed)
|
self.watch(self.screen, "focused", self._focus_changed) # type: ignore[arg-type]
|
||||||
|
|
||||||
def _focus_changed(self, focused: Widget | None) -> None:
|
def _focus_changed(self, focused: Widget | None) -> None:
|
||||||
self._key_text = None
|
self._key_text = None
|
||||||
|
|
|
||||||
|
|
@ -133,5 +133,5 @@ class Header(Widget):
|
||||||
def set_sub_title(sub_title: str) -> None:
|
def set_sub_title(sub_title: str) -> None:
|
||||||
self.query_one(HeaderTitle).sub_text = sub_title
|
self.query_one(HeaderTitle).sub_text = sub_title
|
||||||
|
|
||||||
self.watch(self.app, "title", set_title)
|
self.watch(self.app, "title", set_title) # type: ignore[arg-type]
|
||||||
self.watch(self.app, "sub_title", set_sub_title)
|
self.watch(self.app, "sub_title", set_sub_title) # type: ignore[arg-type]
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ class ListItem(Widget, can_focus=False):
|
||||||
class _ChildClicked(Message):
|
class _ChildClicked(Message):
|
||||||
"""For informing with the parent ListView that we were clicked"""
|
"""For informing with the parent ListView that we were clicked"""
|
||||||
|
|
||||||
pass
|
sender: "ListItem"
|
||||||
|
|
||||||
def on_click(self, event: events.Click) -> None:
|
def on_click(self, event: events.Click) -> None:
|
||||||
self.post_message_no_wait(self._ChildClicked(self))
|
self.post_message_no_wait(self._ChildClicked(self))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import ClassVar
|
from typing import ClassVar, Optional
|
||||||
|
|
||||||
from textual.await_remove import AwaitRemove
|
from textual.await_remove import AwaitRemove
|
||||||
from textual.binding import Binding, BindingType
|
from textual.binding import Binding, BindingType
|
||||||
|
|
@ -8,7 +8,7 @@ from textual.containers import Vertical
|
||||||
from textual.geometry import clamp
|
from textual.geometry import clamp
|
||||||
from textual.message import Message
|
from textual.message import Message
|
||||||
from textual.reactive import reactive
|
from textual.reactive import reactive
|
||||||
from textual.widget import AwaitMount
|
from textual.widget import AwaitMount, Widget
|
||||||
from textual.widgets._list_item import ListItem
|
from textual.widgets._list_item import ListItem
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -35,7 +35,7 @@ class ListView(Vertical, can_focus=True, can_focus_children=False):
|
||||||
| down | Move the cursor down. |
|
| down | Move the cursor down. |
|
||||||
"""
|
"""
|
||||||
|
|
||||||
index = reactive(0, always_update=True)
|
index = reactive[Optional[int]](0, always_update=True)
|
||||||
|
|
||||||
class Highlighted(Message, bubble=True):
|
class Highlighted(Message, bubble=True):
|
||||||
"""Posted when the highlighted item changes.
|
"""Posted when the highlighted item changes.
|
||||||
|
|
@ -96,10 +96,12 @@ class ListView(Vertical, can_focus=True, can_focus_children=False):
|
||||||
@property
|
@property
|
||||||
def highlighted_child(self) -> ListItem | None:
|
def highlighted_child(self) -> ListItem | None:
|
||||||
"""The currently highlighted ListItem, or None if nothing is highlighted."""
|
"""The currently highlighted ListItem, or None if nothing is highlighted."""
|
||||||
if self.index is None:
|
if self.index is not None and 0 <= self.index < len(self._nodes):
|
||||||
|
list_item = self._nodes[self.index]
|
||||||
|
assert isinstance(list_item, ListItem)
|
||||||
|
return list_item
|
||||||
|
else:
|
||||||
return None
|
return None
|
||||||
elif 0 <= self.index < len(self._nodes):
|
|
||||||
return self._nodes[self.index]
|
|
||||||
|
|
||||||
def validate_index(self, index: int | None) -> int | None:
|
def validate_index(self, index: int | None) -> int | None:
|
||||||
"""Clamp the index to the valid range, or set to None if there's nothing to highlight.
|
"""Clamp the index to the valid range, or set to None if there's nothing to highlight.
|
||||||
|
|
@ -129,9 +131,13 @@ class ListView(Vertical, can_focus=True, can_focus_children=False):
|
||||||
"""Updates the highlighting when the index changes."""
|
"""Updates the highlighting when the index changes."""
|
||||||
if self._is_valid_index(old_index):
|
if self._is_valid_index(old_index):
|
||||||
old_child = self._nodes[old_index]
|
old_child = self._nodes[old_index]
|
||||||
|
assert isinstance(old_child, ListItem)
|
||||||
old_child.highlighted = False
|
old_child.highlighted = False
|
||||||
|
|
||||||
|
new_child: Widget | None
|
||||||
if self._is_valid_index(new_index):
|
if self._is_valid_index(new_index):
|
||||||
new_child = self._nodes[new_index]
|
new_child = self._nodes[new_index]
|
||||||
|
assert isinstance(new_child, ListItem)
|
||||||
new_child.highlighted = True
|
new_child.highlighted = True
|
||||||
else:
|
else:
|
||||||
new_child = None
|
new_child = None
|
||||||
|
|
@ -168,14 +174,22 @@ class ListView(Vertical, can_focus=True, can_focus_children=False):
|
||||||
def action_select_cursor(self) -> None:
|
def action_select_cursor(self) -> None:
|
||||||
"""Select the current item in the list."""
|
"""Select the current item in the list."""
|
||||||
selected_child = self.highlighted_child
|
selected_child = self.highlighted_child
|
||||||
|
if selected_child is None:
|
||||||
|
return
|
||||||
self.post_message_no_wait(self.Selected(self, selected_child))
|
self.post_message_no_wait(self.Selected(self, selected_child))
|
||||||
|
|
||||||
def action_cursor_down(self) -> None:
|
def action_cursor_down(self) -> None:
|
||||||
"""Highlight the next item in the list."""
|
"""Highlight the next item in the list."""
|
||||||
|
if self.index is None:
|
||||||
|
self.index = 0
|
||||||
|
return
|
||||||
self.index += 1
|
self.index += 1
|
||||||
|
|
||||||
def action_cursor_up(self) -> None:
|
def action_cursor_up(self) -> None:
|
||||||
"""Highlight the previous item in the list."""
|
"""Highlight the previous item in the list."""
|
||||||
|
if self.index is None:
|
||||||
|
self.index = 0
|
||||||
|
return
|
||||||
self.index -= 1
|
self.index -= 1
|
||||||
|
|
||||||
def on_list_item__child_clicked(self, event: ListItem._ChildClicked) -> None:
|
def on_list_item__child_clicked(self, event: ListItem._ChildClicked) -> None:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue