mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge pull request #560 from Textualize/scroll-view
Scroll view and DataTable Widget
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
# Examples
|
||||
# Textual Examples
|
||||
|
||||
Run any of these examples to demonstrate a Textual features.
|
||||
This directory contains example Textual applications.
|
||||
|
||||
The example code will generate a log file called "textual.log". Tail this file to gain insight in to what Textual is doing.
|
||||
To run them, navigate to the examples directory and enter `python` followed buy the name of the Python file.
|
||||
|
||||
```
|
||||
tail -f textual
|
||||
cd textual/examples
|
||||
python pride.py
|
||||
```
|
||||
|
||||
21
examples/pride.py
Normal file
21
examples/pride.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from textual.app import App
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class PrideApp(App):
|
||||
"""Displays a pride flag."""
|
||||
|
||||
COLORS = ["red", "orange", "yellow", "green", "blue", "purple"]
|
||||
|
||||
def compose(self):
|
||||
for color in self.COLORS:
|
||||
stripe = Static()
|
||||
stripe.styles.height = "1fr"
|
||||
stripe.styles.background = color
|
||||
yield stripe
|
||||
|
||||
|
||||
app = PrideApp()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
11
old examples/README.md
Normal file
11
old examples/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
These examples probably don't rub. We will be back-porting them to the examples dir
|
||||
|
||||
# Examples
|
||||
|
||||
Run any of these examples to demonstrate a Textual features.
|
||||
|
||||
The example code will generate a log file called "textual.log". Tail this file to gain insight in to what Textual is doing.
|
||||
|
||||
```
|
||||
tail -f textual
|
||||
```
|
||||
24
poetry.lock
generated
24
poetry.lock
generated
@@ -113,11 +113,11 @@ python-versions = ">=3.6.1"
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "2.0.12"
|
||||
version = "2.1.0"
|
||||
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.5.0"
|
||||
python-versions = ">=3.6.0"
|
||||
|
||||
[package.extras]
|
||||
unicode_backport = ["unicodedata2"]
|
||||
@@ -136,7 +136,7 @@ importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
description = "Cross-platform colored terminal text."
|
||||
category = "main"
|
||||
optional = false
|
||||
@@ -345,7 +345,7 @@ mkdocs = ">=1.1"
|
||||
|
||||
[[package]]
|
||||
name = "mkdocs-material"
|
||||
version = "8.3.5"
|
||||
version = "8.3.6"
|
||||
description = "Documentation that simply works"
|
||||
category = "dev"
|
||||
optional = false
|
||||
@@ -775,12 +775,12 @@ docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"]
|
||||
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
|
||||
|
||||
[extras]
|
||||
dev = ["aiohttp", "msgpack"]
|
||||
dev = ["aiohttp", "click", "msgpack"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.7"
|
||||
content-hash = "fa967da13a215abd45b601b171943b3dfb94ecbeffa8078d9e53f22e249f513b"
|
||||
content-hash = "8ce8d66466dad1b984673595ebd0cc7bc0d28c7a672269e9b5620c242d87d9ad"
|
||||
|
||||
[metadata.files]
|
||||
aiohttp = [
|
||||
@@ -911,16 +911,16 @@ cfgv = [
|
||||
{file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"},
|
||||
]
|
||||
charset-normalizer = [
|
||||
{file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"},
|
||||
{file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"},
|
||||
{file = "charset-normalizer-2.1.0.tar.gz", hash = "sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413"},
|
||||
{file = "charset_normalizer-2.1.0-py3-none-any.whl", hash = "sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5"},
|
||||
]
|
||||
click = [
|
||||
{file = "click-8.1.2-py3-none-any.whl", hash = "sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e"},
|
||||
{file = "click-8.1.2.tar.gz", hash = "sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72"},
|
||||
]
|
||||
colorama = [
|
||||
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
|
||||
{file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
|
||||
{file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"},
|
||||
{file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"},
|
||||
]
|
||||
commonmark = [
|
||||
{file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"},
|
||||
@@ -1125,8 +1125,8 @@ mkdocs-autorefs = [
|
||||
{file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"},
|
||||
]
|
||||
mkdocs-material = [
|
||||
{file = "mkdocs-material-8.3.5.tar.gz", hash = "sha256:0d7ae82b28fa57a2ad9a4eeb5fd01704cb8fe963eb20c56a68afdd735e52b432"},
|
||||
{file = "mkdocs_material-8.3.5-py2.py3-none-any.whl", hash = "sha256:9190aef365bd6ed43b27f47d1de956d9e67b61f4b9edb444563337ef717cacd3"},
|
||||
{file = "mkdocs-material-8.3.6.tar.gz", hash = "sha256:be8f95c0dfb927339b55b2cc066423dc0b381be9828ff74a5b02df979a859b66"},
|
||||
{file = "mkdocs_material-8.3.6-py2.py3-none-any.whl", hash = "sha256:01f3fbab055751b3b75a64b538e86b9ce0c6a0f8d43620f6287dfa16534443e5"},
|
||||
]
|
||||
mkdocs-material-extensions = [
|
||||
{file = "mkdocs-material-extensions-1.0.3.tar.gz", hash = "sha256:bfd24dfdef7b41c312ede42648f9eb83476ea168ec163b613f9abd12bbfddba2"},
|
||||
|
||||
@@ -76,8 +76,9 @@ App > Screen {
|
||||
|
||||
|
||||
Tweet {
|
||||
height: 12;
|
||||
width: 80;
|
||||
height:12;
|
||||
width: 100%;
|
||||
|
||||
margin: 1 3;
|
||||
background: $panel;
|
||||
color: $text-panel;
|
||||
@@ -93,21 +94,21 @@ Tweet {
|
||||
|
||||
|
||||
.scrollable {
|
||||
width: 80;
|
||||
|
||||
overflow-y: scroll;
|
||||
max-width:80;
|
||||
margin: 1 2;
|
||||
height: 20;
|
||||
align-horizontal: center;
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
.code {
|
||||
|
||||
height: 34;
|
||||
width: 100%;
|
||||
|
||||
|
||||
height: auto;
|
||||
|
||||
}
|
||||
|
||||
|
||||
TweetHeader {
|
||||
height:1;
|
||||
background: $accent;
|
||||
@@ -138,8 +139,8 @@ Tweet.scroll-horizontal TweetBody {
|
||||
|
||||
/* padding: 1 0 0 0 ; */
|
||||
|
||||
transition: background 200ms in_out_cubic, color 300ms in_out_cubic;
|
||||
|
||||
transition: background 400ms in_out_cubic, color 400ms in_out_cubic;
|
||||
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
@@ -187,7 +188,7 @@ OptionItem:hover {
|
||||
}
|
||||
|
||||
Error {
|
||||
width: 80;
|
||||
width: 100%;
|
||||
height:3;
|
||||
background: $error;
|
||||
color: $text-error;
|
||||
@@ -200,7 +201,7 @@ Error {
|
||||
}
|
||||
|
||||
Warning {
|
||||
width: 80;
|
||||
width: 100%;
|
||||
height:3;
|
||||
background: $warning;
|
||||
color: $text-warning-fade-1;
|
||||
@@ -212,8 +213,8 @@ Warning {
|
||||
}
|
||||
|
||||
Success {
|
||||
width: 80;
|
||||
height:3;
|
||||
width: 100%;
|
||||
height:3;
|
||||
box-sizing: border-box;
|
||||
background: $success-lighten-3;
|
||||
color: $text-success-lighten-3-fade-1;
|
||||
|
||||
@@ -61,7 +61,7 @@ class TweetHeader(Widget):
|
||||
|
||||
|
||||
class TweetBody(Widget):
|
||||
short_lorem = Reactive[bool](False)
|
||||
short_lorem = Reactive(False)
|
||||
|
||||
def render(self) -> Text:
|
||||
return lorem_short_text if self.short_lorem else lorem_long_text
|
||||
@@ -120,6 +120,11 @@ class BasicApp(App, css_path="basic.css"):
|
||||
Warning(),
|
||||
Tweet(TweetBody(), classes="scroll-horizontal"),
|
||||
Success(),
|
||||
Tweet(TweetBody(), classes="scroll-horizontal"),
|
||||
Tweet(TweetBody(), classes="scroll-horizontal"),
|
||||
Tweet(TweetBody(), classes="scroll-horizontal"),
|
||||
Tweet(TweetBody(), classes="scroll-horizontal"),
|
||||
Tweet(TweetBody(), classes="scroll-horizontal"),
|
||||
),
|
||||
footer=Widget(),
|
||||
sidebar=Widget(
|
||||
@@ -149,9 +154,8 @@ class BasicApp(App, css_path="basic.css"):
|
||||
|
||||
def key_t(self):
|
||||
# Pressing "t" toggles the content of the TweetBody widget, from a long "Lorem ipsum..." to a shorter one.
|
||||
tweet_body = self.screen.query("TweetBody").first()
|
||||
tweet_body = self.query("TweetBody").first()
|
||||
tweet_body.short_lorem = not tweet_body.short_lorem
|
||||
tweet_body.refresh(layout=True)
|
||||
|
||||
def key_v(self):
|
||||
self.get_child(id="content").scroll_to_widget(self.scroll_to_target)
|
||||
@@ -164,3 +168,19 @@ app = BasicApp()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
|
||||
from textual.geometry import Region
|
||||
from textual.color import Color
|
||||
|
||||
print(Region.intersection.cache_info())
|
||||
print(Region.overlaps.cache_info())
|
||||
print(Region.union.cache_info())
|
||||
print(Region.split_vertical.cache_info())
|
||||
print(Region.__contains__.cache_info())
|
||||
from textual.css.scalar import Scalar
|
||||
|
||||
print(Scalar.resolve_dimension.cache_info())
|
||||
|
||||
from rich.style import Style
|
||||
|
||||
print(Style._add.cache_info())
|
||||
|
||||
76
sandbox/table.py
Normal file
76
sandbox/table.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import DataTable
|
||||
|
||||
from rich.syntax import Syntax
|
||||
from rich.table import Table
|
||||
|
||||
CODE = '''\
|
||||
def loop_first_last(values: Iterable[T]) -> Iterable[tuple[bool, bool, T]]:
|
||||
"""Iterate and generate a tuple with a flag for first and last value."""
|
||||
iter_values = iter(values)
|
||||
try:
|
||||
previous_value = next(iter_values)
|
||||
except StopIteration:
|
||||
return
|
||||
first = True
|
||||
for value in iter_values:
|
||||
yield first, False, previous_value
|
||||
first = False
|
||||
previous_value = value
|
||||
yield first, True, previous_value'''
|
||||
|
||||
test_table = Table(title="Star Wars Movies")
|
||||
|
||||
test_table.add_column("Released", style="cyan", no_wrap=True)
|
||||
test_table.add_column("Title", style="magenta")
|
||||
test_table.add_column("Box Office", justify="right", style="green")
|
||||
|
||||
test_table.add_row("Dec 20, 2019", "Star Wars: The Rise of Skywalker", "$952,110,690")
|
||||
test_table.add_row("May 25, 2018", "Solo: A Star Wars Story", "$393,151,347")
|
||||
test_table.add_row(
|
||||
"Dec 15, 2017", "Star Wars Ep. V111: The Last Jedi", "$1,332,539,889"
|
||||
)
|
||||
test_table.add_row("Dec 16, 2016", "Rogue One: A Star Wars Story", "$1,332,439,889")
|
||||
|
||||
|
||||
class TableApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
table = self.table = DataTable(id="data")
|
||||
table.add_column("Foo", width=20)
|
||||
table.add_column("Bar", width=60)
|
||||
table.add_column("Baz", width=20)
|
||||
table.add_column("Foo", width=16)
|
||||
table.add_column("Bar", width=16)
|
||||
table.add_column("Baz", width=16)
|
||||
|
||||
for n in range(200):
|
||||
height = 1
|
||||
row = [f"row [b]{n}[/b] col [i]{c}[/i]" for c in range(6)]
|
||||
if n == 10:
|
||||
row[1] = Syntax(CODE, "python", line_numbers=True, indent_guides=True)
|
||||
height = 13
|
||||
|
||||
if n == 30:
|
||||
row[1] = test_table
|
||||
height = 13
|
||||
table.add_row(*row, height=height)
|
||||
yield table
|
||||
|
||||
def on_mount(self):
|
||||
self.bind("d", "toggle_dark")
|
||||
self.bind("z", "toggle_zebra")
|
||||
self.bind("x", "exit")
|
||||
|
||||
def action_toggle_dark(self) -> None:
|
||||
self.app.dark = not self.app.dark
|
||||
|
||||
def action_toggle_zebra(self) -> None:
|
||||
self.table.zebra_stripes = not self.table.zebra_stripes
|
||||
|
||||
def action_exit(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
app = TableApp()
|
||||
if __name__ == "__main__":
|
||||
print(app.run())
|
||||
148
src/textual/_cache.py
Normal file
148
src/textual/_cache.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
|
||||
A LRU (Least Recently Used) Cache container.
|
||||
|
||||
Use when you want to cache slow operations and new keys are a good predictor
|
||||
of subsequent keys.
|
||||
|
||||
Note that stdlib's @lru_cache is implemented in C and faster! It's best to use
|
||||
@lru_cache where you are caching things that are fairly quick and called many times.
|
||||
Use LRUCache where you want increased flexibility and you are caching slow operations
|
||||
where the overhead of the cache is a small fraction of the total processing time.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from threading import Lock
|
||||
from typing import Dict, Generic, KeysView, TypeVar, overload
|
||||
|
||||
CacheKey = TypeVar("CacheKey")
|
||||
CacheValue = TypeVar("CacheValue")
|
||||
DefaultValue = TypeVar("DefaultValue")
|
||||
|
||||
|
||||
class LRUCache(Generic[CacheKey, CacheValue]):
|
||||
"""
|
||||
A dictionary-like container with a maximum size.
|
||||
|
||||
If an additional item is added when the LRUCache is full, the least
|
||||
recently used key is discarded to make room for the new item.
|
||||
|
||||
The implementation is similar to functools.lru_cache, which uses a (doubly)
|
||||
linked list to keep track of the most recently used items.
|
||||
|
||||
Each entry is stored as [PREV, NEXT, KEY, VALUE] where PREV is a reference
|
||||
to the previous entry, and NEXT is a reference to the next value.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, maxsize: int) -> None:
|
||||
self._maxsize = maxsize
|
||||
self._cache: Dict[CacheKey, list[object]] = {}
|
||||
self._full = False
|
||||
self._head: list[object] = []
|
||||
self._lock = Lock()
|
||||
super().__init__()
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._cache)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._cache)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the cache."""
|
||||
with self._lock:
|
||||
self._cache.clear()
|
||||
self._full = False
|
||||
self._head = []
|
||||
|
||||
def keys(self) -> KeysView[CacheKey]:
|
||||
"""Get cache keys."""
|
||||
# Mostly for tests
|
||||
return self._cache.keys()
|
||||
|
||||
def set(self, key: CacheKey, value: CacheValue) -> None:
|
||||
"""Set a value.
|
||||
|
||||
Args:
|
||||
key (CacheKey): Key.
|
||||
value (CacheValue): Value.
|
||||
"""
|
||||
with self._lock:
|
||||
link = self._cache.get(key)
|
||||
if link is None:
|
||||
head = self._head
|
||||
if not head:
|
||||
# First link references itself
|
||||
self._head[:] = [head, head, key, value]
|
||||
else:
|
||||
# Add a new root to the beginning
|
||||
self._head = [head[0], head, key, value]
|
||||
# Updated references on previous root
|
||||
head[0][1] = self._head # type: ignore[index]
|
||||
head[0] = self._head
|
||||
self._cache[key] = self._head
|
||||
|
||||
if self._full or len(self._cache) > self._maxsize:
|
||||
# Cache is full, we need to evict the oldest one
|
||||
self._full = True
|
||||
head = self._head
|
||||
last = head[0]
|
||||
last[0][1] = head # type: ignore[index]
|
||||
head[0] = last[0] # type: ignore[index]
|
||||
del self._cache[last[2]] # type: ignore[index]
|
||||
|
||||
__setitem__ = set
|
||||
|
||||
@overload
|
||||
def get(self, key: CacheKey) -> CacheValue | None:
|
||||
...
|
||||
|
||||
@overload
|
||||
def get(self, key: CacheKey, default: DefaultValue) -> CacheValue | DefaultValue:
|
||||
...
|
||||
|
||||
def get(
|
||||
self, key: CacheKey, default: DefaultValue | None = None
|
||||
) -> CacheValue | DefaultValue | None:
|
||||
"""Get a value from the cache, or return a default if the key is not present.
|
||||
|
||||
Args:
|
||||
key (CacheKey): Key
|
||||
default (Optional[DefaultValue], optional): Default to return if key is not present. Defaults to None.
|
||||
|
||||
Returns:
|
||||
Union[CacheValue, Optional[DefaultValue]]: Either the value or a default.
|
||||
"""
|
||||
link = self._cache.get(key)
|
||||
if link is None:
|
||||
return default
|
||||
with self._lock:
|
||||
if link is not self._head:
|
||||
# Remove link from list
|
||||
link[0][1] = link[1] # type: ignore[index]
|
||||
link[1][0] = link[0] # type: ignore[index]
|
||||
head = self._head
|
||||
# Move link to head of list
|
||||
link[0] = head[0]
|
||||
link[1] = head
|
||||
self._head = head[0][1] = head[0] = link # type: ignore[index]
|
||||
|
||||
return link[3] # type: ignore[return-value]
|
||||
|
||||
def __getitem__(self, key: CacheKey) -> CacheValue:
|
||||
link = self._cache[key]
|
||||
with self._lock:
|
||||
if link is not self._head:
|
||||
link[0][1] = link[1] # type: ignore[index]
|
||||
link[1][0] = link[0] # type: ignore[index]
|
||||
head = self._head
|
||||
link[0] = head[0]
|
||||
link[1] = head
|
||||
self._head = head[0][1] = head[0] = link # type: ignore[index]
|
||||
return link[3] # type: ignore[return-value]
|
||||
|
||||
def __contains__(self, key: CacheKey) -> bool:
|
||||
return key in self._cache
|
||||
@@ -13,6 +13,7 @@ without having to render the entire screen.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from itertools import chain
|
||||
from operator import attrgetter, itemgetter
|
||||
import sys
|
||||
from typing import Callable, cast, Iterator, Iterable, NamedTuple, TYPE_CHECKING
|
||||
@@ -26,6 +27,7 @@ from rich.style import Style
|
||||
from . import errors
|
||||
from .geometry import Region, Offset, Size
|
||||
|
||||
from ._profile import timer
|
||||
from ._loop import loop_last
|
||||
from ._segment_tools import line_crop
|
||||
from ._types import Lines
|
||||
@@ -67,7 +69,7 @@ class MapGeometry(NamedTuple):
|
||||
CompositorMap: TypeAlias = "dict[Widget, MapGeometry]"
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
@rich.repr.auto(angular=True)
|
||||
class LayoutUpdate:
|
||||
"""A renderable containing the result of a render for a given region."""
|
||||
|
||||
@@ -88,43 +90,45 @@ class LayoutUpdate:
|
||||
yield new_line
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
x, y, width, height = self.region
|
||||
yield "x", x
|
||||
yield "y", y
|
||||
yield "width", width
|
||||
yield "height", height
|
||||
yield self.region
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class SpansUpdate:
|
||||
@rich.repr.auto(angular=True)
|
||||
class ChopsUpdate:
|
||||
"""A renderable that applies updated spans to the screen."""
|
||||
|
||||
def __init__(
|
||||
self, spans: list[tuple[int, int, list[Segment]]], crop_y: int
|
||||
self, chops: list[dict[int, list[Segment] | None]], crop: Region
|
||||
) -> None:
|
||||
"""Apply spans, which consist of a tuple of (LINE, OFFSET, SEGMENTS)
|
||||
"""A renderable which updates chops (fragments of lines).
|
||||
|
||||
Args:
|
||||
spans (list[tuple[int, int, list[Segment]]]): A list of spans.
|
||||
crop_y (int): The y extent of the crop region
|
||||
chops (list[dict[int, list[Segment] | None]]): A mapping of offsets to list of segments, per line.
|
||||
crop (Region): Region to restrict update to.
|
||||
"""
|
||||
self.spans = spans
|
||||
self.last_y = crop_y - 1
|
||||
self.chops = chops
|
||||
self.crop = crop
|
||||
|
||||
def __rich_console__(
|
||||
self, console: Console, options: ConsoleOptions
|
||||
) -> RenderResult:
|
||||
move_to = Control.move_to
|
||||
new_line = Segment.line()
|
||||
last_y = self.last_y
|
||||
for y, x, segments in self.spans:
|
||||
yield move_to(x, y)
|
||||
yield from segments
|
||||
chops = self.chops
|
||||
crop = self.crop
|
||||
last_y = crop.y_max - 1
|
||||
x1, x2 = crop.x_extents
|
||||
for y in crop.y_range:
|
||||
line = chops[y]
|
||||
for x, segments in line.items():
|
||||
if segments is not None and x2 > x >= x1:
|
||||
yield move_to(x, y)
|
||||
yield from segments
|
||||
if y != last_y:
|
||||
yield new_line
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield [(y, x, "...") for y, x, _segments in self.spans]
|
||||
yield None, self.crop
|
||||
|
||||
|
||||
@rich.repr.auto(angular=True)
|
||||
@@ -315,10 +319,10 @@ class Compositor:
|
||||
container_region = region.shrink(widget.styles.gutter)
|
||||
container_size = container_region.size
|
||||
|
||||
# Containers (widgets with layout) require adding children
|
||||
if widget.is_container:
|
||||
# Widgets with scrollbars (containers or scroll view) require additional processing
|
||||
if widget.is_scrollable:
|
||||
# The region that contains the content (container region minus scrollbars)
|
||||
child_region = widget._arrange_container(container_region)
|
||||
child_region = widget._get_scrollable_region(container_region)
|
||||
|
||||
# Adjust the clip region accordingly
|
||||
sub_clip = clip.intersection(child_region)
|
||||
@@ -326,27 +330,28 @@ class Compositor:
|
||||
# The region covered by children relative to parent widget
|
||||
total_region = child_region.reset_origin
|
||||
|
||||
# Arrange the layout
|
||||
placements, arranged_widgets = widget._arrange(child_region.size)
|
||||
widgets.update(arranged_widgets)
|
||||
placements = sorted(placements, key=get_order)
|
||||
if widget.is_container:
|
||||
# Arrange the layout
|
||||
placements, arranged_widgets = widget._arrange(child_region.size)
|
||||
widgets.update(arranged_widgets)
|
||||
placements = sorted(placements, key=get_order)
|
||||
|
||||
# An offset added to all placements
|
||||
placement_offset = (
|
||||
container_region.origin + layout_offset - widget.scroll_offset
|
||||
)
|
||||
# An offset added to all placements
|
||||
placement_offset = (
|
||||
container_region.origin + layout_offset - widget.scroll_offset
|
||||
)
|
||||
|
||||
# Add all the widgets
|
||||
for sub_region, sub_widget, z in placements:
|
||||
# Combine regions with children to calculate the "virtual size"
|
||||
total_region = total_region.union(sub_region)
|
||||
if sub_widget is not None:
|
||||
add_widget(
|
||||
sub_widget,
|
||||
sub_region + placement_offset,
|
||||
order + (z,),
|
||||
sub_clip,
|
||||
)
|
||||
# Add all the widgets
|
||||
for sub_region, sub_widget, z in placements:
|
||||
# Combine regions with children to calculate the "virtual size"
|
||||
total_region = total_region.union(sub_region)
|
||||
if sub_widget is not None:
|
||||
add_widget(
|
||||
sub_widget,
|
||||
sub_region + placement_offset,
|
||||
order + (z,),
|
||||
sub_clip,
|
||||
)
|
||||
|
||||
# Add any scrollbars
|
||||
for chrome_widget, chrome_region in widget._arrange_scrollbars(
|
||||
@@ -360,14 +365,23 @@ class Compositor:
|
||||
container_size,
|
||||
)
|
||||
|
||||
# Add the container widget, which will render a background
|
||||
map[widget] = MapGeometry(
|
||||
region + layout_offset,
|
||||
order,
|
||||
clip,
|
||||
total_region.size,
|
||||
container_size,
|
||||
)
|
||||
if widget.is_container:
|
||||
# Add the container widget, which will render a background
|
||||
map[widget] = MapGeometry(
|
||||
region + layout_offset,
|
||||
order,
|
||||
clip,
|
||||
total_region.size,
|
||||
container_size,
|
||||
)
|
||||
else:
|
||||
map[widget] = MapGeometry(
|
||||
child_region + layout_offset,
|
||||
order,
|
||||
clip,
|
||||
child_region.size,
|
||||
container_size,
|
||||
)
|
||||
|
||||
else:
|
||||
# Add the widget to the map
|
||||
@@ -431,7 +445,9 @@ class Compositor:
|
||||
|
||||
x -= region.x
|
||||
y -= region.y
|
||||
lines = widget.get_render_lines(y, y + 1)
|
||||
|
||||
lines = widget.render_lines(Region(0, y, region.width, 1))
|
||||
|
||||
if not lines:
|
||||
return Style.null()
|
||||
end = 0
|
||||
@@ -530,31 +546,34 @@ class Compositor:
|
||||
if not region:
|
||||
continue
|
||||
if region in clip:
|
||||
yield region, clip, widget.get_render_lines()
|
||||
yield region, clip, widget.render_lines(
|
||||
Region(0, 0, region.width, region.height)
|
||||
)
|
||||
elif overlaps(clip, region):
|
||||
new_x, new_y, new_width, new_height = intersection(region, clip)
|
||||
clipped_region = intersection(region, clip)
|
||||
if not clipped_region:
|
||||
continue
|
||||
new_x, new_y, new_width, new_height = clipped_region
|
||||
delta_x = new_x - region.x
|
||||
delta_y = new_y - region.y
|
||||
crop_x = delta_x + new_width
|
||||
lines = widget.get_render_lines(delta_y, delta_y + new_height)
|
||||
lines = [line_crop(line, delta_x, crop_x) for line in lines]
|
||||
lines = widget.render_lines(
|
||||
Region(delta_x, delta_y, new_width, new_height)
|
||||
)
|
||||
yield region, clip, lines
|
||||
|
||||
@classmethod
|
||||
def _assemble_chops(
|
||||
cls, chops: list[dict[int, list[Segment] | None]]
|
||||
) -> list[list[Segment]]:
|
||||
|
||||
# Pretty sure we don't need to sort the bucket items
|
||||
"""Combine chops in to lines."""
|
||||
from_iterable = chain.from_iterable
|
||||
segment_lines: list[list[Segment]] = [
|
||||
sum(
|
||||
[line for line in bucket.values() if line is not None],
|
||||
[],
|
||||
)
|
||||
list(from_iterable(line for line in bucket.values() if line is not None))
|
||||
for bucket in chops
|
||||
]
|
||||
return segment_lines
|
||||
|
||||
@timer("render")
|
||||
def render(self, full: bool = False) -> RenderableType | None:
|
||||
"""Render a layout.
|
||||
|
||||
@@ -579,7 +598,7 @@ class Compositor:
|
||||
is_rendered_line = lambda y: True
|
||||
elif update_regions:
|
||||
# Create a crop regions that surrounds all updates
|
||||
crop = Region.from_union(list(update_regions)).intersection(screen_region)
|
||||
crop = Region.from_union(update_regions).intersection(screen_region)
|
||||
spans = list(self._regions_to_spans(update_regions))
|
||||
is_rendered_line = {y for y, _, _ in spans}.__contains__
|
||||
else:
|
||||
@@ -589,6 +608,7 @@ class Compositor:
|
||||
|
||||
# Maps each cut on to a list of segments
|
||||
cuts = self.cuts
|
||||
|
||||
# dict.fromkeys is a callable which takes a list of ints returns a dict which maps ints on to a list of Segments or None.
|
||||
fromkeys = cast(
|
||||
"Callable[[list[int]], dict[int, list[Segment] | None]]", dict.fromkeys
|
||||
@@ -597,6 +617,8 @@ class Compositor:
|
||||
chops: list[dict[int, list[Segment] | None]]
|
||||
chops = [fromkeys(cut_set) for cut_set in cuts]
|
||||
|
||||
cut_segments: Iterable[list[Segment]]
|
||||
|
||||
# Go through all the renders in reverse order and fill buckets with no render
|
||||
renders = self._get_renders(crop)
|
||||
intersection = Region.intersection
|
||||
@@ -607,19 +629,23 @@ class Compositor:
|
||||
for y, line in zip(render_region.y_range, lines):
|
||||
if not is_rendered_line(y):
|
||||
continue
|
||||
|
||||
chops_line = chops[y]
|
||||
if all(chops_line):
|
||||
continue
|
||||
|
||||
first_cut, last_cut = render_region.x_extents
|
||||
final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)]
|
||||
|
||||
if len(final_cuts) == 2:
|
||||
if len(final_cuts) <= 2:
|
||||
# Two cuts, which means the entire line
|
||||
cut_segments = [line]
|
||||
else:
|
||||
render_x = render_region.x
|
||||
relative_cuts = [cut - render_x for cut in final_cuts]
|
||||
_, *cut_segments = divide(line, relative_cuts)
|
||||
relative_cuts = [cut - render_x for cut in final_cuts[1:]]
|
||||
cut_segments = divide(line, relative_cuts)
|
||||
|
||||
# Since we are painting front to back, the first segments for a cut "wins"
|
||||
chops_line = chops[y]
|
||||
for cut, segments in zip(final_cuts, cut_segments):
|
||||
if chops_line[cut] is None:
|
||||
chops_line[cut] = segments
|
||||
@@ -628,13 +654,7 @@ class Compositor:
|
||||
render_lines = self._assemble_chops(chops)
|
||||
return LayoutUpdate(render_lines, screen_region)
|
||||
else:
|
||||
crop_y, crop_y2 = crop.y_extents
|
||||
render_lines = self._assemble_chops(chops[crop_y:crop_y2])
|
||||
render_spans = [
|
||||
(y, x1, line_crop(render_lines[y - crop_y], x1, x2))
|
||||
for y, x1, x2 in spans
|
||||
]
|
||||
return SpansUpdate(render_spans, crop_y2)
|
||||
return ChopsUpdate(chops, crop)
|
||||
|
||||
def __rich_console__(
|
||||
self, console: Console, options: ConsoleOptions
|
||||
|
||||
@@ -5,10 +5,10 @@ from typing import Iterator, overload, TYPE_CHECKING
|
||||
import rich.repr
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .dom import DOMNode
|
||||
from .widget import Widget
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
@rich.repr.auto(angular=True)
|
||||
class NodeList:
|
||||
"""
|
||||
A container for widgets that forms one level of hierarchy.
|
||||
@@ -19,7 +19,8 @@ class NodeList:
|
||||
|
||||
def __init__(self) -> None:
|
||||
# The nodes in the list
|
||||
self._nodes: list[DOMNode] = []
|
||||
self._nodes: list[Widget] = []
|
||||
self._nodes_set: set[Widget] = set()
|
||||
# Increments when list is updated (used for caching)
|
||||
self._updates = 0
|
||||
|
||||
@@ -35,29 +36,31 @@ class NodeList:
|
||||
def __len__(self) -> int:
|
||||
return len(self._nodes)
|
||||
|
||||
def __contains__(self, widget: DOMNode) -> bool:
|
||||
def __contains__(self, widget: Widget) -> bool:
|
||||
return widget in self._nodes
|
||||
|
||||
def _append(self, widget: DOMNode) -> None:
|
||||
if widget not in self._nodes:
|
||||
def _append(self, widget: Widget) -> None:
|
||||
if widget not in self._nodes_set:
|
||||
self._nodes.append(widget)
|
||||
self._nodes_set.add(widget)
|
||||
self._updates += 1
|
||||
|
||||
def _clear(self) -> None:
|
||||
del self._nodes[:]
|
||||
self._updates += 1
|
||||
if self._nodes:
|
||||
self._nodes.clear()
|
||||
self._nodes_set.clear()
|
||||
self._updates += 1
|
||||
|
||||
def __iter__(self) -> Iterator[DOMNode]:
|
||||
def __iter__(self) -> Iterator[Widget]:
|
||||
return iter(self._nodes)
|
||||
|
||||
@overload
|
||||
def __getitem__(self, index: int) -> DOMNode:
|
||||
def __getitem__(self, index: int) -> Widget:
|
||||
...
|
||||
|
||||
@overload
|
||||
def __getitem__(self, index: slice) -> list[DOMNode]:
|
||||
def __getitem__(self, index: slice) -> list[Widget]:
|
||||
...
|
||||
|
||||
def __getitem__(self, index: int | slice) -> DOMNode | list[DOMNode]:
|
||||
assert self._nodes is not None
|
||||
def __getitem__(self, index: int | slice) -> Widget | list[Widget]:
|
||||
return self._nodes[index]
|
||||
|
||||
@@ -7,14 +7,16 @@ from __future__ import annotations
|
||||
from rich.segment import Segment
|
||||
|
||||
|
||||
def line_crop(segments: list[Segment], start: int, end: int) -> list[Segment]:
|
||||
def line_crop(
|
||||
segments: list[Segment], start: int, end: int, total: int
|
||||
) -> list[Segment]:
|
||||
"""Crops a list of segments between two cell offsets.
|
||||
|
||||
Args:
|
||||
segments (list[Segment]): A list of Segments for a line.
|
||||
start (int): Start offset
|
||||
end (int): End offset
|
||||
|
||||
end (int): End offset (exclusive)
|
||||
total (int): Total cell length of segments.
|
||||
Returns:
|
||||
list[Segment]: A new shorter list of segments
|
||||
"""
|
||||
@@ -35,6 +37,13 @@ def line_crop(segments: list[Segment], start: int, end: int) -> list[Segment]:
|
||||
else:
|
||||
return []
|
||||
|
||||
if end >= total:
|
||||
# The end crop is the end of the segments, so we can collect all remaining segments
|
||||
if segment:
|
||||
add_segment(segment)
|
||||
output_segments.extend(iter_segments)
|
||||
return output_segments
|
||||
|
||||
pos = start
|
||||
while segment is not None:
|
||||
end_pos = pos + segment.cell_length
|
||||
|
||||
@@ -111,7 +111,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
CSS = """
|
||||
App {
|
||||
background: $surface;
|
||||
color: $text-surface;
|
||||
color: $text-surface;
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -149,7 +149,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
self.console = Console(
|
||||
file=(open(os.devnull, "wt") if self.is_headless else sys.__stdout__),
|
||||
markup=True,
|
||||
markup=False,
|
||||
highlight=False,
|
||||
emoji=False,
|
||||
)
|
||||
@@ -194,7 +194,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self.design = DEFAULT_COLORS
|
||||
|
||||
self.stylesheet = Stylesheet(variables=self.get_css_variables())
|
||||
self._require_styles_update = False
|
||||
self._require_stylesheet_update = False
|
||||
self.css_path = css_path or self.CSS_PATH
|
||||
|
||||
self.registry: set[MessagePump] = set()
|
||||
@@ -584,7 +584,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
Should be called whenever CSS classes / pseudo classes change.
|
||||
|
||||
"""
|
||||
self._require_styles_update = True
|
||||
self._require_stylesheet_update = True
|
||||
self.check_idle()
|
||||
|
||||
def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None:
|
||||
@@ -817,9 +817,9 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
async def on_idle(self) -> None:
|
||||
"""Perform actions when there are no messages in the queue."""
|
||||
if self._require_styles_update:
|
||||
await self.post_message(messages.StylesUpdated(self))
|
||||
self._require_styles_update = False
|
||||
if self._require_stylesheet_update:
|
||||
self._require_stylesheet_update = False
|
||||
self.stylesheet.update(self, animate=True)
|
||||
|
||||
def _register_child(self, parent: DOMNode, child: DOMNode) -> bool:
|
||||
if child not in self.registry:
|
||||
@@ -1135,9 +1135,6 @@ class App(Generic[ReturnType], DOMNode):
|
||||
async def action_toggle_class(self, selector: str, class_name: str) -> None:
|
||||
self.screen.query(selector).toggle_class(class_name)
|
||||
|
||||
async def handle_styles_updated(self, message: messages.StylesUpdated) -> None:
|
||||
self.stylesheet.update(self, animate=True)
|
||||
|
||||
def handle_terminal_supports_synchronized_output(
|
||||
self, message: messages.TerminalSupportsSynchronizedOutput
|
||||
) -> None:
|
||||
|
||||
@@ -235,7 +235,6 @@ class Color(NamedTuple):
|
||||
r, g, b, _ = self
|
||||
return Color(r, g, b, alpha)
|
||||
|
||||
@lru_cache(maxsize=2048)
|
||||
def blend(self, destination: Color, factor: float) -> Color:
|
||||
"""Generate a new color between two colors.
|
||||
|
||||
@@ -352,6 +351,7 @@ class Color(NamedTuple):
|
||||
raise AssertionError("Can't get here if RE_COLOR matches")
|
||||
return color
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def darken(self, amount: float) -> Color:
|
||||
"""Darken the color by a given amount.
|
||||
|
||||
@@ -376,6 +376,7 @@ class Color(NamedTuple):
|
||||
"""
|
||||
return self.darken(-amount).clamped
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def get_contrast_text(self, alpha=0.95) -> Color:
|
||||
"""Get a light or dark color that best contrasts this color, for use with text.
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ def property_invalid_value_help_text(
|
||||
HelpText: Renderable for displaying the help text for this property
|
||||
"""
|
||||
property_name = _contextualize_property_name(property_name, context)
|
||||
summary = f"Invalid CSS property [i]{property_name}[/]"
|
||||
summary = f"Invalid CSS property {property_name!r}"
|
||||
if suggested_property_name:
|
||||
suggested_property_name = _contextualize_property_name(
|
||||
suggested_property_name, context
|
||||
|
||||
@@ -19,11 +19,12 @@ def match(selector_sets: Iterable[SelectorSet], node: DOMNode) -> bool:
|
||||
bool: True if the node matches the selector, otherwise False.
|
||||
"""
|
||||
return any(
|
||||
_check_selectors(selector_set.selectors, node) for selector_set in selector_sets
|
||||
_check_selectors(selector_set.selectors, node.css_path_nodes)
|
||||
for selector_set in selector_sets
|
||||
)
|
||||
|
||||
|
||||
def _check_selectors(selectors: list[Selector], node: DOMNode) -> bool:
|
||||
def _check_selectors(selectors: list[Selector], css_path_nodes: list[DOMNode]) -> bool:
|
||||
"""Match a list of selectors against a node.
|
||||
|
||||
Args:
|
||||
@@ -36,7 +37,7 @@ def _check_selectors(selectors: list[Selector], node: DOMNode) -> bool:
|
||||
|
||||
DESCENDENT = CombinatorType.DESCENDENT
|
||||
|
||||
css_path_nodes = node.css_path_nodes
|
||||
node = css_path_nodes[-1]
|
||||
path_count = len(css_path_nodes)
|
||||
selector_count = len(selectors)
|
||||
|
||||
|
||||
@@ -80,6 +80,14 @@ class Selector:
|
||||
self.specificity = (specificity1, specificity2 + 1, specificity3)
|
||||
|
||||
def check(self, node: DOMNode) -> bool:
|
||||
"""Check if a given node matches the selector.
|
||||
|
||||
Args:
|
||||
node (DOMNode): A DOM node.
|
||||
|
||||
Returns:
|
||||
bool: True if the selector matches, otherwise False.
|
||||
"""
|
||||
return self._checks[self.type](node)
|
||||
|
||||
def _check_universal(self, node: DOMNode) -> bool:
|
||||
|
||||
@@ -223,6 +223,14 @@ class Scalar(NamedTuple):
|
||||
|
||||
@classmethod
|
||||
def from_number(cls, value: float) -> Scalar:
|
||||
"""Create a scalar with cells unit.
|
||||
|
||||
Args:
|
||||
value (float): A number of cells.
|
||||
|
||||
Returns:
|
||||
Scalar: New Scalar.
|
||||
"""
|
||||
return cls(float(value), Unit.CELLS, Unit.WIDTH)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -266,6 +266,14 @@ class StylesBase(ABC):
|
||||
spacing = self.padding + self.border.spacing + self.margin
|
||||
return spacing
|
||||
|
||||
@property
|
||||
def auto_dimensions(self) -> bool:
|
||||
"""Check if width or height are set to 'auto'."""
|
||||
has_rule = self.has_rule
|
||||
return (has_rule("width") and self.width.is_auto) or (
|
||||
has_rule("height") and self.height.is_auto
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def has_rule(self, rule: str) -> bool:
|
||||
"""Check if a rule is set on this Styles object.
|
||||
|
||||
@@ -20,11 +20,12 @@ from .errors import StylesheetError
|
||||
from .match import _check_selectors
|
||||
from .model import RuleSet
|
||||
from .parse import parse
|
||||
from .styles import RulesMap
|
||||
from .styles import RulesMap, Styles
|
||||
from .tokenize import tokenize_values, Token
|
||||
from .tokenizer import TokenizeError
|
||||
from .types import Specificity3, Specificity4
|
||||
from ..dom import DOMNode
|
||||
from .. import messages
|
||||
|
||||
|
||||
class StylesheetParseError(StylesheetError):
|
||||
@@ -258,7 +259,7 @@ class Stylesheet:
|
||||
@classmethod
|
||||
def _check_rule(cls, rule: RuleSet, node: DOMNode) -> Iterable[Specificity3]:
|
||||
for selector_set in rule.selector_set:
|
||||
if _check_selectors(selector_set.selectors, node):
|
||||
if _check_selectors(selector_set.selectors, node.css_path_nodes):
|
||||
yield selector_set.specificity
|
||||
|
||||
def apply(self, node: DOMNode, animate: bool = False) -> None:
|
||||
@@ -283,10 +284,6 @@ class Stylesheet:
|
||||
|
||||
_check_rule = self._check_rule
|
||||
|
||||
# Collect default node CSS rules
|
||||
for key, default_specificity, value in node._default_rules:
|
||||
rule_attributes[key].append((default_specificity, value))
|
||||
|
||||
# Collect the rules defined in the stylesheet
|
||||
for rule in reversed(self.rules):
|
||||
for specificity in _check_rule(rule, node):
|
||||
@@ -306,8 +303,13 @@ class Stylesheet:
|
||||
)
|
||||
|
||||
self.replace_rules(node, node_rules, animate=animate)
|
||||
if isinstance(node, Widget):
|
||||
node._refresh_scrollbars()
|
||||
|
||||
node.component_styles.clear()
|
||||
for component in node.COMPONENT_CLASSES:
|
||||
virtual_node = DOMNode(classes=component)
|
||||
virtual_node.set_parent(node)
|
||||
self.apply(virtual_node, animate=False)
|
||||
node.component_styles[component] = virtual_node.styles
|
||||
|
||||
@classmethod
|
||||
def replace_rules(
|
||||
@@ -331,8 +333,7 @@ class Stylesheet:
|
||||
current_render_rules = styles.get_render_rules()
|
||||
|
||||
# Calculate replacement rules (defaults + new rules)
|
||||
new_styles = node._default_styles.copy()
|
||||
new_styles.merge_rules(rules)
|
||||
new_styles = Styles(node, rules)
|
||||
|
||||
if new_styles == base_styles:
|
||||
# Nothing to change, return early
|
||||
@@ -376,6 +377,8 @@ class Stylesheet:
|
||||
for key in modified_rule_keys:
|
||||
setattr(base_styles, key, get_rule(key))
|
||||
|
||||
node.post_message_no_wait(messages.StylesUpdated(sender=node))
|
||||
|
||||
def update(self, root: DOMNode, animate: bool = False) -> None:
|
||||
"""Update a node and its children."""
|
||||
apply = self.apply
|
||||
|
||||
@@ -55,6 +55,8 @@ class ColorSystem:
|
||||
"primary",
|
||||
"secondary",
|
||||
"background",
|
||||
"primary-background",
|
||||
"secondary-background",
|
||||
"surface",
|
||||
"panel",
|
||||
"warning",
|
||||
|
||||
@@ -176,9 +176,9 @@ class ClientHandler:
|
||||
message_time = time()
|
||||
if (
|
||||
last_message_time is not None
|
||||
and message_time - last_message_time > 1
|
||||
and message_time - last_message_time > 0.5
|
||||
):
|
||||
# Print a rule if it has been longer than a second since the last message
|
||||
# Print a rule if it has been longer than half a second since the last message
|
||||
self.service.console.rule()
|
||||
self.service.console.print(
|
||||
DevConsoleLog(
|
||||
|
||||
@@ -23,6 +23,7 @@ from .message_pump import MessagePump
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .app import App
|
||||
from .css.styles import StylesBase
|
||||
from .css.query import DOMQuery
|
||||
from .screen import Screen
|
||||
|
||||
@@ -42,6 +43,9 @@ class DOMNode(MessagePump):
|
||||
# Custom CSS
|
||||
CSS: ClassVar[str] = ""
|
||||
|
||||
# Virtual DOM nodes
|
||||
COMPONENT_CLASSES: ClassVar[set[str]] = set()
|
||||
|
||||
# True if this node inherits the CSS from the base class.
|
||||
_inherit_css: ClassVar[bool] = True
|
||||
# List of names of base class (lower cased) that inherit CSS
|
||||
@@ -61,8 +65,9 @@ class DOMNode(MessagePump):
|
||||
self._css_styles: Styles = Styles(self)
|
||||
self._inline_styles: Styles = Styles(self)
|
||||
self.styles = RenderStyles(self, self._css_styles, self._inline_styles)
|
||||
self._default_styles = Styles()
|
||||
self._default_rules = self._default_styles.extract_rules((0, 0, 0))
|
||||
# A mapping of class names to Styles set in COMPONENT_CLASSES
|
||||
self.component_styles: dict[str, StylesBase] = {}
|
||||
|
||||
super().__init__()
|
||||
|
||||
def __init_subclass__(cls, inherit_css: bool = True) -> None:
|
||||
@@ -355,7 +360,14 @@ class DOMNode(MessagePump):
|
||||
style = Style()
|
||||
for node in reversed(self.ancestors):
|
||||
style += node.styles.text_style
|
||||
return style
|
||||
|
||||
@property
|
||||
def rich_style(self) -> Style:
|
||||
"""Get a Rich Style object for this DOMNode."""
|
||||
(_, _), (background, color) = self.colors
|
||||
style = Style.from_color(color.rich_color, background.rich_color)
|
||||
style += self.text_style
|
||||
return style
|
||||
|
||||
@property
|
||||
@@ -380,7 +392,6 @@ class DOMNode(MessagePump):
|
||||
@property
|
||||
def ancestors(self) -> list[DOMNode]:
|
||||
"""Get a list of Nodes by tracing ancestors all the way back to App."""
|
||||
|
||||
nodes: list[DOMNode] = [self]
|
||||
add_node = nodes.append
|
||||
node = self
|
||||
@@ -420,9 +431,6 @@ class DOMNode(MessagePump):
|
||||
node.set_dirty()
|
||||
node._layout_required = True
|
||||
|
||||
def on_style_change(self) -> None:
|
||||
pass
|
||||
|
||||
def add_child(self, node: DOMNode) -> None:
|
||||
"""Add a new child node.
|
||||
|
||||
|
||||
@@ -87,7 +87,6 @@ class LinuxDriver(Driver):
|
||||
width, height = terminal_size
|
||||
textual_size = Size(width, height)
|
||||
event = events.Resize(self._target, textual_size, textual_size)
|
||||
self.console.size = terminal_size
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._target.post_message(event),
|
||||
loop=loop,
|
||||
|
||||
@@ -6,9 +6,18 @@ Functions and classes to manage terminal geometry (anything involving coordinate
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast, NamedTuple, Sequence, Tuple, Union, TypeVar
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from typing import Any, Collection, NamedTuple, Tuple, TypeVar, Union, cast
|
||||
|
||||
SpacingDimensions = Union[int, Tuple[int], Tuple[int, int], Tuple[int, int, int, int]]
|
||||
if sys.version_info >= (3, 10):
|
||||
from typing import TypeAlias
|
||||
else: # pragma: no cover
|
||||
from typing_extensions import TypeAlias
|
||||
|
||||
SpacingDimensions: TypeAlias = Union[
|
||||
int, Tuple[int], Tuple[int, int], Tuple[int, int, int, int]
|
||||
]
|
||||
|
||||
T = TypeVar("T", int, float)
|
||||
|
||||
@@ -182,11 +191,11 @@ class Region(NamedTuple):
|
||||
height: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_union(cls, regions: Sequence[Region]) -> Region:
|
||||
def from_union(cls, regions: Collection[Region]) -> Region:
|
||||
"""Create a Region from the union of other regions.
|
||||
|
||||
Args:
|
||||
regions (Iterable[Region]): One or more regions.
|
||||
regions (Collection[Region]): One or more regions.
|
||||
|
||||
Returns:
|
||||
Region: A Region that encloses all other regions.
|
||||
@@ -240,7 +249,7 @@ class Region(NamedTuple):
|
||||
The end value is non inclusive.
|
||||
|
||||
Returns:
|
||||
tuple[int, int]: [description]
|
||||
tuple[int, int]: Pair of x coordinates (row numbers).
|
||||
"""
|
||||
return (self.x, self.x + self.width)
|
||||
|
||||
@@ -251,7 +260,7 @@ class Region(NamedTuple):
|
||||
The end value is non inclusive.
|
||||
|
||||
Returns:
|
||||
tuple[int, int]: [description]
|
||||
tuple[int, int]: Pair of y coordinates (line numbers).
|
||||
"""
|
||||
return (self.y, self.y + self.height)
|
||||
|
||||
@@ -356,6 +365,7 @@ class Region(NamedTuple):
|
||||
height + expand_height * 2,
|
||||
)
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def overlaps(self, other: Region) -> bool:
|
||||
"""Check if another region overlaps this region.
|
||||
|
||||
@@ -433,6 +443,7 @@ class Region(NamedTuple):
|
||||
self_x, self_y, width, height = self
|
||||
return Region(self_x + x, self_y + y, width, height)
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def __contains__(self, other: Any) -> bool:
|
||||
"""Check if a point is in this region."""
|
||||
if isinstance(other, Region):
|
||||
@@ -483,6 +494,7 @@ class Region(NamedTuple):
|
||||
height=max(0, height - top - bottom),
|
||||
)
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def intersection(self, region: Region) -> Region:
|
||||
"""Get the overlapping portion of the two regions.
|
||||
|
||||
@@ -507,8 +519,9 @@ class Region(NamedTuple):
|
||||
|
||||
return Region(rx1, ry1, rx2 - rx1, ry2 - ry1)
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def union(self, region: Region) -> Region:
|
||||
"""Get a new region that contains both regions.
|
||||
"""Get the smallest region that contains both regions.
|
||||
|
||||
Args:
|
||||
region (Region): Another region.
|
||||
@@ -524,6 +537,7 @@ class Region(NamedTuple):
|
||||
)
|
||||
return union_region
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def split(self, cut_x: int, cut_y: int) -> tuple[Region, Region, Region, Region]:
|
||||
"""Split a region in to 4 from given x and y offsets (cuts).
|
||||
|
||||
@@ -563,6 +577,7 @@ class Region(NamedTuple):
|
||||
_Region(x + cut_x, y + cut_y, width - cut_x, height - cut_y),
|
||||
)
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def split_vertical(self, cut: int) -> tuple[Region, Region]:
|
||||
"""Split a region in to two, from a given x offset.
|
||||
|
||||
@@ -591,6 +606,7 @@ class Region(NamedTuple):
|
||||
Region(x + cut, y, width - cut, height),
|
||||
)
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def split_horizontal(self, cut: int) -> tuple[Region, Region]:
|
||||
"""Split a region in to two, from a given x offset.
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ class VerticalLayout(Layout):
|
||||
|
||||
box_models = [
|
||||
widget.get_box_model(size, parent_size, fraction_unit)
|
||||
for widget in cast("list[Widget]", parent.children)
|
||||
for widget in parent.children
|
||||
]
|
||||
|
||||
margins = [
|
||||
|
||||
@@ -57,7 +57,7 @@ class Prompt(Message, system=True):
|
||||
"""Used to 'wake up' an event loop."""
|
||||
|
||||
def can_replace(self, message: Message) -> bool:
|
||||
return isinstance(message, StylesUpdated)
|
||||
return isinstance(message, Prompt)
|
||||
|
||||
|
||||
class TerminalSupportsSynchronizedOutput(Message):
|
||||
|
||||
@@ -21,8 +21,8 @@ if sys.version_info >= (3, 8):
|
||||
else:
|
||||
from typing_extensions import Final
|
||||
|
||||
# Screen updates will be batched so that they don't happen more often than 20 times per second:
|
||||
UPDATE_PERIOD: Final = 1 / 20
|
||||
# Screen updates will be batched so that they don't happen more often than 60 times per second:
|
||||
UPDATE_PERIOD: Final = 1 / 60
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
@@ -30,7 +30,8 @@ class Screen(Widget):
|
||||
"""A widget for the root of the app."""
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
Screen {
|
||||
|
||||
layout: vertical;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
116
src/textual/scroll_view.py
Normal file
116
src/textual/scroll_view.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import RenderableType
|
||||
|
||||
|
||||
from .geometry import Size
|
||||
from .widget import Widget
|
||||
|
||||
|
||||
class ScrollView(Widget):
|
||||
"""
|
||||
A base class for a Widget that handles it's own scrolling (i.e. doesn't rely
|
||||
on the compositor to render children).
|
||||
|
||||
"""
|
||||
|
||||
CSS = """
|
||||
|
||||
ScrollView {
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, name: str | None = None, id: str | None = None, classes: str | None = None
|
||||
) -> None:
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
|
||||
@property
|
||||
def is_scrollable(self) -> bool:
|
||||
"""Always scrollable."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_transparent(self) -> bool:
|
||||
"""Not transparent, i.e. renders something."""
|
||||
return False
|
||||
|
||||
def on_mount(self):
|
||||
self._refresh_scrollbars()
|
||||
|
||||
def get_content_width(self, container: Size, viewport: Size) -> int:
|
||||
"""Gets the width of the content area.
|
||||
|
||||
Args:
|
||||
container (Size): Size of the container (immediate parent) widget.
|
||||
viewport (Size): Size of the viewport.
|
||||
|
||||
Returns:
|
||||
int: The optimal width of the content.
|
||||
"""
|
||||
return self.virtual_size.width
|
||||
|
||||
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
|
||||
"""Gets the height (number of lines) in the content area.
|
||||
|
||||
Args:
|
||||
container (Size): Size of the container (immediate parent) widget.
|
||||
viewport (Size): Size of the viewport.
|
||||
width (int): Width of renderable.
|
||||
|
||||
Returns:
|
||||
int: The height of the content.
|
||||
"""
|
||||
return self.virtual_size.height
|
||||
|
||||
def size_updated(
|
||||
self, size: Size, virtual_size: Size, container_size: Size
|
||||
) -> None:
|
||||
"""Called when size is updated.
|
||||
|
||||
Args:
|
||||
size (Size): New size.
|
||||
virtual_size (Size): New virtual size.
|
||||
container_size (Size): New container size.
|
||||
"""
|
||||
virtual_size = self.virtual_size
|
||||
if self._size != size:
|
||||
self._size = size
|
||||
self._container_size = container_size
|
||||
|
||||
self._refresh_scrollbars()
|
||||
width, height = self.container_size
|
||||
if self.show_vertical_scrollbar:
|
||||
self.vertical_scrollbar.window_virtual_size = virtual_size.height
|
||||
self.vertical_scrollbar.window_size = height
|
||||
if self.show_horizontal_scrollbar:
|
||||
self.horizontal_scrollbar.window_virtual_size = virtual_size.width
|
||||
self.horizontal_scrollbar.window_size = width
|
||||
|
||||
self.scroll_x = self.validate_scroll_x(self.scroll_x)
|
||||
self.scroll_y = self.validate_scroll_y(self.scroll_y)
|
||||
self.refresh(layout=False)
|
||||
self.call_later(self.scroll_to, self.scroll_x, self.scroll_y)
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
"""Render the scrollable region (if `render_lines` is not implemented).
|
||||
|
||||
Returns:
|
||||
RenderableType: Renderable object.
|
||||
"""
|
||||
from rich.panel import Panel
|
||||
|
||||
return Panel(f"{self.scroll_offset} {self.show_vertical_scrollbar}")
|
||||
|
||||
def watch_scroll_x(self, new_value: float) -> None:
|
||||
"""Called when horizontal bar is scrolled."""
|
||||
self.horizontal_scrollbar.position = int(new_value)
|
||||
self.refresh(layout=False)
|
||||
|
||||
def watch_scroll_y(self, new_value: float) -> None:
|
||||
"""Called when vertical bar is scrolled."""
|
||||
self.vertical_scrollbar.position = int(new_value)
|
||||
self.refresh(layout=False)
|
||||
@@ -4,6 +4,7 @@ from fractions import Fraction
|
||||
from typing import (
|
||||
Any,
|
||||
Awaitable,
|
||||
ClassVar,
|
||||
TYPE_CHECKING,
|
||||
Callable,
|
||||
Iterable,
|
||||
@@ -36,6 +37,8 @@ from ._layout import Layout
|
||||
from .reactive import Reactive, watch
|
||||
from .renderables.opacity import Opacity
|
||||
from .renderables.tint import Tint
|
||||
from ._segment_tools import line_crop
|
||||
from .css.styles import Styles
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -51,6 +54,8 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class RenderCache(NamedTuple):
|
||||
"""Stores results of a previous render."""
|
||||
|
||||
size: Size
|
||||
lines: Lines
|
||||
|
||||
@@ -66,6 +71,20 @@ class RenderCache(NamedTuple):
|
||||
@rich.repr.auto
|
||||
class Widget(DOMNode):
|
||||
|
||||
CSS = """
|
||||
Widget{
|
||||
scrollbar-background: $panel-darken-2;
|
||||
scrollbar-background-hover: $panel-darken-3;
|
||||
scrollbar-color: $system;
|
||||
scrollbar-color-active: $secondary-darken-1;
|
||||
scrollbar-size-vertical: 2;
|
||||
scrollbar-size-horizontal: 1;
|
||||
|
||||
}
|
||||
"""
|
||||
|
||||
COMPONENT_CLASSES: ClassVar[set[str]] = set()
|
||||
|
||||
can_focus: bool = False
|
||||
can_focus_children: bool = True
|
||||
|
||||
@@ -78,7 +97,6 @@ class Widget(DOMNode):
|
||||
) -> None:
|
||||
|
||||
self._size = Size(0, 0)
|
||||
self._virtual_size = Size(0, 0)
|
||||
self._container_size = Size(0, 0)
|
||||
self._layout_required = False
|
||||
self._repaint_required = False
|
||||
@@ -104,13 +122,14 @@ class Widget(DOMNode):
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self.add_children(*children)
|
||||
|
||||
virtual_size = Reactive(Size(0, 0), layout=True)
|
||||
auto_width = Reactive(True)
|
||||
auto_height = Reactive(True)
|
||||
has_focus = Reactive(False)
|
||||
descendant_has_focus = Reactive(False)
|
||||
mouse_over = Reactive(False)
|
||||
scroll_x = Reactive(0.0, repaint=False, layout=True)
|
||||
scroll_y = Reactive(0.0, repaint=False, layout=True)
|
||||
scroll_x = Reactive(0.0, repaint=False, layout=False)
|
||||
scroll_y = Reactive(0.0, repaint=False, layout=False)
|
||||
scroll_target_x = Reactive(0.0, repaint=False)
|
||||
scroll_target_y = Reactive(0.0, repaint=False)
|
||||
show_vertical_scrollbar = Reactive(False, layout=True)
|
||||
@@ -268,11 +287,13 @@ class Widget(DOMNode):
|
||||
|
||||
return height
|
||||
|
||||
async def watch_scroll_x(self, new_value: float) -> None:
|
||||
def watch_scroll_x(self, new_value: float) -> None:
|
||||
self.horizontal_scrollbar.position = int(new_value)
|
||||
self.refresh(layout=True)
|
||||
|
||||
async def watch_scroll_y(self, new_value: float) -> None:
|
||||
def watch_scroll_y(self, new_value: float) -> None:
|
||||
self.vertical_scrollbar.position = int(new_value)
|
||||
self.refresh(layout=True)
|
||||
|
||||
def validate_scroll_x(self, value: float) -> float:
|
||||
return clamp(value, 0, self.max_scroll_x)
|
||||
@@ -343,7 +364,7 @@ class Widget(DOMNode):
|
||||
|
||||
def _refresh_scrollbars(self) -> None:
|
||||
"""Refresh scrollbar visibility."""
|
||||
if not self.is_container:
|
||||
if not self.is_scrollable:
|
||||
return
|
||||
|
||||
styles = self.styles
|
||||
@@ -380,7 +401,7 @@ class Widget(DOMNode):
|
||||
tuple[bool, bool]: A tuple of (<vertical scrollbar enabled>, <horizontal scrollbar enabled>)
|
||||
|
||||
"""
|
||||
if not self.is_container:
|
||||
if not self.is_scrollable:
|
||||
return False, False
|
||||
|
||||
enabled = self.show_vertical_scrollbar, self.show_horizontal_scrollbar
|
||||
@@ -501,10 +522,10 @@ class Widget(DOMNode):
|
||||
)
|
||||
|
||||
def scroll_home(self, *, animate: bool = True) -> bool:
|
||||
return self.scroll_to(0, 0, animate=animate)
|
||||
return self.scroll_to(0, 0, animate=animate, duration=1)
|
||||
|
||||
def scroll_end(self, *, animate: bool = True) -> bool:
|
||||
return self.scroll_to(0, self.max_scroll_y, animate=animate)
|
||||
return self.scroll_to(0, self.max_scroll_y, animate=animate, duration=1)
|
||||
|
||||
def scroll_left(self, *, animate: bool = True) -> bool:
|
||||
return self.scroll_to(x=self.scroll_target_x - 1, animate=animate)
|
||||
@@ -623,7 +644,7 @@ class Widget(DOMNode):
|
||||
if pseudo_classes:
|
||||
yield "pseudo_classes", set(pseudo_classes)
|
||||
|
||||
def _arrange_container(self, region: Region) -> Region:
|
||||
def _get_scrollable_region(self, region: Region) -> Region:
|
||||
"""Adjusts the Widget region to accommodate scrollbars.
|
||||
|
||||
Args:
|
||||
@@ -644,7 +665,8 @@ class Widget(DOMNode):
|
||||
|
||||
if show_horizontal_scrollbar and show_vertical_scrollbar:
|
||||
(region, _, _, _) = region.split(
|
||||
-vertical_scrollbar_thickness, -horizontal_scrollbar_thickness
|
||||
-vertical_scrollbar_thickness,
|
||||
-horizontal_scrollbar_thickness,
|
||||
)
|
||||
elif show_vertical_scrollbar:
|
||||
region, _ = region.split_vertical(-vertical_scrollbar_thickness)
|
||||
@@ -768,10 +790,6 @@ class Widget(DOMNode):
|
||||
x, y = self.styles.content_gutter.top_left
|
||||
return Offset(x, y)
|
||||
|
||||
@property
|
||||
def virtual_size(self) -> Size:
|
||||
return self._virtual_size
|
||||
|
||||
@property
|
||||
def region(self) -> Region:
|
||||
"""The region occupied by this widget, relative to the Screen."""
|
||||
@@ -791,7 +809,7 @@ class Widget(DOMNode):
|
||||
Returns:
|
||||
bool: ``True`` if there is background color, otherwise ``False``.
|
||||
"""
|
||||
return self.is_container and self.styles.background.is_transparent
|
||||
return self.is_scrollable and self.styles.background.is_transparent
|
||||
|
||||
@property
|
||||
def console(self) -> Console:
|
||||
@@ -812,13 +830,22 @@ class Widget(DOMNode):
|
||||
|
||||
@property
|
||||
def is_container(self) -> bool:
|
||||
"""Check if this widget is a container (contains other widgets)
|
||||
"""Check if this widget is a container (contains other widgets).
|
||||
|
||||
Returns:
|
||||
bool: True if this widget is a container.
|
||||
"""
|
||||
return self.styles.layout is not None or bool(self.children)
|
||||
|
||||
@property
|
||||
def is_scrollable(self) -> bool:
|
||||
"""Check if this Widget may be scrolled.
|
||||
|
||||
Returns:
|
||||
bool: True if this widget may be scrolled.
|
||||
"""
|
||||
return self.is_container
|
||||
|
||||
def watch_mouse_over(self, value: bool) -> None:
|
||||
"""Update from CSS if mouse over state changes."""
|
||||
self.app.update_styles()
|
||||
@@ -827,18 +854,14 @@ class Widget(DOMNode):
|
||||
"""Update from CSS if has focus state changes."""
|
||||
self.app.update_styles()
|
||||
|
||||
def on_style_change(self) -> None:
|
||||
self.set_dirty()
|
||||
self.check_idle()
|
||||
|
||||
def size_updated(
|
||||
self, size: Size, virtual_size: Size, container_size: Size
|
||||
) -> None:
|
||||
if self._size != size or self._virtual_size != virtual_size:
|
||||
if self._size != size or self.virtual_size != virtual_size:
|
||||
self._size = size
|
||||
self._virtual_size = virtual_size
|
||||
self.virtual_size = virtual_size
|
||||
self._container_size = container_size
|
||||
if self.is_container:
|
||||
if self.is_scrollable:
|
||||
self._refresh_scrollbars()
|
||||
width, height = self.container_size
|
||||
if self.show_vertical_scrollbar:
|
||||
@@ -866,21 +889,27 @@ class Widget(DOMNode):
|
||||
self._render_cache = RenderCache(self.size, lines)
|
||||
self._dirty_regions.clear()
|
||||
|
||||
def get_render_lines(
|
||||
self, start: int | None = None, end: int | None = None
|
||||
) -> Lines:
|
||||
"""Get segment lines to render the widget.
|
||||
def _crop_lines(self, lines: Lines, x1, x2) -> Lines:
|
||||
width = self.size.width
|
||||
if (x1, x2) != (0, width):
|
||||
lines = [line_crop(line, x1, x2, width) for line in lines]
|
||||
return lines
|
||||
|
||||
def render_lines(self, crop: Region) -> Lines:
|
||||
"""Render the widget in to lines.
|
||||
|
||||
Args:
|
||||
start (int | None, optional): line start index, or None for first line. Defaults to None.
|
||||
end (int | None, optional): line end index, or None for last line. Defaults to None.
|
||||
crop (Region): Region within visible area to.
|
||||
|
||||
Returns:
|
||||
Lines: A list of lists of segments.
|
||||
Lines: A list of list of segments
|
||||
"""
|
||||
if self._dirty_regions:
|
||||
self._render_lines()
|
||||
lines = self._render_cache.lines[start:end]
|
||||
|
||||
x1, y1, x2, y2 = crop.corners
|
||||
lines = self._render_cache.lines[y1:y2]
|
||||
lines = self._crop_lines(lines, x1, x2)
|
||||
return lines
|
||||
|
||||
def get_style_at(self, x: int, y: int) -> Style:
|
||||
@@ -912,6 +941,9 @@ class Widget(DOMNode):
|
||||
self._content_height_cache = (None, 0)
|
||||
self.set_dirty()
|
||||
self._repaint_required = True
|
||||
if isinstance(self.parent, Widget) and self.styles.auto_dimensions:
|
||||
self.parent.refresh(layout=True)
|
||||
|
||||
self.check_idle()
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
@@ -1026,84 +1058,84 @@ class Widget(DOMNode):
|
||||
break
|
||||
|
||||
def on_mouse_scroll_down(self, event) -> None:
|
||||
if self.is_container:
|
||||
if self.is_scrollable:
|
||||
if self.scroll_down(animate=False):
|
||||
event.stop()
|
||||
|
||||
def on_mouse_scroll_up(self, event) -> None:
|
||||
if self.is_container:
|
||||
if self.is_scrollable:
|
||||
if self.scroll_up(animate=False):
|
||||
event.stop()
|
||||
|
||||
def handle_scroll_to(self, message: ScrollTo) -> None:
|
||||
if self.is_container:
|
||||
if self.is_scrollable:
|
||||
self.scroll_to(message.x, message.y, animate=message.animate, duration=0.1)
|
||||
message.stop()
|
||||
|
||||
def handle_scroll_up(self, event: ScrollUp) -> None:
|
||||
if self.is_container:
|
||||
if self.is_scrollable:
|
||||
self.scroll_page_up()
|
||||
event.stop()
|
||||
|
||||
def handle_scroll_down(self, event: ScrollDown) -> None:
|
||||
if self.is_container:
|
||||
if self.is_scrollable:
|
||||
self.scroll_page_down()
|
||||
event.stop()
|
||||
|
||||
def handle_scroll_left(self, event: ScrollLeft) -> None:
|
||||
if self.is_container:
|
||||
if self.is_scrollable:
|
||||
self.scroll_page_left()
|
||||
event.stop()
|
||||
|
||||
def handle_scroll_right(self, event: ScrollRight) -> None:
|
||||
if self.is_container:
|
||||
if self.is_scrollable:
|
||||
self.scroll_page_right()
|
||||
event.stop()
|
||||
|
||||
def key_home(self) -> bool:
|
||||
if self.is_container:
|
||||
if self.is_scrollable:
|
||||
self.scroll_home()
|
||||
return True
|
||||
return False
|
||||
|
||||
def key_end(self) -> bool:
|
||||
if self.is_container:
|
||||
if self.is_scrollable:
|
||||
self.scroll_end()
|
||||
return True
|
||||
return False
|
||||
|
||||
def key_left(self) -> bool:
|
||||
if self.is_container:
|
||||
if self.is_scrollable:
|
||||
self.scroll_left()
|
||||
return True
|
||||
return False
|
||||
|
||||
def key_right(self) -> bool:
|
||||
if self.is_container:
|
||||
if self.is_scrollable:
|
||||
self.scroll_right()
|
||||
return True
|
||||
return False
|
||||
|
||||
def key_down(self) -> bool:
|
||||
if self.is_container:
|
||||
if self.is_scrollable:
|
||||
self.scroll_up()
|
||||
return True
|
||||
return False
|
||||
|
||||
def key_up(self) -> bool:
|
||||
if self.is_container:
|
||||
if self.is_scrollable:
|
||||
self.scroll_down()
|
||||
return True
|
||||
return False
|
||||
|
||||
def key_pagedown(self) -> bool:
|
||||
if self.is_container:
|
||||
if self.is_scrollable:
|
||||
self.scroll_page_down()
|
||||
return True
|
||||
return False
|
||||
|
||||
def key_pageup(self) -> bool:
|
||||
if self.is_container:
|
||||
if self.is_scrollable:
|
||||
self.scroll_page_up()
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -12,6 +12,7 @@ if typing.TYPE_CHECKING:
|
||||
# `__init__.pyi` file in this same folder - otherwise text editors and type checkers won't be able to "see" them.
|
||||
__all__ = [
|
||||
"Button",
|
||||
"DataTable",
|
||||
"DirectoryTree",
|
||||
"Footer",
|
||||
"Header",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# This stub file must re-export every classes exposed in the __init__.py's `__all__` list:
|
||||
from ._button import Button as Button
|
||||
from ._data_table import DataTable
|
||||
from ._directory_tree import DirectoryTree as DirectoryTree
|
||||
from ._footer import Footer as Footer
|
||||
from ._header import Header as Header
|
||||
|
||||
384
src/textual/widgets/_data_table.py
Normal file
384
src/textual/widgets/_data_table.py
Normal file
@@ -0,0 +1,384 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from itertools import chain
|
||||
from typing import Callable, ClassVar, Generic, TypeVar, cast
|
||||
|
||||
from rich.console import RenderableType
|
||||
from rich.padding import Padding
|
||||
from rich.protocol import is_renderable
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
from rich.text import Text, TextType
|
||||
|
||||
from .._cache import LRUCache
|
||||
from .._segment_tools import line_crop
|
||||
from .._types import Lines
|
||||
from ..geometry import Region, Size
|
||||
from ..reactive import Reactive
|
||||
from .._profile import timer
|
||||
from ..scroll_view import ScrollView
|
||||
from ..widget import Widget
|
||||
from .. import messages
|
||||
|
||||
CellType = TypeVar("CellType")
|
||||
|
||||
|
||||
def default_cell_formatter(obj: object) -> RenderableType | None:
|
||||
if isinstance(obj, str):
|
||||
return Text.from_markup(obj)
|
||||
if not is_renderable(obj):
|
||||
raise TypeError(f"Table cell {obj!r} is not renderable")
|
||||
return cast(RenderableType, obj)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Column:
|
||||
label: Text
|
||||
width: int
|
||||
visible: bool = False
|
||||
index: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Row:
|
||||
index: int
|
||||
height: int
|
||||
cell_renderables: list[RenderableType] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Cell:
|
||||
value: object
|
||||
|
||||
|
||||
class Header(Widget):
|
||||
pass
|
||||
|
||||
|
||||
class DataTable(ScrollView, Generic[CellType]):
|
||||
|
||||
CSS = """
|
||||
DataTable {
|
||||
background: $surface;
|
||||
color: $text-surface;
|
||||
}
|
||||
DataTable > .datatable--header {
|
||||
text-style: bold;
|
||||
background: $primary;
|
||||
color: $text-primary;
|
||||
}
|
||||
DataTable > .datatable--fixed {
|
||||
text-style: bold;
|
||||
background: $primary-darken-2;
|
||||
color: $text-primary-darken-2;
|
||||
}
|
||||
|
||||
DataTable > .datatable--odd-row {
|
||||
|
||||
}
|
||||
|
||||
DataTable > .datatable--even-row {
|
||||
background: $primary 10%;
|
||||
}
|
||||
|
||||
.-dark-mode DataTable > .datatable--even-row {
|
||||
background: $primary 15%;
|
||||
}
|
||||
|
||||
DataTable > .datatable--highlight {
|
||||
background: $secondary;
|
||||
color: $text-secondary;
|
||||
}
|
||||
"""
|
||||
|
||||
COMPONENT_CLASSES: ClassVar[set[str]] = {
|
||||
"datatable--header",
|
||||
"datatable--fixed",
|
||||
"datatable--odd-row",
|
||||
"datatable--even-row",
|
||||
"datatable--highlight",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self.columns: list[Column] = []
|
||||
self.rows: dict[int, Row] = {}
|
||||
self.data: dict[int, list[CellType]] = {}
|
||||
self.row_count = 0
|
||||
|
||||
self._y_offsets: list[tuple[int, int]] = []
|
||||
|
||||
self._row_render_cache: LRUCache[tuple[int, int, Style], tuple[Lines, Lines]]
|
||||
self._row_render_cache = LRUCache(1000)
|
||||
|
||||
self._cell_render_cache: LRUCache[tuple[int, int, Style], Lines]
|
||||
self._cell_render_cache = LRUCache(10000)
|
||||
|
||||
self._line_cache: LRUCache[tuple[int, int, int, int], list[Segment]]
|
||||
self._line_cache = LRUCache(1000)
|
||||
|
||||
show_header = Reactive(True)
|
||||
fixed_rows = Reactive(0)
|
||||
fixed_columns = Reactive(1)
|
||||
zebra_stripes = Reactive(False)
|
||||
header_height = Reactive(1)
|
||||
|
||||
def _clear_caches(self) -> None:
|
||||
self._row_render_cache.clear()
|
||||
self._cell_render_cache.clear()
|
||||
self._line_cache.clear()
|
||||
|
||||
def get_row_height(self, row_index: int) -> int:
|
||||
if row_index == -1:
|
||||
return self.header_height
|
||||
return self.rows[row_index].height
|
||||
|
||||
async def handle_styles_updated(self, message: messages.StylesUpdated) -> None:
|
||||
self._clear_caches()
|
||||
|
||||
def watch_show_header(self, show_header: bool) -> None:
|
||||
self._clear_caches()
|
||||
|
||||
def watch_fixed_rows(self, fixed_rows: int) -> None:
|
||||
self._clear_caches()
|
||||
|
||||
def watch_zebra_stripes(self, zebra_stripes: bool) -> None:
|
||||
self._clear_caches()
|
||||
|
||||
def _update_dimensions(self) -> None:
|
||||
"""Called to recalculate the virtual (scrollable) size."""
|
||||
total_width = sum(column.width for column in self.columns)
|
||||
self.virtual_size = Size(
|
||||
total_width,
|
||||
len(self._y_offsets) + (self.header_height if self.show_header else 0),
|
||||
)
|
||||
|
||||
def add_column(self, label: TextType, *, width: int = 10) -> None:
|
||||
"""Add a column to the table.
|
||||
|
||||
Args:
|
||||
label (TextType): A str or Text object containing the label (shown top of column)
|
||||
width (int, optional): Width of the column in cells. Defaults to 10.
|
||||
"""
|
||||
text_label = Text.from_markup(label) if isinstance(label, str) else label
|
||||
self.columns.append(Column(text_label, width, index=len(self.columns)))
|
||||
self._update_dimensions()
|
||||
self.refresh()
|
||||
|
||||
def add_row(self, *cells: CellType, height: int = 1) -> None:
|
||||
"""Add a row.
|
||||
|
||||
Args:
|
||||
height (int, optional): The height of a row (in lines). Defaults to 1.
|
||||
"""
|
||||
row_index = self.row_count
|
||||
self.data[row_index] = list(cells)
|
||||
self.rows[row_index] = Row(row_index, height=height)
|
||||
|
||||
for line_no in range(height):
|
||||
self._y_offsets.append((row_index, line_no))
|
||||
|
||||
self.row_count += 1
|
||||
self._update_dimensions()
|
||||
self.refresh()
|
||||
|
||||
def _get_row_renderables(self, row_index: int) -> list[RenderableType]:
|
||||
"""Get renderables for the given row.
|
||||
|
||||
Args:
|
||||
row_index (int): Index of the row.
|
||||
|
||||
Returns:
|
||||
list[RenderableType]: List of renderables
|
||||
"""
|
||||
|
||||
if row_index == -1:
|
||||
row = [column.label for column in self.columns]
|
||||
return row
|
||||
|
||||
data = self.data.get(row_index)
|
||||
empty = Text()
|
||||
if data is None:
|
||||
return [empty for _ in self.columns]
|
||||
else:
|
||||
return [default_cell_formatter(datum) or empty for datum in data]
|
||||
|
||||
def _render_cell(
|
||||
self, row_index: int, column_index: int, style: Style, width: int
|
||||
) -> Lines:
|
||||
"""Render the given cell.
|
||||
|
||||
Args:
|
||||
row_index (int): Index of the row.
|
||||
column_index (int): Index of the column.
|
||||
style (Style): Style to apply.
|
||||
width (int): Width of the cell.
|
||||
|
||||
Returns:
|
||||
Lines: A list of segments per line.
|
||||
"""
|
||||
cell_key = (row_index, column_index, style)
|
||||
if cell_key not in self._cell_render_cache:
|
||||
style += Style.from_meta({"row": row_index, "column": column_index})
|
||||
height = (
|
||||
self.header_height if row_index == -1 else self.rows[row_index].height
|
||||
)
|
||||
cell = self._get_row_renderables(row_index)[column_index]
|
||||
lines = self.app.console.render_lines(
|
||||
Padding(cell, (0, 1)),
|
||||
self.app.console.options.update_dimensions(width, height),
|
||||
style=style,
|
||||
)
|
||||
self._cell_render_cache[cell_key] = lines
|
||||
return self._cell_render_cache[cell_key]
|
||||
|
||||
def _render_row(
|
||||
self, row_index: int, line_no: int, base_style: Style
|
||||
) -> tuple[Lines, Lines]:
|
||||
"""Render a row in to lines for each cell.
|
||||
|
||||
Args:
|
||||
row_index (int): Index of the row.
|
||||
line_no (int): Line number (on screen, 0 is top)
|
||||
base_style (Style): Base style of row.
|
||||
|
||||
Returns:
|
||||
tuple[Lines, Lines]: Lines for fixed cells, and Lines for scrollable cells.
|
||||
"""
|
||||
|
||||
cache_key = (row_index, line_no, base_style)
|
||||
|
||||
if cache_key in self._row_render_cache:
|
||||
return self._row_render_cache[cache_key]
|
||||
|
||||
render_cell = self._render_cell
|
||||
|
||||
if self.fixed_columns:
|
||||
fixed_style = self.component_styles["datatable--fixed"].node.rich_style
|
||||
fixed_style += Style.from_meta({"fixed": True})
|
||||
fixed_row = [
|
||||
render_cell(row_index, column.index, fixed_style, column.width)[line_no]
|
||||
for column in self.columns[: self.fixed_columns]
|
||||
]
|
||||
else:
|
||||
fixed_row = []
|
||||
|
||||
if row_index == -1:
|
||||
row_style = self.component_styles["datatable--header"].node.rich_style
|
||||
else:
|
||||
if self.zebra_stripes:
|
||||
component_row_style = (
|
||||
"datatable--odd-row" if row_index % 2 else "datatable--even-row"
|
||||
)
|
||||
row_style = self.component_styles[component_row_style].node.rich_style
|
||||
else:
|
||||
row_style = base_style
|
||||
|
||||
scrollable_row = [
|
||||
render_cell(row_index, column.index, row_style, column.width)[line_no]
|
||||
for column in self.columns
|
||||
]
|
||||
|
||||
row_pair = (fixed_row, scrollable_row)
|
||||
self._row_render_cache[cache_key] = row_pair
|
||||
return row_pair
|
||||
|
||||
def _get_offsets(self, y: int) -> tuple[int, int]:
|
||||
"""Get row number and line offset for a given line.
|
||||
|
||||
Args:
|
||||
y (int): Y coordinate relative to screen top.
|
||||
|
||||
Returns:
|
||||
tuple[int, int]: Line number and line offset within cell.
|
||||
"""
|
||||
if self.show_header:
|
||||
if y < self.header_height:
|
||||
return (-1, y)
|
||||
y -= self.header_height
|
||||
return self._y_offsets[y]
|
||||
|
||||
def _render_line(
|
||||
self, y: int, x1: int, x2: int, base_style: Style
|
||||
) -> list[Segment]:
|
||||
"""Render a line in to a list of segments.
|
||||
|
||||
Args:
|
||||
y (int): Y coordinate of line
|
||||
x1 (int): X start crop.
|
||||
x2 (int): X end crop (exclusive).
|
||||
base_style (Style): Style to apply to line.
|
||||
|
||||
Returns:
|
||||
list[Segment]: List of segments for rendering.
|
||||
"""
|
||||
|
||||
width = self.content_region.width
|
||||
|
||||
cache_key = (y, x1, x2, width)
|
||||
if cache_key in self._line_cache:
|
||||
return self._line_cache[cache_key]
|
||||
|
||||
row_index, line_no = self._get_offsets(y)
|
||||
|
||||
fixed, scrollable = self._render_row(row_index, line_no, base_style)
|
||||
fixed_width = sum(column.width for column in self.columns[: self.fixed_columns])
|
||||
|
||||
fixed_line: list[Segment] = list(chain.from_iterable(fixed)) if fixed else []
|
||||
scrollable_line: list[Segment] = list(chain.from_iterable(scrollable))
|
||||
|
||||
segments = fixed_line + line_crop(scrollable_line, x1 + fixed_width, x2, width)
|
||||
|
||||
remaining_width = width - (fixed_width + min(width, (x2 - x1 + fixed_width)))
|
||||
if remaining_width > 0:
|
||||
segments.append(Segment(" " * remaining_width, base_style))
|
||||
elif remaining_width < 0:
|
||||
segments = Segment.adjust_line_length(segments, width, style=base_style)
|
||||
|
||||
simplified_segments = list(Segment.simplify(segments))
|
||||
|
||||
self._line_cache[cache_key] = simplified_segments
|
||||
return segments
|
||||
|
||||
@timer("render_lines")
|
||||
def render_lines(self, crop: Region) -> Lines:
|
||||
"""Render lines within a given region.
|
||||
|
||||
Args:
|
||||
crop (Region): Region to crop to.
|
||||
|
||||
Returns:
|
||||
Lines: A list of segments for every line within crop region.
|
||||
"""
|
||||
|
||||
scroll_x, scroll_y = self.scroll_offset
|
||||
x1, y1, x2, y2 = crop.translate(scroll_x, scroll_y).corners
|
||||
|
||||
base_style = self.rich_style
|
||||
|
||||
fixed_top_row_count = sum(
|
||||
self.get_row_height(row_index) for row_index in range(self.fixed_rows)
|
||||
)
|
||||
if self.show_header:
|
||||
fixed_top_row_count += self.get_row_height(-1)
|
||||
|
||||
render_line = self._render_line
|
||||
fixed_lines = [
|
||||
render_line(y, x1, x2, base_style) for y in range(0, fixed_top_row_count)
|
||||
]
|
||||
lines = [render_line(y, x1, x2, base_style) for y in range(y1, y2)]
|
||||
|
||||
for line_index, y in enumerate(range(y1, y2)):
|
||||
if y - scroll_y < fixed_top_row_count:
|
||||
lines[line_index] = fixed_lines[line_index]
|
||||
|
||||
return lines
|
||||
|
||||
def on_mouse_move(self, event):
|
||||
print(self.get_style_at(event.x, event.y).meta)
|
||||
@@ -205,21 +205,21 @@ def test_widget_style_size_fails_if_data_type_is_not_supported(size_dimension_in
|
||||
# short text: full width, no scrollbar
|
||||
["auto", "auto", 1, "short_text", 80, False],
|
||||
# long text: reduced width, scrollbar
|
||||
["auto", "auto", 1, "long_text", 79, True],
|
||||
["auto", "auto", 1, "long_text", 78, True],
|
||||
# short text, `scrollbar-gutter: stable`: reduced width, no scrollbar
|
||||
["auto", "stable", 1, "short_text", 79, False],
|
||||
["auto", "stable", 1, "short_text", 78, False],
|
||||
# long text, `scrollbar-gutter: stable`: reduced width, scrollbar
|
||||
["auto", "stable", 1, "long_text", 79, True],
|
||||
["auto", "stable", 1, "long_text", 78, True],
|
||||
# ------------------------------------------------
|
||||
# ----- And now let's see the behaviour with `overflow-y: scroll`:
|
||||
# short text: reduced width, scrollbar
|
||||
["scroll", "auto", 1, "short_text", 79, True],
|
||||
["scroll", "auto", 1, "short_text", 78, True],
|
||||
# long text: reduced width, scrollbar
|
||||
["scroll", "auto", 1, "long_text", 79, True],
|
||||
["scroll", "auto", 1, "long_text", 78, True],
|
||||
# short text, `scrollbar-gutter: stable`: reduced width, scrollbar
|
||||
["scroll", "stable", 1, "short_text", 79, True],
|
||||
["scroll", "stable", 1, "short_text", 78, True],
|
||||
# long text, `scrollbar-gutter: stable`: reduced width, scrollbar
|
||||
["scroll", "stable", 1, "long_text", 79, True],
|
||||
["scroll", "stable", 1, "long_text", 78, True],
|
||||
# ------------------------------------------------
|
||||
# ----- Finally, let's check the behaviour with `overflow-y: hidden`:
|
||||
# short text: full width, no scrollbar
|
||||
@@ -227,9 +227,9 @@ def test_widget_style_size_fails_if_data_type_is_not_supported(size_dimension_in
|
||||
# long text: full width, no scrollbar
|
||||
["hidden", "auto", 1, "long_text", 80, False],
|
||||
# short text, `scrollbar-gutter: stable`: reduced width, no scrollbar
|
||||
["hidden", "stable", 1, "short_text", 79, False],
|
||||
["hidden", "stable", 1, "short_text", 78, False],
|
||||
# long text, `scrollbar-gutter: stable`: reduced width, no scrollbar
|
||||
["hidden", "stable", 1, "long_text", 79, False],
|
||||
["hidden", "stable", 1, "long_text", 78, False],
|
||||
# ------------------------------------------------
|
||||
# ----- Bonus round with a custom scrollbar size, now that we can set this:
|
||||
["auto", "auto", 3, "short_text", 80, False],
|
||||
|
||||
@@ -179,7 +179,7 @@ def test_did_you_mean_for_css_property_names(
|
||||
|
||||
_, help_text = err.value.errors.rules[0].errors[0] # type: Any, HelpText
|
||||
displayed_css_property_name = css_property_name.replace("_", "-")
|
||||
expected_summary = f"Invalid CSS property [i]{displayed_css_property_name}[/]"
|
||||
expected_summary = f"Invalid CSS property {displayed_css_property_name!r}"
|
||||
if expected_property_name_suggestion:
|
||||
expected_summary += f'. Did you mean "{expected_property_name_suggestion}"?'
|
||||
assert help_text.summary == expected_summary
|
||||
|
||||
129
tests/test_cache.py
Normal file
129
tests/test_cache.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import pytest
|
||||
|
||||
from textual._cache import LRUCache
|
||||
|
||||
|
||||
def test_lru_cache():
|
||||
cache = LRUCache(3)
|
||||
|
||||
# insert some values
|
||||
cache["foo"] = 1
|
||||
cache["bar"] = 2
|
||||
cache["baz"] = 3
|
||||
assert "foo" in cache
|
||||
assert "bar" in cache
|
||||
assert "baz" in cache
|
||||
|
||||
# Cache size is 3, so the following should kick oldest one out
|
||||
cache["egg"] = 4
|
||||
assert "foo" not in cache
|
||||
assert "egg" in cache
|
||||
|
||||
# cache is now full
|
||||
# look up two keys
|
||||
cache["bar"]
|
||||
cache["baz"]
|
||||
|
||||
# Insert a new value
|
||||
cache["eggegg"] = 5
|
||||
assert len(cache) == 3
|
||||
# Check it kicked out the 'oldest' key
|
||||
assert "egg" not in cache
|
||||
assert "eggegg" in cache
|
||||
|
||||
|
||||
def test_lru_cache_get():
|
||||
cache = LRUCache(3)
|
||||
|
||||
# insert some values
|
||||
cache["foo"] = 1
|
||||
cache["bar"] = 2
|
||||
cache["baz"] = 3
|
||||
assert "foo" in cache
|
||||
|
||||
# Cache size is 3, so the following should kick oldest one out
|
||||
cache["egg"] = 4
|
||||
# assert len(cache) == 3
|
||||
assert cache.get("foo") is None
|
||||
assert "egg" in cache
|
||||
|
||||
# cache is now full
|
||||
# look up two keys
|
||||
cache.get("bar")
|
||||
cache.get("baz")
|
||||
|
||||
# Insert a new value
|
||||
cache["eggegg"] = 5
|
||||
# Check it kicked out the 'oldest' key
|
||||
assert "egg" not in cache
|
||||
assert "eggegg" in cache
|
||||
|
||||
|
||||
def test_lru_cache_mapping():
|
||||
"""Test cache values can be set and read back."""
|
||||
cache = LRUCache(3)
|
||||
cache["foo"] = 1
|
||||
cache.set("bar", 2)
|
||||
cache.set("baz", 3)
|
||||
assert cache["foo"] == 1
|
||||
assert cache["bar"] == 2
|
||||
assert cache.get("baz") == 3
|
||||
|
||||
|
||||
def test_lru_cache_clear():
|
||||
cache = LRUCache(3)
|
||||
assert len(cache) == 0
|
||||
cache["foo"] = 1
|
||||
assert "foo" in cache
|
||||
assert len(cache) == 1
|
||||
cache.clear()
|
||||
assert "foo" not in cache
|
||||
assert len(cache) == 0
|
||||
|
||||
|
||||
def test_lru_cache_bool():
|
||||
cache = LRUCache(3)
|
||||
assert not cache
|
||||
cache["foo"] = "bar"
|
||||
assert cache
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"keys,expected",
|
||||
[
|
||||
((), ()),
|
||||
(("foo",), ("foo",)),
|
||||
(("foo", "bar"), ("foo", "bar")),
|
||||
(("foo", "bar", "baz"), ("foo", "bar", "baz")),
|
||||
(("foo", "bar", "baz", "egg"), ("bar", "baz", "egg")),
|
||||
(("foo", "bar", "baz", "egg", "bob"), ("baz", "egg", "bob")),
|
||||
],
|
||||
)
|
||||
def test_lru_cache_evicts(keys: list[str], expected: list[str]):
|
||||
"""Test adding adding additional values evicts oldest key"""
|
||||
cache = LRUCache(3)
|
||||
for value, key in enumerate(keys):
|
||||
cache[key] = value
|
||||
assert tuple(cache.keys()) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"keys,expected_len",
|
||||
[
|
||||
((), 0),
|
||||
(("foo",), 1),
|
||||
(("foo", "bar"), 2),
|
||||
(("foo", "bar", "baz"), 3),
|
||||
(("foo", "bar", "baz", "egg"), 3),
|
||||
(("foo", "bar", "baz", "egg", "bob"), 3),
|
||||
],
|
||||
)
|
||||
def test_lru_cache_len(keys: list[str], expected_len: int):
|
||||
"""Test adding adding additional values evicts oldest key"""
|
||||
cache = LRUCache(3)
|
||||
for value, key in enumerate(keys):
|
||||
cache[key] = value
|
||||
assert len(cache) == expected_len
|
||||
@@ -19,7 +19,13 @@ SCREEN_H = 8 # height of our Screens
|
||||
SCREEN_SIZE = Size(SCREEN_W, SCREEN_H)
|
||||
PLACEHOLDERS_DEFAULT_H = 3 # the default height for our Placeholder widgets
|
||||
|
||||
# TODO: Brittle test
|
||||
# These tests are currently way to brittle due to the CSS layout not being final
|
||||
# They are also very hard to follow, if they break its not clear *what* went wrong
|
||||
# Going to leave them marked as "skip" for now.
|
||||
|
||||
|
||||
@pytest.mark.skip("brittle")
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration_test # this is a slow test, we may want to skip them in some contexts
|
||||
@pytest.mark.parametrize(
|
||||
@@ -227,6 +233,7 @@ async def test_border_edge_types_impact_on_widget_size(
|
||||
assert top_left_edge_char_is_a_visible_one == expects_visible_char_at_top_left_edge
|
||||
|
||||
|
||||
@pytest.mark.skip("brittle")
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"large_widget_size,container_style,expected_large_widget_visible_region_size",
|
||||
|
||||
@@ -12,9 +12,10 @@ def test_line_crop():
|
||||
Segment("Hello", bold),
|
||||
Segment(" World!", italic),
|
||||
]
|
||||
total = sum(segment.cell_length for segment in segments)
|
||||
|
||||
assert line_crop(segments, 1, 2) == [Segment("e", bold)]
|
||||
assert line_crop(segments, 4, 20) == [
|
||||
assert line_crop(segments, 1, 2, total) == [Segment("e", bold)]
|
||||
assert line_crop(segments, 4, 20, total) == [
|
||||
Segment("o", bold),
|
||||
Segment(" World!", italic),
|
||||
]
|
||||
@@ -27,16 +28,23 @@ def test_line_crop_emoji():
|
||||
Segment("Hello", bold),
|
||||
Segment("💩💩💩", italic),
|
||||
]
|
||||
assert line_crop(segments, 8, 11) == [Segment(" 💩", italic)]
|
||||
assert line_crop(segments, 9, 11) == [Segment("💩", italic)]
|
||||
total = sum(segment.cell_length for segment in segments)
|
||||
assert line_crop(segments, 8, 11, total) == [Segment(" 💩", italic)]
|
||||
assert line_crop(segments, 9, 11, total) == [Segment("💩", italic)]
|
||||
|
||||
|
||||
def test_line_crop_edge():
|
||||
segments = [Segment("foo"), Segment("bar"), Segment("baz")]
|
||||
assert line_crop(segments, 2, 9) == [Segment("o"), Segment("bar"), Segment("baz")]
|
||||
assert line_crop(segments, 3, 9) == [Segment("bar"), Segment("baz")]
|
||||
assert line_crop(segments, 4, 9) == [Segment("ar"), Segment("baz")]
|
||||
assert line_crop(segments, 4, 8) == [Segment("ar"), Segment("ba")]
|
||||
total = sum(segment.cell_length for segment in segments)
|
||||
|
||||
assert line_crop(segments, 2, 9, total) == [
|
||||
Segment("o"),
|
||||
Segment("bar"),
|
||||
Segment("baz"),
|
||||
]
|
||||
assert line_crop(segments, 3, 9, total) == [Segment("bar"), Segment("baz")]
|
||||
assert line_crop(segments, 4, 9, total) == [Segment("ar"), Segment("baz")]
|
||||
assert line_crop(segments, 4, 8, total) == [Segment("ar"), Segment("ba")]
|
||||
|
||||
|
||||
def test_line_crop_edge_2():
|
||||
@@ -49,7 +57,8 @@ def test_line_crop_edge_2():
|
||||
"─╮",
|
||||
),
|
||||
]
|
||||
result = line_crop(segments, 30, 60)
|
||||
total = sum(segment.cell_length for segment in segments)
|
||||
result = line_crop(segments, 30, 60, total)
|
||||
expected = []
|
||||
print(repr(result))
|
||||
assert result == expected
|
||||
|
||||
@@ -139,7 +139,7 @@ class AppTest(App):
|
||||
|
||||
x -= region.x
|
||||
y -= region.y
|
||||
lines = widget.get_render_lines(y, y + 1)
|
||||
lines = widget.render_lines(Region(0, y, region.width, 1))
|
||||
if not lines:
|
||||
return ""
|
||||
end = 0
|
||||
|
||||
Reference in New Issue
Block a user