Merge branch 'main' into tree-node-parent-prop

This commit is contained in:
Will McGugan
2023-01-06 02:11:35 -08:00
committed by GitHub
17 changed files with 133 additions and 94 deletions

View File

@@ -14,7 +14,9 @@ jobs:
- name: Install FAQtory - name: Install FAQtory
run: pip install FAQtory run: pip install FAQtory
- name: Run Suggest - 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 - name: Read suggest.md
id: suggest id: suggest
uses: juliangruber/read-file-action@v1 uses: juliangruber/read-file-action@v1

View File

@@ -10,11 +10,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added ### Added
- Added `TreeNode.parent` -- a read-only property for accessing a node's parent https://github.com/Textualize/textual/issues/1397 - 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 ### Changed
- `MouseScrollUp` and `MouseScrollDown` now inherit from `MouseEvent` and have attached modifier keys. https://github.com/Textualize/textual/pull/1458 - `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 ## [0.9.1] - 2022-12-30
### Added ### Added

12
FAQ.md
View File

@@ -1,6 +1,7 @@
# Frequently Asked Questions # Frequently Asked Questions
- [Does Textual support images?](#does-textual-support-images) - [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 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) - [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. See also the [rich-pixels](https://github.com/darrenburns/rich-pixels) project for a Rich renderable for images that works with Textual.
<a name="how-can-i-fix-importerror-cannot-import-name-composeresult-from-textualapp-"></a>
## 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
```
<a name="how-do-i-center-a-widget-in-a-screen"></a> <a name="how-do-i-center-a-widget-in-a-screen"></a>
## How do I center a widget in a screen? ## How do I center a widget in a screen?

View File

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

View File

@@ -1,6 +1,7 @@
from __future__ import annotations 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 import rich.repr
@@ -13,7 +14,7 @@ class DuplicateIds(Exception):
@rich.repr.auto(angular=True) @rich.repr.auto(angular=True)
class NodeList(Sequence): class NodeList(Sequence["Widget"]):
""" """
A container for widgets that forms one level of hierarchy. A container for widgets that forms one level of hierarchy.
@@ -46,10 +47,10 @@ class NodeList(Sequence):
def __len__(self) -> int: def __len__(self) -> int:
return len(self._nodes) return len(self._nodes)
def __contains__(self, widget: Widget) -> bool: def __contains__(self, widget: object) -> bool:
return widget in self._nodes 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. """Return the index of the given widget.
Args: Args:
@@ -61,7 +62,7 @@ class NodeList(Sequence):
Raises: Raises:
ValueError: If the widget is not in the node list. 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: 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""" """Get the widget for the given widget_id, or None if there's no matches in this list"""

View File

@@ -4,8 +4,7 @@ from rich.console import ConsoleOptions, Console, RenderResult
from rich.traceback import Traceback from rich.traceback import Traceback
from ._help_renderables import HelpText from ._help_renderables import HelpText
from .tokenize import Token from .tokenizer import Token, TokenError
from .tokenizer import TokenError
class DeclarationError(Exception): class DeclarationError(Exception):
@@ -32,7 +31,7 @@ class StyleValueError(ValueError):
error is raised. 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) super().__init__(*args)
self.help_text = help_text self.help_text = help_text

View File

@@ -242,7 +242,7 @@ class Tokenizer:
while True: while True:
if line_no >= len(self.lines): if line_no >= len(self.lines):
raise EOFError( 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] line = self.lines[line_no]
match = expect.search(line, col_no) match = expect.search(line, col_no)

View File

@@ -40,8 +40,8 @@ async def _on_startup(app: Application) -> None:
def _run_devtools(verbose: bool, exclude: list[str] | None = None) -> None: def _run_devtools(verbose: bool, exclude: list[str] | None = None) -> None:
app = _make_devtools_aiohttp_app(verbose=verbose, exclude=exclude) app = _make_devtools_aiohttp_app(verbose=verbose, exclude=exclude)
def noop_print(_: str): def noop_print(_: str) -> None:
return None pass
try: try:
run_app( run_app(
@@ -77,7 +77,3 @@ def _make_devtools_aiohttp_app(
) )
return app return app
if __name__ == "__main__":
_run_devtools()

View File

@@ -129,7 +129,7 @@ class Offset(NamedTuple):
""" """
x1, y1 = self x1, y1 = self
x2, y2 = other 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 return distance
@@ -217,6 +217,8 @@ class Size(NamedTuple):
def __contains__(self, other: Any) -> bool: def __contains__(self, other: Any) -> bool:
try: try:
x: int
y: int
x, y = other x, y = other
except Exception: except Exception:
raise TypeError( raise TypeError(

View File

@@ -3,24 +3,23 @@ from __future__ import annotations
import rich.repr import rich.repr
import asyncio import asyncio
from typing import TYPE_CHECKING from typing import Generic
if TYPE_CHECKING: from .app import App, ReturnType
from .app import App
@rich.repr.auto(angular=True) @rich.repr.auto(angular=True)
class Pilot: class Pilot(Generic[ReturnType]):
"""Pilot object to drive an app.""" """Pilot object to drive an app."""
def __init__(self, app: App) -> None: def __init__(self, app: App[ReturnType]) -> None:
self._app = app self._app = app
def __rich_repr__(self) -> rich.repr.Result: def __rich_repr__(self) -> rich.repr.Result:
yield "app", self._app yield "app", self._app
@property @property
def app(self) -> App: def app(self) -> App[ReturnType]:
"""App: A reference to the application.""" """App: A reference to the application."""
return self._app return self._app
@@ -47,7 +46,7 @@ class Pilot:
"""Wait for any animation to complete.""" """Wait for any animation to complete."""
await self._app.animator.wait_for_idle() 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. """Exit the app with the given result.
Args: Args:

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import statistics import statistics
from typing import Sequence, Iterable, Callable, TypeVar from typing import Generic, Sequence, Iterable, Callable, TypeVar
from rich.color import Color from rich.color import Color
from rich.console import ConsoleOptions, Console, RenderResult from rich.console import ConsoleOptions, Console, RenderResult
@@ -12,8 +12,10 @@ from textual.renderables._blend_colors import blend_colors
T = TypeVar("T", int, float) T = TypeVar("T", int, float)
SummaryFunction = Callable[[Sequence[T]], float]
class Sparkline:
class Sparkline(Generic[T]):
"""A sparkline representing a series of data. """A sparkline representing a series of data.
Args: Args:
@@ -33,16 +35,16 @@ class Sparkline:
width: int | None, width: int | None,
min_color: Color = Color.from_rgb(0, 255, 0), min_color: Color = Color.from_rgb(0, 255, 0),
max_color: Color = Color.from_rgb(255, 0, 0), max_color: Color = Color.from_rgb(255, 0, 0),
summary_function: Callable[[list[T]], float] = max, summary_function: SummaryFunction[T] = max,
) -> None: ) -> None:
self.data = data self.data: Sequence[T] = data
self.width = width self.width = width
self.min_color = Style.from_color(min_color) self.min_color = Style.from_color(min_color)
self.max_color = Style.from_color(max_color) self.max_color = Style.from_color(max_color)
self.summary_function = summary_function self.summary_function: SummaryFunction[T] = summary_function
@classmethod @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 """Partition ``data`` into ``num_buckets`` buckets. For example, the data
[1, 2, 3, 4] partitioned into 2 buckets is [[1, 2], [3, 4]]. [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) minimum, maximum = min(self.data), max(self.data)
extent = maximum - minimum or 1 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 bars_rendered = 0
step = len(buckets) / width step = len(buckets) / width
summary_function = self.summary_function summary_function = self.summary_function
min_color, max_color = self.min_color.color, self.max_color.color 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: while bars_rendered < width:
partition = buckets[int(bucket_index)] partition = buckets[int(bucket_index)]
partition_summary = summary_function(partition) partition_summary = summary_function(partition)
@@ -94,10 +98,16 @@ class Sparkline:
if __name__ == "__main__": if __name__ == "__main__":
console = Console() console = Console()
def last(l): def last(l: Sequence[T]) -> T:
return l[-1] 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] 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") console.print(f"data = {nums}\n")
for f in funcs: for f in funcs:

View File

@@ -1,5 +1,5 @@
import functools import functools
from typing import Iterable from typing import Iterable, Tuple, cast
from rich.cells import cell_len from rich.cells import cell_len
from rich.color import Color from rich.color import Color
@@ -62,12 +62,17 @@ class TextOpacity:
_Segment = Segment _Segment = Segment
_from_color = Style.from_color _from_color = Style.from_color
if opacity == 0: 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) invisible_style = _from_color(bgcolor=style.bgcolor)
yield _Segment(cell_len(text) * " ", invisible_style) yield _Segment(cell_len(text) * " ", invisible_style)
else: else:
for segment in segments: 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: if not style:
yield segment yield segment
continue continue
@@ -85,40 +90,3 @@ class TextOpacity:
) -> RenderResult: ) -> RenderResult:
segments = console.render(self.renderable, options) segments = console.render(self.renderable, options)
return self.process_segments(segments, self.opacity) 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)

View File

@@ -225,14 +225,15 @@ class ScrollBar(Widget):
def render(self) -> RenderableType: def render(self) -> RenderableType:
styles = self.parent.styles styles = self.parent.styles
background = ( if self.grabbed:
styles.scrollbar_background_hover background = styles.scrollbar_background_active
if self.mouse_over color = styles.scrollbar_color_active
else styles.scrollbar_background elif self.mouse_over:
) background = styles.scrollbar_background_hover
color = ( color = styles.scrollbar_color_hover
styles.scrollbar_color_active if self.grabbed else styles.scrollbar_color else:
) background = styles.scrollbar_background
color = styles.scrollbar_color
color = background + color color = background + color
scrollbar_style = Style.from_color(color.rich_color, background.rich_color) scrollbar_style = Style.from_color(color.rich_color, background.rich_color)
return ScrollBarRender( return ScrollBarRender(

View File

@@ -109,8 +109,8 @@ class Strip:
def __len__(self) -> int: def __len__(self) -> int:
return len(self._segments) return len(self._segments)
def __eq__(self, strip: Strip) -> bool: def __eq__(self, strip: object) -> bool:
return ( return isinstance(strip, Strip) and (
self._segments == strip._segments and self.cell_length == strip.cell_length self._segments == strip._segments and self.cell_length == strip.cell_length
) )

View File

@@ -190,8 +190,10 @@ class Widget(DOMNode):
Widget{ Widget{
scrollbar-background: $panel-darken-1; scrollbar-background: $panel-darken-1;
scrollbar-background-hover: $panel-darken-2; scrollbar-background-hover: $panel-darken-2;
scrollbar-background-active: $panel-darken-3;
scrollbar-color: $primary-lighten-1; scrollbar-color: $primary-lighten-1;
scrollbar-color-active: $warning-darken-1; scrollbar-color-active: $warning-darken-1;
scrollbar-color-hover: $primary-lighten-1;
scrollbar-corner-color: $panel-darken-1; scrollbar-corner-color: $panel-darken-1;
scrollbar-size-vertical: 2; scrollbar-size-vertical: 2;
scrollbar-size-horizontal: 1; scrollbar-size-horizontal: 1;

View File

@@ -31,12 +31,12 @@ TOGGLE_STYLE = Style.from_meta({"toggle": True})
@dataclass @dataclass
class _TreeLine: class _TreeLine(Generic[TreeDataType]):
path: list[TreeNode] path: list[TreeNode[TreeDataType]]
last: bool last: bool
@property @property
def node(self) -> TreeNode: def node(self) -> TreeNode[TreeDataType]:
"""TreeNode: The node associated with this line.""" """TreeNode: The node associated with this line."""
return self.path[-1] return self.path[-1]
@@ -71,10 +71,10 @@ class TreeNode(Generic[TreeDataType]):
self._tree = tree self._tree = tree
self._parent = parent self._parent = parent
self._id = id self._id = id
self._label = label self._label = tree.process_label(label)
self.data = data self.data = data
self._expanded = expanded self._expanded = expanded
self._children: list[TreeNode] = [] self._children: list[TreeNode[TreeDataType]] = []
self._hover_ = False self._hover_ = False
self._selected_ = False self._selected_ = False
@@ -168,6 +168,15 @@ class TreeNode(Generic[TreeDataType]):
self._updates += 1 self._updates += 1
self._tree._invalidate() 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: def set_label(self, label: TextType) -> None:
"""Set a new label for the node. """Set a new label for the node.
@@ -471,11 +480,11 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
self._updates += 1 self._updates += 1
self.refresh() 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. """Move the cursor to the given node, or reset cursor.
Args: 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 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)) 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. """Scroll to the given node.
Args: Args:
node (TreeNode): Node to scroll in to view. node (TreeNode[TreeDataType]): Node to scroll in to view.
""" """
line = node._line line = node._line
if line != -1: if line != -1:
@@ -633,7 +642,9 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
root = self.root 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] child_path = [*path, node]
node._line = len(lines) node._line = len(lines)
add_line(TreeLine(child_path, last)) add_line(TreeLine(child_path, last))

View File

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