Merge branch 'main' into modernize-metadata

This commit is contained in:
Will McGugan
2022-11-07 12:05:33 +00:00
committed by GitHub
104 changed files with 1436 additions and 471 deletions

6
.gitignore vendored
View File

@@ -119,3 +119,9 @@ tests/snapshot_tests/output
# Sandbox folder - convenient place for us to develop small test apps without leaving the repo
sandbox/
# Cache of screenshots used in the docs
.screenshot_cache
# Used by mkdocs-material social plugin
.cache

View File

@@ -12,10 +12,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Dropped support for mounting "named" and "anonymous" widgets via
`App.mount` and `Widget.mount`. Both methods now simply take one or more
widgets as positional arguments.
- `DOMNode.query_one` now raises a `TooManyMatches` exception if there is
more than one matching node.
https://github.com/Textualize/textual/issues/1096
- `App.mount` and `Widget.mount` have new `before` and `after` parameters https://github.com/Textualize/textual/issues/778
### Added
- Added `init` param to reactive.watch
- `CSS_PATH` can now be a list of CSS files https://github.com/Textualize/textual/pull/1079
- Added `DOMQuery.only_one` https://github.com/Textualize/textual/issues/1096
- Writes to stdout are now done in a thread, for smoother animation. https://github.com/Textualize/textual/pull/1104
## [0.3.0] - 2022-10-31

View File

@@ -11,8 +11,10 @@ format:
format-check:
black --check src
docs-serve:
rm -rf .screenshot_cache
mkdocs serve
docs-build:
mkdocs build
docs-deploy:
rm -rf .screenshot_cache
mkdocs gh-deploy

5
docs/api/index.md Normal file
View File

@@ -0,0 +1,5 @@
# API
This is a API-level reference to the Textual API. Click the links to your left (or in the burger menu) to open a reference for each module.
If you are new to Textual, you may want to read the [tutorial](./../tutorial.md) or [guide](../guide/index.md) first.

4
docs/blog/.authors.yml Normal file
View File

@@ -0,0 +1,4 @@
willmcgugan:
name: Will McGugan
description: CEO / code-monkey
avatar: https://github.com/willmcgugan.png

3
docs/blog/index.md Normal file
View File

@@ -0,0 +1,3 @@
# Textual Blog
Welcome to the Textual blog, where we post about the latest releases and developments in the Textual world.

View File

@@ -0,0 +1,19 @@
---
draft: false
date: 2022-11-06
categories:
- News
authors:
- willmcgugan
---
# New Blog
Welcome to the first post on the Textual blog.
<!-- more -->
I plan on using this as a place to make announcements regarding new releases of Textual, and any other relevant news.
The first piece of news is that we've reorganized this site a little. The Events, Styles, and Widgets references are now under "Reference", and what used to be under "Reference" is now "API" which contains API-level documentation. I hope that's a little clearer than it used to be!

View File

@@ -5,4 +5,17 @@
<!-- Fathom - beautiful, simple website analytics -->
<script src="https://cdn.usefathom.com/script.js" data-site="TAUKXRLQ" defer></script>
<!-- / Fathom -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@textualizeio">
<meta name="twitter:creator" content="@willmcgugan">
<meta property="og:title" content="Textual - {{ page.title }}">
<meta property="og:type" content="article">
<meta property="og:url" content="{{ page.canonical_url | url }}">
<meta property="og:site_name" content="Textual Documentation">
<meta property="og:description" content="Textual is a TUI framework for Python, inspired by modern web development.">
<meta property="og:image" content="https://raw.githubusercontent.com/Textualize/textual/main/imgs/textual.png">
{% endblock %}

View File

@@ -1,3 +1,5 @@
# Events
A reference to Textual [events](../guide/events.md).
See the links to the left of the page, or in the hamburger menu (three horizontal bars, top left).

View File

@@ -4,7 +4,7 @@ Textual uses CSS to apply style to widgets. If you have any exposure to web deve
## Stylesheets
CSS stands for _Cascading Stylesheets_. A stylesheet is a list of styles and rules about how those styles should be applied to a web page. In the case of Textual, the stylesheet applies [styles](./styles.md) to widgets but otherwise it is the same idea.
CSS stands for _Cascading Stylesheets_. A stylesheet is a list of styles and rules about how those styles should be applied to a web page. In the case of Textual, the stylesheet applies [styles](./styles.md) to widgets, but otherwise it is the same idea.
When Textual loads CSS it sets attributes on your widgets' `style` object. The effect is the same as if you had set attributes in Python.
@@ -48,7 +48,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 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 semicolon. Such rules are typically written one per line, but you could add additional rules as long as they are separated by semicolons.
The first rule in the above example reads `"dock: top;"`. The rule name is `dock` which tells Textual to place the widget on an 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 most appropriate for a header.
@@ -93,7 +93,7 @@ This doesn't look much like a tree yet. Let's add a header and a footer to this
```{.textual path="docs/examples/guide/dom2.py"}
```
With a header and a footer widget the DOM looks the this:
With a header and a footer widget the DOM looks like this:
<div class="excalidraw">
--8<-- "docs/images/dom2.excalidraw.svg"
@@ -132,7 +132,7 @@ Here's the output from this example:
```
You may recognize some of the elements in the above screenshot, but it doesn't quite look like a dialog. This is because we haven't added a stylesheet.
You may recognize some elements in the above screenshot, but it doesn't quite look like a dialog. This is because we haven't added a stylesheet.
## CSS files
@@ -142,7 +142,8 @@ To add a stylesheet set the `CSS_PATH` classvar to a relative path:
--8<-- "docs/examples/guide/dom4.py"
```
You may have noticed that some of the constructors have additional keyword arguments: `id` and `classes`. These are used by the CSS to identify parts of the DOM. We will cover these in the next section.
You may have noticed that some constructors have additional keyword arguments: `id` and `classes`.
These are used by the CSS to identify parts of the DOM. We will cover these in the next section.
Here's the CSS file we are applying:
@@ -158,6 +159,10 @@ With the CSS in place, the output looks very different:
```
### Using multiple CSS files
You can also set the `CSS_PATH` class variable to a list of paths. Textual will combine the rules from all of the supplied paths.
### Why CSS?
It is reasonable to ask why use CSS at all? Python is a powerful and expressive language. Wouldn't it be easier to set styles in your `.py` files?
@@ -178,7 +183,7 @@ Being able to iterate on the design without restarting the application makes it
A selector is the text which precedes the curly braces in a set of rules. It tells Textual which widgets it should apply the rules to.
Selectors can target a kind of widget or a very specific widget. For instance you could have a selector that modifies all buttons, or you could target an individual button used in one dialog. This gives you a lot of flexibility in customizing your user interface.
Selectors can target a kind of widget or a very specific widget. For instance, you could have a selector that modifies all buttons, or you could target an individual button used in one dialog. This gives you a lot of flexibility in customizing your user interface.
Let's look at the selectors supported by Textual CSS.
@@ -201,7 +206,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`.
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`.
```sass
Static {

View File

@@ -42,7 +42,7 @@ Action strings have the following format:
- The name of an action on is own will call the action method with no parameters. For example, an action string of `"bell"` will call `action_bell()`.
- Actions may be followed by braces containing Python objects. For example, the action string `set_background("red")` will call `action_set_background("red")`.
- Actions may be prefixed with a _namespace_ (see below) follow by a dot.
- Actions may be prefixed with a _namespace_ (see below) followed by a dot.
<div class="excalidraw">
--8<-- "docs/images/actions/format.excalidraw.svg"

102
docs/guide/design.md Normal file
View File

@@ -0,0 +1,102 @@
# Design System
Textual's design system consists of a number of predefined colors and guidelines for how to use them in your app.
You don't have to follow these guidelines, but if you do, you will be able to mix builtin widgets with third party widgets and your own creations, without worrying about clashing colors.
!!! information
Textual's color system is based on Google's Material design system, modified to suit the terminal.
## Designing with Colors
Textual pre-defines a number of colors as [CSS variables](../guide/CSS.md#css-variables). For instance, the CSS variable `$primary` is set to `#004578` (the blue used in headers). You can use `$primary` in place of the color in the [background](../styles/background.md) and [color](../styles/color.md) rules, or other any other rule that accepts a color.
Here's an example of CSS that uses color variables:
```sass
MyWidget {
background: $primary;
color: $text;
}
```
Using variables rather than explicit colors allows Textual to apply color themes. Textual supplies a default light and dark theme, but in the future many more themes will be available.
### Base Colors
There are 12 *base* colors defined in the color scheme. The following table lists each of the color names (as used in CSS) and a description of where to use them.
| Color | Description |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `$primary` | The primary color, can be considered the *branding* color. Typically used for titles, and backgrounds for strong emphasis. |
| `$secondary` | An alternative branding color, used for similar purposes as `$primary`, where an app needs to differentiate something from the primary color. |
| `$primary-background` | The primary color applied to a background. On light mode this is the same as `$primary`. In dark mode this is a dimmed version of `$primary`. |
| `$secondary-background` | The secondary color applied to a background. On light mode this is the same as `$secondary`. In dark mode this is a dimmed version of `$secondary`. |
| `$background` | A color used for the background, where there is no content. |
| `$surface` | The color underneath text. |
| `$panel` | A color used to differentiate a part of the UI form the main content. Typically used for dialogs or sidebars. |
| `$boost` | A color with alpha that can be used to create *layers* on a background. |
| `$warning` | Indicates a warning. Text or background. |
| `$error` | Indicates an error. Text or background. |
| `$success` | Used to indicate success. Text or background. |
| `$accent` | Used sparingly to draw attention to a part of the UI (typically borders around focused widgets). |
### Shades
For every color, Textual generates 3 dark shades and 3 light shades.
- Add `-lighten-1`, `-lighten-2`, or `-lighten-3` to the color's variable name to get lighter shades (3 is the lightest).
- Add `-darken-1`, `-darken-2`, and `-darken-3` to a color to get the darker shades (3 is the darkest).
For example, `$secondary-darken-1` is a slightly darkened `$secondary`, and `$error-lighten-3` is a very light version of the `$error` color.
### Dark mode
There are two color themes in Textual, a light mode and dark mode. You can switch between them by toggling the `dark` attribute on the App class.
In dark mode `$background` and `$surface` are off-black. Dark mode also set `$primary-background` and `$secondary-background` to dark versions of `$primary` and `$secondary`.
### Text color
The design system defines three CSS variables you should use for text color.
- `$text` sets the color of text in your app. Most text in your app should have this color.
- `$text-muted` sets a slightly faded text color. Use this for text which has lower importance. For instance a sub-title or supplementary information.
- `$text-disabled` sets faded out text which indicates it has been disabled. For instance, menu items which are not applicable and can't be clicked.
You can set these colors via the [color](../styles/color.md) property. The design system uses `auto` colors for text, which means that Textual will pick either white or black (whichever has better contrast).
!!! information
These text colors all have some alpha applied, so that even `$text` isn't pure white or pure black. This is done because blending in a little of the background color produces text that is not so harsh on the eyes.
### Theming
In a future version of Textual you will be able to modify theme colors directly, and allow users to configure preferred themes.
## Color Preview
Run the following from the command line to preview the colors defined in the color system:
```bash
textual colors
```
## Theme Reference
Here's a list of the colors defined in the default light and dark themes.
```{.rich title="Textual Theme Colors"}
from rich import print
from textual.app import DEFAULT_COLORS
from textual.design import show_design
output = show_design(DEFAULT_COLORS["light"], DEFAULT_COLORS["dark"])
```

View File

@@ -1,4 +1,4 @@
# Textual Guide
# Guide
Welcome to the Textual Guide! An in-depth reference on how to build apps with Textual.

View File

@@ -1,20 +1,16 @@
---
hide:
- navigation
---
# Help
Here's where to go if you need help with Textual.
If you need help with any aspect of Textual, let us know! We would be happy to hear from you.
## Bugs and feature requests
Report bugs via GitHub on the Textual [issues](https://github.com/Textualize/textual/issues) page. You can also post feature requests via GitHub issues, but see the [roadmap](./roadmap.md) first.
## Discord Server
For more realtime feedback or chat, join our discord server to connect with the [Textual community](https://discord.gg/Enf6Z3qhVr).
## Forum
Visit the [Textual forum](https://community.textualize.io/) for Textual (and Rich) discussions.
## Discord Server
For more realtime feedback or chat, join our discord server to connect with the [Textual community](https://discord.gg/Enf6Z3qhVr).

View File

@@ -1,3 +1,35 @@
# Reference
A reference to the Textual public APIs.
Welcome to the Textual Reference.
<div class="grid cards" markdown>
- :octicons-book-16:{ .lg .middle } __Events__
---
Events are how Textual communicates with your application.
:octicons-arrow-right-24: [Events Reference](../events/index.md)
- :octicons-book-16:{ .lg .middle } __Styles__
---
All the styles you can use to take your Textual app to the next level.
[:octicons-arrow-right-24: Styles Reference](../styles/index.md)
- :octicons-book-16:{ .lg .middle } __Widgets__
---
How to use the many widgets builtin to Textual.
:octicons-arrow-right-24: [Widgets Reference](../widgets/index.md)
</div>

View File

@@ -1,3 +1,9 @@
---
hide:
- navigation
---
# Roadmap
We ([textualize.io](https://www.textualize.io/)) are actively building and maintaining Textual.

View File

@@ -1,3 +1,5 @@
# Styles
A reference to Widget [styles](../guide/styles.md).
See the links to the left of the page, or in the hamburger menu (three horizontal bars, top left).

View File

@@ -13,11 +13,11 @@ h3 .doc-heading code {
monospace;
}
body[data-md-color-primary="black"] .excalidraw svg {
body[data-md-color-primary="indigo"] .excalidraw svg {
filter: invert(100%) hue-rotate(180deg);
}
body[data-md-color-primary="black"] .excalidraw svg rect {
body[data-md-color-primary="indigo"] .excalidraw svg rect {
fill: transparent;
}

View File

@@ -55,4 +55,4 @@ _No other attributes_
## See Also
* [Button](../reference/button.md) code reference
* [Button](../api/button.md) code reference

View File

@@ -29,7 +29,7 @@ The example below shows checkboxes in various states.
## Reactive Attributes
| Name | Type | Default | Description |
|---------|--------|---------|------------------------------------|
| ------- | ------ | ------- | ---------------------------------- |
| `value` | `bool` | `False` | The default value of the checkbox. |
## Messages
@@ -43,7 +43,7 @@ The `Checkbox.Changed` message is sent when the checkbox is toggled.
#### Attributes
| attribute | type | purpose |
|-----------|--------|--------------------------------|
| --------- | ------ | ------------------------------ |
| `value` | `bool` | The new value of the checkbox. |
## Additional Notes
@@ -54,4 +54,4 @@ The `Checkbox.Changed` message is sent when the checkbox is toggled.
## See Also
- [Checkbox](../reference/checkbox.md) code reference
- [Checkbox](../api/checkbox.md) code reference

View File

@@ -39,4 +39,4 @@ This widget sends no messages.
## See Also
* [Footer](../reference/footer.md) code reference
* [Footer](../api/footer.md) code reference

View File

@@ -32,4 +32,4 @@ This widget sends no messages.
## See Also
* [Header](../reference/header.md) code reference
* [Header](../api/header.md) code reference

View File

@@ -1,3 +1,5 @@
# Widgets
A reference to the builtin [widgets](../guide/widgets.md).
See the links to the left of the page, or in the hamburger menu (three horizontal bars, top left).

View File

@@ -54,7 +54,7 @@ The `Input.Submitted` message is sent when you press ++enter++ with the text fie
#### Attributes
| attribute | type | purpose |
|-----------|-------|----------------------------------|
| --------- | ----- | -------------------------------- |
| `value` | `str` | The new value in the text input. |
@@ -64,4 +64,4 @@ The `Input.Submitted` message is sent when you press ++enter++ with the text fie
## See Also
* [Input](../reference/input.md) code reference
* [Input](../api/input.md) code reference

View File

@@ -31,4 +31,4 @@ This widget sends no messages.
## See Also
* [Static](../reference/static.md) code reference
* [Static](../api/static.md) code reference

View File

@@ -1,12 +1,13 @@
site_name: Textual
site_url: https://textual.textualize.io/
repo_url: https://github.com/textualize/textual/
edit_uri: edit/css/docs/
edit_uri: edit/main/docs/
nav:
- Introduction:
- "index.md"
- "getting_started.md"
- "help.md"
- "tutorial.md"
- Guide:
- "guide/index.md"
@@ -14,6 +15,7 @@ nav:
- "guide/app.md"
- "guide/styles.md"
- "guide/CSS.md"
- "guide/design.md"
- "guide/queries.md"
- "guide/layout.md"
- "guide/events.md"
@@ -24,97 +26,100 @@ nav:
- "guide/animation.md"
- "guide/screens.md"
- "roadmap.md"
- Events:
- "events/index.md"
- "events/blur.md"
- "events/descendant_blur.md"
- "events/descendant_focus.md"
- "events/enter.md"
- "events/focus.md"
- "events/hide.md"
- "events/key.md"
- "events/leave.md"
- "events/load.md"
- "events/mount.md"
- "events/mouse_capture.md"
- "events/click.md"
- "events/mouse_down.md"
- "events/mouse_move.md"
- "events/mouse_release.md"
- "events/mouse_scroll_down.md"
- "events/mouse_scroll_up.md"
- "events/mouse_up.md"
- "events/paste.md"
- "events/resize.md"
- "events/screen_resume.md"
- "events/screen_suspend.md"
- "events/show.md"
- Styles:
- "styles/index.md"
- "styles/align.md"
- "styles/background.md"
- "styles/border.md"
- "styles/box_sizing.md"
- "styles/color.md"
- "styles/content_align.md"
- "styles/display.md"
- "styles/dock.md"
- "styles/grid.md"
- "styles/height.md"
- "styles/layer.md"
- "styles/layers.md"
- "styles/layout.md"
- "styles/links.md"
- "styles/margin.md"
- "styles/max_height.md"
- "styles/max_width.md"
- "styles/min_height.md"
- "styles/min_width.md"
- "styles/offset.md"
- "styles/opacity.md"
- "styles/outline.md"
- "styles/overflow.md"
- "styles/padding.md"
- "styles/scrollbar.md"
- "styles/scrollbar_gutter.md"
- "styles/scrollbar_size.md"
- "styles/text_align.md"
- "styles/text_style.md"
- "styles/text_opacity.md"
- "styles/tint.md"
- "styles/visibility.md"
- "styles/width.md"
- Widgets:
- "widgets/index.md"
- "widgets/button.md"
- "widgets/checkbox.md"
- "widgets/data_table.md"
- "widgets/footer.md"
- "widgets/header.md"
- "widgets/input.md"
- "widgets/static.md"
- "widgets/tree_control.md"
- Reference:
- "reference/app.md"
- "reference/button.md"
- "reference/color.md"
- "reference/containers.md"
- "reference/dom_node.md"
- "reference/events.md"
- "reference/footer.md"
- "reference/geometry.md"
- "reference/header.md"
- "reference/index.md"
- "reference/message_pump.md"
- "reference/message.md"
- "reference/pilot.md"
- "reference/query.md"
- "reference/reactive.md"
- "reference/screen.md"
- "reference/static.md"
- "reference/timer.md"
- "reference/widget.md"
- "help.md"
- Events:
- "events/index.md"
- "events/blur.md"
- "events/descendant_blur.md"
- "events/descendant_focus.md"
- "events/enter.md"
- "events/focus.md"
- "events/hide.md"
- "events/key.md"
- "events/leave.md"
- "events/load.md"
- "events/mount.md"
- "events/mouse_capture.md"
- "events/click.md"
- "events/mouse_down.md"
- "events/mouse_move.md"
- "events/mouse_release.md"
- "events/mouse_scroll_down.md"
- "events/mouse_scroll_up.md"
- "events/mouse_up.md"
- "events/paste.md"
- "events/resize.md"
- "events/screen_resume.md"
- "events/screen_suspend.md"
- "events/show.md"
- Styles:
- "styles/index.md"
- "styles/align.md"
- "styles/background.md"
- "styles/border.md"
- "styles/box_sizing.md"
- "styles/color.md"
- "styles/content_align.md"
- "styles/display.md"
- "styles/dock.md"
- "styles/grid.md"
- "styles/height.md"
- "styles/layer.md"
- "styles/layers.md"
- "styles/layout.md"
- "styles/links.md"
- "styles/margin.md"
- "styles/max_height.md"
- "styles/max_width.md"
- "styles/min_height.md"
- "styles/min_width.md"
- "styles/offset.md"
- "styles/opacity.md"
- "styles/outline.md"
- "styles/overflow.md"
- "styles/padding.md"
- "styles/scrollbar.md"
- "styles/scrollbar_gutter.md"
- "styles/scrollbar_size.md"
- "styles/text_align.md"
- "styles/text_style.md"
- "styles/text_opacity.md"
- "styles/tint.md"
- "styles/visibility.md"
- "styles/width.md"
- Widgets:
- "widgets/index.md"
- "widgets/button.md"
- "widgets/checkbox.md"
- "widgets/data_table.md"
- "widgets/footer.md"
- "widgets/header.md"
- "widgets/input.md"
- "widgets/static.md"
- "widgets/tree_control.md"
- API:
- "api/index.md"
- "api/app.md"
- "api/button.md"
- "api/color.md"
- "api/containers.md"
- "api/dom_node.md"
- "api/events.md"
- "api/footer.md"
- "api/geometry.md"
- "api/header.md"
- "api/message_pump.md"
- "api/message.md"
- "api/pilot.md"
- "api/query.md"
- "api/reactive.md"
- "api/screen.md"
- "api/static.md"
- "api/timer.md"
- "api/widget.md"
- "Blog":
- blog/index.md
@@ -170,13 +175,21 @@ theme:
name: Switch to dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: black
primary: indigo
toggle:
icon: material/weather-night
name: Switch to light mode
plugins:
- blog:
- rss:
match_path: blog/posts/.*
date_from_meta:
as_creation: date
categories:
- categories
- tags
- search:
- autorefs:
- mkdocstrings:

View File

@@ -55,6 +55,10 @@ replacement = 'https://github.com/Textualize/textual/blob/main/\1'
pattern = '(?<=\]\()\./(.+?\.(png|svg))(?=\))'
replacement = 'https://raw.githubusercontent.com/Textualize/textual/main/\1'
[tool.poetry.group.dev.dependencies]
mkdocs-rss-plugin = "^1.5.0"
[tool.black]
includes = "src"

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import inspect
import sys
from typing import Callable
import rich.repr
@@ -12,11 +11,7 @@ __all__ = ["log", "panic"]
from ._context import active_app
from ._log import LogGroup, LogVerbosity
if sys.version_info >= (3, 10):
from typing import TypeAlias
else: # pragma: no cover
from typing_extensions import TypeAlias
from ._typing import TypeAlias
LogCallable: TypeAlias = "Callable"

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import sys
from functools import lru_cache
from typing import cast, Tuple, Union
@@ -11,11 +10,7 @@ from rich.style import Style
from .color import Color
from .css.types import EdgeStyle, EdgeType
if sys.version_info >= (3, 10):
from typing import TypeAlias
else: # pragma: no cover
from typing_extensions import TypeAlias
from ._typing import TypeAlias
INNER = 1
OUTER = 2

View File

@@ -13,10 +13,9 @@ without having to render the entire screen.
from __future__ import annotations
import sys
from itertools import chain
from operator import itemgetter
from typing import TYPE_CHECKING, Callable, Iterable, NamedTuple, cast
from typing import TYPE_CHECKING, Iterable, NamedTuple, cast
import rich.repr
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
@@ -27,15 +26,9 @@ from rich.style import Style
from . import errors
from ._cells import cell_len
from ._loop import loop_last
from ._profile import timer
from ._types import Lines
from .geometry import Offset, Region, Size
if sys.version_info >= (3, 10):
from typing import TypeAlias
else: # pragma: no cover
from typing_extensions import TypeAlias
from ._typing import TypeAlias
if TYPE_CHECKING:
from .widget import Widget
@@ -165,8 +158,7 @@ class ChopsUpdate:
yield new_line
def __rich_repr__(self) -> rich.repr.Result:
return
yield
yield from ()
@rich.repr.auto(angular=True)

View File

@@ -1,6 +1,8 @@
from __future__ import annotations
import hashlib
import os
from pathlib import Path
import shlex
from typing import Iterable
@@ -9,6 +11,9 @@ from textual.pilot import Pilot
from textual._import_app import import_app
SCREENSHOT_CACHE = ".screenshot_cache"
# This module defines our "Custom Fences", powered by SuperFences
# @link https://facelessuser.github.io/pymdown-extensions/extensions/superfences/#custom-fences
def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str:
@@ -74,6 +79,24 @@ def take_svg_screenshot(
if title is None:
title = app.title
def get_cache_key(app: App) -> str:
hash = hashlib.md5()
file_paths = [app_path] + app.css_path
for path in file_paths:
with open(path, "rb") as source_file:
hash.update(source_file.read())
hash.update(f"{press}-{title}-{terminal_size}".encode("utf-8"))
cache_key = f"{hash.hexdigest()}.svg"
return cache_key
if app_path is not None:
screenshot_cache = Path(SCREENSHOT_CACHE)
screenshot_cache.mkdir(exist_ok=True)
screenshot_path = screenshot_cache / get_cache_key(app)
if screenshot_path.exists():
return screenshot_path.read_text()
async def auto_pilot(pilot: Pilot) -> None:
app = pilot.app
await pilot.press(*press)
@@ -85,6 +108,10 @@ def take_svg_screenshot(
auto_pilot=auto_pilot,
size=terminal_size,
)
if app_path is not None:
screenshot_path.write_text(svg)
assert svg is not None
return svg
@@ -99,11 +126,16 @@ def rich(source, language, css_class, options, md, attrs, **kwargs) -> str:
title = attrs.get("title", "Rich")
rows = int(attrs.get("lines", 24))
columns = int(attrs.get("columns", 80))
console = Console(
file=io.StringIO(),
record=True,
force_terminal=True,
color_system="truecolor",
width=columns,
height=rows,
)
error_console = Console(stderr=True)

View File

@@ -16,6 +16,23 @@ class AppFail(Exception):
pass
def shebang_python(candidate: Path) -> bool:
"""Does the given file look like it's run with Python?
Args:
candidate (Path): The candidate file to check.
Returns:
bool: ``True`` if it looks to #! python, ``False`` if not.
"""
try:
with candidate.open("rb") as source:
first_line = source.readline()
except IOError:
return False
return first_line.startswith(b"#!") and b"python" in first_line
def import_app(import_name: str) -> App:
"""Import an app from a path or import name.
@@ -35,9 +52,14 @@ def import_app(import_name: str) -> App:
from textual.app import App, WINDOWS
import_name, *argv = shlex.split(import_name, posix=not WINDOWS)
drive, import_name = os.path.splitdrive(import_name)
lib, _colon, name = import_name.partition(":")
if lib.endswith(".py"):
if drive:
lib = os.path.join(drive, os.sep, lib)
if lib.endswith(".py") or shebang_python(Path(lib)):
path = os.path.abspath(lib)
sys.path.append(str(Path(path).parent))
try:
@@ -62,7 +84,7 @@ def import_app(import_name: str) -> App:
except KeyError:
raise AppFail(f"App {name!r} not found in {lib!r}")
else:
# Find a App class or instance that is *not* the base class
# Find an App class or instance that is *not* the base class
apps = [
value
for value in global_vars.values()
@@ -96,6 +118,8 @@ def import_app(import_name: str) -> App:
except AttributeError:
raise AppFail(f"Unable to find {name!r} in {module!r}")
sys.argv[:] = [import_name, *argv]
if inspect.isclass(app) and issubclass(app, App):
app = app()

View File

@@ -1,22 +1,14 @@
from __future__ import annotations
from abc import ABC, abstractmethod
import sys
from typing import ClassVar, NamedTuple, TYPE_CHECKING
from .geometry import Region, Size, Spacing
if sys.version_info >= (3, 10):
from typing import TypeAlias
else: # pragma: no cover
from typing_extensions import TypeAlias
from ._typing import TypeAlias
if TYPE_CHECKING:
from .widget import Widget
ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]"
DockArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget], Spacing]"

View File

@@ -39,6 +39,20 @@ class NodeList(Sequence):
def __contains__(self, widget: Widget) -> bool:
return widget in self._nodes
def index(self, widget: Widget) -> int:
"""Return the index of the given widget.
Args:
widget (Widget): The widget to find in the node list.
Returns:
int: The index of the widget in the node list.
Raises:
ValueError: If the widget is not in the node list.
"""
return self._nodes.index(widget)
def _append(self, widget: Widget) -> None:
"""Append a Widget.
@@ -50,6 +64,17 @@ class NodeList(Sequence):
self._nodes_set.add(widget)
self._updates += 1
def _insert(self, index: int, widget: Widget) -> None:
"""Insert a Widget.
Args:
widget (Widget): A widget.
"""
if widget not in self._nodes_set:
self._nodes.insert(index, widget)
self._nodes_set.add(widget)
self._updates += 1
def _remove(self, widget: Widget) -> None:
"""Remove a widget from the list.

View File

@@ -155,8 +155,7 @@ class Parser(Generic[T]):
yield popleft()
def parse(self, on_token: Callable[[T], None]) -> Generator[Awaitable, str, None]:
return
yield
yield from ()
if __name__ == "__main__":

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import inspect
import sys
from pathlib import Path, PurePath

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import sys
from typing import TYPE_CHECKING, Callable, Iterable, List
from rich.segment import Segment
@@ -11,22 +10,16 @@ from ._filter import LineFilter
from ._opacity import _apply_opacity
from ._segment_tools import line_crop, line_pad, line_trim
from ._types import Lines
from ._typing import TypeAlias
from .color import Color
from .geometry import Region, Size, Spacing
from .renderables.text_opacity import TextOpacity
from .renderables.tint import Tint
if sys.version_info >= (3, 10):
from typing import TypeAlias
else: # pragma: no cover
from typing_extensions import TypeAlias
if TYPE_CHECKING:
from .css.styles import StylesBase
from .widget import Widget
RenderLineCallback: TypeAlias = Callable[[int], List[Segment]]

View File

@@ -1,13 +1,8 @@
import sys
from typing import Awaitable, Callable, List, TYPE_CHECKING, Union
from rich.segment import Segment
if sys.version_info >= (3, 8):
from typing import Protocol
else:
from typing_extensions import Protocol
from textual._typing import Protocol
if TYPE_CHECKING:
from .message import Message

11
src/textual/_typing.py Normal file
View File

@@ -0,0 +1,11 @@
import sys
if sys.version_info >= (3, 10):
from typing import TypeAlias
else: # pragma: no cover
from typing_extensions import TypeAlias
if sys.version_info >= (3, 8):
from typing import Final, Literal, Protocol, TypedDict
else:
from typing_extensions import Final, Literal, Protocol, TypedDict

View File

@@ -1,36 +1,33 @@
from __future__ import annotations
import asyncio
from asyncio import Task
from contextlib import asynccontextmanager
import inspect
import io
import os
import platform
import sys
import threading
import unicodedata
import warnings
from contextlib import redirect_stderr, redirect_stdout
from asyncio import Task
from contextlib import asynccontextmanager, redirect_stderr, redirect_stdout
from datetime import datetime
from pathlib import Path, PurePath
from queue import Queue
from time import perf_counter
from typing import (
TYPE_CHECKING,
Any,
Callable,
Coroutine,
Generic,
Iterable,
List,
Type,
TYPE_CHECKING,
TypeVar,
cast,
Union,
cast,
)
from weakref import WeakSet, WeakValueDictionary
from ._ansi_sequences import SYNC_END, SYNC_START
from ._path import _make_path_object_relative
import nanoid
import rich
import rich.repr
@@ -40,11 +37,14 @@ from rich.segment import Segment, Segments
from rich.traceback import Traceback
from . import Logger, LogGroup, LogVerbosity, actions, events, log, messages
from ._animator import Animator, DEFAULT_EASING, Animatable, EasingFunction
from ._animator import DEFAULT_EASING, Animatable, Animator, EasingFunction
from ._ansi_sequences import SYNC_END, SYNC_START
from ._callback import invoke
from ._context import active_app
from ._event_broker import NoHandler, extract_handler_actions
from ._filter import LineFilter, Monochrome
from ._path import _make_path_object_relative
from ._typing import TypeAlias, Final
from .binding import Binding, Bindings
from .css.query import NoMatches
from .css.stylesheet import Stylesheet
@@ -66,11 +66,6 @@ if TYPE_CHECKING:
from .devtools.client import DevtoolsClient
from .pilot import Pilot
if sys.version_info >= (3, 10):
from typing import TypeAlias
else: # pragma: no cover
from typing_extensions import TypeAlias
PLATFORM = platform.system()
WINDOWS = PLATFORM == "Windows"
@@ -126,10 +121,16 @@ class ScreenStackError(ScreenError):
"""Raised when attempting to pop the last screen from the stack."""
class CssPathError(Exception):
"""Raised when supplied CSS path(s) are invalid."""
ReturnType = TypeVar("ReturnType")
class _NullFile:
"""A file-like where writes go nowhere."""
def write(self, text: str) -> None:
pass
@@ -137,23 +138,89 @@ class _NullFile:
pass
CSSPathType = Union[str, PurePath, None]
MAX_QUEUED_WRITES: Final[int] = 30
class _WriterThread(threading.Thread):
"""A thread / file-like to do writes to stdout in the background."""
def __init__(self) -> None:
super().__init__(daemon=True)
self._queue: Queue[str | None] = Queue(MAX_QUEUED_WRITES)
self._file = sys.__stdout__
def write(self, text: str) -> None:
"""Write text. Text will be enqueued for writing.
Args:
text (str): Text to write to the file.
"""
self._queue.put(text)
def isatty(self) -> bool:
"""Pretend to be a terminal.
Returns:
bool: True if this is a tty.
"""
return True
def fileno(self) -> int:
"""Get file handle number.
Returns:
int: File number of proxied file.
"""
return self._file.fileno()
def flush(self) -> None:
"""Flush the file (a no-op, because flush is done in the thread)."""
return
def run(self) -> None:
"""Run the thread."""
write = self._file.write
flush = self._file.flush
get = self._queue.get
qsize = self._queue.qsize
# Read from the queue, write to the file.
# Flush when there is a break.
while True:
text: str | None = get()
empty = qsize() == 0
if text is None:
break
write(text)
if empty:
flush()
def stop(self) -> None:
"""Stop the thread, and block until it finished."""
self._queue.put(None)
self.join()
CSSPathType = Union[str, PurePath, List[Union[str, PurePath]], None]
@rich.repr.auto
class App(Generic[ReturnType], DOMNode):
"""The base class for Textual Applications.
Args:
driver_class (Type[Driver] | None, optional): Driver class or ``None`` to auto-detect. Defaults to None.
css_path (str | PurePath | None, optional): Path to CSS or ``None`` for no CSS file. Defaults to None.
css_path (str | PurePath | list[str | PurePath] | None, optional): Path to CSS or ``None`` for no CSS file.
Defaults to None. To load multiple CSS files, pass a list of strings or paths which will be loaded in order.
watch_css (bool, optional): Watch CSS for changes. Defaults to False.
Raises:
CssPathError: When the supplied CSS path(s) are an unexpected type.
"""
# Inline CSS for quick scripts (generally css_path should be preferred.)
CSS = ""
"""Inline CSS, useful for quick scripts. This is loaded after CSS_PATH,
and therefore takes priority in the event of a specificity clash."""
# Default (lowest priority) CSS
# Default (the lowest priority) CSS
DEFAULT_CSS = """
App {
background: $background;
@@ -190,8 +257,17 @@ class App(Generic[ReturnType], DOMNode):
no_color = environ.pop("NO_COLOR", None)
if no_color is not None:
self._filter = Monochrome()
self._writer_thread: _WriterThread | None = None
if sys.__stdout__ is None:
file = _NullFile()
else:
self._writer_thread = _WriterThread()
self._writer_thread.start()
file = self._writer_thread
self.console = Console(
file=sys.__stdout__ if sys.__stdout__ is not None else _NullFile(),
file=file,
markup=False,
highlight=False,
emoji=False,
@@ -227,15 +303,30 @@ class App(Generic[ReturnType], DOMNode):
self.stylesheet = Stylesheet(variables=self.get_css_variables())
self._require_stylesheet_update: set[DOMNode] = set()
# We want the CSS path to be resolved from the location of the App subclass
css_path = css_path or self.CSS_PATH
if css_path is not None:
# When value(s) are supplied for CSS_PATH, we normalise them to a list of Paths.
if isinstance(css_path, str):
css_path = Path(css_path)
css_path = _make_path_object_relative(css_path, self) if css_path else None
css_paths = [Path(css_path)]
elif isinstance(css_path, PurePath):
css_paths = [css_path]
elif isinstance(css_path, list):
css_paths = []
for path in css_path:
css_paths.append(Path(path) if isinstance(path, str) else path)
else:
raise CssPathError(
"Expected a str, Path or list[str | Path] for the CSS_PATH."
)
self.css_path = css_path
# We want the CSS path to be resolved from the location of the App subclass
css_paths = [
_make_path_object_relative(css_path, self) for css_path in css_paths
]
else:
css_paths = []
self.css_path = css_paths
self._registry: WeakSet[DOMNode] = WeakSet()
self._installed_screens: WeakValueDictionary[
@@ -366,8 +457,7 @@ class App(Generic[ReturnType], DOMNode):
def compose(self) -> ComposeResult:
"""Yield child widgets for a container."""
return
yield
yield from ()
def get_css_variables(self) -> dict[str, str]:
"""Get a mapping of variables used to pre-populate CSS.
@@ -675,12 +765,16 @@ class App(Generic[ReturnType], DOMNode):
# Wait until the app has performed all startup routines.
await app_ready_event.wait()
# Context manager returns pilot object to manipulate the app
yield Pilot(app)
# Get the app in an active state.
app._set_active()
# Shutdown the app cleanly
await app._shutdown()
await app_task
# Context manager returns pilot object to manipulate the app
try:
yield Pilot(app)
finally:
# Shutdown the app cleanly
await app._shutdown()
await app_task
async def run_async(
self,
@@ -774,15 +868,16 @@ class App(Generic[ReturnType], DOMNode):
async def _on_css_change(self) -> None:
"""Called when the CSS changes (if watch_css is True)."""
if self.css_path is not None:
css_paths = self.css_path
if css_paths:
try:
time = perf_counter()
stylesheet = self.stylesheet.copy()
stylesheet.read(self.css_path)
stylesheet.read_all(css_paths)
stylesheet.parse()
elapsed = (perf_counter() - time) * 1000
self.log.system(
f"<stylesheet> loaded {self.css_path!r} in {elapsed:.0f} ms"
f"<stylesheet> loaded {len(css_paths)} CSS files in {elapsed:.0f} ms"
)
except Exception as error:
# TODO: Catch specific exceptions
@@ -822,27 +917,55 @@ class App(Generic[ReturnType], DOMNode):
self._require_stylesheet_update.add(self.screen if node is None else node)
self.check_idle()
def mount(self, *widgets: Widget) -> AwaitMount:
"""Mount the given widgets.
def mount(
self,
*widgets: Widget,
before: int | str | Widget | None = None,
after: int | str | Widget | None = None,
) -> AwaitMount:
"""Mount the given widgets relative to the app's screen.
Args:
*widgets (Widget): The widget(s) to mount.
before (int | str | Widget, optional): Optional location to mount before.
after (int | str | Widget, optional): Optional location to mount after.
Returns:
AwaitMount: An awaitable object that waits for widgets to be mounted.
"""
return AwaitMount(self._register(self.screen, *widgets))
def mount_all(self, widgets: Iterable[Widget]) -> AwaitMount:
Raises:
MountError: If there is a problem with the mount request.
Note:
Only one of ``before`` or ``after`` can be provided. If both are
provided a ``MountError`` will be raised.
"""
return self.screen.mount(*widgets, before=before, after=after)
def mount_all(
self,
widgets: Iterable[Widget],
before: int | str | Widget | None = None,
after: int | str | Widget | None = None,
) -> AwaitMount:
"""Mount widgets from an iterable.
Args:
widgets (Iterable[Widget]): An iterable of widgets.
before (int | str | Widget, optional): Optional location to mount before.
after (int | str | Widget, optional): Optional location to mount after.
Returns:
AwaitMount: An awaitable object that waits for widgets to be mounted.
Raises:
MountError: If there is a problem with the mount request.
Note:
Only one of ``before`` or ``after`` can be provided. If both are
provided a ``MountError`` will be raised.
"""
return self.mount(*widgets)
return self.mount(*widgets, before=before, after=after)
def is_screen_installed(self, screen: Screen | str) -> bool:
"""Check if a given screen has been installed.
@@ -1141,8 +1264,8 @@ class App(Generic[ReturnType], DOMNode):
self.log.system(features=self.features)
try:
if self.css_path is not None:
self.stylesheet.read(self.css_path)
if self.css_path:
self.stylesheet.read_all(self.css_path)
for path, css, tie_breaker in self.get_default_css():
self.stylesheet.add_source(
css, path=path, is_default_css=True, tie_breaker=tie_breaker
@@ -1290,23 +1413,68 @@ class App(Generic[ReturnType], DOMNode):
self._require_stylesheet_update.clear()
self.stylesheet.update_nodes(nodes, animate=True)
def _register_child(self, parent: DOMNode, child: Widget) -> bool:
def _register_child(
self, parent: DOMNode, child: Widget, before: int | None, after: int | None
) -> None:
"""Register a widget as a child of another.
Args:
parent (DOMNode): Parent node.
child (Widget): The child widget to register.
widgets: The widget to register.
before (int, optional): A location to mount before.
after (int, option): A location to mount after.
"""
# Let's be 100% sure that we've not been asked to do a before and an
# after at the same time. It's possible that we can remove this
# check later on, but for the purposes of development right now,
# it's likely a good idea to keep it here to check assumptions in
# the rest of the code.
if before is not None and after is not None:
raise AppError("Only one of 'before' and 'after' may be specified.")
# If we don't already know about this widget...
if child not in self._registry:
parent.children._append(child)
# Now to figure out where to place it. If we've got a `before`...
if before is not None:
# ...it's safe to NodeList._insert before that location.
parent.children._insert(before, child)
elif after is not None and after != -1:
# In this case we've got an after. -1 holds the special
# position (for now) of meaning "okay really what I mean is
# do an append, like if I'd asked to add with no before or
# after". So... we insert before the next item in the node
# list, iff after isn't -1.
parent.children._insert(after + 1, child)
else:
# At this point we appear to not be adding before or after,
# or we've got a before/after value that really means
# "please append". So...
parent.children._append(child)
# Now that the widget is in the NodeList of its parent, sort out
# the rest of the admin.
self._registry.add(child)
child._attach(parent)
child._post_register(self)
child._start_messages()
return True
return False
def _register(self, parent: DOMNode, *widgets: Widget) -> list[Widget]:
def _register(
self,
parent: DOMNode,
*widgets: Widget,
before: int | None = None,
after: int | None = None,
) -> list[Widget]:
"""Register widget(s) so they may receive events.
Args:
parent (DOMNode): Parent node.
*widgets: The widget(s) to register.
before (int, optional): A location to mount before.
after (int, option): A location to mount after.
Returns:
list[Widget]: List of modified widgets.
@@ -1315,13 +1483,19 @@ class App(Generic[ReturnType], DOMNode):
if not widgets:
return []
apply_stylesheet = self.stylesheet.apply
new_widgets = list(widgets)
if before is not None or after is not None:
# There's a before or after, which means there's going to be an
# insertion, so make it easier to get the new things in the
# correct order.
new_widgets = reversed(new_widgets)
for widget in widgets:
apply_stylesheet = self.stylesheet.apply
for widget in new_widgets:
if not isinstance(widget, Widget):
raise AppError(f"Can't register {widget!r}; expected a Widget instance")
if widget not in self._registry:
self._register_child(parent, widget)
self._register_child(parent, widget, before, after)
if widget.children:
self._register(widget, *widget.children)
apply_stylesheet(widget)
@@ -1402,6 +1576,9 @@ class App(Generic[ReturnType], DOMNode):
if self.devtools is not None and self.devtools.is_connected:
await self._disconnect_devtools()
if self._writer_thread is not None:
self._writer_thread.stop()
async def _on_exit_app(self) -> None:
await self._message_queue.put(None)

View File

@@ -1,15 +1,11 @@
from __future__ import annotations
import sys
from dataclasses import dataclass
from typing import Iterable, MutableMapping
import rich.repr
if sys.version_info >= (3, 10):
from typing import TypeAlias
else: # pragma: no cover
from typing_extensions import TypeAlias
from textual._typing import TypeAlias
BindingType: TypeAlias = "Binding | tuple[str, str, str]"

View File

@@ -52,8 +52,8 @@ class ColorsView(Vertical):
color = f"{color_name}-{level}" if level else color_name
item = ColorItem(
ColorBar(f"${color}", classes="text label"),
ColorBar(f"$text-muted", classes="muted"),
ColorBar(f"$text-disabled", classes="disabled"),
ColorBar("$text-muted", classes="muted"),
ColorBar("$text-disabled", classes="disabled"),
classes=color,
)
items.append(item)

View File

@@ -8,7 +8,7 @@ You can convert from a Textual color to a Rich color with the [rich_color][textu
The following named colors are used by the [parse][textual.color.Color.parse] method.
```{.rich title="colors"}
```{.rich columns="80" title="colors"}
from textual._color_constants import COLOR_NAME_TO_RGB
from textual.color import Color
from rich.table import Table

View File

@@ -1,9 +1,9 @@
from __future__ import annotations
import sys
from dataclasses import dataclass
from typing import Iterable
from typing import Iterable, Sequence
from textual._typing import Literal
from textual.color import ColorParseError
from textual.css._help_renderables import Example, Bullet, HelpText
from textual.css.constants import (
@@ -15,11 +15,6 @@ from textual.css.constants import (
VALID_TEXT_ALIGN,
)
if sys.version_info >= (3, 8):
from typing import Literal, Iterable, Sequence
else:
from typing_extensions import Literal
from textual.css._error_tools import friendly_list
from textual.css.scalar import SYMBOL_UNIT
@@ -331,7 +326,7 @@ def color_property_help_text(
Bullet(
f"The [i]{property_name}[/] property can only be set to a valid color"
),
Bullet(f"Colors can be specified using hex, RGB, or ANSI color names"),
Bullet("Colors can be specified using hex, RGB, or ANSI color names"),
*ContextSpecificBullets(
inline=[
Bullet(
@@ -398,7 +393,7 @@ def border_property_help_text(property_name: str, context: StylingContext) -> He
f"Valid values for <bordertype> are:\n{friendly_list(VALID_BORDER)}"
),
Bullet(
f"Colors can be specified using hex, RGB, or ANSI color names"
"Colors can be specified using hex, RGB, or ANSI color names"
),
],
css=[
@@ -413,7 +408,7 @@ def border_property_help_text(property_name: str, context: StylingContext) -> He
f"Valid values for <bordertype> are:\n{friendly_list(VALID_BORDER)}"
),
Bullet(
f"Colors can be specified using hex, RGB, or ANSI color names"
"Colors can be specified using hex, RGB, or ANSI color names"
),
],
).get_by_context(context),
@@ -462,13 +457,13 @@ def dock_property_help_text(property_name: str, context: StylingContext) -> Help
inline=[
Bullet(
"The 'dock' rule aligns a widget relative to the screen.",
examples=[Example(f'header.styles.dock = "top"')],
examples=[Example('header.styles.dock = "top"')],
)
],
css=[
Bullet(
"The 'dock' rule aligns a widget relative to the screen.",
examples=[Example(f"dock: top")],
examples=[Example("dock: top")],
)
],
).get_by_context(context),
@@ -630,7 +625,7 @@ def integer_help_text(property_name: str) -> HelpText:
summary=f"Invalid value for [i]{property_name}[/]",
bullets=[
Bullet(
markup=f"An integer value is expected here",
markup="An integer value is expected here",
examples=[
Example(f"{property_name}: 2;"),
],

View File

@@ -672,7 +672,7 @@ class StylesBuilder:
def process_layer(self, name: str, tokens: list[Token]) -> None:
if len(tokens) > 1:
self.error(name, tokens[1], f"unexpected tokens in dock-edge declaration")
self.error(name, tokens[1], "unexpected tokens in dock-edge declaration")
self.styles._rules["layer"] = tokens[0].value
def process_layers(self, name: str, tokens: list[Token]) -> None:

View File

@@ -1,13 +1,8 @@
from __future__ import annotations
import sys
import typing
if sys.version_info >= (3, 8):
from typing import Final
else:
from typing_extensions import Final # pragma: no cover
from ..geometry import Spacing
from .._typing import Final
if typing.TYPE_CHECKING:
from .types import EdgeType

View File

@@ -42,6 +42,10 @@ class NoMatches(QueryError):
"""No nodes matched the query."""
class TooManyMatches(QueryError):
"""Too many nodes matched the query."""
class WrongType(QueryError):
"""Query result was not of the correct type."""
@@ -208,6 +212,49 @@ class DOMQuery(Generic[QueryType]):
else:
raise NoMatches(f"No nodes match {self!r}")
@overload
def only_one(self) -> Widget:
...
@overload
def only_one(self, expect_type: type[ExpectType]) -> ExpectType:
...
def only_one(
self, expect_type: type[ExpectType] | None = None
) -> Widget | ExpectType:
"""Get the *only* matching node.
Args:
expect_type (type[ExpectType] | None, optional): Require matched node is of this type,
or None for any type. Defaults to None.
Raises:
WrongType: If the wrong type was found.
TooManyMatches: If there is more than one matching node in the query.
Returns:
Widget | ExpectType: The matching Widget.
"""
# Call on first to get the first item. Here we'll use all of the
# testing and checking it provides.
the_one = self.first(expect_type) if expect_type is not None else self.first()
try:
# Now see if we can access a subsequent item in the nodes. There
# should *not* be anything there, so we *should* get an
# IndexError. We *could* have just checked the length of the
# query, but the idea here is to do the check as cheaply as
# possible.
_ = self.nodes[1]
raise TooManyMatches(
"Call to only_one resulted in more than one matched node"
)
except IndexError:
# The IndexError was got, that's a good thing in this case. So
# we return what we found.
pass
return the_one
@overload
def last(self) -> Widget:
...
@@ -232,16 +279,14 @@ class DOMQuery(Generic[QueryType]):
Returns:
Widget | ExpectType: The matching Widget.
"""
if self.nodes:
last = self.nodes[-1]
if expect_type is not None:
if not isinstance(last, expect_type):
raise WrongType(
f"Query value is wrong type; expected {expect_type}, got {type(last)}"
)
return last
else:
if not self.nodes:
raise NoMatches(f"No nodes match {self!r}")
last = self.nodes[-1]
if expect_type is not None and not isinstance(last, expect_type):
raise WrongType(
f"Query value is wrong type; expected {expect_type}, got {type(last)}"
)
return last
@overload
def results(self) -> Iterator[Widget]:

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import sys
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from functools import lru_cache
@@ -60,11 +59,7 @@ from .types import (
Visibility,
TextAlign,
)
if sys.version_info >= (3, 8):
from typing import TypedDict
else:
from typing_extensions import TypedDict
from .._typing import TypedDict
if TYPE_CHECKING:
from .._layout import Layout

View File

@@ -248,11 +248,24 @@ class Stylesheet:
with open(filename, "rt") as css_file:
css = css_file.read()
path = os.path.abspath(filename)
except Exception as error:
except Exception:
raise StylesheetError(f"unable to read CSS file {filename!r}") from None
self.source[str(path)] = CssSource(css, False, 0)
self._require_parse = True
def read_all(self, paths: list[PurePath]) -> None:
"""Read multiple CSS files, in order.
Args:
paths (list[PurePath]): The paths of the CSS files to read, in order.
Raises:
StylesheetError: If the CSS could not be read.
StylesheetParseError: If the CSS is invalid.
"""
for path in paths:
self.read(path)
def add_source(
self,
css: str,
@@ -268,6 +281,7 @@ class Stylesheet:
Defaults to None.
is_default_css (bool): True if the CSS is defined in the Widget, False if the CSS is defined
in a user stylesheet.
tie_breaker (int): Integer representing the priority of this source.
Raises:
StylesheetError: If the CSS could not be read.

View File

@@ -1,15 +1,9 @@
from __future__ import annotations
import sys
from typing import Tuple
from ..color import Color
if sys.version_info >= (3, 8):
from typing import Literal
else:
from typing_extensions import Literal
from .._typing import Literal
Edge = Literal["top", "right", "bottom", "left"]
DockEdge = Literal["top", "right", "bottom", "left", ""]

View File

@@ -1,5 +1,5 @@
* {
transition: background 250ms linear, color 250ms linear;
transition: background 500ms in_out_cubic, color 500ms in_out_cubic;
}
Screen {

View File

@@ -102,15 +102,15 @@ Here's an example of some CSS used in this app:
EXAMPLE_CSS = """\
Screen {
layers: base overlay notes;
overflow: hidden;
overflow: hidden;
}
Sidebar {
Sidebar {
width: 40;
background: $panel;
transition: offset 500ms in_out_cubic;
background: $panel;
transition: offset 500ms in_out_cubic;
layer: overlay;
}
Sidebar.-hidden {
@@ -142,7 +142,7 @@ Build your own or use the builtin widgets.
- **DataTable** A spreadsheet-like widget for navigating data. Cells may contain text or Rich renderables.
- **TreeControl** An generic tree with expandable nodes.
- **DirectoryTree** A tree of file and folders.
- *... many more planned ...*
- *... many more planned ...*
"""
@@ -219,7 +219,7 @@ class Welcome(Container):
def on_button_pressed(self, event: Button.Pressed) -> None:
self.app.add_note("[b magenta]Start!")
self.app.query_one(".location-first").scroll_visible(speed=50, top=True)
self.app.query_one(".location-first").scroll_visible(duration=0.5, top=True)
class OptionGroup(Container):
@@ -272,7 +272,7 @@ class LocationLink(Static):
self.reveal = reveal
def on_click(self) -> None:
self.app.query_one(self.reveal).scroll_visible(top=True)
self.app.query_one(self.reveal).scroll_visible(top=True, duration=0.5)
self.app.add_note(f"Scrolling to [b]{self.reveal}[/b]")
@@ -319,7 +319,7 @@ class DemoApp(App):
self.query_one(TextLog).write(renderable)
def compose(self) -> ComposeResult:
example_css = "\n".join(Path(self.css_path).read_text().splitlines()[:50])
example_css = "\n".join(Path(self.css_path[0]).read_text().splitlines()[:50])
yield Container(
Sidebar(classes="-hidden"),
Header(show_clock=True),

View File

@@ -213,7 +213,7 @@ def show_design(light: ColorSystem, dark: ColorSystem) -> Table:
background = Color.parse(colors[name]).with_alpha(1.0)
foreground = background + background.get_contrast_text(0.9)
text = Text(name)
text = Text(f"${name}")
yield Padding(text, 1, style=f"{foreground.hex6} on {background.hex6}")

View File

@@ -1,17 +1,11 @@
from __future__ import annotations
import sys
from datetime import datetime
from pathlib import Path
from typing import Iterable
from importlib_metadata import version
if sys.version_info >= (3, 8):
from typing import Literal
else:
from typing_extensions import Literal
from rich.align import Align
from rich.console import Console, ConsoleOptions, RenderResult
from rich.markup import escape
@@ -22,6 +16,7 @@ from rich.styled import Styled
from rich.table import Table
from rich.text import Text
from textual._log import LogGroup
from textual._typing import Literal
DevConsoleMessageLevel = Literal["info", "warning", "error"]

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import re
import sys
from collections import deque
from inspect import getfile
from typing import (
@@ -42,20 +41,10 @@ if TYPE_CHECKING:
from .screen import Screen
from .widget import Widget
if sys.version_info >= (3, 8):
from typing import Literal
else:
from typing_extensions import Literal
if sys.version_info >= (3, 10):
from typing import TypeAlias
else: # pragma: no cover
from typing_extensions import TypeAlias
from textual._typing import Literal, TypeAlias
_re_identifier = re.compile(IDENTIFIER)
WalkMethod: TypeAlias = Literal["depth", "breadth"]
@@ -794,10 +783,7 @@ class DOMNode(MessagePump):
query_selector = selector.__name__
query: DOMQuery[Widget] = DOMQuery(self, filter=query_selector)
if expect_type is None:
return query.first()
else:
return query.first(expect_type)
return query.only_one() if expect_type is None else query.only_one(expect_type)
def set_styles(self, css: str | None = None, **update_styles) -> None:
"""Set custom styles on this object."""

View File

@@ -23,8 +23,7 @@ class Event(Message):
"""The base class for all events."""
def __rich_repr__(self) -> rich.repr.Result:
return
yield
yield from ()
@rich.repr.auto

View File

@@ -1,13 +1,7 @@
from __future__ import annotations
import sys
from typing import cast
if sys.version_info >= (3, 8):
from typing import Final, Literal
else:
from typing_extensions import Final, Literal
from textual._typing import Final, Literal
FEATURES: Final = {"devtools", "debug", "headless"}

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import os
from pathlib import PurePath
from typing import Callable
@@ -9,20 +11,20 @@ from ._callback import invoke
@rich.repr.auto
class FileMonitor:
"""Monitors a file for changes and invokes a callback when it does."""
"""Monitors files for changes and invokes a callback when it does."""
def __init__(self, path: PurePath, callback: Callable) -> None:
self.path = path
def __init__(self, paths: list[PurePath], callback: Callable) -> None:
self.paths = paths
self.callback = callback
self._modified = self._get_modified()
self._modified = self._get_last_modified_time()
def _get_modified(self) -> float:
"""Get the modified time for a file being watched."""
return os.stat(self.path).st_mtime
def _get_last_modified_time(self) -> float:
"""Get the most recent modified time out of all files being watched."""
return max(os.stat(path).st_mtime for path in self.paths)
def check(self) -> bool:
"""Check the monitored file. Return True if it was changed."""
modified = self._get_modified()
"""Check the monitored files. Return True if any were changed since the last modification time."""
modified = self._get_last_modified_time()
changed = modified != self._modified
self._modified = modified
return changed
@@ -32,5 +34,5 @@ class FileMonitor:
await self.on_change()
async def on_change(self) -> None:
"""Called when file changes."""
"""Called when any of the monitored files change."""
await invoke(self.callback)

View File

@@ -6,15 +6,11 @@ Functions and classes to manage terminal geometry (anything involving coordinate
from __future__ import annotations
import sys
from functools import lru_cache
from operator import attrgetter, itemgetter
from typing import Any, Collection, NamedTuple, Tuple, TypeVar, Union, cast
if sys.version_info >= (3, 10):
from typing import TypeAlias
else: # pragma: no cover
from typing_extensions import TypeAlias
from textual._typing import TypeAlias
SpacingDimensions: TypeAlias = Union[
int, Tuple[int], Tuple[int, int], Tuple[int, int, int, int]

View File

@@ -284,6 +284,7 @@ class MessagePump(metaclass=MessagePumpMeta):
await timer.stop()
self._timers.clear()
await self._message_queue.put(events.Unmount(sender=self))
Reactive._reset_object(self)
await self._message_queue.put(None)
if self._task is not None and asyncio.current_task() != self._task:
# Ensure everything is closed before returning

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
from functools import partial
from inspect import isawaitable
from typing import TYPE_CHECKING, Any, Callable, Generic, Type, TypeVar, Union
from weakref import WeakSet
from . import events
from ._callback import count_parameters, invoke
@@ -116,6 +115,16 @@ class Reactive(Generic[ReactiveType]):
setattr(obj, name, default_value)
setattr(obj, "__reactive_initialized", True)
@classmethod
def _reset_object(cls, obj: object) -> None:
"""Reset reactive structures on object (to avoid reference cycles).
Args:
obj (object): A reactive object.
"""
getattr(obj, "__watchers", {}).clear()
getattr(obj, "__computes", []).clear()
def __set_name__(self, owner: Type[MessageTarget], name: str) -> None:
# Check for compute method
@@ -224,8 +233,7 @@ class Reactive(Generic[ReactiveType]):
)
# Check for watchers set via `watch`
watcher_name = f"__{name}_watchers"
watchers = getattr(obj, watcher_name, ())
watchers: list[Callable] = getattr(obj, "__watchers", {}).get(name, [])
for watcher in watchers:
obj.post_message_no_wait(
events.Callback(
@@ -312,11 +320,14 @@ def watch(
callback (Callable[[Any], object]): A callable to call when the attribute changes.
init (bool, optional): True to call watcher initialization. Defaults to True.
"""
watcher_name = f"__{attribute_name}_watchers"
current_value = getattr(obj, attribute_name, None)
if not hasattr(obj, watcher_name):
setattr(obj, watcher_name, WeakSet())
watchers = getattr(obj, watcher_name)
watchers.add(callback)
if not hasattr(obj, "__watchers"):
setattr(obj, "__watchers", {})
watchers: dict[str, list[Callable]] = getattr(obj, "__watchers")
watcher_list = watchers.setdefault(attribute_name, [])
if callback in watcher_list:
return
watcher_list.append(callback)
if init:
current_value = getattr(obj, attribute_name, None)
Reactive._check_watchers(obj, attribute_name, current_value)

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import sys
from typing import Iterable, Iterator
import rich.repr
@@ -13,17 +12,14 @@ from ._compositor import Compositor, MapGeometry
from .timer import Timer
from ._types import CallbackType
from .geometry import Offset, Region, Size
from ._typing import Final
from .reactive import Reactive
from .renderables.blank import Blank
from .widget import Widget
if sys.version_info >= (3, 8):
from typing import Final
else:
from typing_extensions import Final
# Screen updates will be batched so that they don't happen more often than 60 times per second:
UPDATE_PERIOD: Final = 1 / 60
# Screen updates will be batched so that they don't happen more often than 120 times per second:
UPDATE_PERIOD: Final[float] = 1 / 120
@rich.repr.auto

View File

@@ -5,7 +5,6 @@ from fractions import Fraction
from itertools import islice
from operator import attrgetter
from typing import (
Awaitable,
Generator,
TYPE_CHECKING,
ClassVar,
@@ -375,16 +374,100 @@ class Widget(DOMNode):
if self._scrollbar_corner is not None:
yield self._scrollbar_corner
def mount(self, *widgets: Widget) -> AwaitMount:
"""Mount child widgets (making this widget a container).
class MountError(Exception):
"""Error raised when there was a problem with the mount request."""
def _find_mount_point(self, spot: int | str | "Widget") -> tuple["Widget", int]:
"""Attempt to locate the point where the caller wants to mount something.
Args:
spot (int | str | Widget): The spot to find.
Returns:
tuple[Widget, int]: The parent and the location in its child list.
Raises:
Widget.MountError: If there was an error finding where to mount a widget.
The rules of this method are:
- Given an ``int``, parent is ``self`` and location is the integer value.
- Given a ``Widget``, parent is the widget's parent and location is
where the widget is found in the parent's ``children``. If it
can't be found a ``MountError`` will be raised.
- Given a string, it is used to perform a ``query_one`` and then the
result is used as if a ``Widget`` had been given.
"""
# A numeric location means at that point in our child list.
if isinstance(spot, int):
return self, spot
# If we've got a string, that should be treated like a query that
# can be passed to query_one. So let's use that to get a widget to
# work on.
if isinstance(spot, str):
spot = self.query_one(spot, Widget)
# At this point we should have a widget, either because we got given
# one, or because we pulled one out of the query. First off, does it
# have a parent? There's no way we can use it as a sibling to make
# mounting decisions if it doesn't have a parent.
if spot.parent is None:
raise self.MountError(
f"Unable to find relative location of {spot!r} because it has no parent"
)
# We've got a widget. It has a parent. It has (zero or more)
# children. We should be able to go looking for the widget's
# location amongst its parent's children.
try:
return spot.parent, spot.parent.children.index(spot)
except ValueError:
raise self.MountError(f"{spot!r} is not a child of {self!r}") from None
def mount(
self,
*widgets: Widget,
before: int | str | Widget | None = None,
after: int | str | Widget | None = None,
) -> AwaitMount:
"""Mount widgets below this widget (making this widget a container).
Args:
*widgets (Widget): The widget(s) to mount.
before (int | str | Widget, optional): Optional location to mount before.
after (int | str | Widget, optional): Optional location to mount after.
Returns:
AwaitMount: An awaitable object that waits for widgets to be mounted.
Raises:
MountError: If there is a problem with the mount request.
Note:
Only one of ``before`` or ``after`` can be provided. If both are
provided a ``MountError`` will be raised.
"""
return AwaitMount(self.app._register(self, *widgets))
# Saying you want to mount before *and* after something is an error.
if before is not None and after is not None:
raise self.MountError(
"Only one of `before` or `after` can be handled -- not both"
)
# Decide the final resting place depending on what we've been asked
# to do.
if before is not None:
parent, before = self._find_mount_point(before)
elif after is not None:
parent, after = self._find_mount_point(after)
else:
parent = self
return AwaitMount(
self.app._register(parent, *widgets, before=before, after=after)
)
def compose(self) -> ComposeResult:
"""Called by Textual to create child widgets.
@@ -402,8 +485,7 @@ class Widget(DOMNode):
```
"""
return
yield
yield from ()
def _post_register(self, app: App) -> None:
"""Called when the instance is registered.

View File

@@ -4,11 +4,25 @@ import typing
from ..case import camel_to_snake
# ⚠For any new built-in Widget we create, not only do we have to import them here and add them to `__all__`,
# but also to the `__init__.pyi` file in this same folder - otherwise text editors and type checkers won't
# be able to "see" them.
if typing.TYPE_CHECKING:
from ..widget import Widget
from ._button import Button
from ._checkbox import Checkbox
from ._data_table import DataTable
from ._directory_tree import DirectoryTree
from ._footer import Footer
from ._header import Header
from ._placeholder import Placeholder
from ._pretty import Pretty
from ._static import Static
from ._input import Input
from ._text_log import TextLog
from ._tree_control import TreeControl
from ._welcome import Welcome
# ⚠For any new built-in Widget we create, not only we have to add them to the following list, but also to the
# `__init__.pyi` file in this same folder - otherwise text editors and type checkers won't be able to "see" them.
__all__ = [
"Button",
"Checkbox",

View File

@@ -1,14 +1,8 @@
from __future__ import annotations
import sys
from functools import partial
from typing import cast
if sys.version_info >= (3, 8):
from typing import Literal
else:
from typing_extensions import Literal # pragma: no cover
import rich.repr
from rich.console import RenderableType
from rich.text import Text, TextType
@@ -18,6 +12,7 @@ from ..css._error_tools import friendly_list
from ..message import Message
from ..reactive import Reactive
from ..widgets import Static
from .._typing import Literal
ButtonVariant = Literal["default", "primary", "success", "warning", "error"]
_VALID_BUTTON_VARIANTS = {"default", "primary", "success", "warning", "error"}

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import sys
from dataclasses import dataclass, field
from itertools import chain, zip_longest
from typing import ClassVar, Generic, Iterable, NamedTuple, TypeVar, cast
@@ -14,18 +13,13 @@ from rich.text import Text, TextType
from .. import events, messages
from .._cache import LRUCache
from .._profile import timer
from .._segment_tools import line_crop
from .._types import Lines
from ..geometry import Region, Size, Spacing, clamp
from ..reactive import Reactive
from ..render import measure
from ..scroll_view import ScrollView
if sys.version_info >= (3, 8):
from typing import Literal
else:
from typing_extensions import Literal
from .._typing import Literal
CursorType = Literal["cell", "row", "column"]
CELL: CursorType = "cell"
@@ -115,16 +109,15 @@ class Coord(NamedTuple):
class DataTable(ScrollView, Generic[CellType], can_focus=True):
DEFAULT_CSS = """
App.-dark DataTable {
background:;
}
DataTable {
background: $surface ;
color: $text;
color: $text;
}
DataTable > .datatable--header {
DataTable > .datatable--header {
text-style: bold;
background: $primary;
color: $text;
@@ -136,7 +129,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
}
DataTable > .datatable--odd-row {
}
DataTable > .datatable--even-row {

View File

@@ -1,16 +1,9 @@
import sys
from decimal import Decimal
if sys.version_info >= (3, 10):
from typing import Literal
else: # pragma: no cover
from typing_extensions import Literal
import pytest
from rich.style import Style
from textual.app import ComposeResult
from textual.color import Color
from textual.css.errors import StyleValueError
from textual.css.scalar import Scalar, Unit

View File

@@ -344,7 +344,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/align.py]
# name: test_css_property[align.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -502,7 +502,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/background.py]
# name: test_css_property[background.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -657,7 +657,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/border.py]
# name: test_css_property[border.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -815,7 +815,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/box_sizing.py]
# name: test_css_property[box_sizing.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -971,7 +971,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/color.py]
# name: test_css_property[color.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -1128,7 +1128,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/content_align.py]
# name: test_css_property[content_align.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -1285,7 +1285,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/display.py]
# name: test_css_property[display.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -1441,7 +1441,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/grid.py]
# name: test_css_property[grid.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -1598,7 +1598,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/height.py]
# name: test_css_property[height.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -1754,7 +1754,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/layout.py]
# name: test_css_property[layout.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -1912,7 +1912,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/links.py]
# name: test_css_property[links.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -2069,7 +2069,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/margin.py]
# name: test_css_property[margin.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -2226,7 +2226,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/offset.py]
# name: test_css_property[offset.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -2384,7 +2384,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/opacity.py]
# name: test_css_property[opacity.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -2547,7 +2547,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/outline.py]
# name: test_css_property[outline.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -2704,7 +2704,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/overflow.py]
# name: test_css_property[overflow.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -2863,7 +2863,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/padding.py]
# name: test_css_property[padding.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -3018,7 +3018,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/scrollbar_gutter.py]
# name: test_css_property[scrollbar_gutter.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -3174,7 +3174,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/scrollbar_size.py]
# name: test_css_property[scrollbar_size.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -3330,7 +3330,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/scrollbars.py]
# name: test_css_property[scrollbars.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -3487,7 +3487,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/text_align.py]
# name: test_css_property[text_align.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -3649,7 +3649,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/text_opacity.py]
# name: test_css_property[text_opacity.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -3807,7 +3807,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/text_style.py]
# name: test_css_property[text_style.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -3965,7 +3965,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/tint.py]
# name: test_css_property[tint.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -4129,7 +4129,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/visibility.py]
# name: test_css_property[visibility.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -4285,7 +4285,7 @@
'''
# ---
# name: test_css_property_snapshot[docs/examples/styles/width.py]
# name: test_css_property[width.py]
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
@@ -6165,6 +6165,163 @@
'''
# ---
# name: test_multiple_css
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}
.terminal-1292433193-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
.terminal-1292433193-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
.terminal-1292433193-r1 { fill: #8b0000 }
.terminal-1292433193-r2 { fill: #c5c8c6 }
.terminal-1292433193-r3 { fill: #ff0000 }
.terminal-1292433193-r4 { fill: #e1e1e1 }
</style>
<defs>
<clipPath id="terminal-1292433193-clip-terminal">
<rect x="0" y="0" width="975.0" height="584.5999999999999" />
</clipPath>
<clipPath id="terminal-1292433193-line-0">
<rect x="0" y="1.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1292433193-line-1">
<rect x="0" y="25.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1292433193-line-2">
<rect x="0" y="50.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1292433193-line-3">
<rect x="0" y="74.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1292433193-line-4">
<rect x="0" y="99.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1292433193-line-5">
<rect x="0" y="123.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1292433193-line-6">
<rect x="0" y="147.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1292433193-line-7">
<rect x="0" y="172.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1292433193-line-8">
<rect x="0" y="196.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1292433193-line-9">
<rect x="0" y="221.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1292433193-line-10">
<rect x="0" y="245.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1292433193-line-11">
<rect x="0" y="269.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1292433193-line-12">
<rect x="0" y="294.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1292433193-line-13">
<rect x="0" y="318.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1292433193-line-14">
<rect x="0" y="343.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1292433193-line-15">
<rect x="0" y="367.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1292433193-line-16">
<rect x="0" y="391.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1292433193-line-17">
<rect x="0" y="416.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1292433193-line-18">
<rect x="0" y="440.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1292433193-line-19">
<rect x="0" y="465.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1292433193-line-20">
<rect x="0" y="489.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1292433193-line-21">
<rect x="0" y="513.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1292433193-line-22">
<rect x="0" y="538.3" width="976" height="24.65"/>
</clipPath>
</defs>
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="633.6" rx="8"/><text class="terminal-1292433193-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">MultipleCSSApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>
<g transform="translate(9, 41)" clip-path="url(#terminal-1292433193-clip-terminal)">
<rect fill="#ff0000" x="0" y="1.5" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#ff0000" x="48.8" y="1.5" width="927.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#556b2f" x="0" y="25.9" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#556b2f" x="48.8" y="25.9" width="927.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="50.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="99.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="489.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="513.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="538.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="562.7" width="976" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-1292433193-matrix">
<text class="terminal-1292433193-r1" x="0" y="20" textLength="48.8" clip-path="url(#terminal-1292433193-line-0)">#one</text><text class="terminal-1292433193-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-1292433193-line-0)">
</text><text class="terminal-1292433193-r3" x="0" y="44.4" textLength="48.8" clip-path="url(#terminal-1292433193-line-1)">#two</text><text class="terminal-1292433193-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-1292433193-line-1)">
</text><text class="terminal-1292433193-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-1292433193-line-2)">
</text><text class="terminal-1292433193-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-1292433193-line-3)">
</text><text class="terminal-1292433193-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-1292433193-line-4)">
</text><text class="terminal-1292433193-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-1292433193-line-5)">
</text><text class="terminal-1292433193-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-1292433193-line-6)">
</text><text class="terminal-1292433193-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-1292433193-line-7)">
</text><text class="terminal-1292433193-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-1292433193-line-8)">
</text><text class="terminal-1292433193-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-1292433193-line-9)">
</text><text class="terminal-1292433193-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-1292433193-line-10)">
</text><text class="terminal-1292433193-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-1292433193-line-11)">
</text><text class="terminal-1292433193-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-1292433193-line-12)">
</text><text class="terminal-1292433193-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-1292433193-line-13)">
</text><text class="terminal-1292433193-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-1292433193-line-14)">
</text><text class="terminal-1292433193-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-1292433193-line-15)">
</text><text class="terminal-1292433193-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-1292433193-line-16)">
</text><text class="terminal-1292433193-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-1292433193-line-17)">
</text><text class="terminal-1292433193-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-1292433193-line-18)">
</text><text class="terminal-1292433193-r2" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-1292433193-line-19)">
</text><text class="terminal-1292433193-r2" x="976" y="508" textLength="12.2" clip-path="url(#terminal-1292433193-line-20)">
</text><text class="terminal-1292433193-r2" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-1292433193-line-21)">
</text><text class="terminal-1292433193-r2" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-1292433193-line-22)">
</text>
</g>
</g>
</svg>
'''
# ---
# name: test_textlog_max_lines
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">

View File

@@ -6,7 +6,7 @@ from dataclasses import dataclass
from datetime import datetime
from operator import attrgetter
from os import PathLike
from pathlib import Path
from pathlib import Path, PurePath
from typing import Union, List, Optional, Callable, Iterable
import pytest
@@ -31,7 +31,7 @@ TEXTUAL_APP_KEY = pytest.StashKey[App]()
@pytest.fixture
def snap_compare(
snapshot: SnapshotAssertion, request: FixtureRequest
) -> Callable[[str], bool]:
) -> Callable[[str | PurePath], bool]:
"""
This fixture returns a function which can be used to compare the output of a Textual
app with the output of the same app in the past. This is snapshot testing, and it
@@ -39,7 +39,7 @@ def snap_compare(
"""
def compare(
app_path: str,
app_path: str | PurePath,
press: Iterable[str] = ("_",),
terminal_size: tuple[int, int] = (80, 24),
) -> bool:
@@ -50,7 +50,8 @@ def snap_compare(
the snapshot on disk will be updated to match the current screenshot.
Args:
app_path (str): The path of the app.
app_path (str): The path of the app. Relative paths are relative to the location of the
test this function is called from.
press (Iterable[str]): Key presses to run before taking screenshot. "_" is a short pause.
terminal_size (tuple[int, int]): A pair of integers (WIDTH, HEIGHT), representing terminal size.
@@ -58,7 +59,17 @@ def snap_compare(
bool: True if the screenshot matches the snapshot.
"""
node = request.node
app = import_app(app_path)
path = Path(app_path)
if path.is_absolute():
# If the user supplies an absolute path, just use it directly.
app = import_app(str(path.resolve()))
else:
# If a relative path is supplied by the user, it's relative to the location of the pytest node,
# NOT the location that `pytest` was invoked from.
node_path = node.path.parent
resolved = (node_path / app_path).resolve()
app = import_app(str(resolved))
actual_screenshot = take_svg_screenshot(
app=app,
press=press,
@@ -114,16 +125,19 @@ def pytest_sessionfinish(
actual_svg = item.stash.get(TEXTUAL_ACTUAL_SVG_KEY, None)
app = item.stash.get(TEXTUAL_APP_KEY, None)
if snapshot_svg and actual_svg and app:
if app:
path, line_index, name = item.reportinfo()
similarity = (
100
* difflib.SequenceMatcher(
a=str(snapshot_svg), b=str(actual_svg)
).ratio()
)
diffs.append(
SvgSnapshotDiff(
snapshot=str(snapshot_svg),
actual=str(actual_svg),
file_similarity=100
* difflib.SequenceMatcher(
a=str(snapshot_svg), b=str(actual_svg)
).ratio(),
file_similarity=similarity,
test_name=name,
path=path,
line_number=line_index + 1,

View File

@@ -0,0 +1,8 @@
#one {
background: green;
color: cyan;
}
#two {
color: red;
}

View File

@@ -0,0 +1,34 @@
"""Testing multiple CSS files, including app-level CSS
-- element #one
The `background` rule on #one tests a 3-way specificity clash between
classvar CSS and two separate CSS files. The background ends up red
because classvar CSS wins.
The `color` rule tests a clash between loading two external CSS files.
The color ends up as darkred (from 'second.css'), because that file is loaded
second and wins.
-- element #two
This element tests that separate rules applied to the same widget are mixed
correctly. The color is set to cadetblue in 'first.css', and the background is
darkolivegreen in 'second.css'. Both of these should apply.
"""
from textual.app import App, ComposeResult
from textual.widgets import Static
class MultipleCSSApp(App):
CSS = """
#one {
background: red;
}
"""
def compose(self) -> ComposeResult:
yield Static("#one", id="one")
yield Static("#two", id="two")
app = MultipleCSSApp(css_path=["first.css", "second.css"])
if __name__ == '__main__':
app.run()

View File

@@ -0,0 +1,8 @@
#one {
background: blue;
color: darkred;
}
#two {
background: darkolivegreen;
}

View File

@@ -61,6 +61,7 @@
{{ diff.path }}:{{ diff.line_number }}
</span>
</span>
{% if diff.snapshot != "" %}
<div class="form-check form-switch mt-1">
<input class="form-check-input" type="checkbox" role="switch"
id="flexSwitchCheckDefault" onchange="toggleOverlayCheckbox(this, {{ loop.index0 }})">
@@ -68,6 +69,7 @@
Show difference
</label>
</div>
{% endif %}
</div>
<div class="card-body">
<div class="row">
@@ -86,17 +88,38 @@
</div>
</div>
<div class="diff-wrapper-snapshot">
{{ diff.snapshot }}
{% if diff.snapshot != "" %}
{{ diff.snapshot }}
{% else %}
<div class="card">
<div class="card-body">
<h4>No history for this test</h4>
<p class="lead">If you're happy with the content on the left,
save it to disk by running pytest with the <code>--snapshot-update</code> flag.</p>
<h5>Unexpected?</h5>
<p class="lead">
Snapshots are named after the name of the test you call <code>snap_compare</code> in by default.
<br>
If you've renamed a test, the association between the snapshot and the test is lost,
and you'll need to run with <code>--snapshot-update</code> to associate the snapshot
with the new test name.
</p>
</div>
</div>
{% endif %}
</div>
{% if diff.snapshot != "" %}
<div class="w-100 d-flex justify-content-center mt-1">
<span class="small">Historical snapshot</span>
<span class="small">
Historical snapshot
</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{# Modal with debug info: #}
<div class="modal modal-lg fade" id="environmentModal" tabindex="-1"
aria-labelledby="environmentModalLabel"

View File

@@ -1,40 +1,40 @@
from pathlib import Path, PurePosixPath
from pathlib import Path
import pytest
from textual.app import App
from textual.widgets import Input, Button
WIDGET_EXAMPLES_DIR = Path("../../docs/examples/widgets")
LAYOUT_EXAMPLES_DIR = Path("../../docs/examples/guide/layout")
STYLES_EXAMPLES_DIR = Path("../../docs/examples/styles")
# --- Layout related stuff ---
def test_grid_layout_basic(snap_compare):
assert snap_compare("docs/examples/guide/layout/grid_layout1.py")
assert snap_compare(LAYOUT_EXAMPLES_DIR / "grid_layout1.py")
def test_grid_layout_basic_overflow(snap_compare):
assert snap_compare("docs/examples/guide/layout/grid_layout2.py")
assert snap_compare(LAYOUT_EXAMPLES_DIR / "grid_layout2.py")
def test_grid_layout_gutter(snap_compare):
assert snap_compare("docs/examples/guide/layout/grid_layout7_gutter.py")
assert snap_compare(LAYOUT_EXAMPLES_DIR / "grid_layout7_gutter.py")
def test_layers(snap_compare):
assert snap_compare("docs/examples/guide/layout/layers.py")
assert snap_compare(LAYOUT_EXAMPLES_DIR / "layers.py")
def test_horizontal_layout(snap_compare):
assert snap_compare("docs/examples/guide/layout/horizontal_layout.py")
assert snap_compare(LAYOUT_EXAMPLES_DIR / "horizontal_layout.py")
def test_vertical_layout(snap_compare):
assert snap_compare("docs/examples/guide/layout/vertical_layout.py")
assert snap_compare(LAYOUT_EXAMPLES_DIR / "vertical_layout.py")
def test_dock_layout_sidebar(snap_compare):
assert snap_compare("docs/examples/guide/layout/dock_layout2_sidebar.py")
assert snap_compare(LAYOUT_EXAMPLES_DIR / "dock_layout2_sidebar.py")
# --- Widgets - rendering and basic interactions ---
@@ -42,7 +42,6 @@ def test_dock_layout_sidebar(snap_compare):
# When adding a new widget, ideally we should also create a snapshot test
# from these examples which test rendering and simple interactions with it.
def test_checkboxes(snap_compare):
"""Tests checkboxes but also acts a regression test for using
width: auto in a Horizontal layout context."""
@@ -54,7 +53,7 @@ def test_checkboxes(snap_compare):
"enter", # toggle on
"wait:20",
]
assert snap_compare("docs/examples/widgets/checkbox.py", press=press)
assert snap_compare(WIDGET_EXAMPLES_DIR / "checkbox.py", press=press)
def test_input_and_focus(snap_compare):
@@ -64,33 +63,33 @@ def test_input_and_focus(snap_compare):
"tab",
*"Burns", # Tab focus to second input, write "Burns"
]
assert snap_compare("docs/examples/widgets/input.py", press=press)
assert snap_compare(WIDGET_EXAMPLES_DIR / "input.py", press=press)
def test_buttons_render(snap_compare):
# Testing button rendering. We press tab to focus the first button too.
assert snap_compare("docs/examples/widgets/button.py", press=["tab"])
assert snap_compare(WIDGET_EXAMPLES_DIR / "button.py", press=["tab"])
def test_datatable_render(snap_compare):
press = ["tab", "down", "down", "right", "up", "left"]
assert snap_compare("docs/examples/widgets/data_table.py", press=press)
assert snap_compare(WIDGET_EXAMPLES_DIR / "data_table.py", press=press)
def test_footer_render(snap_compare):
assert snap_compare("docs/examples/widgets/footer.py")
assert snap_compare(WIDGET_EXAMPLES_DIR / "footer.py")
def test_header_render(snap_compare):
assert snap_compare("docs/examples/widgets/header.py")
assert snap_compare(WIDGET_EXAMPLES_DIR / "header.py")
def test_textlog_max_lines(snap_compare):
assert snap_compare("tests/snapshots/textlog_max_lines.py", press=[*"abcde", "_"])
assert snap_compare("snapshot_apps/textlog_max_lines.py", press=[*"abcde", "_"])
def test_fr_units(snap_compare):
assert snap_compare("tests/snapshots/fr_units.py")
assert snap_compare("snapshot_apps/fr_units.py")
# --- CSS properties ---
@@ -98,12 +97,18 @@ def test_fr_units(snap_compare):
# If any of these change, something has likely broken, so snapshot each of them.
PATHS = [
str(PurePosixPath(path))
for path in Path("docs/examples/styles").iterdir()
path.name
for path in (Path(__file__).parent / STYLES_EXAMPLES_DIR).iterdir()
if path.suffix == ".py"
]
@pytest.mark.parametrize("path", PATHS)
def test_css_property_snapshot(path, snap_compare):
assert snap_compare(path)
@pytest.mark.parametrize("file_name", PATHS)
def test_css_property(file_name, snap_compare):
path_to_app = STYLES_EXAMPLES_DIR / file_name
assert snap_compare(path_to_app)
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")

View File

@@ -1,3 +1,5 @@
import pytest
from textual.widget import Widget
from textual._node_list import NodeList
@@ -19,6 +21,16 @@ def test_repeat_add_one():
nodes._append(widget)
assert len(nodes)==1
def test_insert():
nodes = NodeList()
widget1 = Widget()
widget2 = Widget()
widget3 = Widget()
nodes._append(widget1)
nodes._append(widget3)
nodes._insert(1,widget2)
assert list(nodes) == [widget1,widget2,widget3]
def test_truthy():
"""Does a node list act as a truthy object?"""
nodes = NodeList()
@@ -35,6 +47,15 @@ def test_contains():
assert widget in nodes
assert Widget() not in nodes
def test_index():
"""Can we get the index of a widget in the list?"""
widget = Widget()
nodes = NodeList()
with pytest.raises(ValueError):
_ = nodes.index(widget)
nodes._append(widget)
assert nodes.index(widget) == 0
def test_remove():
"""Can we remove a widget we've added?"""
widget = Widget()

Some files were not shown because too many files have changed in this diff Show More