mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into modernize-metadata
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
2
Makefile
2
Makefile
@@ -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
5
docs/api/index.md
Normal 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
4
docs/blog/.authors.yml
Normal 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
3
docs/blog/index.md
Normal 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.
|
||||
19
docs/blog/posts/helo-world.md
Normal file
19
docs/blog/posts/helo-world.md
Normal 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!
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
102
docs/guide/design.md
Normal 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"])
|
||||
```
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Textual Guide
|
||||
# Guide
|
||||
|
||||
Welcome to the Textual Guide! An in-depth reference on how to build apps with Textual.
|
||||
|
||||
|
||||
14
docs/help.md
14
docs/help.md
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
---
|
||||
hide:
|
||||
- navigation
|
||||
---
|
||||
|
||||
|
||||
# Roadmap
|
||||
|
||||
We ([textualize.io](https://www.textualize.io/)) are actively building and maintaining Textual.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,4 +55,4 @@ _No other attributes_
|
||||
|
||||
## See Also
|
||||
|
||||
* [Button](../reference/button.md) code reference
|
||||
* [Button](../api/button.md) code reference
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -39,4 +39,4 @@ This widget sends no messages.
|
||||
|
||||
## See Also
|
||||
|
||||
* [Footer](../reference/footer.md) code reference
|
||||
* [Footer](../api/footer.md) code reference
|
||||
|
||||
@@ -32,4 +32,4 @@ This widget sends no messages.
|
||||
|
||||
## See Also
|
||||
|
||||
* [Header](../reference/header.md) code reference
|
||||
* [Header](../api/header.md) code reference
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,4 +31,4 @@ This widget sends no messages.
|
||||
|
||||
## See Also
|
||||
|
||||
* [Static](../reference/static.md) code reference
|
||||
* [Static](../api/static.md) code reference
|
||||
|
||||
195
mkdocs.yml
195
mkdocs.yml
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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]"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import sys
|
||||
from pathlib import Path, PurePath
|
||||
|
||||
|
||||
|
||||
@@ -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]]
|
||||
|
||||
|
||||
|
||||
@@ -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
11
src/textual/_typing.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;"),
|
||||
],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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", ""]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
* {
|
||||
transition: background 250ms linear, color 250ms linear;
|
||||
transition: background 500ms in_out_cubic, color 500ms in_out_cubic;
|
||||
}
|
||||
|
||||
Screen {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
0
tests/snapshot_tests/snapshot_apps/__init__.py
Normal file
0
tests/snapshot_tests/snapshot_apps/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
#one {
|
||||
background: green;
|
||||
color: cyan;
|
||||
}
|
||||
|
||||
#two {
|
||||
color: red;
|
||||
}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,8 @@
|
||||
#one {
|
||||
background: blue;
|
||||
color: darkred;
|
||||
}
|
||||
|
||||
#two {
|
||||
background: darkolivegreen;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user