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:
Rodrigo Girão Serrão 2023-03-01 15:50:15 +00:00 committed by GitHub
parent a283689d1d
commit ed28a7019c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 199 additions and 143 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"
) )

View file

@ -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]

View file

@ -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(
str,
app.run(
headless=True, headless=True,
auto_pilot=auto_pilot, auto_pilot=auto_pilot,
size=terminal_size, size=terminal_size,
),
) )
if app_path is not None: if app_path is not None:

View file

@ -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

View file

@ -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?

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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",
), ),
) )

View file

@ -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

View file

@ -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,

View file

@ -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:

View file

@ -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,

View file

@ -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;
} }

View file

@ -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 = [

View file

@ -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):

View file

@ -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))

View file

@ -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

View file

@ -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:

View file

@ -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)

View file

@ -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"

View file

@ -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.

View file

@ -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()

View file

@ -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,22 +444,26 @@ 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:
if expect_type is None:
return child.get_child_by_id(id)
else:
return child.get_child_by_id(id, expect_type=expect_type) return child.get_child_by_id(id, expect_type=expect_type)
except NoMatches: except NoMatches:
pass pass
@ -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):

View file

@ -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

View file

@ -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

View file

@ -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]

View file

@ -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))

View file

@ -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: