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