mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into alt-compose
This commit is contained in:
21
CHANGELOG.md
21
CHANGELOG.md
@@ -5,12 +5,31 @@ 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/).
|
||||
|
||||
## Unreleased
|
||||
|
||||
## [0.12.0] - Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Added `App.batch_update` https://github.com/Textualize/textual/pull/1832
|
||||
- Added horizontal rule to Markdown https://github.com/Textualize/textual/pull/1832
|
||||
- Added `Widget.disabled` https://github.com/Textualize/textual/pull/1785
|
||||
|
||||
### Changed
|
||||
|
||||
- Scrolling by page now adds to current position.
|
||||
- Markdown lists have been polished: a selection of bullets, better alignment of numbers, style tweaks https://github.com/Textualize/textual/pull/1832
|
||||
- Added alternative method of composing Widgets https://github.com/Textualize/textual/pull/1847
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed `screen.visible_widgets` and `screen.widgets`
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Numbers in a descendant-combined selector no longer cause an error https://github.com/Textualize/textual/issues/1836
|
||||
|
||||
|
||||
## [0.11.1] - 2023-02-17
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -28,12 +28,12 @@
|
||||
|
||||
Screen {
|
||||
layers: ruler;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
Ruler {
|
||||
layer: ruler;
|
||||
dock: right;
|
||||
overflow: hidden;
|
||||
width: 1;
|
||||
background: $accent;
|
||||
}
|
||||
|
||||
@@ -315,6 +315,8 @@ The `background: green` is only applied to the Button underneath the mouse curso
|
||||
|
||||
Here are some other pseudo classes:
|
||||
|
||||
- `:disabled` Matches widgets which are in a disabled state.
|
||||
- `:enabled` Matches widgets which are in an enabled state.
|
||||
- `:focus` Matches widgets which have input focus.
|
||||
- `:focus-within` Matches widgets with a focused a child widget.
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ Input {
|
||||
}
|
||||
|
||||
#results {
|
||||
width: auto;
|
||||
min-height: 100%;
|
||||
padding: 0 1;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
||||
}
|
||||
|
||||
#results-container {
|
||||
|
||||
@@ -7,11 +7,10 @@ try:
|
||||
except ImportError:
|
||||
raise ImportError("Please install httpx with 'pip install httpx' ")
|
||||
|
||||
from rich.markdown import Markdown
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Content
|
||||
from textual.widgets import Input, Static
|
||||
from textual.widgets import Input, Markdown
|
||||
|
||||
|
||||
class DictionaryApp(App):
|
||||
@@ -36,17 +35,22 @@ class DictionaryApp(App):
|
||||
asyncio.create_task(self.lookup_word(message.value))
|
||||
else:
|
||||
# Clear the results
|
||||
self.query_one("#results", Static).update()
|
||||
await self.query_one("#results", Markdown).update("")
|
||||
|
||||
async def lookup_word(self, word: str) -> None:
|
||||
"""Looks up a word."""
|
||||
url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}"
|
||||
async with httpx.AsyncClient() as client:
|
||||
results = (await client.get(url)).json()
|
||||
response = await client.get(url)
|
||||
try:
|
||||
results = response.json()
|
||||
except Exception:
|
||||
self.query_one("#results", Static).update(response.text)
|
||||
return
|
||||
|
||||
if word == self.query_one(Input).value:
|
||||
markdown = self.make_word_markdown(results)
|
||||
self.query_one("#results", Static).update(Markdown(markdown))
|
||||
await self.query_one("#results", Markdown).update(markdown)
|
||||
|
||||
def make_word_markdown(self, results: object) -> str:
|
||||
"""Convert the results in to markdown."""
|
||||
|
||||
@@ -42,6 +42,32 @@ Two tildes indicates strikethrough, e.g. `~~cross out~~` render ~~cross out~~.
|
||||
|
||||
Inline code is indicated by backticks. e.g. `import this`.
|
||||
|
||||
## Lists
|
||||
|
||||
1. Lists can be ordered
|
||||
2. Lists can be unordered
|
||||
- I must not fear.
|
||||
- Fear is the mind-killer.
|
||||
- Fear is the little-death that brings total obliteration.
|
||||
- I will face my fear.
|
||||
- I will permit it to pass over me and through me.
|
||||
- And when it has gone past, I will turn the inner eye to see its path.
|
||||
- Where the fear has gone there will be nothing. Only I will remain.
|
||||
|
||||
### Longer list
|
||||
|
||||
1. **Duke Leto I Atreides**, head of House Atreides
|
||||
2. **Lady Jessica**, Bene Gesserit and concubine of Leto, and mother of Paul and Alia
|
||||
3. **Paul Atreides**, son of Leto and Jessica
|
||||
4. **Alia Atreides**, daughter of Leto and Jessica
|
||||
5. **Gurney Halleck**, troubadour warrior of House Atreides
|
||||
6. **Thufir Hawat**, Mentat and Master of Assassins of House Atreides
|
||||
7. **Duncan Idaho**, swordmaster of House Atreides
|
||||
8. **Dr. Wellington Yueh**, Suk doctor of House Atreides
|
||||
9. **Leto**, first son of Paul and Chani who dies as a toddler
|
||||
10. **Esmar Tuek**, a smuggler on Arrakis
|
||||
11. **Staban Tuek**, son of Esmar
|
||||
|
||||
## Fences
|
||||
|
||||
Fenced code blocks are introduced with three back-ticks and the optional parser. Here we are rendering the code in a sub-widget with syntax highlighting and indent guides.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Simple version of 5x5, developed for/with Textual."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
@@ -192,8 +192,7 @@ class Game(Screen):
|
||||
Args:
|
||||
playable (bool): Should the game currently be playable?
|
||||
"""
|
||||
for cell in self.query(GameCell):
|
||||
cell.disabled = not playable
|
||||
self.query_one(GameGrid).disabled = not playable
|
||||
|
||||
def cell(self, row: int, col: int) -> GameCell:
|
||||
"""Get the cell at a given location.
|
||||
|
||||
@@ -128,4 +128,4 @@ def arrange(
|
||||
|
||||
placements.extend(layout_placements)
|
||||
|
||||
return placements, arrange_widgets, scroll_spacing
|
||||
return DockArrangeResult(placements, arrange_widgets, scroll_spacing)
|
||||
|
||||
@@ -167,6 +167,7 @@ class Compositor:
|
||||
def __init__(self) -> None:
|
||||
# A mapping of Widget on to its "render location" (absolute position / depth)
|
||||
self.map: CompositorMap = {}
|
||||
self._full_map: CompositorMap | None = None
|
||||
self._layers: list[tuple[Widget, MapGeometry]] | None = None
|
||||
|
||||
# All widgets considered in the arrangement
|
||||
@@ -241,29 +242,27 @@ class Compositor:
|
||||
size: Size of the area to be filled.
|
||||
|
||||
Returns:
|
||||
Hidden shown and resized widgets.
|
||||
Hidden, shown, and resized widgets.
|
||||
"""
|
||||
self._cuts = None
|
||||
self._layers = None
|
||||
self._layers_visible = None
|
||||
self._visible_widgets = None
|
||||
self._full_map = None
|
||||
self.root = parent
|
||||
self.size = size
|
||||
|
||||
# Keep a copy of the old map because we're going to compare it with the update
|
||||
old_map = self.map.copy()
|
||||
old_map = self.map
|
||||
old_widgets = old_map.keys()
|
||||
|
||||
map, widgets = self._arrange_root(parent, size)
|
||||
new_widgets = map.keys()
|
||||
|
||||
# Newly visible widgets
|
||||
shown_widgets = new_widgets - old_widgets
|
||||
# Newly hidden widgets
|
||||
hidden_widgets = old_widgets - new_widgets
|
||||
new_widgets = map.keys()
|
||||
|
||||
# Replace map and widgets
|
||||
self.map = map
|
||||
self._full_map = map
|
||||
self.widgets = widgets
|
||||
|
||||
# Contains widgets + geometry for every widget that changed (added, removed, or updated)
|
||||
@@ -272,13 +271,7 @@ class Compositor:
|
||||
# Widgets in both new and old
|
||||
common_widgets = old_widgets & new_widgets
|
||||
|
||||
# Widgets with changed size
|
||||
resized_widgets = {
|
||||
widget
|
||||
for widget, (region, *_) in changes
|
||||
if (widget in common_widgets and old_map[widget].region[2:] != region[2:])
|
||||
}
|
||||
|
||||
# Mark dirty regions.
|
||||
screen_region = size.region
|
||||
if screen_region not in self._dirty_regions:
|
||||
regions = {
|
||||
@@ -291,12 +284,80 @@ class Compositor:
|
||||
}
|
||||
self._dirty_regions.update(regions)
|
||||
|
||||
resized_widgets = {
|
||||
widget
|
||||
for widget, (region, *_) in changes
|
||||
if (widget in common_widgets and old_map[widget].region[2:] != region[2:])
|
||||
}
|
||||
# Newly visible widgets
|
||||
shown_widgets = new_widgets - old_widgets
|
||||
# Newly hidden widgets
|
||||
hidden_widgets = self.widgets - widgets
|
||||
return ReflowResult(
|
||||
hidden=hidden_widgets,
|
||||
shown=shown_widgets,
|
||||
resized=resized_widgets,
|
||||
)
|
||||
|
||||
def reflow_visible(self, parent: Widget, size: Size) -> set[Widget]:
|
||||
"""Reflow only the visible children.
|
||||
|
||||
This is a fast-path for scrolling.
|
||||
|
||||
Args:
|
||||
parent: The root widget.
|
||||
size: Size of the area to be filled.
|
||||
|
||||
Returns:
|
||||
Set of widgets that were exposed by the scroll.
|
||||
|
||||
"""
|
||||
self._cuts = None
|
||||
self._layers = None
|
||||
self._layers_visible = None
|
||||
self._visible_widgets = None
|
||||
self._full_map = None
|
||||
self.root = parent
|
||||
self.size = size
|
||||
|
||||
# Keep a copy of the old map because we're going to compare it with the update
|
||||
old_map = self.map
|
||||
map, widgets = self._arrange_root(parent, size, visible_only=True)
|
||||
|
||||
exposed_widgets = map.keys() - old_map.keys()
|
||||
# Replace map and widgets
|
||||
self.map = map
|
||||
self.widgets = widgets
|
||||
|
||||
# Contains widgets + geometry for every widget that changed (added, removed, or updated)
|
||||
changes = map.items() ^ old_map.items()
|
||||
|
||||
# Mark dirty regions.
|
||||
screen_region = size.region
|
||||
if screen_region not in self._dirty_regions:
|
||||
regions = {
|
||||
region
|
||||
for region in (
|
||||
map_geometry.clip.intersection(map_geometry.region)
|
||||
for _, map_geometry in changes
|
||||
)
|
||||
if region
|
||||
}
|
||||
self._dirty_regions.update(regions)
|
||||
|
||||
return exposed_widgets
|
||||
|
||||
@property
|
||||
def full_map(self) -> CompositorMap:
|
||||
"""Lazily built compositor map that covers all widgets."""
|
||||
if self.root is None or not self.map:
|
||||
return {}
|
||||
if self._full_map is None:
|
||||
map, widgets = self._arrange_root(self.root, self.size, visible_only=False)
|
||||
self._full_map = map
|
||||
|
||||
return self._full_map
|
||||
|
||||
@property
|
||||
def visible_widgets(self) -> dict[Widget, tuple[Region, Region]]:
|
||||
"""Get a mapping of widgets on to region and clip.
|
||||
@@ -322,9 +383,9 @@ class Compositor:
|
||||
return self._visible_widgets
|
||||
|
||||
def _arrange_root(
|
||||
self, root: Widget, size: Size
|
||||
self, root: Widget, size: Size, visible_only: bool = True
|
||||
) -> tuple[CompositorMap, set[Widget]]:
|
||||
"""Arrange a widgets children based on its layout attribute.
|
||||
"""Arrange a widget's children based on its layout attribute.
|
||||
|
||||
Args:
|
||||
root: Top level widget.
|
||||
@@ -337,6 +398,7 @@ class Compositor:
|
||||
|
||||
map: CompositorMap = {}
|
||||
widgets: set[Widget] = set()
|
||||
add_new_widget = widgets.add
|
||||
layer_order: int = 0
|
||||
|
||||
def add_widget(
|
||||
@@ -362,7 +424,7 @@ class Compositor:
|
||||
visible = visibility == "visible"
|
||||
|
||||
if visible:
|
||||
widgets.add(widget)
|
||||
add_new_widget(widget)
|
||||
styles_offset = widget.styles.offset
|
||||
layout_offset = (
|
||||
styles_offset.resolve(region.size, clip.size)
|
||||
@@ -389,69 +451,75 @@ class Compositor:
|
||||
|
||||
if widget.is_container:
|
||||
# Arrange the layout
|
||||
placements, arranged_widgets, spacing = widget._arrange(
|
||||
child_region.size
|
||||
)
|
||||
arrange_result = widget._arrange(child_region.size)
|
||||
arranged_widgets = arrange_result.widgets
|
||||
spacing = arrange_result.spacing
|
||||
widgets.update(arranged_widgets)
|
||||
|
||||
if placements:
|
||||
# An offset added to all placements
|
||||
placement_offset = container_region.offset
|
||||
placement_scroll_offset = (
|
||||
placement_offset - widget.scroll_offset
|
||||
if visible_only:
|
||||
placements = arrange_result.get_visible_placements(
|
||||
container_size.region + widget.scroll_offset
|
||||
)
|
||||
else:
|
||||
placements = arrange_result.placements
|
||||
total_region = total_region.union(arrange_result.total_region)
|
||||
|
||||
# An offset added to all placements
|
||||
placement_offset = container_region.offset
|
||||
placement_scroll_offset = placement_offset - widget.scroll_offset
|
||||
|
||||
_layers = widget.layers
|
||||
layers_to_index = {
|
||||
layer_name: index for index, layer_name in enumerate(_layers)
|
||||
}
|
||||
get_layer_index = layers_to_index.get
|
||||
|
||||
# Add all the widgets
|
||||
for sub_region, margin, sub_widget, z, fixed in reversed(
|
||||
placements
|
||||
):
|
||||
# Combine regions with children to calculate the "virtual size"
|
||||
if fixed:
|
||||
widget_region = sub_region + placement_offset
|
||||
else:
|
||||
total_region = total_region.union(
|
||||
sub_region.grow(spacing + margin)
|
||||
)
|
||||
widget_region = sub_region + placement_scroll_offset
|
||||
|
||||
widget_order = (
|
||||
*order,
|
||||
get_layer_index(sub_widget.layer, 0),
|
||||
z,
|
||||
layer_order,
|
||||
)
|
||||
|
||||
_layers = widget.layers
|
||||
layers_to_index = {
|
||||
layer_name: index
|
||||
for index, layer_name in enumerate(_layers)
|
||||
}
|
||||
get_layer_index = layers_to_index.get
|
||||
add_widget(
|
||||
sub_widget,
|
||||
sub_region,
|
||||
widget_region,
|
||||
widget_order,
|
||||
layer_order,
|
||||
sub_clip,
|
||||
visible,
|
||||
)
|
||||
|
||||
# Add all the widgets
|
||||
for sub_region, margin, sub_widget, z, fixed in reversed(
|
||||
placements
|
||||
):
|
||||
# Combine regions with children to calculate the "virtual size"
|
||||
if fixed:
|
||||
widget_region = sub_region + placement_offset
|
||||
else:
|
||||
total_region = total_region.union(
|
||||
sub_region.grow(spacing + margin)
|
||||
)
|
||||
widget_region = sub_region + placement_scroll_offset
|
||||
|
||||
widget_order = (
|
||||
*order,
|
||||
get_layer_index(sub_widget.layer, 0),
|
||||
z,
|
||||
layer_order,
|
||||
)
|
||||
|
||||
add_widget(
|
||||
sub_widget,
|
||||
sub_region,
|
||||
widget_region,
|
||||
widget_order,
|
||||
layer_order,
|
||||
sub_clip,
|
||||
visible,
|
||||
)
|
||||
layer_order -= 1
|
||||
layer_order -= 1
|
||||
|
||||
if visible:
|
||||
# Add any scrollbars
|
||||
for chrome_widget, chrome_region in widget._arrange_scrollbars(
|
||||
container_region
|
||||
):
|
||||
map[chrome_widget] = _MapGeometry(
|
||||
chrome_region + layout_offset,
|
||||
order,
|
||||
clip,
|
||||
container_size,
|
||||
container_size,
|
||||
chrome_region,
|
||||
)
|
||||
if any(widget.scrollbars_enabled):
|
||||
for chrome_widget, chrome_region in widget._arrange_scrollbars(
|
||||
container_region
|
||||
):
|
||||
map[chrome_widget] = _MapGeometry(
|
||||
chrome_region + layout_offset,
|
||||
order,
|
||||
clip,
|
||||
container_size,
|
||||
container_size,
|
||||
chrome_region,
|
||||
)
|
||||
|
||||
map[widget] = _MapGeometry(
|
||||
region + layout_offset,
|
||||
@@ -519,7 +587,10 @@ class Compositor:
|
||||
try:
|
||||
return self.map[widget].region.offset
|
||||
except KeyError:
|
||||
raise errors.NoWidget("Widget is not in layout")
|
||||
try:
|
||||
return self.full_map[widget].region.offset
|
||||
except KeyError:
|
||||
raise errors.NoWidget("Widget is not in layout")
|
||||
|
||||
def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]:
|
||||
"""Get the widget under a given coordinate.
|
||||
@@ -601,10 +672,15 @@ class Compositor:
|
||||
Widget's composition information.
|
||||
|
||||
"""
|
||||
if self.root is None or not self.map:
|
||||
raise errors.NoWidget("Widget is not in layout")
|
||||
try:
|
||||
region = self.map[widget]
|
||||
except KeyError:
|
||||
raise errors.NoWidget("Widget is not in layout")
|
||||
try:
|
||||
return self.full_map[widget]
|
||||
except KeyError:
|
||||
raise errors.NoWidget("Widget is not in layout")
|
||||
else:
|
||||
return region
|
||||
|
||||
@@ -788,6 +864,7 @@ class Compositor:
|
||||
widget: Widget to update.
|
||||
|
||||
"""
|
||||
self._full_map = None
|
||||
regions: list[Region] = []
|
||||
add_region = regions.append
|
||||
get_widget = self.visible_widgets.__getitem__
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, ClassVar, NamedTuple
|
||||
|
||||
from ._spatial_map import SpatialMap
|
||||
from .geometry import Region, Size, Spacing
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -11,7 +13,55 @@ if TYPE_CHECKING:
|
||||
from .widget import Widget
|
||||
|
||||
ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]"
|
||||
DockArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget], Spacing]"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DockArrangeResult:
|
||||
placements: list[WidgetPlacement]
|
||||
"""A `WidgetPlacement` for every widget to describe it's location on screen."""
|
||||
widgets: set[Widget]
|
||||
"""A set of widgets in the arrangement."""
|
||||
spacing: Spacing
|
||||
"""Shared spacing around the widgets."""
|
||||
|
||||
_spatial_map: SpatialMap[WidgetPlacement] | None = None
|
||||
|
||||
@property
|
||||
def spatial_map(self) -> SpatialMap[WidgetPlacement]:
|
||||
"""A lazy-calculated spatial map."""
|
||||
if self._spatial_map is None:
|
||||
self._spatial_map = SpatialMap()
|
||||
self._spatial_map.insert(
|
||||
(
|
||||
placement.region.grow(placement.margin),
|
||||
placement.fixed,
|
||||
placement,
|
||||
)
|
||||
for placement in self.placements
|
||||
)
|
||||
|
||||
return self._spatial_map
|
||||
|
||||
@property
|
||||
def total_region(self) -> Region:
|
||||
"""The total area occupied by the arrangement.
|
||||
|
||||
Returns:
|
||||
A Region.
|
||||
"""
|
||||
return self.spatial_map.total_region
|
||||
|
||||
def get_visible_placements(self, region: Region) -> list[WidgetPlacement]:
|
||||
"""Get the placements visible within the given region.
|
||||
|
||||
Args:
|
||||
region: A region.
|
||||
|
||||
Returns:
|
||||
Set of placements.
|
||||
"""
|
||||
visible_placements = self.spatial_map.get_values_in_region(region)
|
||||
return visible_placements
|
||||
|
||||
|
||||
class WidgetPlacement(NamedTuple):
|
||||
@@ -61,7 +111,7 @@ class Layout(ABC):
|
||||
width = 0
|
||||
else:
|
||||
# Use a size of 0, 0 to ignore relative sizes, since those are flexible anyway
|
||||
placements, _, _ = widget._arrange(Size(0, 0))
|
||||
placements = widget._arrange(Size(0, 0)).placements
|
||||
width = max(
|
||||
[
|
||||
placement.region.right + placement.margin.right
|
||||
@@ -89,7 +139,7 @@ class Layout(ABC):
|
||||
height = 0
|
||||
else:
|
||||
# Use a height of zero to ignore relative heights
|
||||
placements, _, _ = widget._arrange(Size(width, 0))
|
||||
placements = widget._arrange(Size(width, 0)).placements
|
||||
height = max(
|
||||
[
|
||||
placement.region.bottom + placement.margin.bottom
|
||||
|
||||
103
src/textual/_spatial_map.py
Normal file
103
src/textual/_spatial_map.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from itertools import product
|
||||
from typing import Generic, Iterable, TypeVar
|
||||
|
||||
from typing_extensions import TypeAlias
|
||||
|
||||
from .geometry import Region
|
||||
|
||||
ValueType = TypeVar("ValueType")
|
||||
GridCoordinate: TypeAlias = "tuple[int, int]"
|
||||
|
||||
|
||||
class SpatialMap(Generic[ValueType]):
|
||||
"""A spatial map allows for data to be associated with rectangular regions
|
||||
in Euclidean space, and efficiently queried.
|
||||
|
||||
When the SpatialMap is populated, a reference to each value is placed into one or
|
||||
more buckets associated with a regular grid that covers 2D space.
|
||||
|
||||
The SpatialMap is able to quickly retrieve the values under a given "window" region
|
||||
by combining the values in the grid squares under the visible area.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, grid_width: int = 100, grid_height: int = 20) -> None:
|
||||
"""Create a spatial map with the given grid size.
|
||||
|
||||
Args:
|
||||
grid_width: Width of a grid square.
|
||||
grid_height: Height of a grid square.
|
||||
"""
|
||||
self._grid_size = (grid_width, grid_height)
|
||||
self.total_region = Region()
|
||||
self._map: defaultdict[GridCoordinate, list[ValueType]] = defaultdict(list)
|
||||
self._fixed: list[ValueType] = []
|
||||
|
||||
def _region_to_grid_coordinates(self, region: Region) -> Iterable[GridCoordinate]:
|
||||
"""Get the grid squares under a region.
|
||||
|
||||
Args:
|
||||
region: A region.
|
||||
|
||||
Returns:
|
||||
Iterable of grid coordinates (tuple of 2 values).
|
||||
"""
|
||||
# (x1, y1) is the coordinate of the top left cell
|
||||
# (x2, y2) is the coordinate of the bottom right cell
|
||||
x1, y1, width, height = region
|
||||
x2 = x1 + width - 1
|
||||
y2 = y1 + height - 1
|
||||
grid_width, grid_height = self._grid_size
|
||||
|
||||
return product(
|
||||
range(x1 // grid_width, x2 // grid_width + 1),
|
||||
range(y1 // grid_height, y2 // grid_height + 1),
|
||||
)
|
||||
|
||||
def insert(
|
||||
self, regions_and_values: Iterable[tuple[Region, bool, ValueType]]
|
||||
) -> None:
|
||||
"""Insert values into the Spatial map.
|
||||
|
||||
Values are associated with their region in Euclidean space, and a boolean that
|
||||
indicates fixed regions. Fixed regions don't scroll and are always visible.
|
||||
|
||||
Args:
|
||||
regions_and_values: An iterable of (REGION, FIXED, VALUE).
|
||||
"""
|
||||
append_fixed = self._fixed.append
|
||||
get_grid_list = self._map.__getitem__
|
||||
_region_to_grid = self._region_to_grid_coordinates
|
||||
total_region = self.total_region
|
||||
for region, fixed, value in regions_and_values:
|
||||
total_region = total_region.union(region)
|
||||
if fixed:
|
||||
append_fixed(value)
|
||||
else:
|
||||
for grid in _region_to_grid(region):
|
||||
get_grid_list(grid).append(value)
|
||||
self.total_region = total_region
|
||||
|
||||
def get_values_in_region(self, region: Region) -> list[ValueType]:
|
||||
"""Get a superset of all the values that intersect with a given region.
|
||||
|
||||
Note that this may return false positives.
|
||||
|
||||
Args:
|
||||
region: A region.
|
||||
|
||||
Returns:
|
||||
Values under the region.
|
||||
"""
|
||||
results: list[ValueType] = self._fixed.copy()
|
||||
add_results = results.extend
|
||||
get_grid_values = self._map.get
|
||||
for grid_coordinate in self._region_to_grid_coordinates(region):
|
||||
grid_values = get_grid_values(grid_coordinate)
|
||||
if grid_values is not None:
|
||||
add_results(grid_values)
|
||||
unique_values = list(dict.fromkeys(results))
|
||||
return unique_values
|
||||
@@ -11,7 +11,12 @@ import unicodedata
|
||||
import warnings
|
||||
from asyncio import Task
|
||||
from concurrent.futures import Future
|
||||
from contextlib import asynccontextmanager, redirect_stderr, redirect_stdout
|
||||
from contextlib import (
|
||||
asynccontextmanager,
|
||||
contextmanager,
|
||||
redirect_stderr,
|
||||
redirect_stdout,
|
||||
)
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from pathlib import Path, PurePath
|
||||
@@ -22,6 +27,7 @@ from typing import (
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Generator,
|
||||
Generic,
|
||||
Iterable,
|
||||
List,
|
||||
@@ -242,6 +248,11 @@ class App(Generic[ReturnType], DOMNode):
|
||||
background: $background;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
*:disabled {
|
||||
opacity: 0.6;
|
||||
text-opacity: 0.8;
|
||||
}
|
||||
"""
|
||||
|
||||
SCREENS: dict[str, Screen | Callable[[], Screen]] = {}
|
||||
@@ -415,6 +426,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self._screenshot: str | None = None
|
||||
self._dom_lock = asyncio.Lock()
|
||||
self._dom_ready = False
|
||||
self._batch_count = 0
|
||||
self.set_class(self.dark, "-dark-mode")
|
||||
|
||||
@property
|
||||
@@ -430,6 +442,30 @@ class App(Generic[ReturnType], DOMNode):
|
||||
except ScreenError:
|
||||
return ()
|
||||
|
||||
@contextmanager
|
||||
def batch_update(self) -> Generator[None, None, None]:
|
||||
"""Suspend all repaints until the end of the batch."""
|
||||
self._begin_batch()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._end_batch()
|
||||
|
||||
def _begin_batch(self) -> None:
|
||||
"""Begin a batch update."""
|
||||
self._batch_count += 1
|
||||
|
||||
def _end_batch(self) -> None:
|
||||
"""End a batch update."""
|
||||
self._batch_count -= 1
|
||||
assert self._batch_count >= 0, "This won't happen if you use `batch_update`"
|
||||
if not self._batch_count:
|
||||
try:
|
||||
self.screen.check_idle()
|
||||
except ScreenStackError:
|
||||
pass
|
||||
self.check_idle()
|
||||
|
||||
def animate(
|
||||
self,
|
||||
attribute: str,
|
||||
@@ -1508,28 +1544,29 @@ class App(Generic[ReturnType], DOMNode):
|
||||
if inspect.isawaitable(ready_result):
|
||||
await ready_result
|
||||
|
||||
try:
|
||||
with self.batch_update():
|
||||
try:
|
||||
await self._dispatch_message(events.Compose(sender=self))
|
||||
await self._dispatch_message(events.Mount(sender=self))
|
||||
try:
|
||||
await self._dispatch_message(events.Compose(sender=self))
|
||||
await self._dispatch_message(events.Mount(sender=self))
|
||||
finally:
|
||||
self._mounted_event.set()
|
||||
|
||||
Reactive._initialize_object(self)
|
||||
|
||||
self.stylesheet.update(self)
|
||||
self.refresh()
|
||||
|
||||
await self.animator.start()
|
||||
|
||||
except Exception:
|
||||
await self.animator.stop()
|
||||
raise
|
||||
|
||||
finally:
|
||||
self._mounted_event.set()
|
||||
|
||||
Reactive._initialize_object(self)
|
||||
|
||||
self.stylesheet.update(self)
|
||||
self.refresh()
|
||||
|
||||
await self.animator.start()
|
||||
|
||||
except Exception:
|
||||
await self.animator.stop()
|
||||
raise
|
||||
|
||||
finally:
|
||||
self._running = True
|
||||
await self._ready()
|
||||
await invoke_ready_callback()
|
||||
self._running = True
|
||||
await self._ready()
|
||||
await invoke_ready_callback()
|
||||
|
||||
try:
|
||||
await self._process_messages_loop()
|
||||
@@ -1615,11 +1652,12 @@ class App(Generic[ReturnType], DOMNode):
|
||||
raise TypeError(
|
||||
f"{self!r} compose() returned an invalid response; {error}"
|
||||
) from error
|
||||
|
||||
await self.mount_all(widgets)
|
||||
|
||||
def _on_idle(self) -> None:
|
||||
"""Perform actions when there are no messages in the queue."""
|
||||
if self._require_stylesheet_update:
|
||||
if self._require_stylesheet_update and not self._batch_count:
|
||||
nodes: set[DOMNode] = {
|
||||
child
|
||||
for node in self._require_stylesheet_update
|
||||
@@ -1782,6 +1820,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
await child._close_messages()
|
||||
|
||||
async def _shutdown(self) -> None:
|
||||
self._begin_update() # Prevents any layout / repaint while shutting down
|
||||
driver = self._driver
|
||||
self._running = False
|
||||
if driver is not None:
|
||||
@@ -1799,6 +1838,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self._writer_thread.stop()
|
||||
|
||||
async def _on_exit_app(self) -> None:
|
||||
self._begin_batch() # Prevent repaint / layout while shutting down
|
||||
await self._message_queue.put(None)
|
||||
|
||||
def refresh(self, *, repaint: bool = True, layout: bool = False) -> None:
|
||||
@@ -1907,7 +1947,6 @@ class App(Generic[ReturnType], DOMNode):
|
||||
# Handle input events that haven't been forwarded
|
||||
# If the event has been forwarded it may have bubbled up back to the App
|
||||
if isinstance(event, events.Compose):
|
||||
self.log(event)
|
||||
screen = Screen(id="_default")
|
||||
self._register(self, screen)
|
||||
self._screen_stack.append(screen)
|
||||
|
||||
@@ -68,7 +68,6 @@ class ColorsApp(App):
|
||||
content.mount(ColorsView())
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
self.bell()
|
||||
self.query(ColorGroup).remove_class("-active")
|
||||
group = self.query_one(f"#group-{event.button.id}", ColorGroup)
|
||||
group.add_class("-active")
|
||||
|
||||
@@ -75,7 +75,7 @@ expect_selector_continue = Expect(
|
||||
selector_id=r"\#[a-zA-Z_\-][a-zA-Z0-9_\-]*",
|
||||
selector_class=r"\.[a-zA-Z_\-][a-zA-Z0-9_\-]*",
|
||||
selector_universal=r"\*",
|
||||
selector=r"[a-zA-Z_\-]+",
|
||||
selector=IDENTIFIER,
|
||||
combinator_child=">",
|
||||
new_selector=r",",
|
||||
declaration_set_start=r"\{",
|
||||
|
||||
@@ -872,6 +872,16 @@ class DOMNode(MessagePump):
|
||||
else:
|
||||
self.remove_class(*class_names)
|
||||
|
||||
def _update_styles(self) -> None:
|
||||
"""Request an update of this node's styles.
|
||||
|
||||
Should be called whenever CSS classes / pseudo classes change.
|
||||
"""
|
||||
try:
|
||||
self.app.update_styles(self)
|
||||
except NoActiveAppError:
|
||||
pass
|
||||
|
||||
def add_class(self, *class_names: str) -> None:
|
||||
"""Add class names to this Node.
|
||||
|
||||
@@ -884,10 +894,7 @@ class DOMNode(MessagePump):
|
||||
self._classes.update(class_names)
|
||||
if old_classes == self._classes:
|
||||
return
|
||||
try:
|
||||
self.app.update_styles(self)
|
||||
except NoActiveAppError:
|
||||
pass
|
||||
self._update_styles()
|
||||
|
||||
def remove_class(self, *class_names: str) -> None:
|
||||
"""Remove class names from this Node.
|
||||
@@ -900,10 +907,7 @@ class DOMNode(MessagePump):
|
||||
self._classes.difference_update(class_names)
|
||||
if old_classes == self._classes:
|
||||
return
|
||||
try:
|
||||
self.app.update_styles(self)
|
||||
except NoActiveAppError:
|
||||
pass
|
||||
self._update_styles()
|
||||
|
||||
def toggle_class(self, *class_names: str) -> None:
|
||||
"""Toggle class names on this Node.
|
||||
@@ -916,10 +920,7 @@ class DOMNode(MessagePump):
|
||||
self._classes.symmetric_difference_update(class_names)
|
||||
if old_classes == self._classes:
|
||||
return
|
||||
try:
|
||||
self.app.update_styles(self)
|
||||
except NoActiveAppError:
|
||||
pass
|
||||
self._update_styles()
|
||||
|
||||
def has_pseudo_class(self, *class_names: str) -> bool:
|
||||
"""Check for pseudo classes (such as hover, focus etc)
|
||||
|
||||
@@ -45,12 +45,24 @@ class Update(Message, verbose=True):
|
||||
|
||||
@rich.repr.auto
|
||||
class Layout(Message, verbose=True):
|
||||
"""Sent by Textual when a layout is required."""
|
||||
|
||||
def can_replace(self, message: Message) -> bool:
|
||||
return isinstance(message, Layout)
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class UpdateScroll(Message, verbose=True):
|
||||
"""Sent by Textual when a scroll update is required."""
|
||||
|
||||
def can_replace(self, message: Message) -> bool:
|
||||
return isinstance(message, UpdateScroll)
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class InvokeLater(Message, verbose=True, bubble=False):
|
||||
"""Sent by Textual to invoke a callback."""
|
||||
|
||||
def __init__(self, sender: MessagePump, callback: CallbackType) -> None:
|
||||
self.callback = callback
|
||||
super().__init__(sender)
|
||||
|
||||
@@ -143,24 +143,25 @@ class Reactive(Generic[ReactiveType]):
|
||||
self.name = name
|
||||
# The internal name where the attribute's value is stored
|
||||
self.internal_name = f"_reactive_{name}"
|
||||
self.compute_name = f"compute_{name}"
|
||||
default = self._default
|
||||
setattr(owner, f"_default_{name}", default)
|
||||
|
||||
def __get__(self, obj: Reactable, obj_type: type[object]) -> ReactiveType:
|
||||
_rich_traceback_omit = True
|
||||
internal_name = self.internal_name
|
||||
if not hasattr(obj, internal_name):
|
||||
self._initialize_reactive(obj, self.name)
|
||||
|
||||
self._initialize_reactive(obj, self.name)
|
||||
|
||||
value: ReactiveType
|
||||
compute_method = getattr(self, f"compute_{self.name}", None)
|
||||
if compute_method is not None:
|
||||
old_value = getattr(obj, self.internal_name)
|
||||
value = getattr(obj, f"compute_{self.name}")()
|
||||
setattr(obj, self.internal_name, value)
|
||||
if hasattr(obj, self.compute_name):
|
||||
value: ReactiveType
|
||||
old_value = getattr(obj, internal_name)
|
||||
_rich_traceback_omit = True
|
||||
value = getattr(obj, self.compute_name)()
|
||||
setattr(obj, internal_name, value)
|
||||
self._check_watchers(obj, self.name, old_value)
|
||||
return value
|
||||
else:
|
||||
value = getattr(obj, self.internal_name)
|
||||
return value
|
||||
return getattr(obj, internal_name)
|
||||
|
||||
def __set__(self, obj: Reactable, value: ReactiveType) -> None:
|
||||
_rich_traceback_omit = True
|
||||
|
||||
@@ -80,16 +80,6 @@ class Screen(Widget):
|
||||
)
|
||||
return self._update_timer
|
||||
|
||||
@property
|
||||
def widgets(self) -> list[Widget]:
|
||||
"""Get all widgets."""
|
||||
return list(self._compositor.map.keys())
|
||||
|
||||
@property
|
||||
def visible_widgets(self) -> list[Widget]:
|
||||
"""Get a list of visible widgets."""
|
||||
return list(self._compositor.visible_widgets)
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
background = self.styles.background
|
||||
if background.is_transparent:
|
||||
@@ -159,11 +149,7 @@ class Screen(Widget):
|
||||
|
||||
@property
|
||||
def focus_chain(self) -> list[Widget]:
|
||||
"""Get widgets that may receive focus, in focus order.
|
||||
|
||||
Returns:
|
||||
List of Widgets in focus order.
|
||||
"""
|
||||
"""A list of widgets that may receive focus, in focus order."""
|
||||
widgets: list[Widget] = []
|
||||
add_widget = widgets.append
|
||||
stack: list[Iterator[Widget]] = [iter(self.focusable_children)]
|
||||
@@ -177,7 +163,7 @@ class Screen(Widget):
|
||||
else:
|
||||
if node.is_container and node.can_focus_children:
|
||||
push(iter(node.focusable_children))
|
||||
if node.can_focus:
|
||||
if node.focusable:
|
||||
add_widget(node)
|
||||
|
||||
return widgets
|
||||
@@ -314,7 +300,7 @@ class Screen(Widget):
|
||||
# It may have been made invisible
|
||||
# Move to a sibling if possible
|
||||
for sibling in widget.visible_siblings:
|
||||
if sibling not in avoiding and sibling.can_focus:
|
||||
if sibling not in avoiding and sibling.focusable:
|
||||
self.set_focus(sibling)
|
||||
break
|
||||
else:
|
||||
@@ -351,7 +337,7 @@ class Screen(Widget):
|
||||
self.focused.post_message_no_wait(events.Blur(self))
|
||||
self.focused = None
|
||||
self.log.debug("focus was removed")
|
||||
elif widget.can_focus:
|
||||
elif widget.focusable:
|
||||
if self.focused != widget:
|
||||
if self.focused is not None:
|
||||
# Blur currently focused widget
|
||||
@@ -368,13 +354,18 @@ class Screen(Widget):
|
||||
# Check for any widgets marked as 'dirty' (needs a repaint)
|
||||
event.prevent_default()
|
||||
|
||||
if self.is_current:
|
||||
if not self.app._batch_count and self.is_current:
|
||||
async with self.app._dom_lock:
|
||||
if self.is_current:
|
||||
if self._layout_required:
|
||||
self._refresh_layout()
|
||||
self._layout_required = False
|
||||
self._scroll_required = False
|
||||
self._dirty_widgets.clear()
|
||||
elif self._scroll_required:
|
||||
self._refresh_layout(scroll=True)
|
||||
self._scroll_required = False
|
||||
|
||||
if self._repaint_required:
|
||||
self._dirty_widgets.clear()
|
||||
self._dirty_widgets.add(self)
|
||||
@@ -423,7 +414,9 @@ class Screen(Widget):
|
||||
self._callbacks.append(callback)
|
||||
self.check_idle()
|
||||
|
||||
def _refresh_layout(self, size: Size | None = None, full: bool = False) -> None:
|
||||
def _refresh_layout(
|
||||
self, size: Size | None = None, full: bool = False, scroll: bool = False
|
||||
) -> None:
|
||||
"""Refresh the layout (can change size and positions of widgets)."""
|
||||
size = self.outer_size if size is None else size
|
||||
if not size:
|
||||
@@ -431,35 +424,64 @@ class Screen(Widget):
|
||||
|
||||
self._compositor.update_widgets(self._dirty_widgets)
|
||||
self.update_timer.pause()
|
||||
ResizeEvent = events.Resize
|
||||
try:
|
||||
hidden, shown, resized = self._compositor.reflow(self, size)
|
||||
Hide = events.Hide
|
||||
Show = events.Show
|
||||
if scroll:
|
||||
exposed_widgets = self._compositor.reflow_visible(self, size)
|
||||
if exposed_widgets:
|
||||
layers = self._compositor.layers
|
||||
|
||||
for widget in hidden:
|
||||
widget.post_message_no_wait(Hide(self))
|
||||
for widget, (
|
||||
region,
|
||||
_order,
|
||||
_clip,
|
||||
virtual_size,
|
||||
container_size,
|
||||
_,
|
||||
) in layers:
|
||||
if widget in exposed_widgets:
|
||||
if widget._size_updated(
|
||||
region.size,
|
||||
virtual_size,
|
||||
container_size,
|
||||
layout=False,
|
||||
):
|
||||
widget.post_message_no_wait(
|
||||
ResizeEvent(
|
||||
self,
|
||||
region.size,
|
||||
virtual_size,
|
||||
container_size,
|
||||
)
|
||||
)
|
||||
else:
|
||||
hidden, shown, resized = self._compositor.reflow(self, size)
|
||||
Hide = events.Hide
|
||||
Show = events.Show
|
||||
|
||||
# We want to send a resize event to widgets that were just added or change since last layout
|
||||
send_resize = shown | resized
|
||||
ResizeEvent = events.Resize
|
||||
for widget in hidden:
|
||||
widget.post_message_no_wait(Hide(self))
|
||||
|
||||
layers = self._compositor.layers
|
||||
for widget, (
|
||||
region,
|
||||
_order,
|
||||
_clip,
|
||||
virtual_size,
|
||||
container_size,
|
||||
_,
|
||||
) in layers:
|
||||
widget._size_updated(region.size, virtual_size, container_size)
|
||||
if widget in send_resize:
|
||||
widget.post_message_no_wait(
|
||||
ResizeEvent(self, region.size, virtual_size, container_size)
|
||||
)
|
||||
# We want to send a resize event to widgets that were just added or change since last layout
|
||||
send_resize = shown | resized
|
||||
|
||||
for widget in shown:
|
||||
widget.post_message_no_wait(Show(self))
|
||||
layers = self._compositor.layers
|
||||
for widget, (
|
||||
region,
|
||||
_order,
|
||||
_clip,
|
||||
virtual_size,
|
||||
container_size,
|
||||
_,
|
||||
) in layers:
|
||||
widget._size_updated(region.size, virtual_size, container_size)
|
||||
if widget in send_resize:
|
||||
widget.post_message_no_wait(
|
||||
ResizeEvent(self, region.size, virtual_size, container_size)
|
||||
)
|
||||
|
||||
for widget in shown:
|
||||
widget.post_message_no_wait(Show(self))
|
||||
|
||||
except Exception as error:
|
||||
self.app._handle_exception(error)
|
||||
@@ -484,6 +506,12 @@ class Screen(Widget):
|
||||
self._layout_required = True
|
||||
self.check_idle()
|
||||
|
||||
async def _on_update_scroll(self, message: messages.UpdateScroll) -> None:
|
||||
message.stop()
|
||||
message.prevent_default()
|
||||
self._scroll_required = True
|
||||
self.check_idle()
|
||||
|
||||
def _screen_resized(self, size: Size):
|
||||
"""Called by App when the screen is resized."""
|
||||
self._refresh_layout(size, full=True)
|
||||
@@ -547,7 +575,7 @@ class Screen(Widget):
|
||||
except errors.NoWidget:
|
||||
self.set_focus(None)
|
||||
else:
|
||||
if isinstance(event, events.MouseUp) and widget.can_focus:
|
||||
if isinstance(event, events.MouseUp) and widget.focusable:
|
||||
if self.focused is not widget:
|
||||
self.set_focus(widget)
|
||||
event.stop()
|
||||
|
||||
@@ -69,14 +69,18 @@ class ScrollView(Widget):
|
||||
return self.virtual_size.height
|
||||
|
||||
def _size_updated(
|
||||
self, size: Size, virtual_size: Size, container_size: Size
|
||||
) -> None:
|
||||
self, size: Size, virtual_size: Size, container_size: Size, layout: bool = True
|
||||
) -> bool:
|
||||
"""Called when size is updated.
|
||||
|
||||
Args:
|
||||
size: New size.
|
||||
virtual_size: New virtual size.
|
||||
container_size: New container size.
|
||||
layout: Perform layout if required.
|
||||
|
||||
Returns:
|
||||
True if anything changed, or False if nothing changed.
|
||||
"""
|
||||
if self._size != size or container_size != container_size:
|
||||
self.refresh()
|
||||
@@ -90,6 +94,9 @@ class ScrollView(Widget):
|
||||
self._container_size = size - self.styles.gutter.totals
|
||||
self._scroll_update(virtual_size)
|
||||
self.scroll_to(self.scroll_x, self.scroll_y, animate=False)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
"""Render the scrollable region (if `render_lines` is not implemented).
|
||||
|
||||
@@ -112,8 +112,14 @@ class ScrollBarRender:
|
||||
if window_size and size and virtual_size and size != virtual_size:
|
||||
step_size = virtual_size / size
|
||||
|
||||
thumb_size = window_size / step_size * len_bars
|
||||
|
||||
if thumb_size < len_bars:
|
||||
virtual_size += step_size
|
||||
step_size = virtual_size / size
|
||||
|
||||
start = int(position / step_size * len_bars)
|
||||
end = start + max(len_bars, int(ceil(window_size / step_size * len_bars)))
|
||||
end = start + max(len_bars, ceil(thumb_size))
|
||||
|
||||
start_index, start_bar = divmod(max(0, start), len_bars)
|
||||
end_index, end_bar = divmod(max(0, end), len_bars)
|
||||
@@ -246,6 +252,7 @@ class ScrollBar(Widget):
|
||||
yield "thickness", self.thickness
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
assert self.parent is not None
|
||||
styles = self.parent.styles
|
||||
if self.grabbed:
|
||||
background = styles.scrollbar_background_active
|
||||
@@ -258,11 +265,25 @@ class ScrollBar(Widget):
|
||||
color = styles.scrollbar_color
|
||||
color = background + color
|
||||
scrollbar_style = Style.from_color(color.rich_color, background.rich_color)
|
||||
return self._render_bar(scrollbar_style)
|
||||
|
||||
def _render_bar(self, scrollbar_style: Style) -> RenderableType:
|
||||
"""Get a renderable for the scrollbar with given style.
|
||||
|
||||
Args:
|
||||
scrollbar_style: Scrollbar style.
|
||||
|
||||
Returns:
|
||||
Scrollbar renderable.
|
||||
"""
|
||||
window_size = (
|
||||
self.window_size if self.window_size < self.window_virtual_size else 0
|
||||
)
|
||||
virtual_size = self.window_virtual_size
|
||||
|
||||
return self.renderer(
|
||||
virtual_size=self.window_virtual_size,
|
||||
window_size=(
|
||||
self.window_size if self.window_size < self.window_virtual_size else 0
|
||||
),
|
||||
virtual_size=ceil(virtual_size),
|
||||
window_size=ceil(window_size),
|
||||
position=self.position,
|
||||
thickness=self.thickness,
|
||||
vertical=self.vertical,
|
||||
@@ -311,19 +332,31 @@ class ScrollBar(Widget):
|
||||
x: float | None = None
|
||||
y: float | None = None
|
||||
if self.vertical:
|
||||
size = self.size.height
|
||||
virtual_size = self.window_virtual_size
|
||||
step_size = virtual_size / size
|
||||
thumb_size = self.window_size / step_size
|
||||
if thumb_size < 1:
|
||||
virtual_size = ceil(virtual_size + step_size)
|
||||
y = round(
|
||||
self.grabbed_position
|
||||
+ (
|
||||
(event.screen_y - self.grabbed.y)
|
||||
* (self.window_virtual_size / self.window_size)
|
||||
* (virtual_size / self.window_size)
|
||||
)
|
||||
)
|
||||
else:
|
||||
size = self.size.width
|
||||
virtual_size = self.window_virtual_size
|
||||
step_size = virtual_size / size
|
||||
thumb_size = self.window_size / step_size
|
||||
if thumb_size < 1:
|
||||
virtual_size = ceil(virtual_size + step_size)
|
||||
x = round(
|
||||
self.grabbed_position
|
||||
+ (
|
||||
(event.screen_x - self.grabbed.x)
|
||||
* (self.window_virtual_size / self.window_size)
|
||||
* (virtual_size / self.window_size)
|
||||
)
|
||||
)
|
||||
await self.post_message(ScrollTo(self, x=x, y=y))
|
||||
|
||||
@@ -41,6 +41,7 @@ from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction
|
||||
from ._arrange import DockArrangeResult, arrange
|
||||
from ._asyncio import create_task
|
||||
from ._compose import compose
|
||||
from ._cache import FIFOCache
|
||||
from ._context import active_app
|
||||
from ._easing import DEFAULT_SCROLL_EASING
|
||||
from ._layout import Layout
|
||||
@@ -228,6 +229,8 @@ class Widget(DOMNode):
|
||||
"""Rich renderable may shrink."""
|
||||
auto_links = Reactive(True)
|
||||
"""Widget will highlight links automatically."""
|
||||
disabled = Reactive(False)
|
||||
"""The disabled state of the widget. `True` if disabled, `False` if not."""
|
||||
|
||||
hover_style: Reactive[Style] = Reactive(Style, repaint=False)
|
||||
highlight_link_id: Reactive[str] = Reactive("")
|
||||
@@ -238,11 +241,13 @@ class Widget(DOMNode):
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
disabled: bool = False,
|
||||
) -> None:
|
||||
self._size = Size(0, 0)
|
||||
self._container_size = Size(0, 0)
|
||||
self._layout_required = False
|
||||
self._repaint_required = False
|
||||
self._scroll_required = False
|
||||
self._default_layout = VerticalLayout()
|
||||
self._animate: BoundAnimator | None = None
|
||||
self.highlight_style: Style | None = None
|
||||
@@ -262,8 +267,9 @@ class Widget(DOMNode):
|
||||
self._content_width_cache: tuple[object, int] = (None, 0)
|
||||
self._content_height_cache: tuple[object, int] = (None, 0)
|
||||
|
||||
self._arrangement_cache_key: tuple[Size, int] = (Size(), -1)
|
||||
self._cached_arrangement: DockArrangeResult | None = None
|
||||
self._arrangement_cache: FIFOCache[
|
||||
tuple[Size, int], DockArrangeResult
|
||||
] = FIFOCache(4)
|
||||
|
||||
self._styles_cache = StylesCache()
|
||||
self._rich_style_cache: dict[str, tuple[Style, Style]] = {}
|
||||
@@ -280,6 +286,7 @@ class Widget(DOMNode):
|
||||
raise WidgetError("A widget can't be its own parent")
|
||||
|
||||
self._add_children(*children)
|
||||
self.disabled = disabled
|
||||
|
||||
virtual_size = Reactive(Size(0, 0), layout=True)
|
||||
auto_width = Reactive(True)
|
||||
@@ -495,14 +502,11 @@ class Widget(DOMNode):
|
||||
assert self.is_container
|
||||
|
||||
cache_key = (size, self._nodes._updates)
|
||||
if (
|
||||
self._arrangement_cache_key == cache_key
|
||||
and self._cached_arrangement is not None
|
||||
):
|
||||
return self._cached_arrangement
|
||||
cached_result = self._arrangement_cache.get(cache_key)
|
||||
if cached_result is not None:
|
||||
return cached_result
|
||||
|
||||
self._arrangement_cache_key = cache_key
|
||||
arrangement = self._cached_arrangement = arrange(
|
||||
arrangement = self._arrangement_cache[cache_key] = arrange(
|
||||
self, self._nodes, size, self.screen.size
|
||||
)
|
||||
|
||||
@@ -510,7 +514,7 @@ class Widget(DOMNode):
|
||||
|
||||
def _clear_arrangement_cache(self) -> None:
|
||||
"""Clear arrangement cache, forcing a new arrange operation."""
|
||||
self._cached_arrangement = None
|
||||
self._arrangement_cache.clear()
|
||||
|
||||
def _get_virtual_dom(self) -> Iterable[Widget]:
|
||||
"""Get widgets not part of the DOM.
|
||||
@@ -1195,6 +1199,20 @@ class Widget(DOMNode):
|
||||
"""
|
||||
return self.virtual_region.grow(self.styles.margin)
|
||||
|
||||
@property
|
||||
def _self_or_ancestors_disabled(self) -> bool:
|
||||
"""Is this widget or any of its ancestors disabled?"""
|
||||
return any(
|
||||
node.disabled
|
||||
for node in self.ancestors_with_self
|
||||
if isinstance(node, Widget)
|
||||
)
|
||||
|
||||
@property
|
||||
def focusable(self) -> bool:
|
||||
"""Can this widget currently receive focus?"""
|
||||
return self.can_focus and not self._self_or_ancestors_disabled
|
||||
|
||||
@property
|
||||
def focusable_children(self) -> list[Widget]:
|
||||
"""Get the children which may be focused.
|
||||
@@ -1732,7 +1750,7 @@ class Widget(DOMNode):
|
||||
|
||||
"""
|
||||
return self.scroll_to(
|
||||
y=self.scroll_target_y - self.container_size.height,
|
||||
y=self.scroll_y - self.container_size.height,
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
@@ -1764,7 +1782,7 @@ class Widget(DOMNode):
|
||||
|
||||
"""
|
||||
return self.scroll_to(
|
||||
y=self.scroll_target_y + self.container_size.height,
|
||||
y=self.scroll_y + self.container_size.height,
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
@@ -1798,7 +1816,7 @@ class Widget(DOMNode):
|
||||
if speed is None and duration is None:
|
||||
duration = 0.3
|
||||
return self.scroll_to(
|
||||
x=self.scroll_target_x - self.container_size.width,
|
||||
x=self.scroll_x - self.container_size.width,
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
@@ -1832,7 +1850,7 @@ class Widget(DOMNode):
|
||||
if speed is None and duration is None:
|
||||
duration = 0.3
|
||||
return self.scroll_to(
|
||||
x=self.scroll_target_x + self.container_size.width,
|
||||
x=self.scroll_x + self.container_size.width,
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
@@ -2102,6 +2120,14 @@ class Widget(DOMNode):
|
||||
Names of the pseudo classes.
|
||||
|
||||
"""
|
||||
node = self
|
||||
while isinstance(node, Widget):
|
||||
if node.disabled:
|
||||
yield "disabled"
|
||||
break
|
||||
node = node._parent
|
||||
else:
|
||||
yield "enabled"
|
||||
if self.mouse_over:
|
||||
yield "hover"
|
||||
if self.has_focus:
|
||||
@@ -2149,21 +2175,29 @@ class Widget(DOMNode):
|
||||
def watch_mouse_over(self, value: bool) -> None:
|
||||
"""Update from CSS if mouse over state changes."""
|
||||
if self._has_hover_style:
|
||||
self.app.update_styles(self)
|
||||
self._update_styles()
|
||||
|
||||
def watch_has_focus(self, value: bool) -> None:
|
||||
"""Update from CSS if has focus state changes."""
|
||||
self.app.update_styles(self)
|
||||
self._update_styles()
|
||||
|
||||
def watch_disabled(self) -> None:
|
||||
"""Update the styles of the widget and its children when disabled is toggled."""
|
||||
self._update_styles()
|
||||
|
||||
def _size_updated(
|
||||
self, size: Size, virtual_size: Size, container_size: Size
|
||||
) -> None:
|
||||
self, size: Size, virtual_size: Size, container_size: Size, layout: bool = True
|
||||
) -> bool:
|
||||
"""Called when the widget's size is updated.
|
||||
|
||||
Args:
|
||||
size: Screen size.
|
||||
virtual_size: Virtual (scrollable) size.
|
||||
container_size: Container size (size of parent).
|
||||
layout: Perform layout if required.
|
||||
|
||||
Returns:
|
||||
True if anything changed, or False if nothing changed.
|
||||
"""
|
||||
if (
|
||||
self._size != size
|
||||
@@ -2171,11 +2205,16 @@ class Widget(DOMNode):
|
||||
or self._container_size != container_size
|
||||
):
|
||||
self._size = size
|
||||
self.virtual_size = virtual_size
|
||||
if layout:
|
||||
self.virtual_size = virtual_size
|
||||
else:
|
||||
self._reactive_virtual_size = virtual_size
|
||||
self._container_size = container_size
|
||||
if self.is_scrollable:
|
||||
self._scroll_update(virtual_size)
|
||||
self.refresh()
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def _scroll_update(self, virtual_size: Size) -> None:
|
||||
"""Update scrollbars visibility and dimensions.
|
||||
@@ -2286,7 +2325,7 @@ class Widget(DOMNode):
|
||||
|
||||
def _refresh_scroll(self) -> None:
|
||||
"""Refreshes the scroll position."""
|
||||
self._layout_required = True
|
||||
self._scroll_required = True
|
||||
self.check_idle()
|
||||
|
||||
def refresh(
|
||||
@@ -2313,8 +2352,7 @@ class Widget(DOMNode):
|
||||
repaint: Repaint the widget (will call render() again). Defaults to True.
|
||||
layout: Also layout widgets in the view. Defaults to False.
|
||||
"""
|
||||
|
||||
if layout:
|
||||
if layout and not self._layout_required:
|
||||
self._layout_required = True
|
||||
for ancestor in self.ancestors:
|
||||
if not isinstance(ancestor, Widget):
|
||||
@@ -2395,6 +2433,9 @@ class Widget(DOMNode):
|
||||
except NoScreen:
|
||||
pass
|
||||
else:
|
||||
if self._scroll_required:
|
||||
self._scroll_required = False
|
||||
screen.post_message_no_wait(messages.UpdateScroll(self))
|
||||
if self._repaint_required:
|
||||
self._repaint_required = False
|
||||
screen.post_message_no_wait(messages.Update(self, self))
|
||||
@@ -2443,6 +2484,18 @@ class Widget(DOMNode):
|
||||
"""
|
||||
self.app.capture_mouse(None)
|
||||
|
||||
def check_message_enabled(self, message: Message) -> bool:
|
||||
# Do the normal checking and get out if that fails.
|
||||
if not super().check_message_enabled(message):
|
||||
return False
|
||||
# Otherwise, if this is a mouse event, the widget receiving the
|
||||
# event must not be disabled at this moment.
|
||||
return (
|
||||
not self._self_or_ancestors_disabled
|
||||
if isinstance(message, (events.MouseEvent, events.Enter, events.Leave))
|
||||
else True
|
||||
)
|
||||
|
||||
async def broker_event(self, event_name: str, event: events.Event) -> bool:
|
||||
return await self.app._broker_event(event_name, event, default_namespace=self)
|
||||
|
||||
@@ -2501,11 +2554,11 @@ class Widget(DOMNode):
|
||||
|
||||
def _on_descendant_blur(self, event: events.DescendantBlur) -> None:
|
||||
if self._has_focus_within:
|
||||
self.app.update_styles(self)
|
||||
self._update_styles()
|
||||
|
||||
def _on_descendant_focus(self, event: events.DescendantBlur) -> None:
|
||||
if self._has_focus_within:
|
||||
self.app.update_styles(self)
|
||||
self._update_styles()
|
||||
|
||||
def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None:
|
||||
if event.ctrl or event.shift:
|
||||
|
||||
@@ -39,11 +39,6 @@ class Button(Static, can_focus=True):
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
Button.-disabled {
|
||||
opacity: 0.4;
|
||||
text-opacity: 0.7;
|
||||
}
|
||||
|
||||
Button:focus {
|
||||
text-style: bold reverse;
|
||||
}
|
||||
@@ -156,9 +151,6 @@ class Button(Static, can_focus=True):
|
||||
variant = reactive("default")
|
||||
"""The variant name for the button."""
|
||||
|
||||
disabled = reactive(False)
|
||||
"""The disabled state of the button; `True` if disabled, `False` if not."""
|
||||
|
||||
class Pressed(Message, bubble=True):
|
||||
"""Event sent when a `Button` is pressed.
|
||||
|
||||
@@ -176,45 +168,35 @@ class Button(Static, can_focus=True):
|
||||
def __init__(
|
||||
self,
|
||||
label: TextType | None = None,
|
||||
disabled: bool = False,
|
||||
variant: ButtonVariant = "default",
|
||||
*,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
disabled: bool = False,
|
||||
):
|
||||
"""Create a Button widget.
|
||||
|
||||
Args:
|
||||
label: The text that appears within the button.
|
||||
disabled: Whether the button is disabled or not.
|
||||
variant: The variant of the button.
|
||||
name: The name of the button.
|
||||
id: The ID of the button in the DOM.
|
||||
classes: The CSS classes of the button.
|
||||
disabled: Whether the button is disabled or not.
|
||||
"""
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
||||
|
||||
if label is None:
|
||||
label = self.css_identifier_styled
|
||||
|
||||
self.label = self.validate_label(label)
|
||||
|
||||
self.disabled = disabled
|
||||
if disabled:
|
||||
self.add_class("-disabled")
|
||||
|
||||
self.variant = self.validate_variant(variant)
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield from super().__rich_repr__()
|
||||
yield "variant", self.variant, "default"
|
||||
yield "disabled", self.disabled, False
|
||||
|
||||
def watch_mouse_over(self, value: bool) -> None:
|
||||
"""Update from CSS if mouse over state changes."""
|
||||
if self._has_hover_style and not self.disabled:
|
||||
self.app.update_styles(self)
|
||||
|
||||
def validate_variant(self, variant: str) -> str:
|
||||
if variant not in _VALID_BUTTON_VARIANTS:
|
||||
@@ -227,10 +209,6 @@ class Button(Static, can_focus=True):
|
||||
self.remove_class(f"-{old_variant}")
|
||||
self.add_class(f"-{variant}")
|
||||
|
||||
def watch_disabled(self, disabled: bool) -> None:
|
||||
self.set_class(disabled, "-disabled")
|
||||
self.can_focus = not disabled
|
||||
|
||||
def validate_label(self, label: RenderableType) -> RenderableType:
|
||||
"""Parse markup for self.label"""
|
||||
if isinstance(label, str):
|
||||
@@ -272,11 +250,11 @@ class Button(Static, can_focus=True):
|
||||
def success(
|
||||
cls,
|
||||
label: TextType | None = None,
|
||||
disabled: bool = False,
|
||||
*,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
disabled: bool = False,
|
||||
) -> Button:
|
||||
"""Utility constructor for creating a success Button variant.
|
||||
|
||||
@@ -292,22 +270,22 @@ class Button(Static, can_focus=True):
|
||||
"""
|
||||
return Button(
|
||||
label=label,
|
||||
disabled=disabled,
|
||||
variant="success",
|
||||
name=name,
|
||||
id=id,
|
||||
classes=classes,
|
||||
disabled=disabled,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def warning(
|
||||
cls,
|
||||
label: TextType | None = None,
|
||||
disabled: bool = False,
|
||||
*,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
disabled: bool = False,
|
||||
) -> Button:
|
||||
"""Utility constructor for creating a warning Button variant.
|
||||
|
||||
@@ -323,22 +301,22 @@ class Button(Static, can_focus=True):
|
||||
"""
|
||||
return Button(
|
||||
label=label,
|
||||
disabled=disabled,
|
||||
variant="warning",
|
||||
name=name,
|
||||
id=id,
|
||||
classes=classes,
|
||||
disabled=disabled,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def error(
|
||||
cls,
|
||||
label: TextType | None = None,
|
||||
disabled: bool = False,
|
||||
*,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
disabled: bool = False,
|
||||
) -> Button:
|
||||
"""Utility constructor for creating an error Button variant.
|
||||
|
||||
@@ -354,9 +332,9 @@ class Button(Static, can_focus=True):
|
||||
"""
|
||||
return Button(
|
||||
label=label,
|
||||
disabled=disabled,
|
||||
variant="error",
|
||||
name=name,
|
||||
id=id,
|
||||
classes=classes,
|
||||
disabled=disabled,
|
||||
)
|
||||
|
||||
@@ -473,8 +473,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
disabled: bool = False,
|
||||
) -> None:
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
||||
self._data: dict[RowKey, dict[ColumnKey, CellType]] = {}
|
||||
"""Contains the cells of the table, indexed by row key and column key.
|
||||
The final positioning of a cell on screen cannot be determined solely by this
|
||||
|
||||
@@ -29,6 +29,7 @@ class DirectoryTree(Tree[DirEntry]):
|
||||
name: The name of the widget, or None for no name. Defaults to None.
|
||||
id: The ID of the widget in the DOM, or None for no ID. Defaults to None.
|
||||
classes: A space-separated list of classes, or None for no classes. Defaults to None.
|
||||
disabled: Whether the directory tree is disabled or not.
|
||||
"""
|
||||
|
||||
COMPONENT_CLASSES: ClassVar[set[str]] = {
|
||||
@@ -87,6 +88,7 @@ class DirectoryTree(Tree[DirEntry]):
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
disabled: bool = False,
|
||||
) -> None:
|
||||
self.path = path
|
||||
super().__init__(
|
||||
@@ -95,6 +97,7 @@ class DirectoryTree(Tree[DirEntry]):
|
||||
name=name,
|
||||
id=id,
|
||||
classes=classes,
|
||||
disabled=disabled,
|
||||
)
|
||||
|
||||
def process_label(self, label: TextType):
|
||||
|
||||
@@ -110,9 +110,6 @@ class Input(Widget, can_focus=True):
|
||||
height: 1;
|
||||
min-height: 1;
|
||||
}
|
||||
Input.-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
Input:focus {
|
||||
border: tall $accent;
|
||||
}
|
||||
@@ -179,6 +176,7 @@ class Input(Widget, can_focus=True):
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
disabled: bool = False,
|
||||
) -> None:
|
||||
"""Initialise the `Input` widget.
|
||||
|
||||
@@ -190,8 +188,9 @@ class Input(Widget, can_focus=True):
|
||||
name: Optional name for the input widget.
|
||||
id: Optional ID for the widget.
|
||||
classes: Optional initial classes for the widget.
|
||||
disabled: Whether the input is disabled or not.
|
||||
"""
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
||||
if value is not None:
|
||||
self.value = value
|
||||
self.placeholder = placeholder
|
||||
|
||||
@@ -73,6 +73,7 @@ class ListView(Vertical, can_focus=True, can_focus_children=False):
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
disabled: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
@@ -81,8 +82,11 @@ class ListView(Vertical, can_focus=True, can_focus_children=False):
|
||||
name: The name of the widget.
|
||||
id: The unique ID of the widget used in CSS/query selection.
|
||||
classes: The CSS classes of the widget.
|
||||
disabled: Whether the ListView is disabled or not.
|
||||
"""
|
||||
super().__init__(*children, name=name, id=id, classes=classes)
|
||||
super().__init__(
|
||||
*children, name=name, id=id, classes=classes, disabled=disabled
|
||||
)
|
||||
self._index = initial_index
|
||||
|
||||
def on_mount(self) -> None:
|
||||
|
||||
@@ -10,7 +10,7 @@ from rich.text import Text
|
||||
from typing_extensions import TypeAlias
|
||||
|
||||
from ..app import ComposeResult
|
||||
from ..containers import Vertical
|
||||
from ..containers import Horizontal, Vertical
|
||||
from ..message import Message
|
||||
from ..reactive import reactive, var
|
||||
from ..widget import Widget
|
||||
@@ -198,6 +198,19 @@ class MarkdownH6(MarkdownHeader):
|
||||
"""
|
||||
|
||||
|
||||
class MarkdownHorizontalRule(MarkdownBlock):
|
||||
"""A horizontal rule."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
MarkdownHorizontalRule {
|
||||
border-bottom: heavy $primary;
|
||||
height: 1;
|
||||
padding-top: 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class MarkdownParagraph(MarkdownBlock):
|
||||
"""A paragraph Markdown block."""
|
||||
|
||||
@@ -225,37 +238,83 @@ class MarkdownBlockQuote(MarkdownBlock):
|
||||
"""
|
||||
|
||||
|
||||
class MarkdownBulletList(MarkdownBlock):
|
||||
class MarkdownList(MarkdownBlock):
|
||||
DEFAULT_CSS = """
|
||||
|
||||
MarkdownList {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
MarkdownList MarkdownList {
|
||||
margin: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class MarkdownBulletList(MarkdownList):
|
||||
"""A Bullet list Markdown block."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
MarkdownBulletList {
|
||||
margin: 0;
|
||||
margin: 0 0 1 0;
|
||||
padding: 0 0;
|
||||
}
|
||||
|
||||
MarkdownBulletList MarkdownBulletList {
|
||||
margin: 0;
|
||||
padding-top: 0;
|
||||
MarkdownBulletList Horizontal {
|
||||
height: auto;
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
MarkdownBulletList Vertical {
|
||||
height: auto;
|
||||
width: 1fr;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
for block in self._blocks:
|
||||
if isinstance(block, MarkdownListItem):
|
||||
bullet = MarkdownBullet()
|
||||
bullet.symbol = block.bullet
|
||||
yield Horizontal(bullet, Vertical(*block._blocks))
|
||||
self._blocks.clear()
|
||||
|
||||
class MarkdownOrderedList(MarkdownBlock):
|
||||
|
||||
class MarkdownOrderedList(MarkdownList):
|
||||
"""An ordered list Markdown block."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
MarkdownOrderedList {
|
||||
margin: 0;
|
||||
margin: 0 0 1 0;
|
||||
padding: 0 0;
|
||||
}
|
||||
|
||||
Markdown OrderedList MarkdownOrderedList {
|
||||
margin: 0;
|
||||
padding-top: 0;
|
||||
MarkdownOrderedList Horizontal {
|
||||
height: auto;
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
MarkdownOrderedList Vertical {
|
||||
height: auto;
|
||||
width: 1fr;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
symbol_size = max(
|
||||
len(block.bullet)
|
||||
for block in self._blocks
|
||||
if isinstance(block, MarkdownListItem)
|
||||
)
|
||||
for block in self._blocks:
|
||||
if isinstance(block, MarkdownListItem):
|
||||
bullet = MarkdownBullet()
|
||||
bullet.symbol = block.bullet.rjust(symbol_size + 1)
|
||||
yield Horizontal(bullet, Vertical(*block._blocks))
|
||||
|
||||
self._blocks.clear()
|
||||
|
||||
|
||||
class MarkdownTable(MarkdownBlock):
|
||||
"""A Table markdown Block."""
|
||||
@@ -329,10 +388,12 @@ class MarkdownBullet(Widget):
|
||||
DEFAULT_CSS = """
|
||||
MarkdownBullet {
|
||||
width: auto;
|
||||
color: $success;
|
||||
text-style: bold;
|
||||
}
|
||||
"""
|
||||
|
||||
symbol = reactive("● ")
|
||||
symbol = reactive("●")
|
||||
"""The symbol for the bullet."""
|
||||
|
||||
def render(self) -> Text:
|
||||
@@ -359,13 +420,13 @@ class MarkdownListItem(MarkdownBlock):
|
||||
self.bullet = bullet
|
||||
super().__init__()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
bullet = MarkdownBullet()
|
||||
bullet.symbol = self.bullet
|
||||
yield bullet
|
||||
yield Vertical(*self._blocks)
|
||||
|
||||
self._blocks.clear()
|
||||
class MarkdownOrderedListItem(MarkdownListItem):
|
||||
pass
|
||||
|
||||
|
||||
class MarkdownUnorderedListItem(MarkdownListItem):
|
||||
pass
|
||||
|
||||
|
||||
class MarkdownFence(MarkdownBlock):
|
||||
@@ -439,6 +500,8 @@ class Markdown(Widget):
|
||||
"""
|
||||
COMPONENT_CLASSES = {"em", "strong", "s", "code_inline"}
|
||||
|
||||
BULLETS = ["⏺ ", "▪ ", "‣ ", "• ", "⭑ "]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
markdown: str | None = None,
|
||||
@@ -501,7 +564,7 @@ class Markdown(Widget):
|
||||
markdown = path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
return False
|
||||
await self.query("MarkdownBlock").remove()
|
||||
|
||||
await self.update(markdown)
|
||||
return True
|
||||
|
||||
@@ -524,6 +587,8 @@ class Markdown(Widget):
|
||||
if token.type == "heading_open":
|
||||
block_id += 1
|
||||
stack.append(HEADINGS[token.tag](id=f"block{block_id}"))
|
||||
elif token.type == "hr":
|
||||
output.append(MarkdownHorizontalRule())
|
||||
elif token.type == "paragraph_open":
|
||||
stack.append(MarkdownParagraph())
|
||||
elif token.type == "blockquote_open":
|
||||
@@ -533,9 +598,20 @@ class Markdown(Widget):
|
||||
elif token.type == "ordered_list_open":
|
||||
stack.append(MarkdownOrderedList())
|
||||
elif token.type == "list_item_open":
|
||||
stack.append(
|
||||
MarkdownListItem(f"{token.info}. " if token.info else "● ")
|
||||
)
|
||||
if token.info:
|
||||
stack.append(MarkdownOrderedListItem(f"{token.info}. "))
|
||||
else:
|
||||
item_count = sum(
|
||||
1
|
||||
for block in stack
|
||||
if isinstance(block, MarkdownUnorderedListItem)
|
||||
)
|
||||
stack.append(
|
||||
MarkdownUnorderedListItem(
|
||||
self.BULLETS[item_count % len(self.BULLETS)]
|
||||
)
|
||||
)
|
||||
|
||||
elif token.type == "table_open":
|
||||
stack.append(MarkdownTable())
|
||||
elif token.type == "tbody_open":
|
||||
@@ -565,6 +641,8 @@ class Markdown(Widget):
|
||||
for child in token.children:
|
||||
if child.type == "text":
|
||||
content.append(child.content, style_stack[-1])
|
||||
if child.type == "softbreak":
|
||||
content.append(" ")
|
||||
elif child.type == "code_inline":
|
||||
content.append(
|
||||
child.content,
|
||||
@@ -627,7 +705,10 @@ class Markdown(Widget):
|
||||
await self.post_message(
|
||||
Markdown.TableOfContentsUpdated(table_of_contents, sender=self)
|
||||
)
|
||||
await self.mount(*output)
|
||||
with self.app.batch_update():
|
||||
await self.query("MarkdownBlock").remove()
|
||||
await self.mount(*output)
|
||||
self.refresh(layout=True)
|
||||
|
||||
|
||||
class MarkdownTableOfContents(Widget, can_focus_children=True):
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from itertools import cycle
|
||||
|
||||
from rich.console import RenderableType
|
||||
from typing_extensions import Literal
|
||||
|
||||
from .. import events
|
||||
@@ -61,10 +62,10 @@ class Placeholder(Widget):
|
||||
overflow: hidden;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
Placeholder.-text {
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
# Consecutive placeholders get assigned consecutive colors.
|
||||
@@ -73,7 +74,7 @@ class Placeholder(Widget):
|
||||
|
||||
variant: Reactive[PlaceholderVariant] = reactive("default")
|
||||
|
||||
_renderables: dict[PlaceholderVariant, RenderResult]
|
||||
_renderables: dict[PlaceholderVariant, str]
|
||||
|
||||
@classmethod
|
||||
def reset_color_cycle(cls) -> None:
|
||||
@@ -119,7 +120,7 @@ class Placeholder(Widget):
|
||||
while next(self._variants_cycle) != self.variant:
|
||||
pass
|
||||
|
||||
def render(self) -> RenderResult:
|
||||
def render(self) -> RenderableType:
|
||||
return self._renderables[self.variant]
|
||||
|
||||
def cycle_variant(self) -> None:
|
||||
@@ -147,6 +148,6 @@ class Placeholder(Widget):
|
||||
|
||||
def on_resize(self, event: events.Resize) -> None:
|
||||
"""Update the placeholder "size" variant with the new placeholder size."""
|
||||
self._renderables["size"] = self._SIZE_RENDER_TEMPLATE.format(*self.size)
|
||||
self._renderables["size"] = self._SIZE_RENDER_TEMPLATE.format(*event.size)
|
||||
if self.variant == "size":
|
||||
self.refresh(layout=True)
|
||||
self.refresh(layout=False)
|
||||
|
||||
@@ -36,6 +36,7 @@ class Static(Widget, inherit_bindings=False):
|
||||
name: Name of widget. Defaults to None.
|
||||
id: ID of Widget. Defaults to None.
|
||||
classes: Space separated list of class names. Defaults to None.
|
||||
disabled: Whether the static is disabled or not.
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
@@ -56,8 +57,9 @@ class Static(Widget, inherit_bindings=False):
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
disabled: bool = False,
|
||||
) -> None:
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
||||
self.expand = expand
|
||||
self.shrink = shrink
|
||||
self.markup = markup
|
||||
|
||||
@@ -100,6 +100,7 @@ class Switch(Widget, can_focus=True):
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
disabled: bool = False,
|
||||
):
|
||||
"""Initialise the switch.
|
||||
|
||||
@@ -109,8 +110,9 @@ class Switch(Widget, can_focus=True):
|
||||
name: The name of the switch.
|
||||
id: The ID of the switch in the DOM.
|
||||
classes: The CSS classes of the switch.
|
||||
disabled: Whether the switch is disabled or not.
|
||||
"""
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
||||
if value:
|
||||
self.slider_pos = 1.0
|
||||
self._reactive_value = value
|
||||
|
||||
@@ -43,8 +43,9 @@ class TextLog(ScrollView, can_focus=True):
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
disabled: bool = False,
|
||||
) -> None:
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
||||
self.max_lines = max_lines
|
||||
self._start_line: int = 0
|
||||
self.lines: list[Strip] = []
|
||||
|
||||
@@ -473,8 +473,9 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
disabled: bool = False,
|
||||
) -> None:
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
||||
|
||||
text_label = self.process_label(label)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from textual.css.parse import substitute_references
|
||||
from textual.css.scalar import Scalar, Unit
|
||||
from textual.css.stylesheet import Stylesheet, StylesheetParseError
|
||||
from textual.css.tokenize import tokenize
|
||||
from textual.css.tokenizer import ReferencedBy, Token
|
||||
from textual.css.tokenizer import ReferencedBy, Token, TokenError
|
||||
from textual.css.transition import Transition
|
||||
from textual.geometry import Spacing
|
||||
from textual.layouts.vertical import VerticalLayout
|
||||
@@ -1189,3 +1189,40 @@ class TestParseTextAlign:
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.add_source(css)
|
||||
assert stylesheet.rules[0].styles.text_align == "start"
|
||||
|
||||
|
||||
class TestTypeNames:
|
||||
def test_type_no_number(self):
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.add_source("TestType {}")
|
||||
assert len(stylesheet.rules) == 1
|
||||
|
||||
def test_type_with_number(self):
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.add_source("TestType1 {}")
|
||||
assert len(stylesheet.rules) == 1
|
||||
|
||||
def test_type_starts_with_number(self):
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.add_source("1TestType {}")
|
||||
with pytest.raises(TokenError):
|
||||
stylesheet.parse()
|
||||
|
||||
def test_combined_type_no_number(self):
|
||||
for separator in " >,":
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.add_source(f"StartType {separator} TestType {{}}")
|
||||
assert len(stylesheet.rules) == 1
|
||||
|
||||
def test_combined_type_with_number(self):
|
||||
for separator in " >,":
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.add_source(f"StartType {separator} TestType1 {{}}")
|
||||
assert len(stylesheet.rules) == 1
|
||||
|
||||
def test_combined_type_starts_with_number(self):
|
||||
for separator in " >,":
|
||||
stylesheet = Stylesheet()
|
||||
stylesheet.add_source(f"StartType {separator} 1TestType {{}}")
|
||||
with pytest.raises(TokenError):
|
||||
stylesheet.parse()
|
||||
|
||||
File diff suppressed because one or more lines are too long
84
tests/snapshot_tests/snapshot_apps/disable_widgets.py
Normal file
84
tests/snapshot_tests/snapshot_apps/disable_widgets.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Vertical, Horizontal
|
||||
from textual.widgets import (
|
||||
Header,
|
||||
Footer,
|
||||
Button,
|
||||
DataTable,
|
||||
Input,
|
||||
ListView,
|
||||
ListItem,
|
||||
Label,
|
||||
Markdown,
|
||||
MarkdownViewer,
|
||||
Tree,
|
||||
TextLog,
|
||||
)
|
||||
|
||||
|
||||
class WidgetDisableTestApp(App[None]):
|
||||
|
||||
CSS = """
|
||||
Horizontal {
|
||||
height: auto;
|
||||
}
|
||||
DataTable, ListView, Tree, TextLog {
|
||||
height: 2;
|
||||
}
|
||||
|
||||
Markdown, MarkdownViewer {
|
||||
height: 1fr;
|
||||
}
|
||||
"""
|
||||
|
||||
@property
|
||||
def data_table(self) -> DataTable:
|
||||
data_table = DataTable[str]()
|
||||
data_table.add_columns("Column 1", "Column 2", "Column 3", "Column 4")
|
||||
data_table.add_rows(
|
||||
[(str(n), str(n * 10), str(n * 100), str(n * 1000)) for n in range(100)]
|
||||
)
|
||||
return data_table
|
||||
|
||||
@property
|
||||
def list_view(self) -> ListView:
|
||||
return ListView(*[ListItem(Label(f"This is list item {n}")) for n in range(20)])
|
||||
|
||||
@property
|
||||
def test_tree(self) -> Tree:
|
||||
tree = Tree[None](label="This is a test tree")
|
||||
for n in range(10):
|
||||
tree.root.add_leaf(f"Leaf {n}")
|
||||
tree.root.expand()
|
||||
return tree
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Vertical(
|
||||
Horizontal(
|
||||
Button(),
|
||||
Button(variant="primary"),
|
||||
Button(variant="success"),
|
||||
Button(variant="warning"),
|
||||
Button(variant="error"),
|
||||
),
|
||||
self.data_table,
|
||||
self.list_view,
|
||||
self.test_tree,
|
||||
TextLog(),
|
||||
Input(),
|
||||
Input(placeholder="This is an empty input with a placeholder"),
|
||||
Input("This is some text in an input"),
|
||||
Markdown("# Hello, World!"),
|
||||
MarkdownViewer("# Hello, World!"),
|
||||
id="test-container",
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(TextLog).write("Hello, World!")
|
||||
self.query_one("#test-container", Vertical).disabled = True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
WidgetDisableTestApp().run()
|
||||
@@ -231,3 +231,7 @@ def test_auto_width_input(snap_compare):
|
||||
|
||||
def test_screen_switch(snap_compare):
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "screen_switch.py", press=["a", "b"])
|
||||
|
||||
|
||||
def test_disabled_widgets(snap_compare):
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "disable_widgets.py")
|
||||
|
||||
17
tests/test_app.py
Normal file
17
tests/test_app.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from textual.app import App
|
||||
|
||||
|
||||
def test_batch_update():
|
||||
"""Test `batch_update` context manager"""
|
||||
app = App()
|
||||
assert app._batch_count == 0 # Start at zero
|
||||
|
||||
with app.batch_update():
|
||||
assert app._batch_count == 1 # Increments in context manager
|
||||
|
||||
with app.batch_update():
|
||||
assert app._batch_count == 2 # Nested updates
|
||||
|
||||
assert app._batch_count == 1 # Exiting decrements
|
||||
|
||||
assert app._batch_count == 0 # Back to zero
|
||||
@@ -9,10 +9,10 @@ from textual.widget import Widget
|
||||
def test_arrange_empty():
|
||||
container = Widget(id="container")
|
||||
|
||||
placements, widgets, spacing = arrange(container, [], Size(80, 24), Size(80, 24))
|
||||
assert placements == []
|
||||
assert widgets == set()
|
||||
assert spacing == Spacing(0, 0, 0, 0)
|
||||
result = arrange(container, [], Size(80, 24), Size(80, 24))
|
||||
assert result.placements == []
|
||||
assert result.widgets == set()
|
||||
assert result.spacing == Spacing(0, 0, 0, 0)
|
||||
|
||||
|
||||
def test_arrange_dock_top():
|
||||
@@ -22,17 +22,16 @@ def test_arrange_dock_top():
|
||||
header.styles.dock = "top"
|
||||
header.styles.height = "1"
|
||||
|
||||
placements, widgets, spacing = arrange(
|
||||
container, [child, header], Size(80, 24), Size(80, 24)
|
||||
)
|
||||
assert placements == [
|
||||
result = arrange(container, [child, header], Size(80, 24), Size(80, 24))
|
||||
|
||||
assert result.placements == [
|
||||
WidgetPlacement(
|
||||
Region(0, 0, 80, 1), Spacing(), header, order=TOP_Z, fixed=True
|
||||
),
|
||||
WidgetPlacement(Region(0, 1, 80, 23), Spacing(), child, order=0, fixed=False),
|
||||
]
|
||||
assert widgets == {child, header}
|
||||
assert spacing == Spacing(1, 0, 0, 0)
|
||||
assert result.widgets == {child, header}
|
||||
assert result.spacing == Spacing(1, 0, 0, 0)
|
||||
|
||||
|
||||
def test_arrange_dock_left():
|
||||
@@ -42,17 +41,15 @@ def test_arrange_dock_left():
|
||||
header.styles.dock = "left"
|
||||
header.styles.width = "10"
|
||||
|
||||
placements, widgets, spacing = arrange(
|
||||
container, [child, header], Size(80, 24), Size(80, 24)
|
||||
)
|
||||
assert placements == [
|
||||
result = arrange(container, [child, header], Size(80, 24), Size(80, 24))
|
||||
assert result.placements == [
|
||||
WidgetPlacement(
|
||||
Region(0, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True
|
||||
),
|
||||
WidgetPlacement(Region(10, 0, 70, 24), Spacing(), child, order=0, fixed=False),
|
||||
]
|
||||
assert widgets == {child, header}
|
||||
assert spacing == Spacing(0, 0, 0, 10)
|
||||
assert result.widgets == {child, header}
|
||||
assert result.spacing == Spacing(0, 0, 0, 10)
|
||||
|
||||
|
||||
def test_arrange_dock_right():
|
||||
@@ -62,17 +59,15 @@ def test_arrange_dock_right():
|
||||
header.styles.dock = "right"
|
||||
header.styles.width = "10"
|
||||
|
||||
placements, widgets, spacing = arrange(
|
||||
container, [child, header], Size(80, 24), Size(80, 24)
|
||||
)
|
||||
assert placements == [
|
||||
result = arrange(container, [child, header], Size(80, 24), Size(80, 24))
|
||||
assert result.placements == [
|
||||
WidgetPlacement(
|
||||
Region(70, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True
|
||||
),
|
||||
WidgetPlacement(Region(0, 0, 70, 24), Spacing(), child, order=0, fixed=False),
|
||||
]
|
||||
assert widgets == {child, header}
|
||||
assert spacing == Spacing(0, 10, 0, 0)
|
||||
assert result.widgets == {child, header}
|
||||
assert result.spacing == Spacing(0, 10, 0, 0)
|
||||
|
||||
|
||||
def test_arrange_dock_bottom():
|
||||
@@ -82,17 +77,15 @@ def test_arrange_dock_bottom():
|
||||
header.styles.dock = "bottom"
|
||||
header.styles.height = "1"
|
||||
|
||||
placements, widgets, spacing = arrange(
|
||||
container, [child, header], Size(80, 24), Size(80, 24)
|
||||
)
|
||||
assert placements == [
|
||||
result = arrange(container, [child, header], Size(80, 24), Size(80, 24))
|
||||
assert result.placements == [
|
||||
WidgetPlacement(
|
||||
Region(0, 23, 80, 1), Spacing(), header, order=TOP_Z, fixed=True
|
||||
),
|
||||
WidgetPlacement(Region(0, 0, 80, 23), Spacing(), child, order=0, fixed=False),
|
||||
]
|
||||
assert widgets == {child, header}
|
||||
assert spacing == Spacing(0, 0, 1, 0)
|
||||
assert result.widgets == {child, header}
|
||||
assert result.spacing == Spacing(0, 0, 1, 0)
|
||||
|
||||
|
||||
def test_arrange_dock_badly():
|
||||
|
||||
84
tests/test_disabled.py
Normal file
84
tests/test_disabled.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Test Widget.disabled."""
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Vertical
|
||||
from textual.widgets import (
|
||||
Button,
|
||||
DataTable,
|
||||
DirectoryTree,
|
||||
Input,
|
||||
ListView,
|
||||
Markdown,
|
||||
MarkdownViewer,
|
||||
Switch,
|
||||
TextLog,
|
||||
Tree,
|
||||
)
|
||||
|
||||
|
||||
class DisableApp(App[None]):
|
||||
"""Application for testing Widget.disabled."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the child widgets."""
|
||||
yield Vertical(
|
||||
Button(),
|
||||
DataTable(),
|
||||
DirectoryTree("."),
|
||||
Input(),
|
||||
ListView(),
|
||||
Switch(),
|
||||
TextLog(),
|
||||
Tree("Test"),
|
||||
Markdown(),
|
||||
MarkdownViewer(),
|
||||
id="test-container",
|
||||
)
|
||||
|
||||
|
||||
async def test_all_initially_enabled() -> None:
|
||||
"""All widgets should start out enabled."""
|
||||
async with DisableApp().run_test() as pilot:
|
||||
assert all(
|
||||
not node.disabled for node in pilot.app.screen.query("#test-container > *")
|
||||
)
|
||||
|
||||
|
||||
async def test_enabled_widgets_have_enabled_pseudo_class() -> None:
|
||||
"""All enabled widgets should have the :enabled pseudoclass."""
|
||||
async with DisableApp().run_test() as pilot:
|
||||
assert all(
|
||||
node.has_pseudo_class("enabled") and not node.has_pseudo_class("disabled")
|
||||
for node in pilot.app.screen.query("#test-container > *")
|
||||
)
|
||||
|
||||
|
||||
async def test_all_individually_disabled() -> None:
|
||||
"""Post-disable all widgets should report being disabled."""
|
||||
async with DisableApp().run_test() as pilot:
|
||||
for node in pilot.app.screen.query("Vertical > *"):
|
||||
node.disabled = True
|
||||
assert all(
|
||||
node.disabled for node in pilot.app.screen.query("#test-container > *")
|
||||
)
|
||||
|
||||
|
||||
async def test_disabled_widgets_have_disabled_pseudo_class() -> None:
|
||||
"""All disabled widgets should have the :disabled pseudoclass."""
|
||||
async with DisableApp().run_test() as pilot:
|
||||
for node in pilot.app.screen.query("#test-container > *"):
|
||||
node.disabled = True
|
||||
assert all(
|
||||
node.has_pseudo_class("disabled") and not node.has_pseudo_class("enabled")
|
||||
for node in pilot.app.screen.query("#test-container > *")
|
||||
)
|
||||
|
||||
|
||||
async def test_disable_via_container() -> None:
|
||||
"""All child widgets should appear (to CSS) as disabled by a container being disabled."""
|
||||
async with DisableApp().run_test() as pilot:
|
||||
pilot.app.screen.query_one("#test-container", Vertical).disabled = True
|
||||
assert all(
|
||||
node.has_pseudo_class("disabled") and not node.has_pseudo_class("enabled")
|
||||
for node in pilot.app.screen.query("#test-container > *")
|
||||
)
|
||||
@@ -328,6 +328,33 @@ async def test_reactive_inheritance():
|
||||
assert tertiary.baz == "baz"
|
||||
|
||||
|
||||
async def test_compute():
|
||||
"""Check compute method is called."""
|
||||
|
||||
class ComputeApp(App):
|
||||
count = var(0)
|
||||
count_double = var(0)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.start = 0
|
||||
super().__init__()
|
||||
|
||||
def compute_count_double(self) -> int:
|
||||
return self.start + self.count * 2
|
||||
|
||||
app = ComputeApp()
|
||||
|
||||
async with app.run_test():
|
||||
assert app.count_double == 0
|
||||
app.count = 1
|
||||
assert app.count_double == 2
|
||||
assert app.count_double == 2
|
||||
app.count = 2
|
||||
assert app.count_double == 4
|
||||
app.start = 10
|
||||
assert app.count_double == 14
|
||||
|
||||
|
||||
async def test_watch_compute():
|
||||
"""Check that watching a computed attribute works."""
|
||||
|
||||
@@ -347,7 +374,9 @@ async def test_watch_compute():
|
||||
|
||||
app = Calculator()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
# Referencing the value calls compute
|
||||
# Setting any reactive values calls compute
|
||||
async with app.run_test():
|
||||
assert app.show_ac is True
|
||||
app.value = "1"
|
||||
assert app.show_ac is False
|
||||
@@ -356,4 +385,4 @@ async def test_watch_compute():
|
||||
app.numbers = "123"
|
||||
assert app.show_ac is False
|
||||
|
||||
assert watch_called == [True, False, True, False]
|
||||
assert watch_called == [True, True, False, False, True, True, False, False]
|
||||
|
||||
64
tests/test_spatial_map.py
Normal file
64
tests/test_spatial_map.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import pytest
|
||||
|
||||
from textual._spatial_map import SpatialMap
|
||||
from textual.geometry import Region
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"region,grid",
|
||||
[
|
||||
(
|
||||
Region(0, 0, 10, 10),
|
||||
[
|
||||
(0, 0),
|
||||
],
|
||||
),
|
||||
(
|
||||
Region(10, 10, 10, 10),
|
||||
[
|
||||
(1, 1),
|
||||
],
|
||||
),
|
||||
(
|
||||
Region(0, 0, 11, 11),
|
||||
[(0, 0), (0, 1), (1, 0), (1, 1)],
|
||||
),
|
||||
(
|
||||
Region(5, 5, 15, 3),
|
||||
[(0, 0), (1, 0)],
|
||||
),
|
||||
(
|
||||
Region(5, 5, 2, 15),
|
||||
[(0, 0), (0, 1)],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_region_to_grid(region, grid):
|
||||
spatial_map = SpatialMap(10, 10)
|
||||
|
||||
assert list(spatial_map._region_to_grid_coordinates(region)) == grid
|
||||
|
||||
|
||||
def test_get_values_in_region() -> None:
|
||||
spatial_map: SpatialMap[str] = SpatialMap(20, 10)
|
||||
|
||||
spatial_map.insert(
|
||||
[
|
||||
(Region(10, 5, 5, 5), False, "foo"),
|
||||
(Region(5, 20, 5, 5), False, "bar"),
|
||||
(Region(0, 0, 40, 1), True, "title"),
|
||||
]
|
||||
)
|
||||
|
||||
assert spatial_map.get_values_in_region(Region(0, 0, 10, 5)) == [
|
||||
"title",
|
||||
"foo",
|
||||
]
|
||||
assert spatial_map.get_values_in_region(Region(0, 1, 10, 5)) == ["title", "foo"]
|
||||
assert spatial_map.get_values_in_region(Region(0, 10, 10, 5)) == ["title"]
|
||||
assert spatial_map.get_values_in_region(Region(0, 20, 10, 5)) == ["title", "bar"]
|
||||
assert spatial_map.get_values_in_region(Region(5, 5, 50, 50)) == [
|
||||
"title",
|
||||
"foo",
|
||||
"bar",
|
||||
]
|
||||
@@ -26,21 +26,18 @@ class VisibleTester(App[None]):
|
||||
async def test_visibility_changes() -> None:
|
||||
"""Test changing visibility via code and CSS."""
|
||||
async with VisibleTester().run_test() as pilot:
|
||||
assert len(pilot.app.screen.visible_widgets) == 5
|
||||
assert pilot.app.query_one("#keep").visible is True
|
||||
assert pilot.app.query_one("#hide-via-code").visible is True
|
||||
assert pilot.app.query_one("#hide-via-css").visible is True
|
||||
|
||||
pilot.app.query_one("#hide-via-code").styles.visibility = "hidden"
|
||||
await pilot.pause(0)
|
||||
assert len(pilot.app.screen.visible_widgets) == 4
|
||||
assert pilot.app.query_one("#keep").visible is True
|
||||
assert pilot.app.query_one("#hide-via-code").visible is False
|
||||
assert pilot.app.query_one("#hide-via-css").visible is True
|
||||
|
||||
pilot.app.query_one("#hide-via-css").set_class(True, "hidden")
|
||||
await pilot.pause(0)
|
||||
assert len(pilot.app.screen.visible_widgets) == 3
|
||||
assert pilot.app.query_one("#keep").visible is True
|
||||
assert pilot.app.query_one("#hide-via-code").visible is False
|
||||
assert pilot.app.query_one("#hide-via-css").visible is False
|
||||
|
||||
Reference in New Issue
Block a user