Merge branch 'main' into placeholder

This commit is contained in:
Rodrigo Girão Serrão
2022-11-18 15:21:40 +00:00
committed by GitHub
58 changed files with 1381 additions and 799 deletions

6
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View 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)

View File

@@ -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.

View File

@@ -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
View File

@@ -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
View File

@@ -0,0 +1 @@
::: textual.widgets.Label

View 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()

View File

@@ -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
View 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

View File

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

View File

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

View File

@@ -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

View File

@@ -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."""

View File

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

View File

@@ -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

View File

@@ -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]:

View File

@@ -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.

View File

@@ -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

View File

@@ -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(

View File

@@ -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.

View File

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

View File

@@ -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:

View File

@@ -68,7 +68,7 @@ ColorGroup.-active {
}
ColorLabel {
Label {
padding: 0 0 1 0;
content-align: center middle;
color: $text;

View File

@@ -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(

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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__(

View File

@@ -25,9 +25,3 @@ class Blank:
for _ in range(height):
yield segment
yield line
if __name__ == "__main__":
from rich import print
print(Blank("red"))

View File

@@ -36,9 +36,3 @@ class VerticalGradient:
),
)
yield Segment(f"{width * ' '}\n", line_color)
if __name__ == "__main__":
from rich import print
print(VerticalGradient("red", "blue"))

View File

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

View File

@@ -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

View File

@@ -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.

View File

@@ -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",

View File

@@ -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

View File

@@ -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.

View File

@@ -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),

View File

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

View 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."""

View File

@@ -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.
"""

View File

@@ -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

View 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;
}

View 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()

View 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()

View File

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

View File

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

View File

@@ -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()

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from textual.app import App, ComposeResult
from textual import events
from textual.containers import Container

View File

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

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

View File

@@ -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."""