This commit is contained in:
Will McGugan
2022-08-24 11:19:29 +01:00
parent 9cc48db79f
commit 6ee4d41bb7
11 changed files with 253 additions and 182 deletions

View File

@@ -16,7 +16,7 @@ CSS is typically stored in an external file with the extension `.css` alongside
Let's look at some Textual CSS.
```css
```sass
Header {
dock: top;
height: 3;
@@ -28,7 +28,7 @@ Header {
This is an example of a CSS _rule set_. There may be many such sections in any given CSS file.
The first line is a _selector_, which tells Textual which Widget(s) to modify. In the above example, the styles will be applied to a widget defined in the Python class `Header`.
Let's break this CSS code down a bit.
```css hl_lines="1"
Header {
@@ -40,7 +40,7 @@ Header {
}
```
The lines inside the curly braces contains CSS _rules_, which consist of a rule name and rule value separated by a colon and ending in a semi-colon. Such rules are typically written one per line, but you could add additional rules as long as they are separated by semi-colons.
The first line is a _selector_ which tells Textual which Widget(s) to modify. In the above example, the styles will be applied to a widget defined in the Python class `Header`.
```css hl_lines="2 3 4 5 6"
Header {
@@ -52,6 +52,8 @@ Header {
}
```
The lines inside the curly braces contains CSS _rules_, which consist of a rule name and rule value separated by a colon and ending in a semi-colon. Such rules are typically written one per line, but you could add additional rules as long as they are separated by semi-colons.
The first rule in the above example reads `"dock: top;"`. The rule name is `dock` which tells Textual to place the widget on a edge of the screen. The text after the colon is `top` which tells Textual to dock to the _top_ of the screen. Other valid values for dock are "right", "bottom", or "left"; but `top` is naturally appropriate for a header.
You may be able to guess what some of the the other rules do. We will cover those later.
@@ -75,7 +77,7 @@ Let's look at a trivial Textual app.
```{.textual path="docs/examples/guide/dom1.py"}
```
When you run this code you will have an instance of an app (ExampleApp) in memory. This app class will also create a Screen object. In DOM terms, the Screen is a _child_ of the app.
When you run this code you will have an instance of an `ExampleApp` in memory. This app class will also create a `Screen` object. In DOM terms, the Screen is a _child_ of the app.
With the above example, the DOM will look like the following:
@@ -121,7 +123,7 @@ To further explore the DOM, we're going to build a simple dialog with a question
--8<-- "docs/examples/guide/dom3.py"
```
We've added a Container to our DOM which (as the name suggests) is a container for other widgets. The container has a number of other widgets passed as positional arguments which will be added as the children of the container. Not all widgets accept child widgets in this way; for instance a Button widget doesn't need any children.
We've added a Container to our DOM which (as the name suggests) is a container for other widgets. The container has a number of other widgets passed as positional arguments which will be added as the children of the container. Not all widgets accept child widgets in this way. A Button widget doesn't require any children, for example.
Here's the DOM created by the above code:
@@ -149,7 +151,7 @@ You may have noticed that some of the constructors have additional keywords argu
Here's the CSS file we are applying:
```python
```sass
--8<-- "docs/examples/guide/dom4.css"
```
@@ -175,7 +177,7 @@ Finally, Textual CSS allows you to _live edit_ the styles in your app. If you ru
textual run my_app.py --dev
```
Being able to iterate on the design without restarting the Python code can make it much easier to design beautiful interfaces.
Being able to iterate on the design without restarting the Python code can make it easier and faster to design beautiful interfaces.
## Selectors
@@ -198,7 +200,7 @@ class Button(Static):
The following rule applies a border to this widget:
```css
```sass
Button {
border: solid blue;
}
@@ -206,7 +208,7 @@ Button {
The type selector will also match a widget's base classes. Consequently a `Static` selector will also style the button because the `Button` Python class extends `Static`.
```css
```sass
Static {
background: blue;
border: rounded white;
@@ -231,15 +233,17 @@ yield Button(id="next")
You can match an ID with a selector starting with a hash (`#`). Here is how you might draw a red outline around the above button:
```css
```sass
#next {
outline: red;
}
```
A Widget's `id` attribute can not be changed after the Widget has been constructed.
### Class-name selector
Every widget can have a number of class names applied. The term "class" here is borrowed from web CSS, and has a different meaning to a Python class. You can think of a CSS class as a tag of sorts. Widgets with the same tag may share a particular style.
Every widget can have a number of class names applied. The term "class" here is borrowed from web CSS, and has a different meaning to a Python class. You can think of a CSS class as a tag of sorts. Widgets with the same tag will share styles.
CSS classes are set via the widgets `classes` parameter in the constructor. Here's an example:
@@ -257,7 +261,7 @@ yield Button(classes="error disabled")
To match a Widget with a given class in CSS you can precede the class name with a dot (`.`). Here's a rule with a class selector to match the `"success"` class name:
```css
```sass
.success {
background: green;
color: white;
@@ -270,19 +274,28 @@ To match a Widget with a given class in CSS you can precede the class name with
Class name selectors may be _chained_ together by appending another full stop and class name. The selector will match a widget that has _all_ of the class names set. For instance, the following sets a red background on widgets that have both `error` _and_ `disabled` class names.
```css
```sass
.error.disabled {
background: darkred;
}
```
Unlike the `id` attribute a Widget's classes can be changed after the Widget was created. Adding and removing CSS classes is the recommended way of changing the display while your app is running. There are a few methods you can use to manage CSS classes.
- [add_class()][textual.dom.DOMNode.add_class] Adds one or more classes to a widget.
- [remove_class()][textual.dom.DOMNode.remove_class] Removes class name(s) from a widget.
- [toggle_class()][textual.dom.DOMNode.toggle_class] Removes a class name if it is present, or adds the name if its not already present.
- [has_class()][textual.dom.DOMNode.has_class] Checks if a class(es) is set on a widget.
- [classes][textual.dom.DOMNode.classes] Is a frozen set of the class(es) set on a widget.
### Universal selector
The _universal_ selectors is denoted by an asterisk and will match _all_ widgets.
For example, the following will draw a red outline around all widgets:
```css
```sass
* {
outline: solid red;
}
@@ -292,7 +305,7 @@ For example, the following will draw a red outline around all widgets:
Pseudo classes can be used to match widgets in a particular state. Psuedo classes are set automatically by Textual. For instance, you might want a button to have a green background when the mouse cursor moves over it. We can do this with the `:hover` pseudo selector.
```css
```sass
Button:hover {
background: green;
}
@@ -321,7 +334,7 @@ Here's a section of DOM to illustrate this combinator:
Let's say we want to make the text of the buttons in the dialog bold, but we _don't_ want to change the Button in the sidebar. We can do this with the following rule:
```css hl_lines="1"
```sass hl_lines="1"
#dialog Button {
text-style: bold;
}
@@ -349,7 +362,7 @@ Let's use this to match the Button in the sidebar given the following DOM:
We can use the following CSS to style all buttons which have a parent with an ID of `sidebar`:
```css
```sass
#sidebar > Button {
text-style: underline;
}
@@ -375,7 +388,7 @@ The specificity rules are usually enough to fix any conflicts in your stylesheet
Here's an example that makes buttons blue when hovered over with the mouse, regardless of any other selectors that match Buttons:
```css hl_lines="2"
```sass hl_lines="2"
Button:hover {
background: blue !important;
}

View File

@@ -16,7 +16,7 @@ You can run Textual apps with the `run` subcommand. If you supply a path to a Py
textual run my_app.py
```
The `run` sub-command assumes you have a Application instance called `app` in the global scope of your Python file. If the application is called something different, you can specify it with a colon following the filename:
The `run` sub-command assumes you have a App instance called `app` in the global scope of your Python file. If the application is called something different, you can specify it with a colon following the filename:
```
textual run my_app.py:alternative_app
@@ -24,7 +24,7 @@ textual run my_app.py:alternative_app
!!! note
If the Python file contains a call to app.run() then you can launch the file as you normally would any other Python program. Running your app via `textual run` will give you access to a few Textual features such as dev mode which auto (re) loads your CSS if you change it.
If the Python file contains a call to app.run() then you can launch the file as you normally would any other Python program. Running your app via `textual run` will give you access to a few Textual features such as live editing of CSS files.
## Console
@@ -44,7 +44,7 @@ This should look something like the following:
In the other console, run your application using `textual run` and the `--dev` switch:
```bash
textual run my_app.py --dev
textual run --dev my_app.py
```
Anything you `print` from your application will be displayed in the console window. You can also call the `log()` method on App and Widget objects for advanced formatting. Try it with `self.log(self.tree)`.

1
docs/reference/color.md Normal file
View File

@@ -0,0 +1 @@
::: textual.color

View File

@@ -0,0 +1 @@
::: textual.dom.DOMNode

View File

@@ -6,8 +6,8 @@ nav:
- "getting_started.md"
- "introduction.md"
- Guide:
- "guide/guide.md"
- "guide/devtools.md"
- "guide/guide.md"
- "guide/CSS.md"
- "guide/events.md"
@@ -44,19 +44,19 @@ nav:
- "styles/color.md"
- "styles/content_align.md"
- "styles/display.md"
- "styles/min_height.md"
- "styles/max_height.md"
- "styles/min_width.md"
- "styles/max_width.md"
- "styles/height.md"
- "styles/margin.md"
- "styles/max_height.md"
- "styles/max_width.md"
- "styles/min_height.md"
- "styles/min_width.md"
- "styles/offset.md"
- "styles/outline.md"
- "styles/overflow.md"
- "styles/padding.md"
- "styles/scrollbar.md"
- "styles/scrollbar_size.md"
- "styles/scrollbar_gutter.md"
- "styles/scrollbar_size.md"
- "styles/scrollbar.md"
- "styles/text_style.md"
- "styles/tint.md"
- "styles/visibility.md"
@@ -64,10 +64,13 @@ nav:
- Widgets: "/widgets/"
- Reference:
- "reference/app.md"
- "reference/color.md"
- "reference/dom_node.md"
- "reference/events.md"
- "reference/geometry.md"
- "reference/widget.md"
markdown_extensions:
- admonition
- def_list
@@ -115,6 +118,7 @@ theme:
plugins:
- search:
- autorefs:
- mkdocstrings:
default_handler: python
handlers:

View File

@@ -12,7 +12,18 @@ class ActionError(Exception):
re_action_params = re.compile(r"([\w\.]+)(\(.*?\))")
def parse(action: str) -> tuple[str, tuple[Any, ...]]:
def parse(action: str) -> tuple[str, tuple[object, ...]]:
"""Parses an action string.
Args:
action (str): String containing action.
Raises:
ActionError: If the action has invalid syntax.
Returns:
tuple[str, tuple[object, ...]]: Action name and parameters
"""
params_match = re_action_params.match(action)
if params_match is not None:
action_name, action_params_str = params_match.groups()

View File

@@ -39,16 +39,22 @@ class HLS(NamedTuple):
"""A color in HLS format."""
h: float
"""Hue"""
l: float
"""Lightness"""
s: float
"""Saturation"""
class HSV(NamedTuple):
"""A color in HSV format."""
h: float
"""Hue"""
s: float
"""Saturation"""
v: float
"""Value"""
class Lab(NamedTuple):
@@ -103,9 +109,13 @@ class Color(NamedTuple):
"""A class to represent a single RGB color with alpha."""
r: int
"""Red component (0-255)"""
g: int
"""Green component (0-255)"""
b: int
"""Blue component (0-255)"""
a: float = 1.0
"""Alpha component (0-1)"""
@classmethod
def from_rich_color(cls, rich_color: RichColor) -> Color:
@@ -146,12 +156,22 @@ class Color(NamedTuple):
@property
def is_transparent(self) -> bool:
"""Check if the color is transparent, i.e. has 0 alpha."""
"""Check if the color is transparent, i.e. has 0 alpha.
Returns:
bool: True if transparent, otherwise False.
"""
return self.a == 0
@property
def clamped(self) -> Color:
"""Get a color with all components saturated to maximum and minimum values."""
"""Get a color with all components saturated to maximum and minimum values.
Returns:
Color: A color object.
"""
r, g, b, a = self
_clamp = clamp
color = Color(
@@ -164,7 +184,11 @@ class Color(NamedTuple):
@property
def rich_color(self) -> RichColor:
"""This color encoded in Rich's Color class."""
"""This color encoded in Rich's Color class.
Returns:
RichColor: A color object as used by Rich.
"""
r, g, b, _a = self
return RichColor(
f"#{r:02x}{g:02x}{b:02x}", _TRUECOLOR, None, ColorTriplet(r, g, b)
@@ -172,25 +196,43 @@ class Color(NamedTuple):
@property
def normalized(self) -> tuple[float, float, float]:
"""A tuple of the color components normalized to between 0 and 1."""
"""A tuple of the color components normalized to between 0 and 1.
Returns:
tuple[float, float, float]: Normalized components.
"""
r, g, b, _a = self
return (r / 255, g / 255, b / 255)
@property
def rgb(self) -> tuple[int, int, int]:
"""Get just the red, green, and blue components."""
"""Get just the red, green, and blue components.
Returns:
tuple[int, int, int]: Color components
"""
r, g, b, _ = self
return (r, g, b)
@property
def hls(self) -> HLS:
"""Get the color as HLS."""
"""Get the color as HLS.
Returns:
HLS:
"""
r, g, b = self.normalized
return HLS(*rgb_to_hls(r, g, b))
@property
def brightness(self) -> float:
"""Get the human perceptual brightness."""
"""Get the human perceptual brightness.
Returns:
float: Brightness value (0-1).
"""
r, g, b = self.normalized
brightness = (299 * r + 587 * g + 114 * b) / 1000
return brightness

View File

@@ -52,11 +52,7 @@ class NoParent(Exception):
@rich.repr.auto
class DOMNode(MessagePump):
"""A node in a hierarchy of things forming the UI.
Nodes are mountable and may be styled with CSS.
"""
"""The base class for object that can be in the Textual DOM (App and Widget)"""
# Custom CSS
CSS: ClassVar[str] = ""
@@ -285,6 +281,12 @@ class DOMNode(MessagePump):
@property
def classes(self) -> frozenset[str]:
"""A frozenset of the current classes set on the widget.
Returns:
frozenset[str]: Set of class names.
"""
return frozenset(self._classes)
@property
@@ -312,7 +314,10 @@ class DOMNode(MessagePump):
@property
def display(self) -> bool:
"""
Returns: ``True`` if this DOMNode is displayed (``display != "none"``), ``False`` otherwise.
Check if this widget should display or note.
Returns:
bool: ``True`` if this DOMNode is displayed (``display != "none"``) otherwise ``False`` .
"""
return self.styles.display != "none" and not (self._closing or self._closed)
@@ -484,7 +489,12 @@ class DOMNode(MessagePump):
@property
def displayed_children(self) -> list[DOMNode]:
"""The children which don't have display: none set."""
"""The children which don't have display: none set.
Returns:
list[DOMNode]: Children of this widget which will be displayed.
"""
return [child for child in self.children if child.display]
def get_pseudo_classes(self) -> Iterable[str]:

View File

@@ -141,13 +141,22 @@ class Size(NamedTuple):
@property
def region(self) -> Region:
"""Get a region of the same size."""
"""Get a region of the same size.
Returns:
Region: A region with the same size at (0, 0)
"""
width, height = self
return Region(0, 0, width, height)
@property
def line_range(self) -> range:
"""Get a range covering lines."""
"""Get a range covering lines.
Returns:
range:
"""
return range(self.height)
def __add__(self, other: object) -> Size:
@@ -225,7 +234,7 @@ class Region(NamedTuple):
y: int = 0
"""Offset in the y-axis (vertical)"""
width: int = 0
"""The widget of the region"""
"""The width of the region"""
height: int = 0
"""The height of the region"""
@@ -360,45 +369,85 @@ class Region(NamedTuple):
@property
def right(self) -> int:
"""Maximum X value (non inclusive)"""
"""Maximum X value (non inclusive).
Returns:
int: x coordinate
"""
return self.x + self.width
@property
def bottom(self) -> int:
"""Maximum Y value (non inclusive)"""
"""Maximum Y value (non inclusive).
Returns:
int: y coordinate
"""
return self.y + self.height
@property
def area(self) -> int:
"""Get the area within the region."""
"""Get the area within the region.
Returns:
int: area.
"""
return self.width * self.height
@property
def offset(self) -> Offset:
"""Get the start point of the region."""
"""Get the start point of the region.
Returns:
Offset: Top left offset.
"""
return Offset(self.x, self.y)
@property
def bottom_left(self) -> Offset:
"""Bottom left offset of the region."""
"""Bottom left offset of the region.
Returns:
Offset: Bottom left offset.
"""
x, y, _width, height = self
return Offset(x, y + height)
@property
def top_right(self) -> Offset:
"""Top right offset of the region."""
"""Top right offset of the region.
Returns:
Offset: Top right.
"""
x, y, width, _height = self
return Offset(x + width, y)
@property
def bottom_right(self) -> Offset:
"""Bottom right of the region."""
"""Bottom right of the region.
Returns:
Offset: Bottom right.
"""
x, y, width, height = self
return Offset(x + width, y + height)
@property
def size(self) -> Size:
"""Get the size of the region."""
"""Get the size of the region.
Returns:
Size: Size of the region.
"""
return Size(self.width, self.height)
@property
@@ -423,7 +472,12 @@ class Region(NamedTuple):
@property
def reset_offset(self) -> Region:
"""An region of the same size at (0, 0)."""
"""An region of the same size at (0, 0).
Returns:
Region: reset region.
"""
_, _, width, height = self
return Region(0, 0, width, height)

View File

@@ -1,118 +0,0 @@
from __future__ import annotations
from typing import Collection
from rich.console import RenderableType
from .geometry import Region, Size
from .widget import Widget
class ScrollView(Widget):
"""
A base class for a Widget that handles it's own scrolling (i.e. doesn't rely
on the compositor to render children).
"""
CSS = """
ScrollView {
overflow-y: auto;
overflow-x: auto;
}
"""
def __init__(
self, name: str | None = None, id: str | None = None, classes: str | None = None
) -> None:
super().__init__(name=name, id=id, classes=classes)
@property
def is_scrollable(self) -> bool:
"""Always scrollable."""
return True
@property
def is_transparent(self) -> bool:
"""Not transparent, i.e. renders something."""
return False
def on_mount(self):
self._refresh_scrollbars()
def get_content_width(self, container: Size, viewport: Size) -> int:
"""Gets the width of the content area.
Args:
container (Size): Size of the container (immediate parent) widget.
viewport (Size): Size of the viewport.
Returns:
int: The optimal width of the content.
"""
return self.virtual_size.width
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
"""Gets the height (number of lines) in the content area.
Args:
container (Size): Size of the container (immediate parent) widget.
viewport (Size): Size of the viewport.
width (int): Width of renderable.
Returns:
int: The height of the content.
"""
return self.virtual_size.height
def size_updated(
self, size: Size, virtual_size: Size, container_size: Size
) -> None:
"""Called when size is updated.
Args:
size (Size): New size.
virtual_size (Size): New virtual size.
container_size (Size): New container size.
"""
virtual_size = self.virtual_size
if self._size != size:
self._size = size
self._container_size = container_size
self._refresh_scrollbars()
width, height = self.container_size
if self.show_vertical_scrollbar:
self.vertical_scrollbar.window_virtual_size = virtual_size.height
self.vertical_scrollbar.window_size = height
if self.show_horizontal_scrollbar:
self.horizontal_scrollbar.window_virtual_size = virtual_size.width
self.horizontal_scrollbar.window_size = width
self.scroll_x = self.validate_scroll_x(self.scroll_x)
self.scroll_y = self.validate_scroll_y(self.scroll_y)
self.refresh(layout=False)
self.call_later(self.scroll_to, self.scroll_x, self.scroll_y)
def render(self) -> RenderableType:
"""Render the scrollable region (if `render_lines` is not implemented).
Returns:
RenderableType: Renderable object.
"""
from rich.panel import Panel
return Panel(f"{self.scroll_offset} {self.show_vertical_scrollbar}")
def watch_scroll_x(self, new_value: float) -> None:
"""Called when horizontal bar is scrolled."""
self.horizontal_scrollbar.position = int(new_value)
self.refresh(layout=False)
def watch_scroll_y(self, new_value: float) -> None:
"""Called when vertical bar is scrolled."""
self.vertical_scrollbar.position = int(new_value)
self.refresh(layout=False)

View File

@@ -72,6 +72,10 @@ class RenderCache(NamedTuple):
@rich.repr.auto
class Widget(DOMNode):
"""
A Widget is the base class for Textual widgets. Extent this class (or a sub-class) when defining your own widgets.
"""
CSS = """
Widget{
@@ -488,6 +492,11 @@ class Widget(DOMNode):
@property
def scrollbar_gutter(self) -> Spacing:
"""Spacing required to fit scrollbar(s)
Returns:
Spacing: Scrollbar gutter spacing.
"""
gutter = Spacing(
0, self.scrollbar_size_vertical, self.scrollbar_size_horizontal, 0
)
@@ -495,39 +504,73 @@ class Widget(DOMNode):
@property
def gutter(self) -> Spacing:
"""Spacing for padding / border / scrollbars."""
"""Spacing for padding / border / scrollbars.
Returns:
Spacing: Additional spacing around content area.
"""
return self.styles.gutter + self.scrollbar_gutter
@property
def size(self) -> Size:
"""The size of the content area."""
"""The size of the content area.
Returns:
Size: Content area size.
"""
return self.content_region.size
@property
def outer_size(self) -> Size:
"""The size of the widget (including padding and border)."""
"""The size of the widget (including padding and border).
Returns:
Size: Outer size.
"""
return self._size
@property
def container_size(self) -> Size:
"""The size of the container (parent widget)."""
"""The size of the container (parent widget).
Returns:
Size: Container size.
"""
return self._container_size
@property
def content_region(self) -> Region:
"""Gets an absolute region containing the content (minus padding and border)."""
"""Gets an absolute region containing the content (minus padding and border).
Returns:
Region: Screen region that contains a widget's content.
"""
content_region = self.region.shrink(self.gutter)
return content_region
@property
def content_offset(self) -> Offset:
"""An offset from the Widget origin where the content begins."""
"""An offset from the Widget origin where the content begins.
Returns:
Offset: Offset from widget's origin.
"""
x, y = self.gutter.top_left
return Offset(x, y)
@property
def region(self) -> Region:
"""The region occupied by this widget, relative to the Screen."""
"""The region occupied by this widget, relative to the Screen.
Raises:
NoScreen: If there is no screen.
errors.NoWidget: If the widget is not on the screen.
Returns:
Region: Region within screen occupied by widget.
"""
try:
return self.screen.find_widget(self).region
except NoScreen:
@@ -596,7 +639,12 @@ class Widget(DOMNode):
@property
def console(self) -> Console:
"""Get the current console."""
"""Get the current console.
Returns:
Console: A Rich console object.
"""
return active_app.get().console
@property
@@ -631,7 +679,12 @@ class Widget(DOMNode):
@property
def layer(self) -> str:
"""Get the name of this widgets layer."""
"""Get the name of this widgets layer.
Returns:
str: Name of layer.
"""
return self.styles.layer or "default"
@property