mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' of github.com:Textualize/textual into cursor-blink-bug
This commit is contained in:
4
.github/workflows/new_issue.yml
vendored
4
.github/workflows/new_issue.yml
vendored
@@ -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
|
||||
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -5,6 +5,21 @@ 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 `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
|
||||
@@ -23,8 +38,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
|
||||
|
||||
12
FAQ.md
12
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.
|
||||
|
||||
<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>
|
||||
## How do I center a widget in a screen?
|
||||
|
||||
|
||||
14
questions/compose-result.question.md
Normal file
14
questions/compose-result.question.md
Normal 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
|
||||
```
|
||||
@@ -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"""
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import platform
|
||||
import sys
|
||||
|
||||
from asyncio import sleep as asyncio_sleep, get_running_loop
|
||||
from asyncio import get_running_loop
|
||||
from asyncio import sleep as asyncio_sleep
|
||||
from time import monotonic, perf_counter
|
||||
|
||||
PLATFORM = platform.system()
|
||||
@@ -15,11 +14,31 @@ else:
|
||||
|
||||
|
||||
if WINDOWS:
|
||||
# sleep on windows as a resolution of 15ms
|
||||
# Python3.11 is somewhat better, but this home-grown version beats it
|
||||
# Deduced from practical experiments
|
||||
|
||||
from ._win_sleep import sleep as win_sleep
|
||||
|
||||
async def sleep(sleep_for: float) -> None:
|
||||
await get_running_loop().run_in_executor(None, win_sleep, sleep_for)
|
||||
async def sleep(secs: float) -> None:
|
||||
"""Sleep for a given number of seconds.
|
||||
|
||||
Args:
|
||||
secs (float): Number of seconds to sleep for.
|
||||
"""
|
||||
await get_running_loop().run_in_executor(None, win_sleep, secs)
|
||||
|
||||
else:
|
||||
sleep = asyncio_sleep
|
||||
|
||||
async def sleep(secs: float) -> None:
|
||||
"""Sleep for a given number of seconds.
|
||||
|
||||
Args:
|
||||
secs (float): Number of seconds to sleep for.
|
||||
"""
|
||||
# From practical experiments, asyncio.sleep sleeps for at least half a millisecond too much
|
||||
# Presumably there is overhead asyncio itself which accounts for this
|
||||
# We will reduce the sleep to compensate, and also don't sleep at all for less than half a millisecond
|
||||
sleep_for = secs - 0.0005
|
||||
if sleep_for > 0:
|
||||
await asyncio_sleep(sleep_for)
|
||||
|
||||
@@ -1,55 +1,70 @@
|
||||
import ctypes
|
||||
from ctypes.wintypes import LARGE_INTEGER
|
||||
"""
|
||||
A version of `time.sleep` that is more accurate than the standard library (even on Python 3.11).
|
||||
|
||||
This should only be imported on Windows.
|
||||
|
||||
"""
|
||||
|
||||
from time import sleep as time_sleep
|
||||
|
||||
|
||||
__all__ = ["sleep"]
|
||||
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
|
||||
INFINITE = 0xFFFFFFFF
|
||||
WAIT_FAILED = 0xFFFFFFFF
|
||||
CREATE_WAITABLE_TIMER_HIGH_RESOLUTION = 0x00000002
|
||||
TIMER_ALL_ACCESS = 0x1F0003
|
||||
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes.wintypes import LARGE_INTEGER
|
||||
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
except Exception:
|
||||
sleep = time_sleep
|
||||
else:
|
||||
|
||||
def sleep(sleep_for: float) -> None:
|
||||
"""A replacement sleep for Windows.
|
||||
def sleep(secs: float) -> None:
|
||||
"""A replacement sleep for Windows.
|
||||
|
||||
Python 3.11 added a more accurate sleep. This may be used on < Python 3.11
|
||||
Note that unlike `time.sleep` this *may* sleep for slightly less than the
|
||||
specified time. This is generally not an issue for Textual's use case.
|
||||
|
||||
Args:
|
||||
sleep_for (float): Seconds to sleep for.
|
||||
"""
|
||||
Args:
|
||||
secs (float): Seconds to sleep for.
|
||||
"""
|
||||
|
||||
# Subtract a millisecond to account for overhead
|
||||
sleep_for = max(0, sleep_for - 0.001)
|
||||
if sleep_for < 0.0005:
|
||||
# Less than 0.5ms and its not worth doing the sleep
|
||||
return
|
||||
# Subtract a millisecond to account for overhead
|
||||
sleep_for = max(0, secs - 0.001)
|
||||
if sleep_for < 0.0005:
|
||||
# Less than 0.5ms and its not worth doing the sleep
|
||||
return
|
||||
|
||||
handle = kernel32.CreateWaitableTimerExW(
|
||||
None,
|
||||
None,
|
||||
CREATE_WAITABLE_TIMER_HIGH_RESOLUTION,
|
||||
0x1F0003,
|
||||
)
|
||||
if not handle:
|
||||
time_sleep(sleep_for)
|
||||
return
|
||||
handle = kernel32.CreateWaitableTimerExW(
|
||||
None,
|
||||
None,
|
||||
CREATE_WAITABLE_TIMER_HIGH_RESOLUTION,
|
||||
TIMER_ALL_ACCESS,
|
||||
)
|
||||
if not handle:
|
||||
time_sleep(sleep_for)
|
||||
return
|
||||
|
||||
if not kernel32.SetWaitableTimer(
|
||||
handle,
|
||||
ctypes.byref(LARGE_INTEGER(int(sleep_for * -10_000_000))),
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
):
|
||||
kernel32.CloseHandle(handle)
|
||||
time_sleep(sleep_for)
|
||||
return
|
||||
try:
|
||||
if not kernel32.SetWaitableTimer(
|
||||
handle,
|
||||
ctypes.byref(LARGE_INTEGER(int(sleep_for * -10_000_000))),
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
):
|
||||
kernel32.CloseHandle(handle)
|
||||
time_sleep(sleep_for)
|
||||
return
|
||||
|
||||
if kernel32.WaitForSingleObject(handle, INFINITE) == WAIT_FAILED:
|
||||
time_sleep(sleep_for)
|
||||
kernel32.CloseHandle(handle)
|
||||
if kernel32.WaitForSingleObject(handle, INFINITE) == WAIT_FAILED:
|
||||
time_sleep(sleep_for)
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
@@ -54,36 +54,40 @@ class XTermParser(Parser[events.Event]):
|
||||
if sgr_match:
|
||||
_buttons, _x, _y, state = sgr_match.groups()
|
||||
buttons = int(_buttons)
|
||||
button = (buttons + 1) & 3
|
||||
x = int(_x) - 1
|
||||
y = int(_y) - 1
|
||||
delta_x = x - self.last_x
|
||||
delta_y = y - self.last_y
|
||||
self.last_x = x
|
||||
self.last_y = y
|
||||
event: events.Event
|
||||
event_class: type[events.MouseEvent]
|
||||
|
||||
if buttons & 64:
|
||||
event = (
|
||||
events.MouseScrollUp if button == 1 else events.MouseScrollDown
|
||||
)(sender, x, y)
|
||||
else:
|
||||
event = (
|
||||
events.MouseMove
|
||||
if buttons & 32
|
||||
else (events.MouseDown if state == "M" else events.MouseUp)
|
||||
)(
|
||||
sender,
|
||||
x,
|
||||
y,
|
||||
delta_x,
|
||||
delta_y,
|
||||
button,
|
||||
bool(buttons & 4),
|
||||
bool(buttons & 8),
|
||||
bool(buttons & 16),
|
||||
screen_x=x,
|
||||
screen_y=y,
|
||||
event_class = (
|
||||
events.MouseScrollDown if buttons & 1 else events.MouseScrollUp
|
||||
)
|
||||
button = 0
|
||||
else:
|
||||
if buttons & 32:
|
||||
event_class = events.MouseMove
|
||||
else:
|
||||
event_class = events.MouseDown if state == "M" else events.MouseUp
|
||||
|
||||
button = (buttons + 1) & 3
|
||||
|
||||
event = event_class(
|
||||
sender,
|
||||
x,
|
||||
y,
|
||||
delta_x,
|
||||
delta_y,
|
||||
button,
|
||||
bool(buttons & 4),
|
||||
bool(buttons & 8),
|
||||
bool(buttons & 16),
|
||||
screen_x=x,
|
||||
screen_y=y,
|
||||
)
|
||||
return event
|
||||
return None
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -419,22 +419,14 @@ class MouseUp(MouseEvent, bubble=True, verbose=True):
|
||||
pass
|
||||
|
||||
|
||||
class MouseScrollDown(InputEvent, bubble=True, verbose=True):
|
||||
__slots__ = ["x", "y"]
|
||||
|
||||
def __init__(self, sender: MessageTarget, x: int, y: int) -> None:
|
||||
super().__init__(sender)
|
||||
self.x = x
|
||||
self.y = y
|
||||
@rich.repr.auto
|
||||
class MouseScrollDown(MouseEvent, bubble=True):
|
||||
pass
|
||||
|
||||
|
||||
class MouseScrollUp(InputEvent, bubble=True, verbose=True):
|
||||
__slots__ = ["x", "y"]
|
||||
|
||||
def __init__(self, sender: MessageTarget, x: int, y: int) -> None:
|
||||
super().__init__(sender)
|
||||
self.x = x
|
||||
self.y = y
|
||||
@rich.repr.auto
|
||||
class MouseScrollUp(MouseEvent, bubble=True):
|
||||
pass
|
||||
|
||||
|
||||
class Click(MouseEvent, bubble=True):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -123,8 +123,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
|
||||
)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -121,6 +121,11 @@ class TreeNode(Generic[TreeDataType]):
|
||||
"""NodeID: Get the node ID."""
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def parent(self) -> TreeNode[TreeDataType] | None:
|
||||
"""TreeNode[TreeDataType] | None: The parent of the node."""
|
||||
return self._parent
|
||||
|
||||
@property
|
||||
def is_expanded(self) -> bool:
|
||||
"""bool: Check if the node is expanded."""
|
||||
@@ -163,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.
|
||||
|
||||
@@ -466,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
|
||||
|
||||
@@ -570,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:
|
||||
@@ -628,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))
|
||||
|
||||
@@ -236,14 +236,14 @@ def test_mouse_move(parser, sequence, shift, meta, button):
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sequence",
|
||||
"sequence, shift, meta",
|
||||
[
|
||||
"\x1b[<64;18;25M",
|
||||
"\x1b[<68;18;25M",
|
||||
"\x1b[<72;18;25M",
|
||||
("\x1b[<64;18;25M", False, False),
|
||||
("\x1b[<68;18;25M", True, False),
|
||||
("\x1b[<72;18;25M", False, True),
|
||||
],
|
||||
)
|
||||
def test_mouse_scroll_up(parser, sequence):
|
||||
def test_mouse_scroll_up(parser, sequence, shift, meta):
|
||||
"""Scrolling the mouse with and without modifiers held down.
|
||||
We don't currently capture modifier keys in scroll events.
|
||||
"""
|
||||
@@ -256,17 +256,19 @@ def test_mouse_scroll_up(parser, sequence):
|
||||
assert isinstance(event, MouseScrollUp)
|
||||
assert event.x == 17
|
||||
assert event.y == 24
|
||||
assert event.shift is shift
|
||||
assert event.meta is meta
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sequence",
|
||||
"sequence, shift, meta",
|
||||
[
|
||||
"\x1b[<65;18;25M",
|
||||
"\x1b[<69;18;25M",
|
||||
"\x1b[<73;18;25M",
|
||||
("\x1b[<65;18;25M", False, False),
|
||||
("\x1b[<69;18;25M", True, False),
|
||||
("\x1b[<73;18;25M", False, True),
|
||||
],
|
||||
)
|
||||
def test_mouse_scroll_down(parser, sequence):
|
||||
def test_mouse_scroll_down(parser, sequence, shift, meta):
|
||||
events = list(parser.feed(sequence))
|
||||
|
||||
assert len(events) == 1
|
||||
@@ -276,6 +278,8 @@ def test_mouse_scroll_down(parser, sequence):
|
||||
assert isinstance(event, MouseScrollDown)
|
||||
assert event.x == 17
|
||||
assert event.y == 24
|
||||
assert event.shift is shift
|
||||
assert event.meta is meta
|
||||
|
||||
|
||||
def test_mouse_event_detected_but_info_not_parsed(parser):
|
||||
|
||||
17
tests/tree/test_tree_node_label.py
Normal file
17
tests/tree/test_tree_node_label.py
Normal 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")
|
||||
10
tests/tree/test_tree_node_parent.py
Normal file
10
tests/tree/test_tree_node_parent.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from textual.widgets import TreeNode, Tree
|
||||
|
||||
def test_tree_node_parent() -> None:
|
||||
"""It should be possible to access a TreeNode's parent."""
|
||||
tree = Tree[None]("Anakin")
|
||||
child = tree.root.add("Leia")
|
||||
grandchild = child.add("Ben")
|
||||
assert tree.root.parent is None
|
||||
assert grandchild.parent == child
|
||||
assert child.parent == tree.root
|
||||
Reference in New Issue
Block a user