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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 80b6147b9..f852ea6d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added `TreeNode.parent` -- a read-only property for accessing a node's parent https://github.com/Textualize/textual/issues/1397 +- Added public `TreeNode` label access via `TreeNode.label` https://github.com/Textualize/textual/issues/1396 ### Changed - `MouseScrollUp` and `MouseScrollDown` now inherit from `MouseEvent` and have attached modifier keys. https://github.com/Textualize/textual/pull/1458 +### 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 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 +``` diff --git a/src/textual/_node_list.py b/src/textual/_node_list.py index 7032b0cc7..6e7a4fba1 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..82e3fabab 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( @@ -77,7 +77,3 @@ def _make_devtools_aiohttp_app( ) return app - - -if __name__ == "__main__": - _run_devtools() diff --git a/src/textual/geometry.py b/src/textual/geometry.py index e29790e30..9b5c09f03 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -129,7 +129,7 @@ class Offset(NamedTuple): """ x1, y1 = self x2, y2 = other - distance = ((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)) ** 0.5 + distance: float = ((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)) ** 0.5 return distance @@ -217,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/pilot.py b/src/textual/pilot.py index 5d0427905..ed118a2dc 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -3,24 +3,23 @@ from __future__ import annotations import rich.repr import asyncio -from typing import TYPE_CHECKING +from typing import 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 +46,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..755c12440 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,10 @@ 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 +35,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 +75,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 + bucket_index = 0.0 bars_rendered = 0 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 +98,16 @@ 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..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 +from typing import Iterable, Tuple, cast from rich.cells import cell_len from rich.color import Color @@ -62,12 +62,17 @@ class TextOpacity: _Segment = Segment _from_color = Style.from_color if opacity == 0: - for text, style, control in segments: + for text, style, control in cast( + # 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 = 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 @@ -85,40 +90,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, end, step): - 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.value = value - sleep(0.05) 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( 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/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; diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index f6638ca9c..39b63a037 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] @@ -71,10 +71,10 @@ 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] = [] + self._children: list[TreeNode[TreeDataType]] = [] self._hover_ = False self._selected_ = False @@ -168,6 +168,15 @@ 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) -> None: + self.set_label(new_label) + def set_label(self, label: TextType) -> None: """Set a new label for the node. @@ -471,11 +480,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 @@ -575,11 +584,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: @@ -633,7 +642,9 @@ 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)) diff --git a/tests/tree/test_tree_node_label.py b/tests/tree/test_tree_node_label.py new file mode 100644 index 000000000..55af8088e --- /dev/null +++ b/tests/tree/test_tree_node_label.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")