From 906fc6e2166ee822f31eb0666536906c4a5d4ab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 4 Jan 2023 14:49:06 +0000 Subject: [PATCH 01/16] Fix #1479. --- src/textual/scrollbar.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 917e36a69..f9db9c71b 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -225,14 +225,15 @@ class ScrollBar(Widget): def render(self) -> RenderableType: styles = self.parent.styles - background = ( - styles.scrollbar_background_hover - if self.mouse_over - else styles.scrollbar_background - ) - color = ( - styles.scrollbar_color_active if self.grabbed else styles.scrollbar_color - ) + if self.grabbed: + background = styles.scrollbar_background_active + color = styles.scrollbar_color_active + elif self.mouse_over: + background = styles.scrollbar_background_hover + color = styles.scrollbar_color_hover + else: + background = styles.scrollbar_background + color = styles.scrollbar_color color = background + color scrollbar_style = Style.from_color(color.rich_color, background.rich_color) return ScrollBarRender( From e717c3cfada7a9b67c9dd77c43032692ce3d2360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 4 Jan 2023 14:51:43 +0000 Subject: [PATCH 02/16] Update changelog. --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ee7b9a67..dac91e707 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## Unreleased + +### Fixed + +- The styles `scrollbar-background-active` and `scrollbar-color-hover` are no longer ignored https://github.com/Textualize/textual/pull/1480 + ## [0.9.1] - 2022-12-30 ### Added From f2108f0475a84eb0e6b366e3f9e09367197844e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 4 Jan 2023 15:01:12 +0000 Subject: [PATCH 03/16] Make CI happy. --- src/textual/_win_sleep.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/_win_sleep.py b/src/textual/_win_sleep.py index 4d417a07e..e90930afc 100644 --- a/src/textual/_win_sleep.py +++ b/src/textual/_win_sleep.py @@ -12,7 +12,6 @@ WAIT_FAILED = 0xFFFFFFFF CREATE_WAITABLE_TIMER_HIGH_RESOLUTION = 0x00000002 - def sleep(sleep_for: float) -> None: """A replacement sleep for Windows. From a1e63a1c023909731ad0f5f2337d7e75e000b3f8 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 5 Jan 2023 09:21:52 +0000 Subject: [PATCH 04/16] Process the label on construction of a TreeNode Currently there's an asymmetry in how the label is handled for a TreeNode. If a str label is passed to the constructor it stays as a str type. On the other hand, if it's set via set_label, it gets processed into a Rich Text type. This commit removes that asymmetry. --- src/textual/widgets/_tree.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index e23385502..d457d8533 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -71,7 +71,7 @@ class TreeNode(Generic[TreeDataType]): self._tree = tree self._parent = parent self._id = id - self._label = label + self._label = tree.process_label(label) self.data = data self._expanded = expanded self._children: list[TreeNode] = [] From b8a329638e15f469452fb11a9d382e6794875c90 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 5 Jan 2023 09:37:08 +0000 Subject: [PATCH 05/16] Add public access to a TreeNode's label This adds public support to reading a TreeNode's label, and also setting it too. See #1396. --- CHANGELOG.md | 10 ++++++++-- src/textual/widgets/_tree.py | 11 +++++++++++ tests/test_tree.py | 17 +++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 tests/test_tree.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ee7b9a67..d6485bd50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.10.0] - Unreleased + +### Added + +- Added public `TreeNode` label access via `TreeNode.label` https://github.com/Textualize/textual/issues/1396 + ## [0.9.1] - 2022-12-30 ### Added @@ -23,8 +29,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Widget.render_line now returns a Strip - Fix for slow updates on Windows -- Bumped Rich dependency - +- Bumped Rich dependency + ## [0.8.2] - 2022-12-28 ### Fixed diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index d457d8533..f88dcf60c 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -163,6 +163,17 @@ class TreeNode(Generic[TreeDataType]): self._updates += 1 self._tree._invalidate() + @property + def label(self) -> TextType: + """TextType: The label for the node.""" + return self._label + + @label.setter + def label(self, new_label: TextType) -> TextType: + """TextType: The label for the node.""" + self.set_label(new_label) + return self.label + def set_label(self, label: TextType) -> None: """Set a new label for the node. diff --git a/tests/test_tree.py b/tests/test_tree.py new file mode 100644 index 000000000..55af8088e --- /dev/null +++ b/tests/test_tree.py @@ -0,0 +1,17 @@ +from textual.widgets import Tree, TreeNode +from rich.text import Text + +def test_tree_node_label() -> None: + """It should be possible to modify a TreeNode's label.""" + node = TreeNode(Tree[None]("Xenomorph Lifecycle"), None, 0, "Facehugger") + assert node.label == Text("Facehugger") + node.label = "Chestbuster" + assert node.label == Text("Chestbuster") + +def test_tree_node_label_via_tree() -> None: + """It should be possible to modify a TreeNode's label when created via a Tree.""" + tree = Tree[None]("Xenomorph Lifecycle") + node = tree.root.add("Facehugger") + assert node.label == Text("Facehugger") + node.label = "Chestbuster" + assert node.label == Text("Chestbuster") From d39c59c4147c386efd53e5fad608d5cdec59eea5 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 5 Jan 2023 10:01:52 +0000 Subject: [PATCH 06/16] Move the TreeNode label tests into a better-named file There's going to be a whole bunch of tests relating to the Tree and TreeNode coming, let's make sure this ends up being fairly granular. (side thought: it might be a good time soon to revisit all the tests for Textual and try and wrangle them into some tidy structure) --- tests/{test_tree.py => tree/test_tree_node_label.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_tree.py => tree/test_tree_node_label.py} (100%) diff --git a/tests/test_tree.py b/tests/tree/test_tree_node_label.py similarity index 100% rename from tests/test_tree.py rename to tests/tree/test_tree_node_label.py From 05b10d86273f2da74e787bb37718b2a31ea6eda3 Mon Sep 17 00:00:00 2001 From: Nitzan Shaked Date: Fri, 30 Dec 2022 15:10:21 +0200 Subject: [PATCH 07/16] the very bare minimum typing fixes I need now. Sorry :/ --- src/textual/_node_list.py | 11 ++++++----- src/textual/css/errors.py | 5 ++--- src/textual/css/tokenizer.py | 2 +- src/textual/devtools/server.py | 6 +++--- src/textual/geometry.py | 5 ++++- src/textual/pilot.py | 12 ++++++------ src/textual/renderables/sparkline.py | 23 +++++++++++++---------- src/textual/renderables/text_opacity.py | 7 ++++--- src/textual/strip.py | 4 ++-- src/textual/widgets/_tree.py | 18 +++++++++--------- 10 files changed, 50 insertions(+), 43 deletions(-) diff --git a/src/textual/_node_list.py b/src/textual/_node_list.py index 7032b0cc7..5a464405a 100644 --- a/src/textual/_node_list.py +++ b/src/textual/_node_list.py @@ -1,6 +1,7 @@ from __future__ import annotations +import sys -from typing import TYPE_CHECKING, Iterator, Sequence, overload +from typing import TYPE_CHECKING, Any, Iterator, Sequence, overload import rich.repr @@ -13,7 +14,7 @@ class DuplicateIds(Exception): @rich.repr.auto(angular=True) -class NodeList(Sequence): +class NodeList(Sequence[Widget]): """ A container for widgets that forms one level of hierarchy. @@ -46,10 +47,10 @@ class NodeList(Sequence): def __len__(self) -> int: return len(self._nodes) - def __contains__(self, widget: Widget) -> bool: + def __contains__(self, widget: object) -> bool: return widget in self._nodes - def index(self, widget: Widget) -> int: + def index(self, widget: Any, start: int = 0, stop: int = sys.maxsize) -> int: """Return the index of the given widget. Args: @@ -61,7 +62,7 @@ class NodeList(Sequence): Raises: ValueError: If the widget is not in the node list. """ - return self._nodes.index(widget) + return self._nodes.index(widget, start, stop) def _get_by_id(self, widget_id: str) -> Widget | None: """Get the widget for the given widget_id, or None if there's no matches in this list""" diff --git a/src/textual/css/errors.py b/src/textual/css/errors.py index c0a5ca95d..a326db43b 100644 --- a/src/textual/css/errors.py +++ b/src/textual/css/errors.py @@ -4,8 +4,7 @@ from rich.console import ConsoleOptions, Console, RenderResult from rich.traceback import Traceback from ._help_renderables import HelpText -from .tokenize import Token -from .tokenizer import TokenError +from .tokenizer import Token, TokenError class DeclarationError(Exception): @@ -32,7 +31,7 @@ class StyleValueError(ValueError): error is raised. """ - def __init__(self, *args, help_text: HelpText | None = None): + def __init__(self, *args: object, help_text: HelpText | None = None): super().__init__(*args) self.help_text = help_text diff --git a/src/textual/css/tokenizer.py b/src/textual/css/tokenizer.py index 51c8bf622..ca5094fc8 100644 --- a/src/textual/css/tokenizer.py +++ b/src/textual/css/tokenizer.py @@ -242,7 +242,7 @@ class Tokenizer: while True: if line_no >= len(self.lines): raise EOFError( - self.path, self.code, line_no, col_no, "Unexpected end of file" + self.path, self.code, (line_no, col_no), "Unexpected end of file" ) line = self.lines[line_no] match = expect.search(line, col_no) diff --git a/src/textual/devtools/server.py b/src/textual/devtools/server.py index ab13ca8ee..3e32f628f 100644 --- a/src/textual/devtools/server.py +++ b/src/textual/devtools/server.py @@ -40,8 +40,8 @@ async def _on_startup(app: Application) -> None: def _run_devtools(verbose: bool, exclude: list[str] | None = None) -> None: app = _make_devtools_aiohttp_app(verbose=verbose, exclude=exclude) - def noop_print(_: str): - return None + def noop_print(_: str) -> None: + pass try: run_app( @@ -80,4 +80,4 @@ def _make_devtools_aiohttp_app( if __name__ == "__main__": - _run_devtools() + _run_devtools(True) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index e29790e30..2dbb2c22a 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -7,6 +7,7 @@ Functions and classes to manage terminal geometry (anything involving coordinate from __future__ import annotations from functools import lru_cache +import math from operator import attrgetter, itemgetter from typing import Any, Collection, NamedTuple, Tuple, TypeVar, Union, cast @@ -129,7 +130,7 @@ class Offset(NamedTuple): """ x1, y1 = self x2, y2 = other - distance = ((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)) ** 0.5 + distance = math.sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)) return distance @@ -218,6 +219,8 @@ class Size(NamedTuple): def __contains__(self, other: Any) -> bool: try: x, y = other + assert isinstance(x, int) + assert isinstance(y, int) except Exception: raise TypeError( "Dimensions.__contains__ requires an iterable of two integers" diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 5d0427905..d13d1bb8d 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -3,24 +3,24 @@ from __future__ import annotations import rich.repr import asyncio -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Generic if TYPE_CHECKING: - from .app import App + from .app import App, ReturnType @rich.repr.auto(angular=True) -class Pilot: +class Pilot(Generic[ReturnType]): """Pilot object to drive an app.""" - def __init__(self, app: App) -> None: + def __init__(self, app: App[ReturnType]) -> None: self._app = app def __rich_repr__(self) -> rich.repr.Result: yield "app", self._app @property - def app(self) -> App: + def app(self) -> App[ReturnType]: """App: A reference to the application.""" return self._app @@ -47,7 +47,7 @@ class Pilot: """Wait for any animation to complete.""" await self._app.animator.wait_for_idle() - async def exit(self, result: object) -> None: + async def exit(self, result: ReturnType) -> None: """Exit the app with the given result. Args: diff --git a/src/textual/renderables/sparkline.py b/src/textual/renderables/sparkline.py index 5630c6bb6..eb55a6d56 100644 --- a/src/textual/renderables/sparkline.py +++ b/src/textual/renderables/sparkline.py @@ -1,7 +1,7 @@ from __future__ import annotations import statistics -from typing import Sequence, Iterable, Callable, TypeVar +from typing import Generic, Sequence, Iterable, Callable, TypeVar from rich.color import Color from rich.console import ConsoleOptions, Console, RenderResult @@ -12,8 +12,9 @@ from textual.renderables._blend_colors import blend_colors T = TypeVar("T", int, float) +SummaryFunction = Callable[[Sequence[T]], float] -class Sparkline: +class Sparkline(Generic[T]): """A sparkline representing a series of data. Args: @@ -33,16 +34,16 @@ class Sparkline: width: int | None, min_color: Color = Color.from_rgb(0, 255, 0), max_color: Color = Color.from_rgb(255, 0, 0), - summary_function: Callable[[list[T]], float] = max, + summary_function: SummaryFunction[T] = max, ) -> None: - self.data = data + self.data: Sequence[T] = data self.width = width self.min_color = Style.from_color(min_color) self.max_color = Style.from_color(max_color) - self.summary_function = summary_function + self.summary_function: SummaryFunction[T] = summary_function @classmethod - def _buckets(cls, data: Sequence[T], num_buckets: int) -> Iterable[list[T]]: + def _buckets(cls, data: Sequence[T], num_buckets: int) -> Iterable[Sequence[T]]: """Partition ``data`` into ``num_buckets`` buckets. For example, the data [1, 2, 3, 4] partitioned into 2 buckets is [[1, 2], [3, 4]]. @@ -73,13 +74,15 @@ class Sparkline: minimum, maximum = min(self.data), max(self.data) extent = maximum - minimum or 1 - buckets = list(self._buckets(self.data, num_buckets=self.width)) + buckets = tuple(self._buckets(self.data, num_buckets=width)) bucket_index = 0 bars_rendered = 0 - step = len(buckets) / width + step = len(buckets) // width summary_function = self.summary_function min_color, max_color = self.min_color.color, self.max_color.color + assert min_color is not None + assert max_color is not None while bars_rendered < width: partition = buckets[int(bucket_index)] partition_summary = summary_function(partition) @@ -94,10 +97,10 @@ class Sparkline: if __name__ == "__main__": console = Console() - def last(l): + def last(l: Sequence[T]) -> T: return l[-1] - funcs = min, max, last, statistics.median, statistics.mean + funcs: Sequence[SummaryFunction[int]] = (min, max, last, statistics.median, statistics.mean) nums = [10, 2, 30, 60, 45, 20, 7, 8, 9, 10, 50, 13, 10, 6, 5, 4, 3, 7, 20] console.print(f"data = {nums}\n") for f in funcs: diff --git a/src/textual/renderables/text_opacity.py b/src/textual/renderables/text_opacity.py index dffb5667b..638a3458b 100644 --- a/src/textual/renderables/text_opacity.py +++ b/src/textual/renderables/text_opacity.py @@ -1,5 +1,5 @@ import functools -from typing import Iterable +from typing import Iterable, Iterator from rich.cells import cell_len from rich.color import Color @@ -63,6 +63,7 @@ class TextOpacity: _from_color = Style.from_color if opacity == 0: for text, style, control in segments: + assert style is not None invisible_style = _from_color(bgcolor=style.bgcolor) yield _Segment(cell_len(text) * " ", invisible_style) else: @@ -106,7 +107,7 @@ if __name__ == "__main__": opacity_panel = TextOpacity(panel, opacity=0.5) console.print(opacity_panel) - def frange(start, end, step): + def frange(start: float, end: float, step: float) -> Iterator[float]: current = start while current < end: yield current @@ -120,5 +121,5 @@ if __name__ == "__main__": with Live(opacity_panel, refresh_per_second=60) as live: for value in itertools.cycle(frange(0, 1, 0.05)): - opacity_panel.value = value + opacity_panel.opacity = value sleep(0.05) diff --git a/src/textual/strip.py b/src/textual/strip.py index 7a83d5e40..5a4c1404e 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -109,8 +109,8 @@ class Strip: def __len__(self) -> int: return len(self._segments) - def __eq__(self, strip: Strip) -> bool: - return ( + def __eq__(self, strip: object) -> bool: + return isinstance(strip, Strip) and ( self._segments == strip._segments and self.cell_length == strip.cell_length ) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index e23385502..d0d7622f2 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -31,12 +31,12 @@ TOGGLE_STYLE = Style.from_meta({"toggle": True}) @dataclass -class _TreeLine: - path: list[TreeNode] +class _TreeLine(Generic[TreeDataType]): + path: list[TreeNode[TreeDataType]] last: bool @property - def node(self) -> TreeNode: + def node(self) -> TreeNode[TreeDataType]: """TreeNode: The node associated with this line.""" return self.path[-1] @@ -74,7 +74,7 @@ class TreeNode(Generic[TreeDataType]): self._label = label self.data = data self._expanded = expanded - self._children: list[TreeNode] = [] + self._children: list[TreeNode[TreeDataType]] = [] self._hover_ = False self._selected_ = False @@ -466,11 +466,11 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._updates += 1 self.refresh() - def select_node(self, node: TreeNode | None) -> None: + def select_node(self, node: TreeNode[TreeDataType] | None) -> None: """Move the cursor to the given node, or reset cursor. Args: - node (TreeNode | None): A tree node, or None to reset cursor. + node (TreeNode[TreeDataType] | None): A tree node, or None to reset cursor. """ self.cursor_line = -1 if node is None else node._line @@ -570,11 +570,11 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): """ self.scroll_to_region(Region(0, line, self.size.width, 1)) - def scroll_to_node(self, node: TreeNode) -> None: + def scroll_to_node(self, node: TreeNode[TreeDataType]) -> None: """Scroll to the given node. Args: - node (TreeNode): Node to scroll in to view. + node (TreeNode[TreeDataType]): Node to scroll in to view. """ line = node._line if line != -1: @@ -628,7 +628,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): root = self.root - def add_node(path: list[TreeNode], node: TreeNode, last: bool) -> None: + def add_node(path: list[TreeNode[TreeDataType]], node: TreeNode[TreeDataType], last: bool) -> None: child_path = [*path, node] node._line = len(lines) add_line(TreeLine(child_path, last)) From 080687ec609e1588bc3df1acb2c7bba3cef325aa Mon Sep 17 00:00:00 2001 From: Nitzan Shaked Date: Mon, 2 Jan 2023 21:05:12 +0200 Subject: [PATCH 08/16] fix PR comments thus far --- src/textual/_node_list.py | 33 ++++++++------- src/textual/devtools/server.py | 4 -- src/textual/geometry.py | 2 - src/textual/pilot.py | 3 +- src/textual/renderables/sparkline.py | 4 +- src/textual/renderables/text_opacity.py | 54 +++++-------------------- 6 files changed, 29 insertions(+), 71 deletions(-) diff --git a/src/textual/_node_list.py b/src/textual/_node_list.py index 5a464405a..1fe4d135f 100644 --- a/src/textual/_node_list.py +++ b/src/textual/_node_list.py @@ -1,20 +1,19 @@ from __future__ import annotations import sys -from typing import TYPE_CHECKING, Any, Iterator, Sequence, overload +from typing import Any, Hashable, Iterator, Sequence, TypeVar, overload import rich.repr -if TYPE_CHECKING: - from .widget import Widget - class DuplicateIds(Exception): pass +_T = TypeVar("_T", bound=Hashable) + @rich.repr.auto(angular=True) -class NodeList(Sequence[Widget]): +class NodeList(Sequence[_T]): """ A container for widgets that forms one level of hierarchy. @@ -24,13 +23,13 @@ class NodeList(Sequence[Widget]): def __init__(self) -> None: # The nodes in the list - self._nodes: list[Widget] = [] - self._nodes_set: set[Widget] = set() + self._nodes: list[_T] = [] + self._nodes_set: set[_T] = set() # We cache widgets by their IDs too for a quick lookup # Note that only widgets with IDs are cached like this, so # this cache will likely hold fewer values than self._nodes. - self._nodes_by_id: dict[str, Widget] = {} + self._nodes_by_id: dict[str, _T] = {} # Increments when list is updated (used for caching) self._updates = 0 @@ -64,11 +63,11 @@ class NodeList(Sequence[Widget]): """ return self._nodes.index(widget, start, stop) - def _get_by_id(self, widget_id: str) -> Widget | None: + def _get_by_id(self, widget_id: str) -> _T | None: """Get the widget for the given widget_id, or None if there's no matches in this list""" return self._nodes_by_id.get(widget_id) - def _append(self, widget: Widget) -> None: + def _append(self, widget: _T) -> None: """Append a Widget. Args: @@ -83,7 +82,7 @@ class NodeList(Sequence[Widget]): self._nodes_by_id[widget_id] = widget self._updates += 1 - def _insert(self, index: int, widget: Widget) -> None: + def _insert(self, index: int, widget: _T) -> None: """Insert a Widget. Args: @@ -106,7 +105,7 @@ class NodeList(Sequence[Widget]): "The children of a widget must have unique IDs." ) - def _remove(self, widget: Widget) -> None: + def _remove(self, widget: _T) -> None: """Remove a widget from the list. Removing a widget not in the list is a null-op. @@ -130,19 +129,19 @@ class NodeList(Sequence[Widget]): self._nodes_by_id.clear() self._updates += 1 - def __iter__(self) -> Iterator[Widget]: + def __iter__(self) -> Iterator[_T]: return iter(self._nodes) - def __reversed__(self) -> Iterator[Widget]: + def __reversed__(self) -> Iterator[_T]: return reversed(self._nodes) @overload - def __getitem__(self, index: int) -> Widget: + def __getitem__(self, index: int) -> _T: ... @overload - def __getitem__(self, index: slice) -> list[Widget]: + def __getitem__(self, index: slice) -> list[_T]: ... - def __getitem__(self, index: int | slice) -> Widget | list[Widget]: + def __getitem__(self, index: int | slice) -> _T | list[_T]: return self._nodes[index] diff --git a/src/textual/devtools/server.py b/src/textual/devtools/server.py index 3e32f628f..82e3fabab 100644 --- a/src/textual/devtools/server.py +++ b/src/textual/devtools/server.py @@ -77,7 +77,3 @@ def _make_devtools_aiohttp_app( ) return app - - -if __name__ == "__main__": - _run_devtools(True) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 2dbb2c22a..220959dfe 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -219,8 +219,6 @@ class Size(NamedTuple): def __contains__(self, other: Any) -> bool: try: x, y = other - assert isinstance(x, int) - assert isinstance(y, int) except Exception: raise TypeError( "Dimensions.__contains__ requires an iterable of two integers" diff --git a/src/textual/pilot.py b/src/textual/pilot.py index d13d1bb8d..1e58822b4 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -5,8 +5,7 @@ import rich.repr import asyncio from typing import TYPE_CHECKING, Generic -if TYPE_CHECKING: - from .app import App, ReturnType +from .app import App, ReturnType @rich.repr.auto(angular=True) diff --git a/src/textual/renderables/sparkline.py b/src/textual/renderables/sparkline.py index eb55a6d56..17ca5a347 100644 --- a/src/textual/renderables/sparkline.py +++ b/src/textual/renderables/sparkline.py @@ -76,9 +76,9 @@ class Sparkline(Generic[T]): buckets = tuple(self._buckets(self.data, num_buckets=width)) - bucket_index = 0 + bucket_index = 0.0 bars_rendered = 0 - step = len(buckets) // width + step = len(buckets) / width summary_function = self.summary_function min_color, max_color = self.min_color.color, self.max_color.color assert min_color is not None diff --git a/src/textual/renderables/text_opacity.py b/src/textual/renderables/text_opacity.py index 638a3458b..9f6237879 100644 --- a/src/textual/renderables/text_opacity.py +++ b/src/textual/renderables/text_opacity.py @@ -1,5 +1,5 @@ import functools -from typing import Iterable, Iterator +from typing import Iterable, cast from rich.cells import cell_len from rich.color import Color @@ -62,22 +62,25 @@ class TextOpacity: _Segment = Segment _from_color = Style.from_color if opacity == 0: - for text, style, control in segments: + for text, style, control in cast( + Iterable[tuple[str, Style, object]], + segments + ): assert style is not None invisible_style = _from_color(bgcolor=style.bgcolor) yield _Segment(cell_len(text) * " ", invisible_style) else: for segment in segments: - text, style, control = segment - if not style: + text, maybe_style, control = segment + if not maybe_style: yield segment continue - color = style.color - bgcolor = style.bgcolor + color = maybe_style.color + bgcolor = maybe_style.bgcolor if color and color.triplet and bgcolor and bgcolor.triplet: color_style = _get_blended_style_cached(bgcolor, color, opacity) - yield _Segment(text, style + color_style) + yield _Segment(text, maybe_style + color_style) else: yield segment @@ -86,40 +89,3 @@ class TextOpacity: ) -> RenderResult: segments = console.render(self.renderable, options) return self.process_segments(segments, self.opacity) - - -if __name__ == "__main__": - from rich.live import Live - from rich.panel import Panel - from rich.text import Text - - from time import sleep - - console = Console() - - panel = Panel.fit( - Text("Steak: £30", style="#fcffde on #03761e"), - title="Menu", - style="#ffffff on #000000", - ) - console.print(panel) - - opacity_panel = TextOpacity(panel, opacity=0.5) - console.print(opacity_panel) - - def frange(start: float, end: float, step: float) -> Iterator[float]: - current = start - while current < end: - yield current - current += step - - while current >= 0: - yield current - current -= step - - import itertools - - with Live(opacity_panel, refresh_per_second=60) as live: - for value in itertools.cycle(frange(0, 1, 0.05)): - opacity_panel.opacity = value - sleep(0.05) From 5750666ad8e91f48504ee47aa0eb64b64d39c394 Mon Sep 17 00:00:00 2001 From: Nitzan Shaked Date: Mon, 2 Jan 2023 21:50:14 +0200 Subject: [PATCH 09/16] further PR fixes --- src/textual/_node_list.py | 33 +++++++++++++++++---------------- src/textual/pilot.py | 2 +- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/textual/_node_list.py b/src/textual/_node_list.py index 1fe4d135f..6e7a4fba1 100644 --- a/src/textual/_node_list.py +++ b/src/textual/_node_list.py @@ -1,19 +1,20 @@ from __future__ import annotations import sys -from typing import Any, Hashable, Iterator, Sequence, TypeVar, overload +from typing import TYPE_CHECKING, Any, Iterator, Sequence, overload import rich.repr +if TYPE_CHECKING: + from .widget import Widget + class DuplicateIds(Exception): pass -_T = TypeVar("_T", bound=Hashable) - @rich.repr.auto(angular=True) -class NodeList(Sequence[_T]): +class NodeList(Sequence["Widget"]): """ A container for widgets that forms one level of hierarchy. @@ -23,13 +24,13 @@ class NodeList(Sequence[_T]): def __init__(self) -> None: # The nodes in the list - self._nodes: list[_T] = [] - self._nodes_set: set[_T] = set() + self._nodes: list[Widget] = [] + self._nodes_set: set[Widget] = set() # We cache widgets by their IDs too for a quick lookup # Note that only widgets with IDs are cached like this, so # this cache will likely hold fewer values than self._nodes. - self._nodes_by_id: dict[str, _T] = {} + self._nodes_by_id: dict[str, Widget] = {} # Increments when list is updated (used for caching) self._updates = 0 @@ -63,11 +64,11 @@ class NodeList(Sequence[_T]): """ return self._nodes.index(widget, start, stop) - def _get_by_id(self, widget_id: str) -> _T | None: + def _get_by_id(self, widget_id: str) -> Widget | None: """Get the widget for the given widget_id, or None if there's no matches in this list""" return self._nodes_by_id.get(widget_id) - def _append(self, widget: _T) -> None: + def _append(self, widget: Widget) -> None: """Append a Widget. Args: @@ -82,7 +83,7 @@ class NodeList(Sequence[_T]): self._nodes_by_id[widget_id] = widget self._updates += 1 - def _insert(self, index: int, widget: _T) -> None: + def _insert(self, index: int, widget: Widget) -> None: """Insert a Widget. Args: @@ -105,7 +106,7 @@ class NodeList(Sequence[_T]): "The children of a widget must have unique IDs." ) - def _remove(self, widget: _T) -> None: + def _remove(self, widget: Widget) -> None: """Remove a widget from the list. Removing a widget not in the list is a null-op. @@ -129,19 +130,19 @@ class NodeList(Sequence[_T]): self._nodes_by_id.clear() self._updates += 1 - def __iter__(self) -> Iterator[_T]: + def __iter__(self) -> Iterator[Widget]: return iter(self._nodes) - def __reversed__(self) -> Iterator[_T]: + def __reversed__(self) -> Iterator[Widget]: return reversed(self._nodes) @overload - def __getitem__(self, index: int) -> _T: + def __getitem__(self, index: int) -> Widget: ... @overload - def __getitem__(self, index: slice) -> list[_T]: + def __getitem__(self, index: slice) -> list[Widget]: ... - def __getitem__(self, index: int | slice) -> _T | list[_T]: + def __getitem__(self, index: int | slice) -> Widget | list[Widget]: return self._nodes[index] diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 1e58822b4..ed118a2dc 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -3,7 +3,7 @@ from __future__ import annotations import rich.repr import asyncio -from typing import TYPE_CHECKING, Generic +from typing import Generic from .app import App, ReturnType From c1a90c25a14c0e9b78551bfa5a4cc1a6ed339f9f Mon Sep 17 00:00:00 2001 From: Nitzan Shaked Date: Mon, 2 Jan 2023 21:58:23 +0200 Subject: [PATCH 10/16] black --- src/textual/renderables/sparkline.py | 9 ++++++++- src/textual/renderables/text_opacity.py | 3 +-- src/textual/widgets/_tree.py | 4 +++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/textual/renderables/sparkline.py b/src/textual/renderables/sparkline.py index 17ca5a347..755c12440 100644 --- a/src/textual/renderables/sparkline.py +++ b/src/textual/renderables/sparkline.py @@ -14,6 +14,7 @@ T = TypeVar("T", int, float) SummaryFunction = Callable[[Sequence[T]], float] + class Sparkline(Generic[T]): """A sparkline representing a series of data. @@ -100,7 +101,13 @@ if __name__ == "__main__": def last(l: Sequence[T]) -> T: return l[-1] - funcs: Sequence[SummaryFunction[int]] = (min, max, last, statistics.median, statistics.mean) + funcs: Sequence[SummaryFunction[int]] = ( + min, + max, + last, + statistics.median, + statistics.mean, + ) nums = [10, 2, 30, 60, 45, 20, 7, 8, 9, 10, 50, 13, 10, 6, 5, 4, 3, 7, 20] console.print(f"data = {nums}\n") for f in funcs: diff --git a/src/textual/renderables/text_opacity.py b/src/textual/renderables/text_opacity.py index 9f6237879..a0ba1c222 100644 --- a/src/textual/renderables/text_opacity.py +++ b/src/textual/renderables/text_opacity.py @@ -63,8 +63,7 @@ class TextOpacity: _from_color = Style.from_color if opacity == 0: for text, style, control in cast( - Iterable[tuple[str, Style, object]], - segments + Iterable[tuple[str, Style, object]], segments ): assert style is not None invisible_style = _from_color(bgcolor=style.bgcolor) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index d0d7622f2..a96f40a16 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -628,7 +628,9 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): root = self.root - def add_node(path: list[TreeNode[TreeDataType]], node: TreeNode[TreeDataType], last: bool) -> None: + def add_node( + path: list[TreeNode[TreeDataType]], node: TreeNode[TreeDataType], last: bool + ) -> None: child_path = [*path, node] node._line = len(lines) add_line(TreeLine(child_path, last)) From b78ca2bad239f122ee1b8eedc9e322424c0110ea Mon Sep 17 00:00:00 2001 From: Nitzan Shaked Date: Tue, 3 Jan 2023 09:36:25 +0200 Subject: [PATCH 11/16] more PR fixes --- src/textual/geometry.py | 5 +++-- src/textual/renderables/text_opacity.py | 11 +++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 220959dfe..9b5c09f03 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -7,7 +7,6 @@ Functions and classes to manage terminal geometry (anything involving coordinate from __future__ import annotations from functools import lru_cache -import math from operator import attrgetter, itemgetter from typing import Any, Collection, NamedTuple, Tuple, TypeVar, Union, cast @@ -130,7 +129,7 @@ class Offset(NamedTuple): """ x1, y1 = self x2, y2 = other - distance = math.sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)) + distance: float = ((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)) ** 0.5 return distance @@ -218,6 +217,8 @@ class Size(NamedTuple): def __contains__(self, other: Any) -> bool: try: + x: int + y: int x, y = other except Exception: raise TypeError( diff --git a/src/textual/renderables/text_opacity.py b/src/textual/renderables/text_opacity.py index a0ba1c222..2382ebd7f 100644 --- a/src/textual/renderables/text_opacity.py +++ b/src/textual/renderables/text_opacity.py @@ -65,21 +65,20 @@ class TextOpacity: for text, style, control in cast( Iterable[tuple[str, Style, object]], segments ): - assert style is not None invisible_style = _from_color(bgcolor=style.bgcolor) yield _Segment(cell_len(text) * " ", invisible_style) else: for segment in segments: - text, maybe_style, control = segment - if not maybe_style: + text, style, control = cast(tuple[str, Style, object], segment) + if not style: yield segment continue - color = maybe_style.color - bgcolor = maybe_style.bgcolor + color = style.color + bgcolor = style.bgcolor if color and color.triplet and bgcolor and bgcolor.triplet: color_style = _get_blended_style_cached(bgcolor, color, opacity) - yield _Segment(text, maybe_style + color_style) + yield _Segment(text, style + color_style) else: yield segment From 6e9d302e150902f8dd4d8ffbab7645f2a5aea0f3 Mon Sep 17 00:00:00 2001 From: Nitzan Shaked Date: Thu, 5 Jan 2023 14:23:40 +0200 Subject: [PATCH 12/16] fix typing of tuple[] for py3.7 --- src/textual/renderables/text_opacity.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/textual/renderables/text_opacity.py b/src/textual/renderables/text_opacity.py index 2382ebd7f..fdbec1a16 100644 --- a/src/textual/renderables/text_opacity.py +++ b/src/textual/renderables/text_opacity.py @@ -1,5 +1,5 @@ import functools -from typing import Iterable, cast +from typing import Iterable, Tuple, cast from rich.cells import cell_len from rich.color import Color @@ -63,13 +63,16 @@ class TextOpacity: _from_color = Style.from_color if opacity == 0: for text, style, control in cast( - Iterable[tuple[str, Style, object]], segments + # use Tuple rather than tuple so Python 3.7 doesn't complain + Iterable[Tuple[str, Style, object]], + segments, ): invisible_style = _from_color(bgcolor=style.bgcolor) yield _Segment(cell_len(text) * " ", invisible_style) else: for segment in segments: - text, style, control = cast(tuple[str, Style, object], segment) + # use Tuple rather than tuple so Python 3.7 doesn't complain + text, style, control = cast(Tuple[str, Style, object], segment) if not style: yield segment continue From 18eae615cc233d2db8ca9fa8ad82dc0906134bbf Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 5 Jan 2023 14:26:45 +0000 Subject: [PATCH 13/16] Remove unnecessary return from label.setter Python is expressive, but it ain't that expressive. --- src/textual/widgets/_tree.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index f88dcf60c..c3bfd65ee 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -169,10 +169,8 @@ class TreeNode(Generic[TreeDataType]): return self._label @label.setter - def label(self, new_label: TextType) -> TextType: - """TextType: The label for the node.""" + def label(self, new_label: TextType) -> None: self.set_label(new_label) - return self.label def set_label(self, label: TextType) -> None: """Set a new label for the node. From fb9e803863ab468b7dfd6264068bf818fd9593cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 5 Jan 2023 16:22:51 +0000 Subject: [PATCH 14/16] Add default styles for scrollbar colors. --- src/textual/widget.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/textual/widget.py b/src/textual/widget.py index 389c3b996..65ff0bc0d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -190,8 +190,10 @@ class Widget(DOMNode): Widget{ scrollbar-background: $panel-darken-1; scrollbar-background-hover: $panel-darken-2; + scrollbar-background-active: $panel-darken-3; scrollbar-color: $primary-lighten-1; scrollbar-color-active: $warning-darken-1; + scrollbar-color-hover: $primary-lighten-1; scrollbar-corner-color: $panel-darken-1; scrollbar-size-vertical: 2; scrollbar-size-horizontal: 1; From ad70de5e87855a3a49f25589188b36ddc1ee5c15 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 5 Jan 2023 21:06:30 +0000 Subject: [PATCH 15/16] added question --- FAQ.md | 12 ++++++++++++ questions/compose-result.question.md | 14 ++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 questions/compose-result.question.md diff --git a/FAQ.md b/FAQ.md index 570d28464..1372d3263 100644 --- a/FAQ.md +++ b/FAQ.md @@ -1,6 +1,7 @@ # Frequently Asked Questions - [Does Textual support images?](#does-textual-support-images) +- [How can I fix ImportError cannot import name ComposeResult from textual.app ?](#how-can-i-fix-importerror-cannot-import-name-composeresult-from-textualapp-) - [How do I center a widget in a screen?](#how-do-i-center-a-widget-in-a-screen) - [How do I pass arguments to an app?](#how-do-i-pass-arguments-to-an-app) @@ -11,6 +12,17 @@ Textual doesn't have built in support for images yet, but it is on the [Roadmap] See also the [rich-pixels](https://github.com/darrenburns/rich-pixels) project for a Rich renderable for images that works with Textual. + +## How can I fix ImportError cannot import name ComposeResult from textual.app ? + +You likely have an older version of Textual. You can install the latest version by adding the `-U` switch which will force pip to upgrade. + +The following should do it: + +``` +pip install "textual[dev]" -U +``` + ## How do I center a widget in a screen? diff --git a/questions/compose-result.question.md b/questions/compose-result.question.md new file mode 100644 index 000000000..d148f256a --- /dev/null +++ b/questions/compose-result.question.md @@ -0,0 +1,14 @@ +--- +title: "How can I fix ImportError cannot import name ComposeResult from textual.app ?" +alt_titles: + - "Can't import ComposeResult" + - "Error about missing ComposeResult from textual.app" +--- + +You likely have an older version of Textual. You can install the latest version by adding the `-U` switch which will force pip to upgrade. + +The following should do it: + +``` +pip install "textual[dev]" -U +``` From 01e6ae43d00e0b4103e691d46173a7e06f235b35 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Fri, 6 Jan 2023 06:43:41 +0000 Subject: [PATCH 16/16] Sanitise issue titles before running suggest on them Applying https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable to #1472. --- .github/workflows/new_issue.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/new_issue.yml b/.github/workflows/new_issue.yml index 9b6665ec5..a4f561eed 100644 --- a/.github/workflows/new_issue.yml +++ b/.github/workflows/new_issue.yml @@ -14,7 +14,9 @@ jobs: - name: Install FAQtory run: pip install FAQtory - name: Run Suggest - run: faqtory suggest "${{ github.event.issue.title }}" > suggest.md + env: + TITLE: ${{ github.event.issue.title }} + run: faqtory suggest "$TITLE" > suggest.md - name: Read suggest.md id: suggest uses: juliangruber/read-file-action@v1