mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into placeholder
This commit is contained in:
6
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
6
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
**Please review the following checklist.**
|
||||
|
||||
- [ ] Docstrings on all new or modified functions / classes
|
||||
- [ ] Updated documentation
|
||||
- [ ] Updated CHANGELOG.md (where appropriate)
|
||||
6
.github/workflows/comment.yml
vendored
6
.github/workflows/comment.yml
vendored
@@ -13,6 +13,6 @@ jobs:
|
||||
with:
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: |
|
||||
Did we solve your problem?
|
||||
|
||||
Glad we could help!
|
||||
Don't forget to [star](https://github.com/Textualize/textual) the repository!
|
||||
|
||||
Follow [@textualizeio](https://twitter.com/textualizeio) for Textual updates.
|
||||
|
||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -1,5 +1,6 @@
|
||||
# Change Log
|
||||
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
@@ -7,9 +8,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [0.5.0] - Unreleased
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- Add get_child_by_id and get_widget_by_id, remove get_child https://github.com/Textualize/textual/pull/1146
|
||||
- Add easing parameter to Widget.scroll_* methods https://github.com/Textualize/textual/pull/1144
|
||||
- Added Widget.call_later which invokes a callback on idle.
|
||||
- `DOMNode.ancestors` no longer includes `self`.
|
||||
@@ -21,20 +22,30 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- It is now possible to `await` a `Widget.remove`.
|
||||
https://github.com/Textualize/textual/issues/1094
|
||||
- It is now possible to `await` a `DOMQuery.remove`. Note that this changes
|
||||
the return value of `DOMQuery.remove`, which uses to return `self`.
|
||||
the return value of `DOMQuery.remove`, which used to return `self`.
|
||||
https://github.com/Textualize/textual/issues/1094
|
||||
- Added Pilot.wait_for_animation
|
||||
- Added `Widget.move_child` https://github.com/Textualize/textual/issues/1121
|
||||
- Added a `Label` widget https://github.com/Textualize/textual/issues/1190
|
||||
- Support lazy-instantiated Screens (callables in App.SCREENS) https://github.com/Textualize/textual/pull/1185
|
||||
- Display of keys in footer has more sensible defaults https://github.com/Textualize/textual/pull/1213
|
||||
- Add App.get_key_display, allowing custom key_display App-wide https://github.com/Textualize/textual/pull/1213
|
||||
- Added widget `Placeholder`
|
||||
|
||||
### Changed
|
||||
|
||||
- Watchers are now called immediately when setting the attribute if they are synchronous. https://github.com/Textualize/textual/pull/1145
|
||||
- Widget.call_later has been renamed to Widget.call_after_refresh.
|
||||
- Button variant values are now checked at runtime. https://github.com/Textualize/textual/issues/1189
|
||||
- Added caching of some properties in Styles object
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed DataTable row not updating after add https://github.com/Textualize/textual/issues/1026
|
||||
- Fixed issues with animation. Now objects of different types may be animated.
|
||||
- Fixed containers with transparent background not showing borders https://github.com/Textualize/textual/issues/1175
|
||||
- Fixed auto-width in horizontal containers https://github.com/Textualize/textual/pull/1155
|
||||
- Fixed Input cursor invisible when placeholder empty https://github.com/Textualize/textual/pull/1202
|
||||
|
||||
## [0.4.0] - 2022-11-08
|
||||
|
||||
|
||||
10
docs.md
10
docs.md
@@ -1,14 +1,14 @@
|
||||
# Documentation Workflow
|
||||
|
||||
* [Install Hatch](https://hatch.pypa.io/latest/install/)
|
||||
* Run the live-reload server using `hatch run docs:serve` from the project root
|
||||
* Ensure you're inside a *Python 3.10+* virtual environment
|
||||
* Run the live-reload server using `mkdocs serve` from the project root
|
||||
* Create new pages by adding new directories and Markdown files inside `docs/*`
|
||||
|
||||
## Commands
|
||||
|
||||
- `hatch run docs:serve` - Start the live-reloading docs server.
|
||||
- `hatch run docs:build` - Build the documentation site.
|
||||
- `hatch run docs:help` - Print help message and exit.
|
||||
- `mkdocs serve` - Start the live-reloading docs server.
|
||||
- `mkdocs build` - Build the documentation site.
|
||||
- `mkdocs -h` - Print help message and exit.
|
||||
|
||||
## Project layout
|
||||
|
||||
|
||||
1
docs/api/label.md
Normal file
1
docs/api/label.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.widgets.Label
|
||||
12
docs/examples/widgets/label.py
Normal file
12
docs/examples/widgets/label.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Label
|
||||
|
||||
|
||||
class LabelApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Label("Hello, world!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = LabelApp()
|
||||
app.run()
|
||||
@@ -25,7 +25,7 @@ textual run my_app.py
|
||||
|
||||
The `run` sub-command will first look for a `App` instance called `app` in the global scope of your Python file. If there is no `app`, it will create an instance of the first `App` class it finds and run that.
|
||||
|
||||
Alternatively, you can add the name of an `App` instance or class after a colon to run a specific app in the Python file. Here's an example:
|
||||
Alternatively, you can add the name of an `App` instance or class after a colon to run a specific app in the Python file. Here's an example:
|
||||
|
||||
```bash
|
||||
textual run my_app.py:alternative_app
|
||||
@@ -119,6 +119,6 @@ class LogApp(App):
|
||||
self.log(self.tree)
|
||||
|
||||
if __name__ == "__main__":
|
||||
LogApp.run()
|
||||
LogApp().run()
|
||||
|
||||
```
|
||||
|
||||
33
docs/widgets/label.md
Normal file
33
docs/widgets/label.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Label
|
||||
|
||||
A widget which displays static text, but which can also contain more complex Rich renderables.
|
||||
|
||||
- [ ] Focusable
|
||||
- [ ] Container
|
||||
|
||||
## Example
|
||||
|
||||
The example below shows how you can use a `Label` widget to display some text.
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/widgets/label.py"}
|
||||
```
|
||||
|
||||
=== "label.py"
|
||||
|
||||
```python
|
||||
--8<-- "docs/examples/widgets/label.py"
|
||||
```
|
||||
|
||||
## Reactive Attributes
|
||||
|
||||
This widget has no reactive attributes.
|
||||
|
||||
## Messages
|
||||
|
||||
This widget sends no messages.
|
||||
|
||||
## See Also
|
||||
|
||||
* [Label](../api/label.md) code reference
|
||||
@@ -1,14 +1,14 @@
|
||||
# Static
|
||||
|
||||
A widget which displays static content.
|
||||
Can be used for simple text labels, but can also contain more complex Rich renderables.
|
||||
Can be used for Rich renderables and can also for the base for other types of widgets.
|
||||
|
||||
- [ ] Focusable
|
||||
- [x] Container
|
||||
- [ ] Container
|
||||
|
||||
## Example
|
||||
|
||||
The example below shows how you can use a `Static` widget as a simple text label.
|
||||
The example below shows how you can use a `Static` widget as a simple text label (but see [Label](./label.md) as a way of displaying text).
|
||||
|
||||
=== "Output"
|
||||
|
||||
@@ -32,3 +32,4 @@ This widget sends no messages.
|
||||
## See Also
|
||||
|
||||
* [Static](../api/static.md) code reference
|
||||
* [Label](./label.md)
|
||||
|
||||
@@ -13,7 +13,7 @@ from textual.containers import Horizontal
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.screen import Screen
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Footer, Button, Static
|
||||
from textual.widgets import Footer, Button, Label
|
||||
from textual.css.query import DOMQuery
|
||||
from textual.reactive import reactive
|
||||
from textual.binding import Binding
|
||||
@@ -33,10 +33,10 @@ class Help(Screen):
|
||||
Returns:
|
||||
ComposeResult: The result of composing the help screen.
|
||||
"""
|
||||
yield Static(Markdown(Path(__file__).with_suffix(".md").read_text()))
|
||||
yield Label(Markdown(Path(__file__).with_suffix(".md").read_text()))
|
||||
|
||||
|
||||
class WinnerMessage(Static):
|
||||
class WinnerMessage(Label):
|
||||
"""Widget to tell the user they have won."""
|
||||
|
||||
MIN_MOVES: Final = 14
|
||||
@@ -91,9 +91,9 @@ class GameHeader(Widget):
|
||||
ComposeResult: The result of composing the game header.
|
||||
"""
|
||||
yield Horizontal(
|
||||
Static(self.app.title, id="app-title"),
|
||||
Static(id="moves"),
|
||||
Static(id="progress"),
|
||||
Label(self.app.title, id="app-title"),
|
||||
Label(id="moves"),
|
||||
Label(id="progress"),
|
||||
)
|
||||
|
||||
def watch_moves(self, moves: int):
|
||||
@@ -102,7 +102,7 @@ class GameHeader(Widget):
|
||||
Args:
|
||||
moves (int): The number of moves made.
|
||||
"""
|
||||
self.query_one("#moves", Static).update(f"Moves: {moves}")
|
||||
self.query_one("#moves", Label).update(f"Moves: {moves}")
|
||||
|
||||
def watch_filled(self, filled: int):
|
||||
"""Watch the on-count reactive and update when it changes.
|
||||
@@ -110,7 +110,7 @@ class GameHeader(Widget):
|
||||
Args:
|
||||
filled (int): The number of cells that are currently on.
|
||||
"""
|
||||
self.query_one("#progress", Static).update(f"Filled: {filled}")
|
||||
self.query_one("#progress", Label).update(f"Filled: {filled}")
|
||||
|
||||
|
||||
class GameCell(Button):
|
||||
@@ -311,7 +311,7 @@ class FiveByFive(App[None]):
|
||||
CSS_PATH = "five_by_five.css"
|
||||
"""The name of the stylesheet for the app."""
|
||||
|
||||
SCREENS = {"help": Help()}
|
||||
SCREENS = {"help": Help}
|
||||
"""The pre-loaded screens for the application."""
|
||||
|
||||
BINDINGS = [("ctrl+d", "toggle_dark", "Toggle Dark Mode")]
|
||||
|
||||
10
mkdocs.yml
10
mkdocs.yml
@@ -96,6 +96,7 @@ nav:
|
||||
- "widgets/footer.md"
|
||||
- "widgets/header.md"
|
||||
- "widgets/input.md"
|
||||
- "widgets/label.md"
|
||||
- "widgets/placeholder.md"
|
||||
- "widgets/static.md"
|
||||
- "widgets/tree_control.md"
|
||||
@@ -110,6 +111,7 @@ nav:
|
||||
- "api/footer.md"
|
||||
- "api/geometry.md"
|
||||
- "api/header.md"
|
||||
- "api/label.md"
|
||||
- "api/message_pump.md"
|
||||
- "api/message.md"
|
||||
- "api/pilot.md"
|
||||
@@ -186,13 +188,13 @@ plugins:
|
||||
|
||||
- blog:
|
||||
- rss:
|
||||
match_path: blog/posts/.*
|
||||
match_path: blog/posts/.*
|
||||
date_from_meta:
|
||||
as_creation: date
|
||||
categories:
|
||||
- categories
|
||||
- release
|
||||
- tags
|
||||
- tags
|
||||
- search:
|
||||
- autorefs:
|
||||
- mkdocstrings:
|
||||
@@ -216,10 +218,10 @@ extra_css:
|
||||
|
||||
extra:
|
||||
social:
|
||||
- icon: fontawesome/brands/twitter
|
||||
- icon: fontawesome/brands/twitter
|
||||
link: https://twitter.com/textualizeio
|
||||
name: textualizeio on Twitter
|
||||
- icon: fontawesome/brands/github
|
||||
- icon: fontawesome/brands/github
|
||||
link: https://github.com/textualize/textual/
|
||||
name: Textual on Github
|
||||
- icon: fontawesome/brands/discord
|
||||
|
||||
@@ -192,6 +192,8 @@ class Animator:
|
||||
await self._timer.stop()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
self._idle_event.set()
|
||||
|
||||
def bind(self, obj: object) -> BoundAnimator:
|
||||
"""Bind the animator to a given objects."""
|
||||
|
||||
@@ -3,9 +3,7 @@ from __future__ import annotations
|
||||
from functools import lru_cache
|
||||
from typing import cast, Tuple, Union
|
||||
|
||||
from rich.console import Console, ConsoleOptions, RenderResult, RenderableType
|
||||
import rich.repr
|
||||
from rich.segment import Segment, SegmentLines
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
|
||||
from .color import Color
|
||||
@@ -158,164 +156,6 @@ def render_row(
|
||||
return [Segment(box2.text * width, box2.style)]
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class Border:
|
||||
"""Renders Textual CSS borders.
|
||||
|
||||
This is analogous to Rich's `Box` but more flexible. Different borders may be
|
||||
applied to each of the four edges, and more advanced borders can be achieved through
|
||||
various combinations of Widget and parent background colors.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
renderable: RenderableType,
|
||||
borders: Borders,
|
||||
inner_color: Color,
|
||||
outer_color: Color,
|
||||
outline: bool = False,
|
||||
):
|
||||
self.renderable = renderable
|
||||
self.edge_styles = borders
|
||||
self.outline = outline
|
||||
|
||||
(
|
||||
(top, top_color),
|
||||
(right, right_color),
|
||||
(bottom, bottom_color),
|
||||
(left, left_color),
|
||||
) = borders
|
||||
self._sides: tuple[EdgeType, EdgeType, EdgeType, EdgeType]
|
||||
self._sides = (top, right, bottom, left)
|
||||
from_color = Style.from_color
|
||||
|
||||
self._styles = (
|
||||
from_color(top_color.rich_color),
|
||||
from_color(right_color.rich_color),
|
||||
from_color(bottom_color.rich_color),
|
||||
from_color(left_color.rich_color),
|
||||
)
|
||||
self.inner_style = from_color(bgcolor=inner_color.rich_color)
|
||||
self.outer_style = from_color(bgcolor=outer_color.rich_color)
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield self.renderable
|
||||
yield self.edge_styles
|
||||
|
||||
def _crop_renderable(self, lines: list[list[Segment]], width: int) -> None:
|
||||
"""Crops a renderable in place.
|
||||
|
||||
Args:
|
||||
lines (list[list[Segment]]): Segment lines.
|
||||
width (int): Desired width.
|
||||
"""
|
||||
top, right, bottom, left = self._sides
|
||||
# the 4 following lines rely on the fact that we normalise "none" and "hidden" to en empty string
|
||||
has_left = bool(left)
|
||||
has_right = bool(right)
|
||||
has_top = bool(top)
|
||||
has_bottom = bool(bottom)
|
||||
|
||||
if has_top:
|
||||
lines.pop(0)
|
||||
if has_bottom and lines:
|
||||
lines.pop(-1)
|
||||
|
||||
# TODO: Divide is probably quite inefficient here,
|
||||
# It could be much faster for the specific case of one off the start end end
|
||||
divide = Segment.divide
|
||||
if has_left and has_right:
|
||||
for line in lines:
|
||||
_, line[:] = divide(line, [1, width - 1])
|
||||
elif has_left:
|
||||
for line in lines:
|
||||
_, line[:] = divide(line, [1, width])
|
||||
elif has_right:
|
||||
for line in lines:
|
||||
line[:], _ = divide(line, [width - 1, width])
|
||||
|
||||
def __rich_console__(
|
||||
self, console: "Console", options: "ConsoleOptions"
|
||||
) -> "RenderResult":
|
||||
top, right, bottom, left = self._sides
|
||||
style = console.get_style(self.inner_style)
|
||||
outer_style = console.get_style(self.outer_style)
|
||||
top_style, right_style, bottom_style, left_style = self._styles
|
||||
|
||||
# ditto than in `_crop_renderable` ☝
|
||||
has_left = bool(left)
|
||||
has_right = bool(right)
|
||||
has_top = bool(top)
|
||||
has_bottom = bool(bottom)
|
||||
|
||||
width = options.max_width - has_left - has_right
|
||||
|
||||
if width <= 2:
|
||||
lines = console.render_lines(self.renderable, options, new_lines=True)
|
||||
yield SegmentLines(lines)
|
||||
return
|
||||
|
||||
if self.outline:
|
||||
render_options = options
|
||||
else:
|
||||
if options.height is None:
|
||||
render_options = options.update_width(width)
|
||||
else:
|
||||
new_height = options.height - has_top - has_bottom
|
||||
if new_height >= 1:
|
||||
render_options = options.update_dimensions(width, new_height)
|
||||
else:
|
||||
render_options = options.update_width(width)
|
||||
|
||||
lines = console.render_lines(self.renderable, render_options)
|
||||
if self.outline:
|
||||
self._crop_renderable(lines, options.max_width)
|
||||
|
||||
_Segment = Segment
|
||||
new_line = _Segment.line()
|
||||
if has_top:
|
||||
box1, box2, box3 = get_box(top, style, outer_style, top_style)[0]
|
||||
if has_left:
|
||||
yield box1 if top == left else _Segment(" ", box2.style)
|
||||
yield _Segment(box2.text * width, box2.style)
|
||||
if has_right:
|
||||
yield box3 if top == left else _Segment(" ", box3.style)
|
||||
yield new_line
|
||||
|
||||
left_segment = get_box(left, style, outer_style, left_style)[1][0]
|
||||
_right_segment = get_box(right, style, outer_style, right_style)[1][2]
|
||||
right_segment = _Segment(_right_segment.text + "\n", _right_segment.style)
|
||||
|
||||
if has_left and has_right:
|
||||
for line in lines:
|
||||
yield left_segment
|
||||
yield from line
|
||||
yield right_segment
|
||||
elif has_left:
|
||||
for line in lines:
|
||||
yield left_segment
|
||||
yield from line
|
||||
yield new_line
|
||||
elif has_right:
|
||||
for line in lines:
|
||||
yield from line
|
||||
yield right_segment
|
||||
else:
|
||||
for line in lines:
|
||||
yield from line
|
||||
yield new_line
|
||||
|
||||
if has_bottom:
|
||||
box1, box2, box3 = get_box(bottom, style, outer_style, bottom_style)[2]
|
||||
if has_left:
|
||||
yield box1 if bottom == left else _Segment(" ", box1.style)
|
||||
yield _Segment(box2.text * width, box2.style)
|
||||
if has_right:
|
||||
yield box3 if bottom == right else _Segment(" ", box3.style)
|
||||
yield new_line
|
||||
|
||||
|
||||
_edge_type_normalization_table: dict[EdgeType, EdgeType] = {
|
||||
# i.e. we normalize "border: none;" to "border: ;".
|
||||
# As a result our layout-related calculations that include borders are simpler (and have better performance)
|
||||
@@ -326,49 +166,3 @@ _edge_type_normalization_table: dict[EdgeType, EdgeType] = {
|
||||
|
||||
def normalize_border_value(value: BorderValue) -> BorderValue:
|
||||
return _edge_type_normalization_table.get(value[0], value[0]), value[1]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from rich import print
|
||||
from rich.text import Text
|
||||
from rich.padding import Padding
|
||||
|
||||
from .color import Color
|
||||
|
||||
inner = Color.parse("#303F9F")
|
||||
outer = Color.parse("#212121")
|
||||
|
||||
lorem = """[#C5CAE9]Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus."""
|
||||
text = Text.from_markup(lorem)
|
||||
border = Border(
|
||||
Padding(text, 1, style="on #303F9F"),
|
||||
(
|
||||
("none", Color.parse("#C5CAE9")),
|
||||
("none", Color.parse("#C5CAE9")),
|
||||
("wide", Color.parse("#C5CAE9")),
|
||||
("none", Color.parse("#C5CAE9")),
|
||||
),
|
||||
inner_color=inner,
|
||||
outer_color=outer,
|
||||
)
|
||||
|
||||
print(
|
||||
Padding(border, (1, 2), style="on #212121"),
|
||||
)
|
||||
print()
|
||||
|
||||
border = Border(
|
||||
Padding(text, 1, style="on #303F9F"),
|
||||
(
|
||||
("hkey", Color.parse("#8BC34A")),
|
||||
("hkey", Color.parse("#8BC34A")),
|
||||
("hkey", Color.parse("#8BC34A")),
|
||||
("hkey", Color.parse("#8BC34A")),
|
||||
),
|
||||
inner_color=inner,
|
||||
outer_color=outer,
|
||||
)
|
||||
|
||||
print(
|
||||
Padding(border, (1, 2), style="on #212121"),
|
||||
)
|
||||
|
||||
@@ -633,11 +633,7 @@ class Compositor:
|
||||
def is_visible(widget: Widget) -> bool:
|
||||
"""Return True if the widget is (literally) visible by examining various
|
||||
properties which affect whether it can be seen or not."""
|
||||
return (
|
||||
widget.visible
|
||||
and not widget.is_transparent
|
||||
and widget.styles.opacity > 0
|
||||
)
|
||||
return widget.visible and widget.styles.opacity > 0
|
||||
|
||||
_Region = Region
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ if TYPE_CHECKING:
|
||||
from .widget import Widget
|
||||
|
||||
|
||||
class DuplicateIds(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@rich.repr.auto(angular=True)
|
||||
class NodeList(Sequence):
|
||||
"""
|
||||
@@ -21,6 +25,12 @@ class NodeList(Sequence):
|
||||
# The nodes in the list
|
||||
self._nodes: list[Widget] = []
|
||||
self._nodes_set: set[Widget] = set()
|
||||
|
||||
# We cache widgets by their IDs too for a quick lookup
|
||||
# Note that only widgets with IDs are cached like this, so
|
||||
# this cache will likely hold fewer values than self._nodes.
|
||||
self._nodes_by_id: dict[str, Widget] = {}
|
||||
|
||||
# Increments when list is updated (used for caching)
|
||||
self._updates = 0
|
||||
|
||||
@@ -53,6 +63,10 @@ class NodeList(Sequence):
|
||||
"""
|
||||
return self._nodes.index(widget)
|
||||
|
||||
def _get_by_id(self, widget_id: str) -> Widget | None:
|
||||
"""Get the widget for the given widget_id, or None if there's no matches in this list"""
|
||||
return self._nodes_by_id.get(widget_id)
|
||||
|
||||
def _append(self, widget: Widget) -> None:
|
||||
"""Append a Widget.
|
||||
|
||||
@@ -62,6 +76,10 @@ class NodeList(Sequence):
|
||||
if widget not in self._nodes_set:
|
||||
self._nodes.append(widget)
|
||||
self._nodes_set.add(widget)
|
||||
widget_id = widget.id
|
||||
if widget_id is not None:
|
||||
self._ensure_unique_id(widget_id)
|
||||
self._nodes_by_id[widget_id] = widget
|
||||
self._updates += 1
|
||||
|
||||
def _insert(self, index: int, widget: Widget) -> None:
|
||||
@@ -73,8 +91,20 @@ class NodeList(Sequence):
|
||||
if widget not in self._nodes_set:
|
||||
self._nodes.insert(index, widget)
|
||||
self._nodes_set.add(widget)
|
||||
widget_id = widget.id
|
||||
if widget_id is not None:
|
||||
self._ensure_unique_id(widget_id)
|
||||
self._nodes_by_id[widget_id] = widget
|
||||
self._updates += 1
|
||||
|
||||
def _ensure_unique_id(self, widget_id: str) -> None:
|
||||
if widget_id in self._nodes_by_id:
|
||||
raise DuplicateIds(
|
||||
f"Tried to insert a widget with ID {widget_id!r}, but a widget {self._nodes_by_id[widget_id]!r} "
|
||||
f"already exists with that ID in this list of children. "
|
||||
f"The children of a widget must have unique IDs."
|
||||
)
|
||||
|
||||
def _remove(self, widget: Widget) -> None:
|
||||
"""Remove a widget from the list.
|
||||
|
||||
@@ -86,6 +116,9 @@ class NodeList(Sequence):
|
||||
if widget in self._nodes_set:
|
||||
del self._nodes[self._nodes.index(widget)]
|
||||
self._nodes_set.remove(widget)
|
||||
widget_id = widget.id
|
||||
if widget_id in self._nodes_by_id:
|
||||
del self._nodes_by_id[widget_id]
|
||||
self._updates += 1
|
||||
|
||||
def _clear(self) -> None:
|
||||
@@ -93,6 +126,7 @@ class NodeList(Sequence):
|
||||
if self._nodes:
|
||||
self._nodes.clear()
|
||||
self._nodes_set.clear()
|
||||
self._nodes_by_id.clear()
|
||||
self._updates += 1
|
||||
|
||||
def __iter__(self) -> Iterator[Widget]:
|
||||
|
||||
@@ -53,7 +53,7 @@ def style_links(
|
||||
|
||||
|
||||
class StylesCache:
|
||||
"""Responsible for rendering CSS Styles and keeping a cached of rendered lines.
|
||||
"""Responsible for rendering CSS Styles and keeping a cache of rendered lines.
|
||||
|
||||
The render method applies border, outline, and padding set in the Styles object to widget content.
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from typing import (
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
Callable,
|
||||
)
|
||||
from weakref import WeakSet, WeakValueDictionary
|
||||
|
||||
@@ -55,7 +56,7 @@ from .drivers.headless_driver import HeadlessDriver
|
||||
from .features import FeatureFlag, parse_features
|
||||
from .file_monitor import FileMonitor
|
||||
from .geometry import Offset, Region, Size
|
||||
from .keys import REPLACED_KEYS
|
||||
from .keys import REPLACED_KEYS, _get_key_display
|
||||
from .messages import CallbackType
|
||||
from .reactive import Reactive
|
||||
from .renderables.blank import Blank
|
||||
@@ -101,7 +102,6 @@ DEFAULT_COLORS = {
|
||||
ComposeResult = Iterable[Widget]
|
||||
RenderResult = RenderableType
|
||||
|
||||
|
||||
AutopilotCallbackType: TypeAlias = "Callable[[Pilot], Coroutine[Any, Any, None]]"
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
}
|
||||
"""
|
||||
|
||||
SCREENS: dict[str, Screen] = {}
|
||||
SCREENS: dict[str, Screen | Callable[[], Screen]] = {}
|
||||
_BASE_PATH: str | None = None
|
||||
CSS_PATH: CSSPathType = None
|
||||
TITLE: str | None = None
|
||||
@@ -330,7 +330,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self._registry: WeakSet[DOMNode] = WeakSet()
|
||||
|
||||
self._installed_screens: WeakValueDictionary[
|
||||
str, Screen
|
||||
str, Screen | Callable[[], Screen]
|
||||
] = WeakValueDictionary()
|
||||
self._installed_screens.update(**self.SCREENS)
|
||||
|
||||
@@ -667,6 +667,22 @@ class App(Generic[ReturnType], DOMNode):
|
||||
keys, action, description, show=show, key_display=key_display
|
||||
)
|
||||
|
||||
def get_key_display(self, key: str) -> str:
|
||||
"""For a given key, return how it should be displayed in an app
|
||||
(e.g. in the Footer widget).
|
||||
By key, we refer to the string used in the "key" argument for
|
||||
a Binding instance. By overriding this method, you can ensure that
|
||||
keys are displayed consistently throughout your app, without
|
||||
needing to add a key_display to every binding.
|
||||
|
||||
Args:
|
||||
key (str): The binding key string.
|
||||
|
||||
Returns:
|
||||
str: The display string for the input key.
|
||||
"""
|
||||
return _get_key_display(key)
|
||||
|
||||
async def _press_keys(self, keys: Iterable[str]) -> None:
|
||||
"""A task to send key events."""
|
||||
app = self
|
||||
@@ -704,7 +720,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
# This conditional sleep can be removed after that issue is closed.
|
||||
if key == "tab":
|
||||
await asyncio.sleep(0.05)
|
||||
await asyncio.sleep(0.02)
|
||||
await asyncio.sleep(0.025)
|
||||
await app._animator.wait_for_idle()
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -803,9 +819,11 @@ class App(Generic[ReturnType], DOMNode):
|
||||
terminal_size=size,
|
||||
)
|
||||
finally:
|
||||
if auto_pilot_task is not None:
|
||||
await auto_pilot_task
|
||||
await app._shutdown()
|
||||
try:
|
||||
if auto_pilot_task is not None:
|
||||
await auto_pilot_task
|
||||
finally:
|
||||
await app._shutdown()
|
||||
|
||||
return app.return_value
|
||||
|
||||
@@ -871,7 +889,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
def render(self) -> RenderableType:
|
||||
return Blank(self.styles.background)
|
||||
|
||||
def get_child(self, id: str) -> DOMNode:
|
||||
def get_child_by_id(self, id: str) -> Widget:
|
||||
"""Shorthand for self.screen.get_child(id: str)
|
||||
Returns the first child (immediate descendent) of this DOMNode
|
||||
with the given ID.
|
||||
@@ -885,7 +903,26 @@ class App(Generic[ReturnType], DOMNode):
|
||||
Raises:
|
||||
NoMatches: if no children could be found for this ID
|
||||
"""
|
||||
return self.screen.get_child(id)
|
||||
return self.screen.get_child_by_id(id)
|
||||
|
||||
def get_widget_by_id(self, id: str) -> Widget:
|
||||
"""Shorthand for self.screen.get_widget_by_id(id)
|
||||
Return the first descendant widget with the given ID.
|
||||
|
||||
Performs a breadth-first search rooted at the current screen.
|
||||
It will not return the Screen if that matches the ID.
|
||||
To get the screen, use `self.screen`.
|
||||
|
||||
Args:
|
||||
id (str): The ID to search for in the subtree
|
||||
|
||||
Returns:
|
||||
DOMNode: The first descendant encountered with this ID.
|
||||
|
||||
Raises:
|
||||
NoMatches: if no children could be found for this ID
|
||||
"""
|
||||
return self.screen.get_widget_by_id(id)
|
||||
|
||||
def update_styles(self, node: DOMNode | None = None) -> None:
|
||||
"""Request update of styles.
|
||||
@@ -977,12 +1014,15 @@ class App(Generic[ReturnType], DOMNode):
|
||||
next_screen = self._installed_screens[screen]
|
||||
except KeyError:
|
||||
raise KeyError(f"No screen called {screen!r} installed") from None
|
||||
if callable(next_screen):
|
||||
next_screen = next_screen()
|
||||
self._installed_screens[screen] = next_screen
|
||||
else:
|
||||
next_screen = screen
|
||||
return next_screen
|
||||
|
||||
def _get_screen(self, screen: Screen | str) -> tuple[Screen, AwaitMount]:
|
||||
"""Get an installed screen and a await mount object.
|
||||
"""Get an installed screen and an AwaitMount object.
|
||||
|
||||
If the screen isn't running, it will be registered before it is run.
|
||||
|
||||
@@ -1291,6 +1331,10 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
await self.animator.start()
|
||||
|
||||
except Exception:
|
||||
await self.animator.stop()
|
||||
raise
|
||||
|
||||
finally:
|
||||
await self._ready()
|
||||
await invoke_ready_callback()
|
||||
@@ -1303,10 +1347,11 @@ class App(Generic[ReturnType], DOMNode):
|
||||
pass
|
||||
finally:
|
||||
self._running = False
|
||||
for timer in list(self._timers):
|
||||
await timer.stop()
|
||||
|
||||
await self.animator.stop()
|
||||
try:
|
||||
await self.animator.stop()
|
||||
finally:
|
||||
for timer in list(self._timers):
|
||||
await timer.stop()
|
||||
|
||||
self._running = True
|
||||
try:
|
||||
@@ -1532,7 +1577,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
# Close pre-defined screens
|
||||
for screen in self.SCREENS.values():
|
||||
if screen._running:
|
||||
if isinstance(screen, Screen) and screen._running:
|
||||
await self._prune_node(screen)
|
||||
|
||||
# Close any remaining nodes
|
||||
|
||||
@@ -21,17 +21,17 @@ class NoBinding(Exception):
|
||||
@dataclass(frozen=True)
|
||||
class Binding:
|
||||
key: str
|
||||
"""Key to bind. This can also be a comma-separated list of keys to map multiple keys to a single action."""
|
||||
"""str: Key to bind. This can also be a comma-separated list of keys to map multiple keys to a single action."""
|
||||
action: str
|
||||
"""Action to bind to."""
|
||||
"""str: Action to bind to."""
|
||||
description: str
|
||||
"""Description of action."""
|
||||
"""str: Description of action."""
|
||||
show: bool = True
|
||||
"""Show the action in Footer, or False to hide."""
|
||||
"""bool: Show the action in Footer, or False to hide."""
|
||||
key_display: str | None = None
|
||||
"""How the key should be shown in footer."""
|
||||
"""str | None: How the key should be shown in footer."""
|
||||
universal: bool = False
|
||||
"""Allow forwarding from app to focused widget."""
|
||||
"""bool: Allow forwarding from app to focused widget."""
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
@@ -107,6 +107,16 @@ class Bindings:
|
||||
key_display: str | None = None,
|
||||
universal: bool = False,
|
||||
) -> None:
|
||||
"""Bind keys to an action.
|
||||
|
||||
Args:
|
||||
keys (str): The keys to bind. Can be a comma-separated list of keys.
|
||||
action (str): The action to bind the keys to.
|
||||
description (str, optional): An optional description for the binding.
|
||||
show (bool, optional): A flag to say if the binding should appear in the footer.
|
||||
key_display (str | None, optional): Optional string to display in the footer for the key.
|
||||
universal (bool, optional): Allow forwarding from the app to the focused widget.
|
||||
"""
|
||||
all_keys = [key.strip() for key in keys.split(",")]
|
||||
for key in all_keys:
|
||||
self.keys[key] = Binding(
|
||||
|
||||
@@ -33,8 +33,8 @@ def get_box_model(
|
||||
viewport (Size): The viewport size.
|
||||
width_fraction (Fraction): A fraction used for 1 `fr` unit on the width dimension.
|
||||
height_fraction (Fraction):A fraction used for 1 `fr` unit on the height dimension.
|
||||
get_auto_width (Callable): A callable which accepts container size and parent size and returns a width.
|
||||
get_auto_height (Callable): A callable which accepts container size and parent size and returns a height.
|
||||
get_content_width (Callable[[Size, Size], int]): A callable which accepts container size and parent size and returns a width.
|
||||
get_content_height (Callable[[Size, Size, int], int]): A callable which accepts container size and parent size and returns a height.
|
||||
|
||||
Returns:
|
||||
BoxModel: A tuple with the size of the content area and margin.
|
||||
|
||||
@@ -22,7 +22,3 @@ def camel_to_snake(
|
||||
return f"{lower}_{upper.lower()}"
|
||||
|
||||
return _re_snake.sub(repl, name).lower()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(camel_to_snake("HelloWorldEvent"))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.constants import BORDERS
|
||||
from textual.widgets import Button, Static
|
||||
from textual.widgets import Button, Label
|
||||
from textual.containers import Vertical
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ class BorderApp(App):
|
||||
|
||||
def compose(self):
|
||||
yield BorderButtons()
|
||||
self.text = Static(TEXT, id="text")
|
||||
self.text = Label(TEXT, id="text")
|
||||
yield self.text
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
|
||||
@@ -68,7 +68,7 @@ ColorGroup.-active {
|
||||
}
|
||||
|
||||
|
||||
ColorLabel {
|
||||
Label {
|
||||
padding: 0 0 1 0;
|
||||
content-align: center middle;
|
||||
color: $text;
|
||||
|
||||
@@ -2,7 +2,7 @@ from textual.app import App, ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.design import ColorSystem
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Button, Footer, Static
|
||||
from textual.widgets import Button, Footer, Static, Label
|
||||
|
||||
|
||||
class ColorButtons(Vertical):
|
||||
@@ -28,10 +28,6 @@ class Content(Vertical):
|
||||
pass
|
||||
|
||||
|
||||
class ColorLabel(Static):
|
||||
pass
|
||||
|
||||
|
||||
class ColorsView(Vertical):
|
||||
def compose(self) -> ComposeResult:
|
||||
|
||||
@@ -47,7 +43,7 @@ class ColorsView(Vertical):
|
||||
|
||||
for color_name in ColorSystem.COLOR_NAMES:
|
||||
|
||||
items: list[Widget] = [ColorLabel(f'"{color_name}"')]
|
||||
items: list[Widget] = [Label(f'"{color_name}"')]
|
||||
for level in LEVELS:
|
||||
color = f"{color_name}-{level}" if level else color_name
|
||||
item = ColorItem(
|
||||
|
||||
@@ -8,7 +8,7 @@ from textual.containers import Container, Horizontal, Vertical
|
||||
from textual.reactive import Reactive
|
||||
from textual.scrollbar import ScrollBarRender
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Button, Footer, Static, Input
|
||||
from textual.widgets import Button, Footer, Label, Input
|
||||
|
||||
VIRTUAL_SIZE = 100
|
||||
WINDOW_SIZE = 10
|
||||
@@ -27,7 +27,7 @@ class Bar(Widget):
|
||||
animation_running = Reactive(False)
|
||||
|
||||
DEFAULT_CSS = """
|
||||
|
||||
|
||||
Bar {
|
||||
background: $surface;
|
||||
color: $error;
|
||||
@@ -37,7 +37,7 @@ class Bar(Widget):
|
||||
background: $surface;
|
||||
color: $success;
|
||||
}
|
||||
|
||||
|
||||
"""
|
||||
|
||||
def watch_animation_running(self, running: bool) -> None:
|
||||
@@ -67,14 +67,14 @@ class EasingApp(App):
|
||||
self.animated_bar.position = START_POSITION
|
||||
duration_input = Input("1.0", placeholder="Duration", id="duration-input")
|
||||
|
||||
self.opacity_widget = Static(
|
||||
self.opacity_widget = Label(
|
||||
f"[b]Welcome to Textual![/]\n\n{TEXT}", id="opacity-widget"
|
||||
)
|
||||
|
||||
yield EasingButtons()
|
||||
yield Vertical(
|
||||
Horizontal(
|
||||
Static("Animation Duration:", id="label"), duration_input, id="inputs"
|
||||
Label("Animation Duration:", id="label"), duration_input, id="inputs"
|
||||
),
|
||||
Horizontal(
|
||||
self.animated_bar,
|
||||
|
||||
@@ -366,26 +366,3 @@ def parse(
|
||||
is_default_rules=is_default_rules,
|
||||
tie_breaker=tie_breaker,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(parse_selectors("Foo > Bar.baz { foo: bar"))
|
||||
|
||||
css = """#something {
|
||||
text: on red;
|
||||
transition: offset 5.51s in_out_cubic;
|
||||
offset-x: 100%;
|
||||
}
|
||||
"""
|
||||
|
||||
from textual.css.stylesheet import Stylesheet, StylesheetParseError
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
stylesheet = Stylesheet()
|
||||
try:
|
||||
stylesheet.add_source(css)
|
||||
except StylesheetParseError as e:
|
||||
console.print(e.errors)
|
||||
print(stylesheet)
|
||||
print(stylesheet.css)
|
||||
|
||||
@@ -383,10 +383,3 @@ def percentage_string_to_float(string: str) -> float:
|
||||
else:
|
||||
float_percentage = float(string)
|
||||
return float_percentage
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(Scalar.parse("3.14fr"))
|
||||
s = Scalar.parse("23")
|
||||
print(repr(s))
|
||||
print(repr(s.cells))
|
||||
|
||||
@@ -557,6 +557,7 @@ class StylesBase(ABC):
|
||||
class Styles(StylesBase):
|
||||
node: DOMNode | None = None
|
||||
_rules: RulesMap = field(default_factory=dict)
|
||||
_updates: int = 0
|
||||
|
||||
important: set[str] = field(default_factory=set)
|
||||
|
||||
@@ -577,6 +578,7 @@ class Styles(StylesBase):
|
||||
Returns:
|
||||
bool: ``True`` if a rule was cleared, or ``False`` if it was already not set.
|
||||
"""
|
||||
self._updates += 1
|
||||
return self._rules.pop(rule, None) is not None
|
||||
|
||||
def get_rules(self) -> RulesMap:
|
||||
@@ -592,6 +594,7 @@ class Styles(StylesBase):
|
||||
Returns:
|
||||
bool: ``True`` if the rule changed, otherwise ``False``.
|
||||
"""
|
||||
self._updates += 1
|
||||
if value is None:
|
||||
return self._rules.pop(rule, None) is not None
|
||||
current = self._rules.get(rule)
|
||||
@@ -610,6 +613,7 @@ class Styles(StylesBase):
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the rules to initial state."""
|
||||
self._updates += 1
|
||||
self._rules.clear()
|
||||
|
||||
def merge(self, other: Styles) -> None:
|
||||
@@ -618,10 +622,11 @@ class Styles(StylesBase):
|
||||
Args:
|
||||
other (Styles): A Styles object.
|
||||
"""
|
||||
|
||||
self._updates += 1
|
||||
self._rules.update(other._rules)
|
||||
|
||||
def merge_rules(self, rules: RulesMap) -> None:
|
||||
self._updates += 1
|
||||
self._rules.update(rules)
|
||||
|
||||
def extract_rules(
|
||||
@@ -929,6 +934,18 @@ class RenderStyles(StylesBase):
|
||||
self._base_styles = base
|
||||
self._inline_styles = inline_styles
|
||||
self._animate: BoundAnimator | None = None
|
||||
self._updates: int = 0
|
||||
self._rich_style: tuple[int, Style] | None = None
|
||||
self._gutter: tuple[int, Spacing] | None = None
|
||||
|
||||
@property
|
||||
def _cache_key(self) -> int:
|
||||
"""A key key, that changes when any style is changed.
|
||||
|
||||
Returns:
|
||||
int: An opaque integer.
|
||||
"""
|
||||
return self._updates + self._base_styles._updates + self._inline_styles._updates
|
||||
|
||||
@property
|
||||
def base(self) -> Styles:
|
||||
@@ -946,6 +963,21 @@ class RenderStyles(StylesBase):
|
||||
assert self.node is not None
|
||||
return self.node.rich_style
|
||||
|
||||
@property
|
||||
def gutter(self) -> Spacing:
|
||||
"""Get space around widget.
|
||||
|
||||
Returns:
|
||||
Spacing: Space around widget content.
|
||||
"""
|
||||
if self._gutter is not None:
|
||||
cache_key, gutter = self._gutter
|
||||
if cache_key == self._updates:
|
||||
return gutter
|
||||
gutter = self.padding + self.border.spacing
|
||||
self._gutter = (self._cache_key, gutter)
|
||||
return gutter
|
||||
|
||||
def animate(
|
||||
self,
|
||||
attribute: str,
|
||||
@@ -972,6 +1004,7 @@ class RenderStyles(StylesBase):
|
||||
|
||||
"""
|
||||
if self._animate is None:
|
||||
assert self.node is not None
|
||||
self._animate = self.node.app.animator.bind(self)
|
||||
assert self._animate is not None
|
||||
self._animate(
|
||||
@@ -1003,16 +1036,19 @@ class RenderStyles(StylesBase):
|
||||
|
||||
def merge_rules(self, rules: RulesMap) -> None:
|
||||
self._inline_styles.merge_rules(rules)
|
||||
self._updates += 1
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the rules to initial state."""
|
||||
self._inline_styles.reset()
|
||||
self._updates += 1
|
||||
|
||||
def has_rule(self, rule: str) -> bool:
|
||||
"""Check if a rule has been set."""
|
||||
return self._inline_styles.has_rule(rule) or self._base_styles.has_rule(rule)
|
||||
|
||||
def set_rule(self, rule: str, value: object | None) -> bool:
|
||||
self._updates += 1
|
||||
return self._inline_styles.set_rule(rule, value)
|
||||
|
||||
def get_rule(self, rule: str, default: object = None) -> object:
|
||||
@@ -1022,6 +1058,7 @@ class RenderStyles(StylesBase):
|
||||
|
||||
def clear_rule(self, rule_name: str) -> bool:
|
||||
"""Clear a rule (from inline)."""
|
||||
self._updates += 1
|
||||
return self._inline_styles.clear_rule(rule_name)
|
||||
|
||||
def get_rules(self) -> RulesMap:
|
||||
@@ -1037,25 +1074,3 @@ class RenderStyles(StylesBase):
|
||||
styles.merge(self._inline_styles)
|
||||
combined_css = styles.css
|
||||
return combined_css
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
styles = Styles()
|
||||
|
||||
styles.display = "none"
|
||||
styles.visibility = "hidden"
|
||||
styles.border = ("solid", "rgb(10,20,30)")
|
||||
styles.outline_right = ("solid", "red")
|
||||
styles.text_style = "italic"
|
||||
styles.dock = "bar"
|
||||
styles.layers = "foo bar"
|
||||
|
||||
from rich import print
|
||||
|
||||
print(styles.text_style)
|
||||
print(styles.text)
|
||||
|
||||
print(styles)
|
||||
print(styles.css)
|
||||
|
||||
print(styles.extract_rules((0, 1, 0)))
|
||||
|
||||
@@ -197,18 +197,3 @@ def tokenize_values(values: dict[str, str]) -> dict[str, list[Token]]:
|
||||
name: list(tokenize_value(value, "__name__")) for name, value in values.items()
|
||||
}
|
||||
return value_tokens
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from rich import print
|
||||
|
||||
css = """#something {
|
||||
|
||||
color: rgb(10,12,23)
|
||||
}
|
||||
"""
|
||||
# transition: offset 500 in_out_cubic;
|
||||
tokens = tokenize(css, __name__)
|
||||
print(list(tokens))
|
||||
|
||||
print(tokenize_values({"primary": "rgb(10,20,30)", "secondary": "#ff00ff"}))
|
||||
|
||||
@@ -222,11 +222,3 @@ def show_design(light: ColorSystem, dark: ColorSystem) -> Table:
|
||||
table.add_column("Dark", justify="center")
|
||||
table.add_row(make_shades(light), make_shades(dark))
|
||||
return table
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from .app import DEFAULT_COLORS
|
||||
|
||||
from rich import print
|
||||
|
||||
print(show_design(DEFAULT_COLORS["light"], DEFAULT_COLORS["dark"]))
|
||||
|
||||
@@ -28,7 +28,6 @@ from .css._error_tools import friendly_list
|
||||
from .css.constants import VALID_DISPLAY, VALID_VISIBILITY
|
||||
from .css.errors import DeclarationError, StyleValueError
|
||||
from .css.parse import parse_declarations
|
||||
from .css.query import NoMatches
|
||||
from .css.styles import RenderStyles, Styles
|
||||
from .css.tokenize import IDENTIFIER
|
||||
from .message_pump import MessagePump
|
||||
@@ -645,7 +644,6 @@ class DOMNode(MessagePump):
|
||||
list[DOMNode] | list[WalkType]: A list of nodes.
|
||||
|
||||
"""
|
||||
|
||||
check_type = filter_type or DOMNode
|
||||
|
||||
node_generator = (
|
||||
@@ -661,23 +659,6 @@ class DOMNode(MessagePump):
|
||||
nodes.reverse()
|
||||
return cast("list[DOMNode]", nodes)
|
||||
|
||||
def get_child(self, id: str) -> DOMNode:
|
||||
"""Return the first child (immediate descendent) of this node with the given ID.
|
||||
|
||||
Args:
|
||||
id (str): The ID of the child.
|
||||
|
||||
Returns:
|
||||
DOMNode: The first child of this node with the ID.
|
||||
|
||||
Raises:
|
||||
NoMatches: if no children could be found for this ID
|
||||
"""
|
||||
for child in self.children:
|
||||
if child.id == id:
|
||||
return child
|
||||
raise NoMatches(f"No child found with id={id!r}")
|
||||
|
||||
ExpectType = TypeVar("ExpectType", bound="Widget")
|
||||
|
||||
@overload
|
||||
|
||||
@@ -236,17 +236,3 @@ class LinuxDriver(Driver):
|
||||
finally:
|
||||
with timer("selector.close"):
|
||||
selector.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
from ..app import App
|
||||
|
||||
class MyApp(App):
|
||||
async def on_mount(self, event: events.Mount) -> None:
|
||||
self.set_timer(5, callback=self._close_messages)
|
||||
|
||||
MyApp.run()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unicodedata
|
||||
from enum import Enum
|
||||
|
||||
|
||||
@@ -219,7 +220,34 @@ KEY_ALIASES = {
|
||||
"ctrl+j": ["newline"],
|
||||
}
|
||||
|
||||
KEY_DISPLAY_ALIASES = {
|
||||
"up": "↑",
|
||||
"down": "↓",
|
||||
"left": "←",
|
||||
"right": "→",
|
||||
"backspace": "⌫",
|
||||
"escape": "ESC",
|
||||
"enter": "⏎",
|
||||
}
|
||||
|
||||
|
||||
def _get_key_aliases(key: str) -> list[str]:
|
||||
"""Return all aliases for the given key, including the key itself"""
|
||||
return [key] + KEY_ALIASES.get(key, [])
|
||||
|
||||
|
||||
def _get_key_display(key: str) -> str:
|
||||
"""Given a key (i.e. the `key` string argument to Binding __init__),
|
||||
return the value that should be displayed in the app when referring
|
||||
to this key (e.g. in the Footer widget)."""
|
||||
display_alias = KEY_DISPLAY_ALIASES.get(key)
|
||||
if display_alias:
|
||||
return display_alias
|
||||
|
||||
original_key = REPLACED_KEYS.get(key, key)
|
||||
try:
|
||||
unicode_character = unicodedata.lookup(original_key.upper().replace("_", " "))
|
||||
except KeyError:
|
||||
return original_key.upper()
|
||||
|
||||
return unicode_character
|
||||
|
||||
@@ -72,20 +72,12 @@ class HorizontalLayout(Layout):
|
||||
Returns:
|
||||
int: Width of the content.
|
||||
"""
|
||||
width: int | None = None
|
||||
gutter_width = widget.gutter.width
|
||||
for child in widget.displayed_children:
|
||||
if not child.is_container:
|
||||
child_width = (
|
||||
child.get_content_width(container, viewport)
|
||||
+ gutter_width
|
||||
+ child.gutter.width
|
||||
)
|
||||
if width is None:
|
||||
width = child_width
|
||||
else:
|
||||
width += child_width
|
||||
if width is None:
|
||||
if not widget.displayed_children:
|
||||
width = container.width
|
||||
|
||||
else:
|
||||
placements, *_ = widget._arrange(container)
|
||||
width = max(
|
||||
placement.region.right + placement.margin.right
|
||||
for placement in placements
|
||||
)
|
||||
return width
|
||||
|
||||
@@ -43,7 +43,7 @@ class Reactive(Generic[ReactiveType]):
|
||||
layout (bool, optional): Perform a layout on change. Defaults to False.
|
||||
repaint (bool, optional): Perform a repaint on change. Defaults to True.
|
||||
init (bool, optional): Call watchers on initialize (post mount). Defaults to False.
|
||||
always_update(bool, optional): Call watchers even when the new value equals the old value. Defaults to False.
|
||||
always_update (bool, optional): Call watchers even when the new value equals the old value. Defaults to False.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -76,7 +76,7 @@ class Reactive(Generic[ReactiveType]):
|
||||
default (ReactiveType | Callable[[], ReactiveType]): A default value or callable that returns a default.
|
||||
layout (bool, optional): Perform a layout on change. Defaults to False.
|
||||
repaint (bool, optional): Perform a repaint on change. Defaults to True.
|
||||
always_update(bool, optional): Call watchers even when the new value equals the old value. Defaults to False.
|
||||
always_update (bool, optional): Call watchers even when the new value equals the old value. Defaults to False.
|
||||
|
||||
Returns:
|
||||
Reactive: A Reactive instance which calls watchers or initialize.
|
||||
@@ -292,7 +292,7 @@ class reactive(Reactive[ReactiveType]):
|
||||
layout (bool, optional): Perform a layout on change. Defaults to False.
|
||||
repaint (bool, optional): Perform a repaint on change. Defaults to True.
|
||||
init (bool, optional): Call watchers on initialize (post mount). Defaults to True.
|
||||
always_update(bool, optional): Call watchers even when the new value equals the old value. Defaults to False.
|
||||
always_update (bool, optional): Call watchers even when the new value equals the old value. Defaults to False.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -25,9 +25,3 @@ class Blank:
|
||||
for _ in range(height):
|
||||
yield segment
|
||||
yield line
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from rich import print
|
||||
|
||||
print(Blank("red"))
|
||||
|
||||
@@ -36,9 +36,3 @@ class VerticalGradient:
|
||||
),
|
||||
)
|
||||
yield Segment(f"{width * ' '}\n", line_color)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from rich import print
|
||||
|
||||
print(VerticalGradient("red", "blue"))
|
||||
|
||||
@@ -320,18 +320,3 @@ class ScrollBarCorner(Widget):
|
||||
styles = self.parent.styles
|
||||
color = styles.scrollbar_corner_color
|
||||
return Blank(color)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
thickness = 2
|
||||
console.print(f"Bars thickness: {thickness}")
|
||||
|
||||
console.print("Vertical bar:")
|
||||
console.print(ScrollBarRender.render_bar(thickness=thickness))
|
||||
|
||||
console.print("Horizontal bar:")
|
||||
console.print(ScrollBarRender.render_bar(vertical=False, thickness=thickness))
|
||||
|
||||
@@ -4,7 +4,7 @@ from collections import deque
|
||||
from typing import Iterable, Iterator, TypeVar, overload, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .dom import DOMNode
|
||||
from textual.dom import DOMNode
|
||||
|
||||
WalkType = TypeVar("WalkType", bound=DOMNode)
|
||||
|
||||
@@ -51,6 +51,8 @@ def walk_depth_first(
|
||||
Iterable[DOMNode] | Iterable[WalkType]: An iterable of DOMNodes, or the type specified in ``filter_type``.
|
||||
|
||||
"""
|
||||
from textual.dom import DOMNode
|
||||
|
||||
stack: list[Iterator[DOMNode]] = [iter(root.children)]
|
||||
pop = stack.pop
|
||||
push = stack.append
|
||||
@@ -111,6 +113,8 @@ def walk_breadth_first(
|
||||
Iterable[DOMNode] | Iterable[WalkType]: An iterable of DOMNodes, or the type specified in ``filter_type``.
|
||||
|
||||
"""
|
||||
from textual.dom import DOMNode
|
||||
|
||||
queue: deque[DOMNode] = deque()
|
||||
popleft = queue.popleft
|
||||
extend = queue.extend
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from asyncio import Lock, wait, create_task, Event as AsyncEvent
|
||||
from fractions import Fraction
|
||||
from itertools import islice
|
||||
@@ -41,6 +42,7 @@ from ._styles_cache import StylesCache
|
||||
from ._types import Lines
|
||||
from .binding import NoBinding
|
||||
from .box_model import BoxModel, get_box_model
|
||||
from .css.query import NoMatches
|
||||
from .css.scalar import ScalarOffset
|
||||
from .dom import DOMNode, NoScreen
|
||||
from .geometry import Offset, Region, Size, Spacing, clamp
|
||||
@@ -50,6 +52,7 @@ from .messages import CallbackType
|
||||
from .reactive import Reactive
|
||||
from .render import measure
|
||||
from .await_remove import AwaitRemove
|
||||
from .walk import walk_depth_first
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .app import App, ComposeResult
|
||||
@@ -334,6 +337,43 @@ class Widget(DOMNode):
|
||||
def offset(self, offset: Offset) -> None:
|
||||
self.styles.offset = ScalarOffset.from_offset(offset)
|
||||
|
||||
def get_child_by_id(self, id: str) -> Widget:
|
||||
"""Return the first child (immediate descendent) of this node with the given ID.
|
||||
|
||||
Args:
|
||||
id (str): The ID of the child.
|
||||
|
||||
Returns:
|
||||
DOMNode: The first child of this node with the ID.
|
||||
|
||||
Raises:
|
||||
NoMatches: if no children could be found for this ID
|
||||
"""
|
||||
child = self.children._get_by_id(id)
|
||||
if child is not None:
|
||||
return child
|
||||
raise NoMatches(f"No child found with id={id!r}")
|
||||
|
||||
def get_widget_by_id(self, id: str) -> Widget:
|
||||
"""Return the first descendant widget with the given ID.
|
||||
Performs a depth-first search rooted at this widget.
|
||||
|
||||
Args:
|
||||
id (str): The ID to search for in the subtree
|
||||
|
||||
Returns:
|
||||
DOMNode: The first descendant encountered with this ID.
|
||||
|
||||
Raises:
|
||||
NoMatches: if no children could be found for this ID
|
||||
"""
|
||||
for child in walk_depth_first(self):
|
||||
try:
|
||||
return child.get_child_by_id(id)
|
||||
except NoMatches:
|
||||
pass
|
||||
raise NoMatches(f"No descendant found with id={id!r}")
|
||||
|
||||
def get_component_rich_style(self, name: str) -> Style:
|
||||
"""Get a *Rich* style for a component.
|
||||
|
||||
@@ -461,6 +501,20 @@ class Widget(DOMNode):
|
||||
provided a ``MountError`` will be raised.
|
||||
"""
|
||||
|
||||
# Check for duplicate IDs in the incoming widgets
|
||||
ids_to_mount = [widget.id for widget in widgets if widget.id is not None]
|
||||
unique_ids = set(ids_to_mount)
|
||||
num_unique_ids = len(unique_ids)
|
||||
num_widgets_with_ids = len(ids_to_mount)
|
||||
if num_unique_ids != num_widgets_with_ids:
|
||||
counter = Counter(widget.id for widget in widgets)
|
||||
for widget_id, count in counter.items():
|
||||
if count > 1:
|
||||
raise MountError(
|
||||
f"Tried to insert {count!r} widgets with the same ID {widget_id!r}. "
|
||||
f"Widget IDs must be unique."
|
||||
)
|
||||
|
||||
# Saying you want to mount before *and* after something is an error.
|
||||
if before is not None and after is not None:
|
||||
raise MountError(
|
||||
@@ -480,6 +534,69 @@ class Widget(DOMNode):
|
||||
self.app._register(parent, *widgets, before=before, after=after)
|
||||
)
|
||||
|
||||
def move_child(
|
||||
self,
|
||||
child: int | Widget,
|
||||
before: int | Widget | None = None,
|
||||
after: int | Widget | None = None,
|
||||
) -> None:
|
||||
"""Move a child widget within its parent's list of children.
|
||||
|
||||
Args:
|
||||
child (int | Widget): The child widget to move.
|
||||
before: (int | Widget, optional): Optional location to move before.
|
||||
after: (int | Widget, optional): Optional location to move after.
|
||||
|
||||
Raises:
|
||||
WidgetError: If there is a problem with the child or target.
|
||||
|
||||
Note:
|
||||
Only one of ``before`` or ``after`` can be provided. If neither
|
||||
or both are provided a ``WidgetError`` will be raised.
|
||||
"""
|
||||
|
||||
# One or the other of before or after are required. Can't do
|
||||
# neither, can't do both.
|
||||
if before is None and after is None:
|
||||
raise WidgetError("One of `before` or `after` is required.")
|
||||
elif before is not None and after is not None:
|
||||
raise WidgetError("Only one of `before` or `after` can be handled.")
|
||||
|
||||
def _to_widget(child: int | Widget, called: str) -> Widget:
|
||||
"""Ensure a given child reference is a Widget."""
|
||||
if isinstance(child, int):
|
||||
try:
|
||||
child = self.children[child]
|
||||
except IndexError:
|
||||
raise WidgetError(
|
||||
f"An index of {child} for the child to {called} is out of bounds"
|
||||
) from None
|
||||
else:
|
||||
# We got an actual widget, so let's be sure it really is one of
|
||||
# our children.
|
||||
try:
|
||||
_ = self.children.index(child)
|
||||
except ValueError:
|
||||
raise WidgetError(f"{child!r} is not a child of {self!r}") from None
|
||||
return child
|
||||
|
||||
# Ensure the child and target are widgets.
|
||||
child = _to_widget(child, "move")
|
||||
target = _to_widget(before if after is None else after, "move towards")
|
||||
|
||||
# At this point we should know what we're moving, and it should be a
|
||||
# child; where we're moving it to, which should be within the child
|
||||
# list; and how we're supposed to move it. All that's left is doing
|
||||
# the right thing.
|
||||
self.children._remove(child)
|
||||
if before is not None:
|
||||
self.children._insert(self.children.index(target), child)
|
||||
else:
|
||||
self.children._insert(self.children.index(target) + 1, child)
|
||||
|
||||
# Request a refresh.
|
||||
self.refresh(layout=True)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Called by Textual to create child widgets.
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ if typing.TYPE_CHECKING:
|
||||
from ._directory_tree import DirectoryTree
|
||||
from ._footer import Footer
|
||||
from ._header import Header
|
||||
from ._label import Label
|
||||
from ._placeholder import Placeholder
|
||||
from ._pretty import Pretty
|
||||
from ._static import Static
|
||||
@@ -30,6 +31,7 @@ __all__ = [
|
||||
"DirectoryTree",
|
||||
"Footer",
|
||||
"Header",
|
||||
"Label",
|
||||
"Placeholder",
|
||||
"Pretty",
|
||||
"Static",
|
||||
|
||||
@@ -5,6 +5,7 @@ from ._checkbox import Checkbox as Checkbox
|
||||
from ._directory_tree import DirectoryTree as DirectoryTree
|
||||
from ._footer import Footer as Footer
|
||||
from ._header import Header as Header
|
||||
from ._label import Label as Label
|
||||
from ._placeholder import Placeholder as Placeholder
|
||||
from ._pretty import Pretty as Pretty
|
||||
from ._static import Static as Static
|
||||
|
||||
@@ -168,9 +168,9 @@ class Button(Static, can_focus=True):
|
||||
"""Create a Button widget.
|
||||
|
||||
Args:
|
||||
label (str): The text that appears within the button.
|
||||
disabled (bool): Whether the button is disabled or not.
|
||||
variant (ButtonVariant): The variant of the button.
|
||||
label (str, optional): The text that appears within the button.
|
||||
disabled (bool, optional): Whether the button is disabled or not.
|
||||
variant (ButtonVariant, optional): The variant of the button.
|
||||
name (str | None, optional): The name of the button.
|
||||
id (str | None, optional): The ID of the button in the DOM.
|
||||
classes (str | None, optional): The CSS classes of the button.
|
||||
@@ -186,7 +186,7 @@ class Button(Static, can_focus=True):
|
||||
if disabled:
|
||||
self.add_class("-disabled")
|
||||
|
||||
self.variant = variant
|
||||
self.variant = self.validate_variant(variant)
|
||||
|
||||
label: Reactive[RenderableType] = Reactive("")
|
||||
variant = Reactive.init("default")
|
||||
@@ -267,8 +267,8 @@ class Button(Static, can_focus=True):
|
||||
"""Utility constructor for creating a success Button variant.
|
||||
|
||||
Args:
|
||||
label (str): The text that appears within the button.
|
||||
disabled (bool): Whether the button is disabled or not.
|
||||
label (str, optional): The text that appears within the button.
|
||||
disabled (bool, optional): Whether the button is disabled or not.
|
||||
name (str | None, optional): The name of the button.
|
||||
id (str | None, optional): The ID of the button in the DOM.
|
||||
classes(str | None, optional): The CSS classes of the button.
|
||||
@@ -298,8 +298,8 @@ class Button(Static, can_focus=True):
|
||||
"""Utility constructor for creating a warning Button variant.
|
||||
|
||||
Args:
|
||||
label (str): The text that appears within the button.
|
||||
disabled (bool): Whether the button is disabled or not.
|
||||
label (str, optional): The text that appears within the button.
|
||||
disabled (bool, optional): Whether the button is disabled or not.
|
||||
name (str | None, optional): The name of the button.
|
||||
id (str | None, optional): The ID of the button in the DOM.
|
||||
classes (str | None, optional): The CSS classes of the button.
|
||||
@@ -329,8 +329,8 @@ class Button(Static, can_focus=True):
|
||||
"""Utility constructor for creating an error Button variant.
|
||||
|
||||
Args:
|
||||
label (str): The text that appears within the button.
|
||||
disabled (bool): Whether the button is disabled or not.
|
||||
label (str, optional): The text that appears within the button.
|
||||
disabled (bool, optional): Whether the button is disabled or not.
|
||||
name (str | None, optional): The name of the button.
|
||||
id (str | None, optional): The ID of the button in the DOM.
|
||||
classes (str | None, optional): The CSS classes of the button.
|
||||
|
||||
@@ -7,6 +7,7 @@ from rich.console import RenderableType
|
||||
from rich.text import Text
|
||||
|
||||
from .. import events
|
||||
from ..keys import _get_key_display
|
||||
from ..reactive import Reactive, watch
|
||||
from ..widget import Widget
|
||||
|
||||
@@ -99,11 +100,12 @@ class Footer(Widget):
|
||||
|
||||
for action, bindings in action_to_bindings.items():
|
||||
binding = bindings[0]
|
||||
key_display = (
|
||||
binding.key.upper()
|
||||
if binding.key_display is None
|
||||
else binding.key_display
|
||||
)
|
||||
if binding.key_display is None:
|
||||
key_display = self.app.get_key_display(binding.key)
|
||||
if key_display is None:
|
||||
key_display = binding.key.upper()
|
||||
else:
|
||||
key_display = binding.key_display
|
||||
hovered = self.highlight_key == binding.key
|
||||
key_text = Text.assemble(
|
||||
(f" {key_display} ", highlight_key_style if hovered else key_style),
|
||||
|
||||
@@ -176,6 +176,10 @@ class Input(Widget, can_focus=True):
|
||||
if self.has_focus:
|
||||
cursor_style = self.get_component_rich_style("input--cursor")
|
||||
if self._cursor_visible:
|
||||
# If the placeholder is empty, there's no characters to stylise
|
||||
# to make the cursor flash, so use a single space character
|
||||
if len(placeholder) == 0:
|
||||
placeholder = Text(" ")
|
||||
placeholder.stylize(cursor_style, 0, 1)
|
||||
return placeholder
|
||||
return _InputRenderable(self, self._cursor_visible)
|
||||
|
||||
7
src/textual/widgets/_label.py
Normal file
7
src/textual/widgets/_label.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Provides a simple Label widget."""
|
||||
|
||||
from ._static import Static
|
||||
|
||||
|
||||
class Label(Static):
|
||||
"""A simple label widget for displaying text-oriented renderables."""
|
||||
@@ -177,16 +177,16 @@ class Tabs(Widget):
|
||||
Args:
|
||||
tabs (list[Tab]): A list of Tab objects defining the tabs which should be rendered.
|
||||
active_tab (str, optional): The name of the tab that should be active on first render.
|
||||
active_tab_style (StyleType): Style to apply to the label of the active tab.
|
||||
active_bar_style (StyleType): Style to apply to the underline of the active tab.
|
||||
inactive_tab_style (StyleType): Style to apply to the label of inactive tabs.
|
||||
inactive_bar_style (StyleType): Style to apply to the underline of inactive tabs.
|
||||
inactive_text_opacity (float): Opacity of the text labels of inactive tabs.
|
||||
animation_duration (float): The duration of the tab change animation, in seconds.
|
||||
animation_function (str): The easing function to use for the tab change animation.
|
||||
active_tab_style (StyleType, optional): Style to apply to the label of the active tab.
|
||||
active_bar_style (StyleType, optional): Style to apply to the underline of the active tab.
|
||||
inactive_tab_style (StyleType, optional): Style to apply to the label of inactive tabs.
|
||||
inactive_bar_style (StyleType, optional): Style to apply to the underline of inactive tabs.
|
||||
inactive_text_opacity (float, optional): Opacity of the text labels of inactive tabs.
|
||||
animation_duration (float, optional): The duration of the tab change animation, in seconds.
|
||||
animation_function (str, optional): The easing function to use for the tab change animation.
|
||||
tab_padding (int, optional): The padding at the side of each tab. If None, tabs will
|
||||
automatically be padded such that they fit the available horizontal space.
|
||||
search_by_first_character (bool): If True, entering a character on your keyboard
|
||||
search_by_first_character (bool, optional): If True, entering a character on your keyboard
|
||||
will activate the next tab (in left-to-right order) with a label starting with
|
||||
that character.
|
||||
"""
|
||||
|
||||
@@ -1,46 +1,29 @@
|
||||
from textual.geometry import Size
|
||||
from textual.layouts.horizontal import HorizontalLayout
|
||||
import pytest
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Horizontal
|
||||
from textual.widget import Widget
|
||||
|
||||
|
||||
class SizedWidget(Widget):
|
||||
"""Simple Widget wrapped allowing you to modify the return values for
|
||||
get_content_width and get_content_height via the constructor."""
|
||||
@pytest.fixture
|
||||
async def app():
|
||||
class HorizontalAutoWidth(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
child1 = Widget(id="child1")
|
||||
child1.styles.width = 4
|
||||
child2 = Widget(id="child2")
|
||||
child2.styles.width = 6
|
||||
child3 = Widget(id="child3")
|
||||
child3.styles.width = 5
|
||||
self.horizontal = Horizontal(child1, child2, child3)
|
||||
yield self.horizontal
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*children: Widget,
|
||||
content_width: int = 10,
|
||||
content_height: int = 5,
|
||||
):
|
||||
super().__init__(*children)
|
||||
self.content_width = content_width
|
||||
self.content_height = content_height
|
||||
|
||||
def get_content_width(self, container: Size, viewport: Size) -> int:
|
||||
return self.content_width
|
||||
|
||||
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
|
||||
return self.content_height
|
||||
app = HorizontalAutoWidth()
|
||||
async with app.run_test():
|
||||
yield app
|
||||
|
||||
|
||||
CHILDREN = [
|
||||
SizedWidget(content_width=10, content_height=5),
|
||||
SizedWidget(content_width=4, content_height=2),
|
||||
SizedWidget(content_width=12, content_height=3),
|
||||
]
|
||||
|
||||
|
||||
def test_horizontal_get_content_width():
|
||||
parent = Widget(*CHILDREN)
|
||||
layout = HorizontalLayout()
|
||||
width = layout.get_content_width(widget=parent, container=Size(), viewport=Size())
|
||||
assert width == sum(child.content_width for child in CHILDREN)
|
||||
|
||||
|
||||
def test_horizontal_get_content_width_no_children():
|
||||
parent = Widget()
|
||||
layout = HorizontalLayout()
|
||||
container_size = Size(24, 24)
|
||||
width = layout.get_content_width(widget=parent, container=container_size, viewport=Size())
|
||||
assert width == container_size.width
|
||||
async def test_horizontal_get_content_width(app):
|
||||
size = app.screen.size
|
||||
width = app.horizontal.get_content_width(size, size)
|
||||
assert width == 15
|
||||
|
||||
File diff suppressed because one or more lines are too long
24
tests/snapshot_tests/snapshot_apps/horizontal_auto_width.css
Normal file
24
tests/snapshot_tests/snapshot_apps/horizontal_auto_width.css
Normal file
@@ -0,0 +1,24 @@
|
||||
.widget {
|
||||
background: olivedrab;
|
||||
width: 10;
|
||||
margin: 1;
|
||||
}
|
||||
|
||||
#dock-1 {
|
||||
dock: left;
|
||||
background: dodgerblue;
|
||||
width: 5;
|
||||
}
|
||||
|
||||
#dock-2 {
|
||||
dock: left;
|
||||
background: mediumvioletred;
|
||||
margin: 3;
|
||||
width: 20;
|
||||
}
|
||||
|
||||
#horizontal {
|
||||
width: auto;
|
||||
height: auto;
|
||||
background: darkslateblue;
|
||||
}
|
||||
22
tests/snapshot_tests/snapshot_apps/horizontal_auto_width.py
Normal file
22
tests/snapshot_tests/snapshot_apps/horizontal_auto_width.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Horizontal
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class HorizontalAutoWidth(App):
|
||||
"""
|
||||
Checks that the auto width of the parent Horizontal is correct.
|
||||
"""
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Horizontal(
|
||||
Static("Docked left 1", id="dock-1"),
|
||||
Static("Docked left 2", id="dock-2"),
|
||||
Static("Widget 1", classes="widget"),
|
||||
Static("Widget 2", classes="widget"),
|
||||
id="horizontal",
|
||||
)
|
||||
|
||||
|
||||
app = HorizontalAutoWidth(css_path="horizontal_auto_width.css")
|
||||
if __name__ == '__main__':
|
||||
app.run()
|
||||
36
tests/snapshot_tests/snapshot_apps/key_display.py
Normal file
36
tests/snapshot_tests/snapshot_apps/key_display.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.widgets import Footer
|
||||
|
||||
|
||||
class KeyDisplayApp(App):
|
||||
"""Tests how keys are displayed in the Footer, and ensures
|
||||
that overriding the key_displays works as expected.
|
||||
Exercises both the built-in Textual key display replacements,
|
||||
and user supplied replacements.
|
||||
Will break when we update the Footer - but we should add a similar
|
||||
test (or updated snapshot) for the updated Footer."""
|
||||
BINDINGS = [
|
||||
Binding("question_mark", "question", "Question"),
|
||||
Binding("ctrl+q", "quit", "Quit app"),
|
||||
Binding("escape", "escape", "Escape"),
|
||||
Binding("a", "a", "Letter A"),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Footer()
|
||||
|
||||
def get_key_display(self, key: str) -> str:
|
||||
key_display_replacements = {
|
||||
"escape": "Escape!",
|
||||
"ctrl+q": "^q",
|
||||
}
|
||||
display = key_display_replacements.get(key)
|
||||
if display:
|
||||
return display
|
||||
return super().get_key_display(key)
|
||||
|
||||
|
||||
app = KeyDisplayApp()
|
||||
if __name__ == '__main__':
|
||||
app.run()
|
||||
@@ -2,9 +2,11 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# These paths should be relative to THIS directory.
|
||||
WIDGET_EXAMPLES_DIR = Path("../../docs/examples/widgets")
|
||||
LAYOUT_EXAMPLES_DIR = Path("../../docs/examples/guide/layout")
|
||||
STYLES_EXAMPLES_DIR = Path("../../docs/examples/styles")
|
||||
SNAPSHOT_APPS_DIR = Path("./snapshot_apps")
|
||||
|
||||
|
||||
# --- Layout related stuff ---
|
||||
@@ -29,6 +31,10 @@ def test_horizontal_layout(snap_compare):
|
||||
assert snap_compare(LAYOUT_EXAMPLES_DIR / "horizontal_layout.py")
|
||||
|
||||
|
||||
def test_horizontal_layout_width_auto_dock(snap_compare):
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "horizontal_auto_width.py")
|
||||
|
||||
|
||||
def test_vertical_layout(snap_compare):
|
||||
assert snap_compare(LAYOUT_EXAMPLES_DIR / "vertical_layout.py")
|
||||
|
||||
@@ -117,3 +123,9 @@ def test_css_property(file_name, snap_compare):
|
||||
def test_multiple_css(snap_compare):
|
||||
# Interaction between multiple CSS files and app-level/classvar CSS
|
||||
assert snap_compare("snapshot_apps/multiple_css/multiple_css.py")
|
||||
|
||||
|
||||
# --- Other ---
|
||||
|
||||
def test_key_display(snap_compare):
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "key_display.py")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from textual.css.errors import StyleValueError
|
||||
from textual.css.query import NoMatches
|
||||
from textual.dom import DOMNode, BadIdentifier
|
||||
|
||||
|
||||
@@ -26,37 +25,6 @@ def test_display_set_invalid_value():
|
||||
node.display = "blah"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parent():
|
||||
parent = DOMNode(id="parent")
|
||||
child1 = DOMNode(id="child1")
|
||||
child2 = DOMNode(id="child2")
|
||||
grandchild1 = DOMNode(id="grandchild1")
|
||||
child1._add_child(grandchild1)
|
||||
|
||||
parent._add_child(child1)
|
||||
parent._add_child(child2)
|
||||
|
||||
yield parent
|
||||
|
||||
|
||||
def test_get_child_gets_first_child(parent):
|
||||
child = parent.get_child(id="child1")
|
||||
assert child.id == "child1"
|
||||
assert child.get_child(id="grandchild1").id == "grandchild1"
|
||||
assert parent.get_child(id="child2").id == "child2"
|
||||
|
||||
|
||||
def test_get_child_no_matching_child(parent):
|
||||
with pytest.raises(NoMatches):
|
||||
parent.get_child(id="doesnt-exist")
|
||||
|
||||
|
||||
def test_get_child_only_immediate_descendents(parent):
|
||||
with pytest.raises(NoMatches):
|
||||
parent.get_child(id="grandchild1")
|
||||
|
||||
|
||||
def test_validate():
|
||||
with pytest.raises(BadIdentifier):
|
||||
DOMNode(id="23")
|
||||
|
||||
@@ -11,8 +11,37 @@ skip_py310 = pytest.mark.skipif(
|
||||
)
|
||||
|
||||
|
||||
async def test_installed_screens():
|
||||
class ScreensApp(App):
|
||||
SCREENS = {
|
||||
"home": Screen, # Screen type
|
||||
"one": Screen(), # Screen instance
|
||||
"two": lambda: Screen() # Callable[[], Screen]
|
||||
}
|
||||
|
||||
app = ScreensApp()
|
||||
async with app.run_test() as pilot:
|
||||
pilot.app.push_screen("home") # Instantiates and pushes the "home" screen
|
||||
pilot.app.push_screen("one") # Pushes the pre-instantiated "one" screen
|
||||
pilot.app.push_screen("home") # Pushes the single instance of "home" screen
|
||||
pilot.app.push_screen("two") # Calls the callable, pushes returned Screen instance
|
||||
|
||||
assert len(app.screen_stack) == 5
|
||||
assert app.screen_stack[1] is app.screen_stack[3]
|
||||
assert app.screen is app.screen_stack[4]
|
||||
assert isinstance(app.screen, Screen)
|
||||
assert app.is_screen_installed(app.screen)
|
||||
|
||||
assert pilot.app.pop_screen()
|
||||
assert pilot.app.pop_screen()
|
||||
assert pilot.app.pop_screen()
|
||||
assert pilot.app.pop_screen()
|
||||
with pytest.raises(ScreenStackError):
|
||||
pilot.app.pop_screen()
|
||||
|
||||
|
||||
|
||||
@skip_py310
|
||||
@pytest.mark.asyncio
|
||||
async def test_screens():
|
||||
|
||||
app = App()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual import events
|
||||
from textual.containers import Container
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import pytest
|
||||
import rich
|
||||
|
||||
from textual.app import App
|
||||
from textual._node_list import DuplicateIds
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.css.errors import StyleValueError
|
||||
from textual.css.query import NoMatches
|
||||
from textual.dom import DOMNode
|
||||
from textual.geometry import Size
|
||||
from textual.widget import Widget
|
||||
from textual.widget import Widget, MountError
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -64,3 +68,92 @@ def test_widget_content_width():
|
||||
height = widget3.get_content_height(Size(20, 20), Size(80, 24), width)
|
||||
assert width == 3
|
||||
assert height == 3
|
||||
|
||||
|
||||
class GetByIdApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
grandchild1 = Widget(id="grandchild1")
|
||||
child1 = Widget(grandchild1, id="child1")
|
||||
child2 = Widget(id="child2")
|
||||
|
||||
yield Widget(
|
||||
child1,
|
||||
child2,
|
||||
id="parent",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def hierarchy_app():
|
||||
app = GetByIdApp()
|
||||
async with app.run_test():
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def parent(hierarchy_app):
|
||||
yield hierarchy_app.get_widget_by_id("parent")
|
||||
|
||||
|
||||
def test_get_child_by_id_gets_first_child(parent):
|
||||
child = parent.get_child_by_id(id="child1")
|
||||
assert child.id == "child1"
|
||||
assert child.get_child_by_id(id="grandchild1").id == "grandchild1"
|
||||
assert parent.get_child_by_id(id="child2").id == "child2"
|
||||
|
||||
|
||||
def test_get_child_by_id_no_matching_child(parent):
|
||||
with pytest.raises(NoMatches):
|
||||
parent.get_child_by_id(id="doesnt-exist")
|
||||
|
||||
|
||||
def test_get_child_by_id_only_immediate_descendents(parent):
|
||||
with pytest.raises(NoMatches):
|
||||
parent.get_child_by_id(id="grandchild1")
|
||||
|
||||
|
||||
def test_get_widget_by_id_no_matching_child(parent):
|
||||
with pytest.raises(NoMatches):
|
||||
parent.get_widget_by_id(id="i-dont-exist")
|
||||
|
||||
|
||||
def test_get_widget_by_id_non_immediate_descendants(parent):
|
||||
result = parent.get_widget_by_id("grandchild1")
|
||||
assert result.id == "grandchild1"
|
||||
|
||||
|
||||
def test_get_widget_by_id_immediate_descendants(parent):
|
||||
result = parent.get_widget_by_id("child1")
|
||||
assert result.id == "child1"
|
||||
|
||||
|
||||
def test_get_widget_by_id_doesnt_return_self(parent):
|
||||
with pytest.raises(NoMatches):
|
||||
parent.get_widget_by_id("parent")
|
||||
|
||||
|
||||
def test_get_widgets_app_delegated(hierarchy_app, parent):
|
||||
# Check that get_child_by_id finds the parent, which is a child of the default Screen
|
||||
queried_parent = hierarchy_app.get_child_by_id("parent")
|
||||
assert queried_parent is parent
|
||||
|
||||
# Check that the grandchild (descendant of the default screen) is found
|
||||
grandchild = hierarchy_app.get_widget_by_id("grandchild1")
|
||||
assert grandchild.id == "grandchild1"
|
||||
|
||||
|
||||
def test_widget_mount_ids_must_be_unique_mounting_all_in_one_go(parent):
|
||||
widget1 = Widget(id="hello")
|
||||
widget2 = Widget(id="hello")
|
||||
|
||||
with pytest.raises(MountError):
|
||||
parent.mount(widget1, widget2)
|
||||
|
||||
|
||||
def test_widget_mount_ids_must_be_unique_mounting_multiple_calls(parent):
|
||||
widget1 = Widget(id="hello")
|
||||
widget2 = Widget(id="hello")
|
||||
|
||||
parent.mount(widget1)
|
||||
with pytest.raises(DuplicateIds):
|
||||
parent.mount(widget2)
|
||||
|
||||
100
tests/test_widget_child_moving.py
Normal file
100
tests/test_widget_child_moving.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import pytest
|
||||
|
||||
from textual.app import App
|
||||
from textual.widget import Widget, WidgetError
|
||||
|
||||
async def test_widget_move_child() -> None:
|
||||
"""Test moving a widget in a child list."""
|
||||
|
||||
# Test calling move_child with no direction.
|
||||
async with App().run_test() as pilot:
|
||||
child = Widget(Widget())
|
||||
await pilot.app.mount(child)
|
||||
with pytest.raises(WidgetError):
|
||||
pilot.app.screen.move_child(child)
|
||||
|
||||
# Test calling move_child with more than one direction.
|
||||
async with App().run_test() as pilot:
|
||||
child = Widget(Widget())
|
||||
await pilot.app.mount(child)
|
||||
with pytest.raises(WidgetError):
|
||||
pilot.app.screen.move_child(child, before=1, after=2)
|
||||
|
||||
# Test attempting to move a child that isn't ours.
|
||||
async with App().run_test() as pilot:
|
||||
child = Widget(Widget())
|
||||
await pilot.app.mount(child)
|
||||
with pytest.raises(WidgetError):
|
||||
pilot.app.screen.move_child(Widget(), before=child)
|
||||
|
||||
# Test attempting to move relative to a widget that isn't a child.
|
||||
async with App().run_test() as pilot:
|
||||
child = Widget(Widget())
|
||||
await pilot.app.mount(child)
|
||||
with pytest.raises(WidgetError):
|
||||
pilot.app.screen.move_child(child, before=Widget())
|
||||
|
||||
# Make a background set of widgets.
|
||||
widgets = [Widget(id=f"widget-{n}") for n in range( 10 )]
|
||||
|
||||
# Test attempting to move past the end of the child list.
|
||||
async with App().run_test() as pilot:
|
||||
container = Widget(*widgets)
|
||||
await pilot.app.mount(container)
|
||||
with pytest.raises(WidgetError):
|
||||
container.move_child(widgets[0], before=len(widgets)+10)
|
||||
|
||||
# Test attempting to move before the end of the child list.
|
||||
async with App().run_test() as pilot:
|
||||
container = Widget(*widgets)
|
||||
await pilot.app.mount(container)
|
||||
with pytest.raises(WidgetError):
|
||||
container.move_child(widgets[0], before=-(len(widgets)+10))
|
||||
|
||||
# Test the different permutations of moving one widget before another.
|
||||
perms = (
|
||||
( 1, 0 ),
|
||||
( widgets[1], 0 ),
|
||||
( 1, widgets[ 0 ] ),
|
||||
( widgets[ 1 ], widgets[ 0 ])
|
||||
)
|
||||
for child, target in perms:
|
||||
async with App().run_test() as pilot:
|
||||
container = Widget(*widgets)
|
||||
await pilot.app.mount(container)
|
||||
container.move_child(child, before=target)
|
||||
assert container.children[0].id == "widget-1"
|
||||
assert container.children[1].id == "widget-0"
|
||||
assert container.children[2].id == "widget-2"
|
||||
|
||||
# Test the different permutations of moving one widget after another.
|
||||
perms = (
|
||||
( 0, 1 ),
|
||||
( widgets[0], 1 ),
|
||||
( 0, widgets[ 1 ] ),
|
||||
( widgets[ 0 ], widgets[ 1 ])
|
||||
)
|
||||
for child, target in perms:
|
||||
async with App().run_test() as pilot:
|
||||
container = Widget(*widgets)
|
||||
await pilot.app.mount(container)
|
||||
container.move_child(child, after=target)
|
||||
assert container.children[0].id == "widget-1"
|
||||
assert container.children[1].id == "widget-0"
|
||||
assert container.children[2].id == "widget-2"
|
||||
|
||||
# Test moving after a child after the last child.
|
||||
async with App().run_test() as pilot:
|
||||
container = Widget(*widgets)
|
||||
await pilot.app.mount(container)
|
||||
container.move_child(widgets[0], after=widgets[-1])
|
||||
assert container.children[0].id == "widget-1"
|
||||
assert container.children[-1].id == "widget-0"
|
||||
|
||||
# Test moving after a child after the last child's numeric position.
|
||||
async with App().run_test() as pilot:
|
||||
container = Widget(*widgets)
|
||||
await pilot.app.mount(container)
|
||||
container.move_child(widgets[0], after=widgets[9])
|
||||
assert container.children[0].id == "widget-1"
|
||||
assert container.children[-1].id == "widget-0"
|
||||
@@ -15,18 +15,18 @@ async def test_remove_single_widget():
|
||||
async def test_many_remove_all_widgets():
|
||||
"""It should be possible to remove all widgets on a multi-widget screen."""
|
||||
async with App().run_test() as pilot:
|
||||
await pilot.app.mount(*[Static() for _ in range(1000)])
|
||||
assert len(pilot.app.screen.children) == 1000
|
||||
await pilot.app.mount(*[Static() for _ in range(10)])
|
||||
assert len(pilot.app.screen.children) == 10
|
||||
await pilot.app.query(Static).remove()
|
||||
assert len(pilot.app.screen.children) == 0
|
||||
|
||||
async def test_many_remove_some_widgets():
|
||||
"""It should be possible to remove some widgets on a multi-widget screen."""
|
||||
async with App().run_test() as pilot:
|
||||
await pilot.app.mount(*[Static(id=f"is-{n%2}") for n in range(1000)])
|
||||
assert len(pilot.app.screen.children) == 1000
|
||||
await pilot.app.query("#is-0").remove()
|
||||
assert len(pilot.app.screen.children) == 500
|
||||
await pilot.app.mount(*[Static(classes=f"is-{n%2}") for n in range(10)])
|
||||
assert len(pilot.app.screen.children) == 10
|
||||
await pilot.app.query(".is-0").remove()
|
||||
assert len(pilot.app.screen.children) == 5
|
||||
|
||||
async def test_remove_branch():
|
||||
"""It should be possible to remove a whole branch in the DOM."""
|
||||
|
||||
Reference in New Issue
Block a user