This commit is contained in:
Will McGugan
2022-09-14 11:17:01 +01:00
53 changed files with 1435 additions and 671 deletions

412
README.md
View File

@@ -1,374 +1,112 @@
# Textual
![screenshot](./imgs/textual.png)
![Textual splash image](./imgs/textual.png)
Textual is a TUI (Text User Interface) framework for Python inspired by modern web development. Currently a **Work in Progress**.
Textual is a Python framework for creating interactive applications that run in your terminal.
<details>
<summary> 🎬 Code browser </summary>
<hr>
This is the [code_browser.py](./examples/code_browser.py) example which clocks in at 61 lines (*including* docstrings and blank lines).
https://user-images.githubusercontent.com/554369/189394703-364b5caa-97e0-45db-907d-7b1620d6411f.mov
</details>
> ⚠ **NOTE:** We ([Textualize.io](https://www.textualize.io)) are hard at work on the **css** branch. We will be maintain the 0.1.0 branch for the near future but may not be able to accept API changes. If you would like to contribute code via a PR, please raise a discussion first, to avoid disapointment.
## About
Textual adds interactivity to [Rich](https://github.com/Textualize/rich) with a Python API inspired by modern development development.
Follow [@willmcgugan](https://twitter.com/willmcgugan) for progress updates, or post in Discussions if you have any requests / suggestions.
[![Join the chat at https://gitter.im/textual-ui/community](https://badges.gitter.im/textual-ui/community.svg)](https://gitter.im/textual-ui/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
On modern terminal software (installed by default on most systems), Textual apps can use **16.7 million** colors with mouse support and smooth flicker-free animation. A powerful layout engine and re-usable components makes it possible to build apps that rival the desktop and web experience.
## Compatibility
Textual currently runs on **MacOS / Linux / Window**.
Textual runs on Linux, MacOS, and Windows. Textual requires Python 3.7 or above.
## How it works
## Installing
Textual uses [Rich](https://github.com/willmcgugan/rich) to render rich text, so anything that Rich can render may be used in Textual.
Event handling in Textual is asynchronous (using `async` and `await` keywords). Widgets (UI components) can independently update and communicate with each other via message passing.
Textual has more in common with modern web development than it does with [curses](<https://en.wikipedia.org/wiki/Curses_(programming_library)>); layout is done with CSS grid and (soon) the theme may be customized with CSS. Other techniques are borrowed from JS frameworks such as Vue and React.
## Installation
You can install Textual via pip (`pip install textual`), or by checking out the repo and installing with [poetry](https://python-poetry.org/docs/).
Install Textual via pip:
```
poetry install
pip install textual[dev]
```
Once installed you can run the following command for a quick test, or see examples (below):
The addition of `[dev]` installs Textual development tools.
```
python -m textual.app
## Reference commands
The `textual` command has a few sub-commands to preview Textual styles.
<details>
<summary> 🎬 Easing reference </summary>
<hr>
This is the *easing* reference which demonstrates the easing parameter on animation, with both movement and opacity. You can run it with the following command:
```bash
textual easing
```
Textual requires Python 3.7 or above.
https://user-images.githubusercontent.com/554369/189485538-31e794ff-61d7-4faf-902a-6e90a9d76e5b.mov
</details>
<details>
<summary> 🎬 Borders reference </summary>
<hr>
This is the borders reference which demonstrates some of the borders styles in Textual. You can run it with the following command:
```bash
textual borders
```
https://user-images.githubusercontent.com/554369/189485735-cb2b4135-caee-46d7-a118-66cd7ed9eef5.mov
</details>
## Examples
Until I've written the documentation, the [examples](https://github.com/willmcgugan/textual/tree/main/examples/) may be the best way to learn Textual.
The Textual repository comes with a number of examples you can experiment with or use as a template for your own projects.
You can see some of these examples in action in the [Developer Video Log](#developer-video-log).
<details>
<summary> 📷 Calculator </summary>
<hr>
- [animation.py](https://github.com/willmcgugan/textual/tree/main/examples/animation.py) Demonstration of 60fps animation easing function
- [calculator.py](https://github.com/willmcgugan/textual/tree/main/examples/calculator.py) A "clone" of the MacOS calculator using Grid layout
- [code_viewer.py](https://github.com/willmcgugan/textual/tree/main/examples/code_viewer.py) A demonstration of a tree view which loads syntax highlighted code
- [grid.py](https://github.com/willmcgugan/textual/tree/main/examples/calculator.py) A simple demonstration of adding widgets in a Grid layout
- [grid_auto.py](https://github.com/willmcgugan/textual/tree/main/examples/grid_auto.py) A demonstration of automatic Grid layout
- [simple.py](https://github.com/willmcgugan/textual/tree/main/examples/simple.py) A very simple Textual app with scrolling Markdown view
This is [calculator.py](./examples/calculator.py) which demonstrates Textual grid layouts.
## Building Textual applications
![calculator screenshot](./imgs/calculator.svg)
</details>
_This guide is a work in progress_
<details>
<summary> 📷 Code browser </summary>
<hr>
Let's look at the simplest Textual app which does _something_:
This is [code_browser.py](./examples/code_browser.py) which demonstrates the directory tree widget.
```python
from textual.app import App
![code browser screenshot](./imgs/codebrowser.svg)
</details>
class Beeper(App):
def on_key(self):
self.console.bell()
<details>
<summary> 📷 Stopwatch </summary>
<hr>
Beeper.run()
```
This is the Stopwatch example from the tutorial.
Here we can see a textual app with a single `on_key` method which will handle key events. Pressing any key will result in playing the terminal bell (generally an irritating beep). Hit Ctrl+C to exit.
### Light theme
Event handlers in Textual are defined by convention, not by inheritance (there's no base class with all the handlers defined). Each event has a `name` attribute which for the key event is simply `"key"`. Textual will call the method named `on_<event.name>` if it exists.
![stopwatch light screenshot](./imgs/stopwatch_light.svg)
Let's look at a _slightly_ more interesting example:
### Dark theme
```python
from textual.app import App
![stopwatch dark screenshot](./imgs/stopwatch_dark.svg)
class ColorChanger(App):
def on_key(self, event):
if event.key.isdigit():
self.background = f"on color({event.key})"
ColorChanger.run(log_path="textual.log")
```
You'll notice that the `on_key` method above contains an additional `event` parameter which wasn't present on the beeper example. If the `event` argument is present, Textual will call the handler with an event object. Every event has an associated handler object, in this case it is a KeyEvent which contains additional information regarding which key was pressed.
The key event handler above will set the background attribute if you press the keys 0-9, which turns the terminal to the corresponding [ansi color](https://rich.readthedocs.io/en/latest/appendix/colors.html).
Note that we didn't need to explicitly refresh the screen or draw anything. Setting the `background` attribute to a [Rich style](https://rich.readthedocs.io/en/latest/style.html) is enough for Textual to update the visuals. This is an example of _reactivity_ in Textual. To make changes to the terminal interface you modify the _state_ and let Textual update the UI.
## Widgets
To make more interesting apps you will need to make use of _widgets_, which are independent user interface elements. Textual comes with a (growing) library of widgets, but you can develop your own.
Let's look at an app which contains widgets. We will be using the built-in `Placeholder` widget which you can use to design application layouts before you implement the real content.
```python
from textual.app import App
from textual.widgets import Placeholder
class SimpleApp(App):
async def on_mount(self) -> None:
await self.view.dock(Placeholder(), edge="left", size=40)
await self.view.dock(Placeholder(), Placeholder(), edge="top")
SimpleApp.run(log_path="textual.log")
```
This app contains a single event handler `on_mount`. The mount event is sent when the app or widget is ready to start processing events, and is typically used for initialization. You may have noticed that `on_mount` is an `async` function. Since Textual is an asynchronous framework we will need this if we need to call most other methods.
The `on_mount` method makes two calls to `self.view.dock` which adds widgets to the terminal.
Here's the first line in the mount handler:
```python
await self.view.dock(Placeholder(), edge="left", size=40)
```
Note this method is asynchronous like almost all API methods in Textual. We are awaiting `self.view.dock` which takes a newly constructed Placeholder widget, and docks it on to the `"left"` edge of the terminal with a size of 40 characters. In a real app you might use this to display a side-bar.
The following line is similar:
```python
await self.view.dock(Placeholder(), Placeholder(), edge="top")
```
You will notice that this time we are docking _two_ Placeholder objects onto the `"top"` edge. We haven't set an explicit size this time so Textual will divide the remaining size amongst the two new widgets.
The last line calls the `run` class method in the usual way, but with an argument we haven't seen before: `log_path="textual.log"` tells Textual to write log information to the given file. You can tail textual.log to see events being processed and other debug information.
If you run the above example, you will see something like the following:
![widgets](./imgs/widgets.png)
If you move the mouse over the terminal you will notice that widgets receive mouse events. You can click any of the placeholders to give it input focus.
The dock layout feature is very flexible, but for more sophisticated layouts we can use the grid API. See the [calculator.py](https://github.com/willmcgugan/textual/blob/main/examples/calculator.py) example which makes use of Grid.
### Creating Widgets
You can create your own widgets by subclassing the `textual.widget.Widget` class and implementing a `render()` method which should return anything that can be rendered with [Rich](https://rich.readthedocs.io/en/latest/introduction.html), including a plain string which will be interpreted as [console markup](https://rich.readthedocs.io/en/latest/markup.html).
Let's look at an example with a custom widget:
```python
from rich.panel import Panel
from textual.app import App
from textual.reactive import Reactive
from textual.widget import Widget
class Hover(Widget):
mouse_over = Reactive(False)
def render(self) -> Panel:
return Panel("Hello [b]World[/b]", style=("on red" if self.mouse_over else ""))
def on_enter(self) -> None:
self.mouse_over = True
def on_leave(self) -> None:
self.mouse_over = False
class HoverApp(App):
"""Demonstrates custom widgets"""
async def on_mount(self) -> None:
hovers = (Hover() for _ in range(10))
await self.view.dock(*hovers, edge="top")
HoverApp.run(log_path="textual.log")
```
The `Hover` class is a custom widget which displays a panel containing the classic text "Hello World". The first line in the Hover class may seem a little mysterious at this point:
```python
mouse_over = Reactive(False)
```
This adds a `mouse_over` attribute to your class which is a bool with a default of `False`. Adding attributes like this makes them _reactive_: any changes will result in the widget updating.
The following `render()` method is where you define how the widget should be displayed. In the Hover widget we return a [Panel](https://rich.readthedocs.io/en/latest/panel.html) containing rich text with a background that changes depending on the value of `mouse_over`. The goal here is to add a mouse hover effect to the widget, which we can achieve by handling two events: `Enter` and `Leave`. These events are sent when the mouse enters or leaves the widget.
Here are the two event handlers again:
```python
def on_enter(self) -> None:
self.mouse_over = True
def on_leave(self) -> None:
self.mouse_over = False
```
Both event handlers set the `mouse_over` attribute which will result in the widget's `render()` method being called.
The `HoverApp` has a `on_mount` handler which creates 10 Hover widgets and docks them on the top edge to create a vertical stack:
```python
async def on_mount(self) -> None:
hovers = (Hover() for _ in range(10))
await self.view.dock(*hovers, edge="top")
```
If you run this script you will see something like the following:
![widgets](./imgs/custom.gif)
If you move your mouse over the terminal you should see that the widget under the mouse cursor changes to a red background.
### Actions and key bindings
Actions in Textual are white-listed functions that may be bound to keys. Let's look at a trivial example of binding a key to an action. Here is an app which exits when we hit the Q key:
```python
from textual.app import App
class Quitter(App):
async def on_load(self, event):
await self.bind("q", "quit")
Quitter.run()
```
If you run this you will get a blank terminal which will return to the prompt when you press Q.
Binding is done in the Load event handler. The `bind` method takes the key (in this case "q") and binds it to an action ("quit"). The quit action is built in to Textual and simply exits the app.
To define your own actions, add a method that begins with `action_`, which may take parameters. Let's create a simple action that changes the color of the terminal and binds keys to it:
```python
from textual.app import App
class Colorizer(App):
async def on_load(self, event):
await self.bind("r", "color('red')")
await self.bind("g", "color('green')")
await self.bind("b", "color('blue')")
async def action_color(self, color:str) -> None:
self.background = f"on {color}"
Colorizer.run()
```
If you run this app you can hit the keys R, G, or B to change the color of the background.
In the `on_load` method we have bound the keys R, G, and B to the `color` action with a single parameter. When you press any of these three keys Textual will call the method `action_color` with the appropriate parameter.
You could be forgiven for thinking that `"color('red')"` is Python code which Textual evaluates. This is not the case. The action strings are parsed and may not include expressions or arbitrary code. The reason that strings are used over a callable is that (in a future update) key bindings may be loaded from a configuration file.
### More on Events
_TODO_
### Watchers
_TODO_
### Animation
_TODO_
### Timers and Intervals
Textual has a `set_timer` and a `set_interval` method which work much like their Javascript counterparts. The `set_timer` method will invoke a callable after a given period of time, and `set_interval` will invoke a callable repeatedly. Unlike Javascript these methods expect the time to be in seconds (_not_ milliseconds).
Let's create a simple terminal based clock with the `set_interval` method:
```python
from datetime import datetime
from rich.align import Align
from textual.app import App
from textual.widget import Widget
class Clock(Widget):
def on_mount(self):
self.set_interval(1, self.refresh)
def render(self):
time = datetime.now().strftime("%c")
return Align.center(time, vertical="middle")
class ClockApp(App):
async def on_mount(self):
await self.view.dock(Clock())
ClockApp.run()
```
If you run this app you will see the current time in the center of the terminal until you hit Ctrl+C.
The Clock widget displays the time using [rich.align.Align](https://rich.readthedocs.io/en/latest/reference/align.html) to position it in the center. In the clock's Mount handler there is the following call to `set_interval`:
```python
self.set_interval(1, self.refresh)
```
This tells Textual to call a function (in this case `self.refresh` which updates the widget) once a second. When a widget is refreshed it calls `Clock.render` again to display the latest time.
## Developer Video Log
Since Textual is a visual medium, I'll be documenting new features and milestones here.
### Update 1 - Basic scrolling
[![Textual update 1](https://yt-embed.herokuapp.com/embed?v=zNW7U36GHlU&img=0)](http://www.youtube.com/watch?v=zNW7U36GHlU)
### Update 2 - Keyboard toggle
[![Textual update 2](https://yt-embed.herokuapp.com/embed?v=bTYeFOVNXDI&img=0)](http://www.youtube.com/watch?v=bTYeFOVNXDI)
### Update 3 - New scrollbars and smooth scrolling
[![Textual update 3](https://yt-embed.herokuapp.com/embed?v=4LVl3ClrXIs&img=0)](http://www.youtube.com/watch?v=4LVl3ClrXIs)
### Update 4 - Animation system with easing function
Now with a system to animate changes to values, going from the initial to the final value in small increments over time . Here applied to the scroll position. The animation system supports CSS like _easing functions_. You may be able to tell from the video that the page up / down keys cause the window to first speed up and then slow down.
[![Textual update 4](https://yt-embed.herokuapp.com/embed?v=k2VwOp1YbSk&img=0)](http://www.youtube.com/watch?v=k2VwOp1YbSk)
### Update 5 - New Layout system
A new update system allows for overlapping layers. Animation is now synchronized with the display which makes it very smooth!
[![Textual update 5](https://yt-embed.herokuapp.com/embed?v=XxRnfx2WYRw&img=0)](http://www.youtube.com/watch?v=XxRnfx2WYRw)
### Update 6 - New Layout API
New version (0.1.4) with API updates and the new layout system.
[![Textual update 6](https://yt-embed.herokuapp.com/embed?v=jddccDuVd3E&img=0)](http://www.youtube.com/watch?v=jddccDuVd3E)
### Update 7 - New Grid Layout
**11 July 2021**
Added a new layout system modelled on CSS grid. The example demonstrates how once created a grid will adapt to the available space.
[![Textual update 7](https://yt-embed.herokuapp.com/embed?v=Zh9CEvu73jc&img=0)](http://www.youtube.com/watch?v=Zh9CEvu73jc)
## Update 8 - Tree control and scroll views
**6 Aug 2021**
Added a tree control and refactored the renderer to allow for widgets within a scrollable view
[![Textual update 8](https://yt-embed.herokuapp.com/embed?v=J-dzzD6NQJ4&img=0)](http://www.youtube.com/watch?v=J-dzzD6NQJ4)
</details>

View File

@@ -15,10 +15,10 @@
App > Screen {
background: $surface;
color: $text-surface;
color: $text;
layers: base sidebar;
color: $text-background;
color: $text;
background: $background;
layout: vertical;
@@ -53,7 +53,7 @@ DataTable {
}
#sidebar {
color: $text-panel;
color: $text;
background: $panel;
dock: left;
width: 30;
@@ -71,7 +71,7 @@ DataTable {
#sidebar .title {
height: 1;
background: $primary-background-darken-1;
color: $text-primary-background-darken-1;
color: $text-muted;
border-right: wide $background;
content-align: center middle;
}
@@ -79,14 +79,14 @@ DataTable {
#sidebar .user {
height: 8;
background: $panel-darken-1;
color: $text-panel-darken-1;
color: $text-muted;
border-right: wide $background;
content-align: center middle;
}
#sidebar .content {
background: $panel-darken-2;
color: $text-surface;
color: $text;
border-right: wide $background;
content-align: center middle;
}
@@ -100,7 +100,7 @@ Tweet {
margin: 0 2;
background: $panel;
color: $text-panel;
color: $text;
layout: vertical;
/* border: outer $primary; */
padding: 1;
@@ -130,13 +130,13 @@ Tweet {
TweetHeader {
height:1;
background: $accent;
color: $text-accent
color: $text
}
TweetBody {
width: 100%;
background: $panel;
color: $text-panel;
color: $text;
height: auto;
padding: 0 1 0 0;
}
@@ -147,7 +147,7 @@ Tweet.scroll-horizontal TweetBody {
.button {
background: $accent;
color: $text-accent;
color: $text;
width:20;
height: 3;
/* border-top: hidden $accent-darken-3; */
@@ -163,7 +163,7 @@ Tweet.scroll-horizontal TweetBody {
.button:hover {
background: $accent-lighten-1;
color: $text-accent-lighten-1;
color: $text-disabled;
width: 20;
height: 3;
border: tall $accent-darken-1;
@@ -175,7 +175,7 @@ Tweet.scroll-horizontal TweetBody {
}
#footer {
color: $text-accent;
color: $text;
background: $accent;
height: 1;
@@ -198,7 +198,7 @@ OptionItem {
OptionItem:hover {
height: 3;
color: $text-primary;
color: $text;
background: $primary-darken-1;
/* border-top: hkey $accent2-darken-3;
border-bottom: hkey $accent2-darken-3; */
@@ -210,7 +210,7 @@ Error {
width: 100%;
height:3;
background: $error;
color: $text-error;
color: $text;
border-top: tall $error-darken-2;
border-bottom: tall $error-darken-2;
@@ -223,7 +223,7 @@ Warning {
width: 100%;
height:3;
background: $warning;
color: $text-warning-fade-1;
color: $text-muted;
border-top: tall $warning-darken-2;
border-bottom: tall $warning-darken-2;
@@ -237,7 +237,7 @@ Success {
height:auto;
box-sizing: border-box;
background: $success;
color: $text-success-fade-1;
color: $text-muted;
border-top: hkey $success-darken-2;
border-bottom: hkey $success-darken-2;

View File

@@ -33,7 +33,7 @@ Button {
.started {
text-style: bold;
background: $success;
color: $text-success;
color: $text;
}
.started TimeDisplay {

View File

@@ -33,7 +33,7 @@ Button {
.started {
text-style: bold;
background: $success;
color: $text-success;
color: $text;
}
.started TimeDisplay {

View File

@@ -14,30 +14,45 @@
App > Screen {
background: $surface;
color: $text-surface;
layers: sidebar;
color: $text-background;
background: $background;
color: $text;
layers: base sidebar;
layout: vertical;
overflow: hidden;
}
#tree-container {
overflow-y: auto;
height: 20;
margin: 1 2;
background: $surface;
padding: 1 2;
}
DirectoryTree {
padding: 0 1;
height: auto;
}
DataTable {
/*border:heavy red;*/
/* tint: 10% green; */
/* text-opacity: 50%; */
padding: 1;
margin: 1 2;
height: 12;
height: 24;
}
#sidebar {
color: $text-panel;
background: $panel;
color: $text;
dock: left;
width: 30;
margin-bottom: 1;
offset-x: -100%;
transition: offset 500ms in_out_cubic;
@@ -51,7 +66,7 @@ DataTable {
#sidebar .title {
height: 1;
background: $primary-background-darken-1;
color: $text-primary-background-darken-1;
color: $text;
border-right: wide $background;
content-align: center middle;
}
@@ -59,35 +74,29 @@ DataTable {
#sidebar .user {
height: 8;
background: $panel-darken-1;
color: $text-panel-darken-1;
color: $text;
border-right: wide $background;
content-align: center middle;
}
#sidebar .content {
background: $panel-darken-2;
color: $text-surface;
color: $text;
border-right: wide $background;
content-align: center middle;
}
#header {
color: $text-secondary-background;
background: $secondary-background;
height: 1;
content-align: center middle;
dock: top;
}
Tweet {
height:12;
width: 100%;
margin: 0 2;
margin:0 2;
background: $panel;
color: $text-panel;
color: $text;
layout: vertical;
/* border: outer $primary; */
padding: 1;
@@ -96,14 +105,15 @@ Tweet {
/* scrollbar-gutter: stable; */
align-horizontal: center;
box-sizing: border-box;
}
.scrollable {
overflow-x: auto;
overflow-y: scroll;
margin: 1 2;
height: 20;
height: 24;
align-horizontal: center;
layout: vertical;
}
@@ -117,13 +127,13 @@ Tweet {
TweetHeader {
height:1;
background: $accent;
color: $text-accent
color: $text;
}
TweetBody {
width: 100%;
background: $panel;
color: $text-panel;
color: $text;
height: auto;
padding: 0 1 0 0;
}
@@ -134,7 +144,7 @@ Tweet.scroll-horizontal TweetBody {
.button {
background: $accent;
color: $text-accent;
color: $text;
width:20;
height: 3;
/* border-top: hidden $accent-darken-3; */
@@ -150,7 +160,7 @@ Tweet.scroll-horizontal TweetBody {
.button:hover {
background: $accent-lighten-1;
color: $text-accent-lighten-1;
color: $text;
width: 20;
height: 3;
border: tall $accent-darken-1;
@@ -162,7 +172,7 @@ Tweet.scroll-horizontal TweetBody {
}
#footer {
color: $text-accent;
color: $text;
background: $accent;
height: 1;
@@ -185,7 +195,7 @@ OptionItem {
OptionItem:hover {
height: 3;
color: $text-primary;
color: $text;
background: $primary-darken-1;
/* border-top: hkey $accent2-darken-3;
border-bottom: hkey $accent2-darken-3; */
@@ -197,7 +207,7 @@ Error {
width: 100%;
height:3;
background: $error;
color: $text-error;
color: $text;
border-top: tall $error-darken-2;
border-bottom: tall $error-darken-2;
@@ -210,7 +220,7 @@ Warning {
width: 100%;
height:3;
background: $warning;
color: $text-warning-fade-1;
color: $text;
border-top: tall $warning-darken-2;
border-bottom: tall $warning-darken-2;
@@ -224,7 +234,7 @@ Success {
height:auto;
box-sizing: border-box;
background: $success;
color: $text-success-fade-1;
color: $text;
border-top: hkey $success-darken-2;
border-bottom: hkey $success-darken-2;

View File

@@ -6,42 +6,55 @@ from rich.text import Text
from textual.app import App, ComposeResult
from textual.reactive import Reactive
from textual.widget import Widget
from textual.widgets import Static, DataTable
from textual.widgets import Static, DataTable, DirectoryTree, Header, Footer
from textual.layout import Container
CODE = '''
class Offset(NamedTuple):
"""A point defined by x and y coordinates."""
from __future__ import annotations
x: int = 0
y: int = 0
from typing import Iterable, TypeVar
@property
def is_origin(self) -> bool:
"""Check if the point is at the origin (0, 0)"""
return self == (0, 0)
T = TypeVar("T")
def __bool__(self) -> bool:
return self != (0, 0)
def __add__(self, other: object) -> Offset:
if isinstance(other, tuple):
_x, _y = self
x, y = other
return Offset(_x + x, _y + y)
return NotImplemented
def loop_first(values: Iterable[T]) -> Iterable[tuple[bool, T]]:
"""Iterate and generate a tuple with a flag for first value."""
iter_values = iter(values)
try:
value = next(iter_values)
except StopIteration:
return
yield True, value
for value in iter_values:
yield False, value
def __sub__(self, other: object) -> Offset:
if isinstance(other, tuple):
_x, _y = self
x, y = other
return Offset(_x - x, _y - y)
return NotImplemented
def __mul__(self, other: object) -> Offset:
if isinstance(other, (float, int)):
x, y = self
return Offset(int(x * other), int(y * other))
return NotImplemented
def loop_last(values: Iterable[T]) -> Iterable[tuple[bool, T]]:
"""Iterate and generate a tuple with a flag for last value."""
iter_values = iter(values)
try:
previous_value = next(iter_values)
except StopIteration:
return
for value in iter_values:
yield False, previous_value
previous_value = value
yield True, previous_value
def loop_first_last(values: Iterable[T]) -> Iterable[tuple[bool, bool, T]]:
"""Iterate and generate a tuple with a flag for first and last value."""
iter_values = iter(values)
try:
previous_value = next(iter_values)
except StopIteration:
return
first = True
for value in iter_values:
yield first, False, previous_value
first = False
previous_value = value
yield first, True, previous_value
'''
@@ -96,25 +109,28 @@ class BasicApp(App, css_path="basic.css"):
def on_load(self):
"""Bind keys here."""
self.bind("s", "toggle_class('#sidebar', '-active')")
self.bind("s", "toggle_class('#sidebar', '-active')", description="Sidebar")
self.bind("d", "toggle_dark", description="Dark mode")
self.bind("q", "quit", description="Quit")
self.bind("f", "query_test", description="Query test")
def compose(self):
yield Header()
def compose(self) -> ComposeResult:
table = DataTable()
self.scroll_to_target = Tweet(TweetBody())
yield Static(
Text.from_markup(
"[b]This is a [u]Textual[/u] app, running in the terminal"
),
id="header",
)
yield from (
yield Container(
Tweet(TweetBody()),
Widget(
Static(Syntax(CODE, "python"), classes="code"),
Static(
Syntax(CODE, "python", line_numbers=True, indent_guides=True),
classes="code",
),
classes="scrollable",
),
table,
Widget(DirectoryTree("~/"), id="tree-container"),
Error(),
Tweet(TweetBody(), classes="scrollbar-size-custom"),
Warning(),
@@ -126,7 +142,6 @@ class BasicApp(App, css_path="basic.css"):
Tweet(TweetBody(), classes="scroll-horizontal"),
Tweet(TweetBody(), classes="scroll-horizontal"),
)
yield Widget(id="footer")
yield Widget(
Widget(classes="title"),
Widget(classes="user"),
@@ -136,6 +151,7 @@ class BasicApp(App, css_path="basic.css"):
Widget(classes="content"),
id="sidebar",
)
yield Footer()
table.add_column("Foo", width=20)
table.add_column("Bar", width=20)
@@ -147,12 +163,32 @@ class BasicApp(App, css_path="basic.css"):
for n in range(100):
table.add_row(*[f"Cell ([b]{n}[/b], {col})" for col in range(6)])
def on_mount(self):
self.sub_title = "Widget demo"
async def on_key(self, event) -> None:
await self.dispatch_key(event)
def key_d(self):
def action_toggle_dark(self):
self.dark = not self.dark
def action_query_test(self):
query = self.query("Tweet")
self.log(query)
self.log(query.nodes)
self.log(query)
self.log(query.nodes)
query.set_styles("outline: outer red;")
query = query.exclude(".scroll-horizontal")
self.log(query)
self.log(query.nodes)
# query = query.filter(".rubbish")
# self.log(query)
# self.log(query.first())
async def key_q(self):
await self.shutdown()

View File

@@ -24,7 +24,7 @@ Button {
padding: 0 1;
height: 100%;
background: $primary-lighten-2;
color: $text-primary-lighten-2;
color: $text;
}
#number-0 {

View File

@@ -1,3 +1,7 @@
Screen {
background: $surface-darken-1;
}
#tree-view {
display: none;
scrollbar-gutter: stable;
@@ -13,12 +17,12 @@ CodeBrowser.-show-tree #tree-view {
}
CodeBrowser{
background: $surface-darken-1;
background: $background;
}
DirectoryTree {
padding-right: 1;
padding-right: 1;
}
#code {

View File

@@ -5,7 +5,7 @@ from rich.traceback import Traceback
from textual.app import App, ComposeResult
from textual.layout import Container, Vertical
from textual.reactive import Reactive
from textual.reactive import var
from textual.widgets import DirectoryTree, Footer, Header, Static
@@ -13,11 +13,11 @@ class CodeBrowser(App):
"""Textual code browser app."""
BINDINGS = [
("t", "toggle_tree", "Toggle Tree"),
("f", "toggle_files", "Toggle Files"),
("q", "quit", "Quit"),
]
show_tree = Reactive.init(True)
show_tree = var(True)
def watch_show_tree(self, show_tree: bool) -> None:
"""Called when show_tree is modified."""
@@ -42,17 +42,17 @@ class CodeBrowser(App):
line_numbers=True,
word_wrap=True,
indent_guides=True,
theme="monokai",
theme="github-dark",
)
except Exception:
code_view.update(Traceback(theme="monokai", width=None))
code_view.update(Traceback(theme="github-dark", width=None))
self.sub_title = "ERROR"
else:
code_view.update(syntax)
self.query_one("#code-view").scroll_home(animate=False)
self.sub_title = event.path
def action_toggle_tree(self) -> None:
def action_toggle_files(self) -> None:
self.show_tree = not self.show_tree

172
imgs/calculator.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 38 KiB

193
imgs/codebrowser.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

199
imgs/stopwatch_dark.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

199
imgs/stopwatch_light.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 KiB

After

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

View File

@@ -15,10 +15,10 @@
App > Screen {
background: $surface;
color: $text-surface;
color: $text;
layers: base sidebar;
color: $text-background;
color: $text;
background: $background;
layout: vertical;
@@ -53,7 +53,7 @@ DataTable {
}
#sidebar {
color: $text-panel;
color: $text;
background: $panel;
dock: left;
width: 30;
@@ -70,7 +70,7 @@ DataTable {
#sidebar .title {
height: 1;
background: $primary-background-darken-1;
color: $text-primary-background-darken-1;
color: $text-muted;
border-right: wide $background;
content-align: center middle;
}
@@ -78,14 +78,14 @@ DataTable {
#sidebar .user {
height: 8;
background: $panel-darken-1;
color: $text-panel-darken-1;
color: $text-muted;
border-right: wide $background;
content-align: center middle;
}
#sidebar .content {
background: $panel-darken-2;
color: $text-surface;
color: $text;
border-right: wide $background;
content-align: center middle;
}
@@ -99,7 +99,7 @@ Tweet {
background: $panel;
color: $text-panel;
color: $text;
layout: vertical;
/* border: outer $primary; */
padding: 1;
@@ -129,13 +129,13 @@ Tweet {
TweetHeader {
height:1;
background: $accent;
color: $text-accent
color: $text
}
TweetBody {
width: 100%;
background: $panel;
color: $text-panel;
color: $text;
height: auto;
padding: 0 1 0 0;
}
@@ -146,7 +146,7 @@ Tweet.scroll-horizontal TweetBody {
.button {
background: $accent;
color: $text-accent;
color: $text;
width:20;
height: 3;
/* border-top: hidden $accent-darken-3; */
@@ -162,7 +162,7 @@ Tweet.scroll-horizontal TweetBody {
.button:hover {
background: $accent-lighten-1;
color: $text-accent-lighten-1;
color: $text-disabled;
width: 20;
height: 3;
border: tall $accent-darken-1;
@@ -174,7 +174,7 @@ Tweet.scroll-horizontal TweetBody {
}
#footer {
color: $text-accent;
color: $text;
background: $accent;
height: 1;
@@ -197,7 +197,7 @@ OptionItem {
OptionItem:hover {
height: 3;
color: $text-primary;
color: $text;
background: $primary-darken-1;
/* border-top: hkey $accent2-darken-3;
border-bottom: hkey $accent2-darken-3; */
@@ -209,7 +209,7 @@ Error {
width: 100%;
height:3;
background: $error;
color: $text-error;
color: $text;
border-top: tall $error-darken-2;
border-bottom: tall $error-darken-2;
@@ -222,7 +222,7 @@ Warning {
width: 100%;
height:3;
background: $warning;
color: $text-warning-fade-1;
color: $text-muted;
border-top: tall $warning-darken-2;
border-bottom: tall $warning-darken-2;
@@ -236,7 +236,7 @@ Success {
height:auto;
box-sizing: border-box;
background: $success;
color: $text-success-fade-1;
color: $text-muted;
border-top: hkey $success-darken-2;
border-bottom: hkey $success-darken-2;

View File

@@ -14,16 +14,11 @@
App > Screen {
background: $surface;
color: $text-surface;
layers: base sidebar;
color: $text-background;
background: $background;
color: $text;
layers: base sidebar;
layout: vertical;
overflow: hidden;
}
#tree-container {
@@ -53,8 +48,8 @@ DataTable {
}
#sidebar {
color: $text-panel;
background: $panel;
color: $text;
dock: left;
width: 30;
margin-bottom: 1;
@@ -71,7 +66,7 @@ DataTable {
#sidebar .title {
height: 1;
background: $primary-background-darken-1;
color: $text-primary-background-darken-1;
color: $text;
border-right: wide $background;
content-align: center middle;
}
@@ -79,14 +74,14 @@ DataTable {
#sidebar .user {
height: 8;
background: $panel-darken-1;
color: $text-panel-darken-1;
color: $text;
border-right: wide $background;
content-align: center middle;
}
#sidebar .content {
background: $panel-darken-2;
color: $text-surface;
color: $text;
border-right: wide $background;
content-align: center middle;
}
@@ -101,7 +96,7 @@ Tweet {
margin:0 2;
background: $panel;
color: $text-panel;
color: $text;
layout: vertical;
/* border: outer $primary; */
padding: 1;
@@ -132,13 +127,13 @@ Tweet {
TweetHeader {
height:1;
background: $accent;
color: $text-accent
color: $text;
}
TweetBody {
width: 100%;
background: $panel;
color: $text-panel;
color: $text;
height: auto;
padding: 0 1 0 0;
}
@@ -149,7 +144,7 @@ Tweet.scroll-horizontal TweetBody {
.button {
background: $accent;
color: $text-accent;
color: $text;
width:20;
height: 3;
/* border-top: hidden $accent-darken-3; */
@@ -165,7 +160,7 @@ Tweet.scroll-horizontal TweetBody {
.button:hover {
background: $accent-lighten-1;
color: $text-accent-lighten-1;
color: $text;
width: 20;
height: 3;
border: tall $accent-darken-1;
@@ -177,7 +172,7 @@ Tweet.scroll-horizontal TweetBody {
}
#footer {
color: $text-accent;
color: $text;
background: $accent;
height: 1;
@@ -200,7 +195,7 @@ OptionItem {
OptionItem:hover {
height: 3;
color: $text-primary;
color: $text;
background: $primary-darken-1;
/* border-top: hkey $accent2-darken-3;
border-bottom: hkey $accent2-darken-3; */
@@ -212,7 +207,7 @@ Error {
width: 100%;
height:3;
background: $error;
color: $text-error;
color: $text;
border-top: tall $error-darken-2;
border-bottom: tall $error-darken-2;
@@ -225,7 +220,7 @@ Warning {
width: 100%;
height:3;
background: $warning;
color: $text-warning-fade-1;
color: $text;
border-top: tall $warning-darken-2;
border-bottom: tall $warning-darken-2;
@@ -239,7 +234,7 @@ Success {
height:auto;
box-sizing: border-box;
background: $success;
color: $text-success-fade-1;
color: $text;
border-top: hkey $success-darken-2;
border-bottom: hkey $success-darken-2;

View File

@@ -24,7 +24,7 @@ Button {
padding: 0 1;
height: 100%;
background: $primary-lighten-2;
color: $text-primary-lighten-2;
color: $text;
}
#number-0 {

View File

@@ -33,7 +33,7 @@ class CenterApp(App):
Static {
background: $panel;
color: $text-panel;
color: $text;
content-align: center middle;
}

23
sandbox/will/design.css Normal file
View File

@@ -0,0 +1,23 @@
Screen {
background: $surface;
}
Container {
height: auto;
background: $boost;
}
Panel {
height: auto;
background: $boost;
margin: 1 2;
}
Content {
background: $boost;
padding: 1 2;
margin: 1 2;
color: auto 95%;
}

35
sandbox/will/design.py Normal file
View File

@@ -0,0 +1,35 @@
from textual.app import App
from textual.layout import Container
from textual.widgets import Header, Footer, Static
class Content(Static):
pass
class Panel(Container):
pass
class Panel2(Container):
pass
class DesignApp(App):
BINDINGS = [("d", "toggle_dark", "Toggle dark mode")]
def compose(self):
yield Header()
yield Footer()
yield Container(
Content("content"),
Panel(
Content("more content"),
Content("more content"),
),
)
app = DesignApp(css_path="design.css")
if __name__ == "__main__":
app.run()

21
sandbox/will/offset.css Normal file
View File

@@ -0,0 +1,21 @@
Screen {
layout: center;
}
#parent {
width: 32;
height: 8;
background: $panel;
}
#tag {
color: $text;
background: $success;
padding: 2 4;
width: auto;
offset: -8 -4;
}
#child {
background: red;
}

17
sandbox/will/offset.py Normal file
View File

@@ -0,0 +1,17 @@
from textual import layout
from textual.app import App, ComposeResult
from textual.widgets import Static
class OffsetExample(App):
def compose(self) -> ComposeResult:
yield layout.Vertical(
Static("Child", id="child"),
id="parent"
)
yield Static("Tag", id="tag")
app = OffsetExample(css_path="offset.css")
if __name__ == "__main__":
app.run()

View File

@@ -39,8 +39,8 @@ BORDER_CHARS: dict[EdgeType, tuple[str, str, str]] = {
"outer": ("▛▀▜", "▌ ▐", "▙▄▟"),
"hkey": ("▔▔▔", " ", "▁▁▁"),
"vkey": ("▏ ▕", "▏ ▕", "▏ ▕"),
"tall": ("▕▔▏", "▕ ▏", "▕▁▏"),
"wide": ("▁▁▁", "▏ ▕", "▔▔▔"),
"tall": ("▊▔▎", "▊ ▎", "▊▁▎"),
"wide": ("▁▁▁", "▎ ▋", "▔▔▔"),
}
# Some of the borders are on the widget background and some are on the background of the parent
@@ -62,8 +62,8 @@ BORDER_LOCATIONS: dict[
"outer": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"hkey": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"vkey": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"tall": ((1, 0, 1), (1, 0, 1), (1, 0, 1)),
"wide": ((1, 1, 1), (0, 1, 0), (1, 1, 1)),
"tall": ((2, 0, 1), (2, 0, 1), (2, 0, 1)),
"wide": ((1, 1, 1), (0, 1, 3), (1, 1, 1)),
}
INVISIBLE_EDGE_TYPES = cast("frozenset[EdgeType]", frozenset(("", "none", "hidden")))
@@ -81,7 +81,10 @@ Borders: TypeAlias = Tuple[EdgeStyle, EdgeStyle, EdgeStyle, EdgeStyle]
@lru_cache(maxsize=1024)
def get_box(
name: EdgeType, inner_style: Style, outer_style: Style, style: Style
name: EdgeType,
inner_style: Style,
outer_style: Style,
style: Style,
) -> BoxSegments:
"""Get segments used to render a box.
@@ -107,23 +110,30 @@ def get_box(
(lbottom1, lbottom2, lbottom3),
) = BORDER_LOCATIONS[name]
styles = (inner_style, outer_style)
inner = inner_style + style
outer = outer_style + style
styles = (
inner,
outer,
Style.from_color(outer.bgcolor, inner.color),
Style.from_color(inner.bgcolor, outer.color),
)
return (
(
_Segment(top1, styles[ltop1] + style),
_Segment(top2, styles[ltop2] + style),
_Segment(top3, styles[ltop3] + style),
_Segment(top1, styles[ltop1]),
_Segment(top2, styles[ltop2]),
_Segment(top3, styles[ltop3]),
),
(
_Segment(mid1, styles[lmid1] + style),
_Segment(mid2, styles[lmid2] + style),
_Segment(mid3, styles[lmid3] + style),
_Segment(mid1, styles[lmid1]),
_Segment(mid2, styles[lmid2]),
_Segment(mid3, styles[lmid3]),
),
(
_Segment(bottom1, styles[lbottom1] + style),
_Segment(bottom2, styles[lbottom2] + style),
_Segment(bottom3, styles[lbottom3] + style),
_Segment(bottom1, styles[lbottom1]),
_Segment(bottom2, styles[lbottom2]),
_Segment(bottom3, styles[lbottom3]),
),
)

View File

@@ -14,7 +14,7 @@ without having to render the entire screen.
from __future__ import annotations
from itertools import chain
from operator import attrgetter, itemgetter
from operator import itemgetter
import sys
from typing import Callable, cast, Iterator, Iterable, NamedTuple, TYPE_CHECKING
@@ -26,7 +26,7 @@ from rich.segment import Segment
from rich.style import Style
from . import errors
from .geometry import Region, Offset, Size, Spacing
from .geometry import Region, Offset, Size
from ._cells import cell_len
from ._profile import timer
@@ -55,7 +55,7 @@ class MapGeometry(NamedTuple):
"""Defines the absolute location of a Widget."""
region: Region # The (screen) region occupied by the widget
order: tuple[int, ...] # A tuple of ints defining the painting order
order: tuple[tuple[int, ...], ...] # A tuple of ints defining the painting order
clip: Region # A region to clip the widget by (if a Widget is within a container)
virtual_size: Size # The virtual size (scrollable region) of a widget if it is a container
container_size: Size # The container size (area not occupied by scrollbars)
@@ -344,12 +344,14 @@ class Compositor:
map: CompositorMap = {}
widgets: set[Widget] = set()
layer_order: int = 0
def add_widget(
widget: Widget,
virtual_region: Region,
region: Region,
order: tuple[int, ...],
order: tuple[tuple[int, ...], ...],
layer_order: int,
clip: Region,
) -> None:
"""Called recursively to place a widget and its children in the map.
@@ -413,15 +415,19 @@ class Compositor:
)
widget_region = sub_region + placement_scroll_offset
widget_order = order + (get_layer_index(sub_widget.layer, 0), z)
widget_order = order + (
(get_layer_index(sub_widget.layer, 0), z, layer_order),
)
add_widget(
sub_widget,
sub_region,
widget_region,
widget_order,
layer_order,
sub_clip,
)
layer_order -= 1
# Add any scrollbars
for chrome_widget, chrome_region in widget._arrange_scrollbars(
@@ -457,7 +463,7 @@ class Compositor:
)
# Add top level (root) widget
add_widget(root, size.region, size.region, (0,), size.region)
add_widget(root, size.region, size.region, ((0,),), layer_order, size.region)
return map, widgets
@property

View File

@@ -250,7 +250,7 @@ class StylesCache:
line: Iterable[Segment]
# Draw top or bottom borders (A)
if (border_top and y == 0) or (border_bottom and y == height - 1):
border_color = background + (
border_color = base_background + (
border_top_color if y == 0 else border_bottom_color
)
box_segments = get_box(
@@ -271,9 +271,9 @@ class StylesCache:
pad_bottom and y >= height - gutter.bottom
):
background_style = from_color(bgcolor=background.rich_color)
left_style = from_color(color=border_left_color.rich_color)
left_style = from_color(color=(background + border_left_color).rich_color)
left = get_box(border_left, inner, outer, left_style)[1][0]
right_style = from_color(color=border_right_color.rich_color)
right_style = from_color(color=(background + border_right_color).rich_color)
right = get_box(border_right, inner, outer, right_style)[1][2]
if border_left and border_right:
line = [left, Segment(" " * (width - 2), background_style), right]
@@ -296,9 +296,13 @@ class StylesCache:
if border_left or border_right:
# Add left / right border
left_style = from_color((background + border_left_color).rich_color)
left_style = from_color(
(base_background + border_left_color).rich_color
)
left = get_box(border_left, inner, outer, left_style)[1][0]
right_style = from_color((background + border_right_color).rich_color)
right_style = from_color(
(base_background + border_right_color).rich_color
)
right = get_box(border_right, inner, outer, right_style)[1][2]
if border_left and border_right:
@@ -327,9 +331,9 @@ class StylesCache:
elif outline_left or outline_right:
# Lines in side outline
left_style = from_color((background + outline_left_color).rich_color)
left_style = from_color((base_background + outline_left_color).rich_color)
left = get_box(outline_left, inner, outer, left_style)[1][0]
right_style = from_color((background + outline_right_color).rich_color)
right_style = from_color((base_background + outline_right_color).rich_color)
right = get_box(outline_right, inner, outer, right_style)[1][2]
line = line_trim(list(line), outline_left != "", outline_right != "")
if outline_left and outline_right:

View File

@@ -28,7 +28,7 @@ import rich.repr
from rich.console import Console, RenderableType
from rich.measure import Measurement
from rich.protocol import is_renderable
from rich.segment import Segments
from rich.segment import Segment, Segments
from rich.traceback import Traceback
from . import (
@@ -146,7 +146,7 @@ class App(Generic[ReturnType], DOMNode):
DEFAULT_CSS = """
App {
background: $background;
color: $text-background;
color: $text;
}
"""
@@ -541,9 +541,14 @@ class App(Generic[ReturnType], DOMNode):
except Exception as error:
self._handle_exception(error)
def action_screenshot(self, path: str | None = None) -> None:
def action_toggle_dark(self) -> None:
"""Action to toggle dark mode."""
self.dark = not self.dark
def action_screenshot(self, filename: str | None, path: str = "~/") -> None:
"""Action to save a screenshot."""
self.save_screenshot(path)
self.bell()
self.save_screenshot(filename, path)
def export_screenshot(self, *, title: str | None = None) -> str:
"""Export a SVG screenshot of the current screen.
@@ -566,22 +571,31 @@ class App(Generic[ReturnType], DOMNode):
console.print(screen_render)
return console.export_svg(title=title or self.title)
def save_screenshot(self, path: str | None = None) -> str:
"""Save a screenshot of the current screen.
def save_screenshot(
self,
filename: str | None = None,
path: str = "./",
time_format: str = "%Y-%m-%d %X %f",
) -> str:
"""Save a SVG screenshot of the current screen.
Args:
path (str | None, optional): Path to SVG to save or None to pick
a filename automatically. Defaults to None.
filename (str | None, optional): Filename of SVG screenshot, or None to auto-generate
a filename with the date and time. Defaults to None.
path (str, optional): Path to directory for output. Defaults to current working directory.
time_format(str, optional): Time format to use if filename is None. Defaults to "%Y-%m-%d %X %f".
Returns:
str: Filename of screenshot.
"""
self.bell()
if path is None:
svg_path = f"{self.title.lower()}_{datetime.now().isoformat()}.svg"
svg_path = svg_path.replace("/", "_").replace("\\", "_")
if filename is None:
svg_filename = (
f"{self.title.lower()} {datetime.now().strftime(time_format)}.svg"
)
svg_filename = svg_filename.replace("/", "_").replace("\\", "_")
else:
svg_path = path
svg_filename = filename
svg_path = os.path.expanduser(os.path.join(path, svg_filename))
screenshot_svg = self.export_screenshot()
with open(svg_path, "w") as svg_file:
svg_file.write(screenshot_svg)
@@ -1002,11 +1016,12 @@ class App(Generic[ReturnType], DOMNode):
is_renderable(renderable) for renderable in renderables
), "Can only call panic with strings or Rich renderables"
pre_rendered = [
Segments(self.console.render(renderable, self.console.options))
for renderable in renderables
]
def render(renderable: RenderableType) -> list[Segment]:
"""Render a panic renderables."""
segments = list(self.console.render(renderable, self.console.options))
return segments
pre_rendered = [Segments(render(renderable)) for renderable in renderables]
self._exit_renderables.extend(pre_rendered)
self._close_messages_no_wait()

View File

@@ -38,10 +38,10 @@ class BorderApp(App):
Static {
margin: 2 4;
padding: 2 4;
border: solid $primary;
border: solid $secondary;
height: auto;
background: $panel;
color: $text-panel;
color: $text;
}
"""
@@ -53,7 +53,7 @@ class BorderApp(App):
def on_button_pressed(self, event: Button.Pressed) -> None:
self.text.styles.border = (
event.button.id,
self.stylesheet.variables["primary"],
self.stylesheet._variables["secondary"],
)
self.bell()

View File

@@ -13,12 +13,20 @@ EasingButtons {
#duration-input {
width: 30;
background: $boost;
padding: 0 1;
border: tall transparent;
}
#duration-input:focus {
border: tall $accent;
}
#inputs {
padding: 1;
height: auto;
dock: top;
background: $boost;
}
Bar {
@@ -36,6 +44,11 @@ Bar {
#opacity-widget {
padding: 1;
background: $warning;
color: $text-warning;
color: $text;
border: wide $background;
}
#label {
width: auto;
padding: 1;
}

View File

@@ -79,7 +79,9 @@ class EasingApp(App):
yield EasingButtons()
yield layout.Vertical(
layout.Vertical(Static("Animation Duration:"), duration_input, id="inputs"),
layout.Horizontal(
Static("Animation Duration:", id="label"), duration_input, id="inputs"
),
layout.Horizontal(
self.animated_bar,
layout.Container(self.opacity_widget, id="other"),

View File

@@ -283,6 +283,17 @@ class Color(NamedTuple):
else f"#{r:02X}{g:02X}{b:02X}{int(a*255):02X}"
)
@property
def hex6(self) -> str:
"""The color in CSS hex form, with 6 digits for RGB. Alpha is ignored.
Returns:
str: A CSS hex-style color, e.g. "#46b3de"
"""
r, g, b, a = self.clamped
return f"#{r:02X}{g:02X}{b:02X}"
@property
def css(self) -> str:
"""The color in CSS rgb or rgba form.
@@ -313,12 +324,13 @@ class Color(NamedTuple):
r, g, b, _ = self
return Color(r, g, b, alpha)
def blend(self, destination: Color, factor: float) -> Color:
def blend(self, destination: Color, factor: float, alpha: float = 1) -> Color:
"""Generate a new color between two colors.
Args:
destination (Color): Another color.
factor (float): A blend factor, 0 -> 1
factor (float): A blend factor, 0 -> 1.
alpha (float | None): New alpha for result. Defaults to 1.
Returns:
Color: A new color.
@@ -333,6 +345,7 @@ class Color(NamedTuple):
int(r1 + (r2 - r1) * factor),
int(g1 + (g2 - g1) * factor),
int(b1 + (b2 - b1) * factor),
alpha,
)
def __add__(self, other: object) -> Color:
@@ -452,29 +465,31 @@ class Color(NamedTuple):
return color
@lru_cache(maxsize=1024)
def darken(self, amount: float) -> Color:
def darken(self, amount: float, alpha: float | None = None) -> Color:
"""Darken the color by a given amount.
Args:
amount (float): Value between 0-1 to reduce luminance by.
alpha (float | None, optional): Alpha component for new color or None to copy alpha. Defaults to None.
Returns:
Color: New color.
"""
l, a, b = rgb_to_lab(self)
l -= amount * 100
return lab_to_rgb(Lab(l, a, b)).clamped
return lab_to_rgb(Lab(l, a, b), self.a if alpha is None else alpha).clamped
def lighten(self, amount: float) -> Color:
def lighten(self, amount: float, alpha: float | None = None) -> Color:
"""Lighten the color by a given amount.
Args:
amount (float): Value between 0-1 to increase luminance by.
alpha (float | None, optional): Alpha component for new color or None to copy alpha. Defaults to None.
Returns:
Color: New color.
"""
return self.darken(-amount)
return self.darken(-amount, alpha)
@lru_cache(maxsize=1024)
def get_contrast_text(self, alpha=0.95) -> Color:
@@ -527,7 +542,7 @@ def rgb_to_lab(rgb: Color) -> Lab:
return Lab(116 * y - 16, 500 * (x - y), 200 * (y - z))
def lab_to_rgb(lab: Lab) -> Color:
def lab_to_rgb(lab: Lab, alpha: float = 1.0) -> Color:
"""Convert a CIE-L*ab color to RGB.
Uses the standard RGB color space with a D65/2⁰ standard illuminant.
@@ -552,4 +567,4 @@ def lab_to_rgb(lab: Lab) -> Color:
g = 1.055 * pow(g, 1 / 2.4) - 0.055 if g > 0.0031308 else 12.92 * g
b = 1.055 * pow(b, 1 / 2.4) - 0.055 if b > 0.0031308 else 12.92 * b
return Color(int(r * 255), int(g * 255), int(b * 255))
return Color(int(r * 255), int(g * 255), int(b * 255), alpha)

View File

@@ -2,8 +2,8 @@ from __future__ import annotations
from typing import Iterable
import rich.repr
from rich.console import Console, ConsoleOptions, RenderResult
from rich.highlighter import ReprHighlighter
from rich.markup import render
from rich.text import Text
@@ -42,6 +42,7 @@ class Example:
yield _markup_and_highlight(f" [dim]e.g. [/][i]{self.markup}[/]")
@rich.repr.auto
class Bullet:
"""Renderable for a single 'bullet point' containing information and optionally some examples
pertaining to that information.
@@ -59,10 +60,11 @@ class Bullet:
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
yield _markup_and_highlight(f"{self.markup}")
yield _markup_and_highlight(self.markup)
yield from self.examples
@rich.repr.auto
class HelpText:
"""Renderable for help text - the user is shown this when they
encounter a style-related error (e.g. setting a style property to an invalid

View File

@@ -102,6 +102,13 @@ class IntegerProperty(GenericProperty[int, int]):
raise StyleValueError(f"Expected a number here, got f{value}")
class BooleanProperty(GenericProperty[bool, bool]):
"""A property that requires a True or False value."""
def validate_value(self, value: object) -> bool:
return bool(value)
class ScalarProperty:
"""Descriptor for getting and setting scalar properties. Scalars are numeric values with a unit, e.g. "50vh"."""

View File

@@ -1,10 +1,16 @@
from __future__ import annotations
from functools import lru_cache
from typing import cast, Iterable, NoReturn, Sequence
from typing import Iterable, NoReturn, Sequence, cast
import rich.repr
from .._border import BorderValue, normalize_border_value
from .._duration import _duration_as_seconds
from .._easing import EASING
from ..color import Color, ColorParseError
from ..geometry import Spacing, SpacingDimensions, clamp
from ..suggestions import get_suggestion
from ._error_tools import friendly_list
from ._help_renderables import HelpText
from ._help_text import (
@@ -33,34 +39,28 @@ from .constants import (
VALID_ALIGN_VERTICAL,
VALID_BORDER,
VALID_BOX_SIZING,
VALID_EDGE,
VALID_DISPLAY,
VALID_EDGE,
VALID_OVERFLOW,
VALID_VISIBILITY,
VALID_STYLE_FLAGS,
VALID_SCROLLBAR_GUTTER,
VALID_STYLE_FLAGS,
VALID_TEXT_ALIGN,
VALID_VISIBILITY,
)
from .errors import DeclarationError, StyleValueError
from .model import Declaration
from .scalar import (
Scalar,
ScalarOffset,
Unit,
ScalarError,
ScalarOffset,
ScalarParseError,
Unit,
percentage_string_to_float,
)
from .styles import DockGroup, Styles
from .styles import Styles
from .tokenize import Token
from .transition import Transition
from .types import BoxSizing, Edge, Display, Overflow, Visibility, EdgeType
from .._border import normalize_border_value, BorderValue
from ..color import Color, ColorParseError
from .._duration import _duration_as_seconds
from .._easing import EASING
from ..geometry import Spacing, SpacingDimensions, clamp
from ..suggestions import get_suggestion
from .types import BoxSizing, Display, Edge, EdgeType, Overflow, Visibility
def _join_tokens(tokens: Iterable[Token], joiner: str = "") -> str:
@@ -434,6 +434,7 @@ class StylesBuilder:
process_padding_left = _process_space_partial
def _parse_border(self, name: str, tokens: list[Token]) -> BorderValue:
border_type: EdgeType = "solid"
border_color = Color(0, 255, 0)
@@ -553,7 +554,7 @@ class StylesBuilder:
self.styles._rules["offset"] = ScalarOffset(x, y)
def process_layout(self, name: str, tokens: list[Token]) -> None:
from ..layouts.factory import get_layout, MissingLayout
from ..layouts.factory import MissingLayout, get_layout
if tokens:
if len(tokens) != 1:
@@ -580,7 +581,9 @@ class StylesBuilder:
alpha: float | None = None
for token in tokens:
if token.name == "scalar":
if name == "color" and token.name == "token" and token.value == "auto":
self.styles._rules["auto_color"] = True
elif token.name == "scalar":
alpha_scalar = Scalar.parse(token.value)
if alpha_scalar.unit != Unit.PERCENT:
self.error(name, token, "alpha must be given as a percentage.")
@@ -598,9 +601,9 @@ class StylesBuilder:
else:
self.error(name, token, color_property_help_text(name, context="css"))
if color is not None:
if color is not None or alpha is not None:
if alpha is not None:
color = color.with_alpha(alpha)
color = (color or Color(255, 255, 255)).with_alpha(alpha)
self.styles._rules[name] = color
process_tint = process_color

View File

@@ -301,9 +301,7 @@ def substitute_references(
for _token in reference_tokens:
yield _token.with_reference(
ReferencedBy(
name=ref_name,
location=ref_location,
length=ref_length,
ref_name, ref_location, ref_length, token.code
)
)
else:
@@ -318,13 +316,10 @@ def substitute_references(
variable_tokens = variables[variable_name]
ref_location = token.location
ref_length = len(token.value)
for token in variable_tokens:
yield token.with_reference(
ReferencedBy(
name=variable_name,
location=ref_location,
length=ref_length,
)
ref_code = token.code
for _token in variable_tokens:
yield _token.with_reference(
ReferencedBy(variable_name, ref_location, ref_length, ref_code)
)
else:
_unresolved(variable_name, variables.keys(), token)
@@ -336,6 +331,7 @@ def parse(
css: str,
path: str | PurePath,
variables: dict[str, str] | None = None,
variable_tokens: dict[str, list[Token]] | None = None,
is_default_rules: bool = False,
tie_breaker: int = 0,
) -> Iterable[RuleSet]:
@@ -349,7 +345,11 @@ def parse(
is_default_rules (bool): True if the rules we're extracting are
default (i.e. in Widget.DEFAULT_CSS) rules. False if they're from user defined CSS.
"""
variable_tokens = tokenize_values(variables or {})
reference_tokens = tokenize_values(variables) if variables is not None else {}
if variable_tokens:
reference_tokens.update(variable_tokens)
tokens = iter(substitute_references(tokenize(css, path), variable_tokens))
while True:
token = next(tokens, None)

View File

@@ -16,6 +16,7 @@ from ..color import Color
from ..geometry import Offset, Spacing
from ._style_properties import (
AlignProperty,
BooleanProperty,
BorderProperty,
BoxProperty,
ColorProperty,
@@ -83,6 +84,7 @@ class RulesMap(TypedDict, total=False):
visibility: Visibility
layout: "Layout"
auto_color: bool
color: Color
background: Color
text_style: Style
@@ -183,6 +185,7 @@ class StylesBase(ABC):
"min_height",
"max_width",
"max_height",
"auto_color",
"color",
"background",
"opacity",
@@ -202,6 +205,7 @@ class StylesBase(ABC):
visibility = StringEnumProperty(VALID_VISIBILITY, "visible")
layout = LayoutProperty()
auto_color = BooleanProperty(default=False)
color = ColorProperty(Color(255, 255, 255))
background = ColorProperty(Color(0, 0, 0, 0), background=True)
text_style = StyleFlagsProperty()

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
import os
from collections import defaultdict
from functools import partial
from operator import itemgetter
from pathlib import Path, PurePath
from typing import Iterable, NamedTuple, cast
@@ -39,14 +38,9 @@ class StylesheetParseError(StylesheetError):
class StylesheetErrors:
def __init__(
self, rules: list[RuleSet], variables: dict[str, str] | None = None
) -> None:
def __init__(self, rules: list[RuleSet]) -> None:
self.rules = rules
self.variables: dict[str, str] = {}
self._css_variables: dict[str, list[Token]] = {}
if variables:
self.set_variables(variables)
@classmethod
def _get_snippet(cls, code: str, line_no: int) -> RenderableType:
@@ -61,11 +55,6 @@ class StylesheetErrors:
)
return syntax
def set_variables(self, variable_map: dict[str, str]) -> None:
"""Pre-populate CSS variables."""
self.variables.update(variable_map)
self._css_variables = tokenize_values(self.variables)
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
@@ -105,7 +94,10 @@ class StylesheetErrors:
title = Text.assemble(Text("Error at ", style="bold red"), path_text)
yield ""
yield Panel(
self._get_snippet(token.code, line_no),
self._get_snippet(
token.referenced_by.code if token.referenced_by else token.code,
line_no,
),
title=title,
title_align="left",
border_style="red",
@@ -138,13 +130,20 @@ class Stylesheet:
def __init__(self, *, variables: dict[str, str] | None = None) -> None:
self._rules: list[RuleSet] = []
self._rules_map: dict[str, list[RuleSet]] | None = None
self.variables = variables or {}
self._variables = variables or {}
self.__variable_tokens: dict[str, list[Token]] | None = None
self.source: dict[str, CssSource] = {}
self._require_parse = False
def __rich_repr__(self) -> rich.repr.Result:
yield list(self.source.keys())
@property
def _variable_tokens(self) -> dict[str, list[Token]]:
if self.__variable_tokens is None:
self.__variable_tokens = tokenize_values(self._variables)
return self.__variable_tokens
@property
def rules(self) -> list[RuleSet]:
"""List of rule sets.
@@ -183,7 +182,7 @@ class Stylesheet:
Returns:
Stylesheet: New stylesheet.
"""
stylesheet = Stylesheet(variables=self.variables.copy())
stylesheet = Stylesheet(variables=self._variables.copy())
stylesheet.source = self.source.copy()
return stylesheet
@@ -193,7 +192,8 @@ class Stylesheet:
Args:
variables (dict[str, str]): A mapping of name to variable.
"""
self.variables = variables
self._variables = variables
self._variables_tokens = None
def _parse_rules(
self,
@@ -222,7 +222,7 @@ class Stylesheet:
parse(
css,
path,
variables=self.variables,
variable_tokens=self._variable_tokens,
is_default_rules=is_default_rules,
tie_breaker=tie_breaker,
)
@@ -317,7 +317,7 @@ class Stylesheet:
"""
# Do this in a fresh Stylesheet so if there are errors we don't break self.
stylesheet = Stylesheet(variables=self.variables)
stylesheet = Stylesheet(variables=self._variables)
for path, (css, is_defaults, tie_breaker) in self.source.items():
stylesheet.add_source(
css, path, is_default_css=is_defaults, tie_breaker=tie_breaker

View File

@@ -118,6 +118,7 @@ class ReferencedBy(NamedTuple):
name: str
location: tuple[int, int]
length: int
code: str
@rich.repr.auto
@@ -209,6 +210,7 @@ class Tokenizer:
message,
)
iter_groups = iter(match.groups())
next(iter_groups)
for name, value in zip(expect.names, iter_groups):

View File

@@ -15,7 +15,7 @@ NUMBER_OF_SHADES = 3
# Where no content exists
DEFAULT_DARK_BACKGROUND = "#121212"
# What text usually goes on top off
DEFAULT_DARK_SURFACE = "#292929"
DEFAULT_DARK_SURFACE = "#121212"
DEFAULT_LIGHT_SURFACE = "#f5f5f5"
DEFAULT_LIGHT_BACKGROUND = "#efefef"
@@ -38,6 +38,7 @@ class ColorSystem:
"secondary-background",
"surface",
"panel",
"boost",
"warning",
"error",
"success",
@@ -55,6 +56,7 @@ class ColorSystem:
background: str | None = None,
surface: str | None = None,
panel: str | None = None,
boost: str | None = None,
dark: bool = False,
luminosity_spread: float = 0.15,
text_alpha: float = 0.95,
@@ -73,6 +75,7 @@ class ColorSystem:
self.background = parse(background)
self.surface = parse(surface)
self.panel = parse(panel)
self.boost = parse(boost)
self._dark = dark
self._luminosity_spread = luminosity_spread
self._text_alpha = text_alpha
@@ -121,8 +124,12 @@ class ColorSystem:
background = self.background or Color.parse(DEFAULT_LIGHT_BACKGROUND)
surface = self.surface or Color.parse(DEFAULT_LIGHT_SURFACE)
boost = self.boost or background.get_contrast_text(1.0).with_alpha(0.07)
if self.panel is None:
panel = surface.blend(primary, luminosity_spread)
if dark:
panel += boost
else:
panel = self.panel
@@ -153,6 +160,7 @@ class ColorSystem:
("secondary-background", secondary),
("background", background),
("panel", panel),
("boost", boost),
("surface", surface),
("warning", warning),
("error", error),
@@ -178,14 +186,10 @@ class ColorSystem:
else:
shade_color = color.lighten(luminosity_delta)
colors[f"{name}{shade_name}"] = shade_color.hex
for fade in range(3):
text_color = shade_color.get_contrast_text(text_alpha)
if fade > 0:
text_color = text_color.blend(shade_color, fade * 0.1 + 0.15)
on_name = f"text-{name}{shade_name}-fade-{fade}"
else:
on_name = f"text-{name}{shade_name}"
colors[on_name] = text_color.hex
colors["text"] = "auto 95%"
colors["text-muted"] = "auto 80%"
colors["text-disabled"] = "auto 60%"
return colors
@@ -206,16 +210,12 @@ def show_design(light: ColorSystem, dark: ColorSystem) -> Table:
def make_shades(system: ColorSystem):
colors = system.generate()
for name in system.shades:
background = colors[name]
foreground = colors[f"text-{name}"]
text = Text(f"{background} ", style=f"{foreground} on {background}")
for fade in range(3):
foreground = colors[
f"text-{name}-fade-{fade}" if fade else f"text-{name}"
]
text.append(f"{name} ", style=f"{foreground} on {background}")
background = Color.parse(colors[name]).with_alpha(1.0)
foreground = background + background.get_contrast_text(0.9)
yield Padding(text, 1, style=f"{foreground} on {background}")
text = Text(name)
yield Padding(text, 1, style=f"{foreground.hex6} on {background.hex6}")
table = Table(box=None, expand=True)
table.add_column("Light", justify="center")

View File

@@ -497,6 +497,8 @@ class DOMNode(MessagePump):
if styles.has_rule("color"):
color = styles.color
style += styles.text_style
if styles.has_rule("auto_color") and styles.auto_color:
color = background.get_contrast_text(color.a)
style += Style.from_color(
(background + color).rich_color, background.rich_color
)
@@ -534,7 +536,11 @@ class DOMNode(MessagePump):
background += styles.background
if styles.has_rule("color"):
base_color = color
if styles.auto_color:
color = background.get_contrast_text(color.a)
else:
color = styles.color
return (base_background, base_color, background, color)
@property

View File

@@ -33,6 +33,7 @@ class Screen(Widget):
Screen {
layout: vertical;
overflow-y: auto;
background: $surface;
}
"""

View File

@@ -1070,7 +1070,9 @@ class Widget(DOMNode):
while isinstance(widget.parent, Widget) and widget is not self:
container = widget.parent
scroll_offset = container.scroll_to_region(region, animate=animate)
scroll_offset = container.scroll_to_region(
region, spacing=widget.parent.gutter, animate=animate
)
if scroll_offset:
scrolled = True
@@ -1108,6 +1110,10 @@ class Widget(DOMNode):
window = self.content_region.at_offset(self.scroll_offset)
if spacing is not None:
window = window.shrink(spacing)
if window in region:
return Offset()
delta_x, delta_y = Region.get_scroll_to_visible(window, region)
scroll_x, scroll_y = self.scroll_offset
delta = Offset(

View File

@@ -33,10 +33,9 @@ class Button(Widget, can_focus=True):
Button {
width: auto;
min-width: 16;
width: auto;
height: 3;
background: $panel;
color: $text-panel;
color: $text;
border: none;
border-top: tall $panel-lighten-2;
border-bottom: tall $panel-darken-3;
@@ -56,7 +55,7 @@ class Button(Widget, can_focus=True):
Button:hover {
border-top: tall $panel-lighten-1;
background: $panel-darken-2;
color: $text-panel-darken-2;
color: $text;
}
Button.-active {
@@ -69,7 +68,7 @@ class Button(Widget, can_focus=True):
/* Primary variant */
Button.-primary {
background: $primary;
color: $text-primary;
color: $text;
border-top: tall $primary-lighten-3;
border-bottom: tall $primary-darken-3;
@@ -77,8 +76,8 @@ class Button(Widget, can_focus=True):
Button.-primary:hover {
background: $primary-darken-2;
color: $text-primary-darken-2;
color: $text;
border-top: tall $primary-lighten-2;
}
Button.-primary.-active {
@@ -91,14 +90,14 @@ class Button(Widget, can_focus=True):
/* Success variant */
Button.-success {
background: $success;
color: $text-success;
color: $text;
border-top: tall $success-lighten-2;
border-bottom: tall $success-darken-3;
}
Button.-success:hover {
background: $success-darken-2;
color: $text-success-darken-2;
color: $text;
}
Button.-success.-active {
@@ -111,14 +110,14 @@ class Button(Widget, can_focus=True):
/* Warning variant */
Button.-warning {
background: $warning;
color: $text-warning;
color: $text;
border-top: tall $warning-lighten-2;
border-bottom: tall $warning-darken-3;
}
Button.-warning:hover {
background: $warning-darken-2;
color: $text-warning-darken-1;
color: $text;
}
@@ -132,7 +131,7 @@ class Button(Widget, can_focus=True):
/* Error variant */
Button.-error {
background: $error;
color: $text-error;
color: $text;
border-top: tall $error-lighten-2;
border-bottom: tall $error-darken-3;
@@ -140,7 +139,7 @@ class Button(Widget, can_focus=True):
Button.-error:hover {
background: $error-darken-1;
color: $text-error-darken-2;
color: $text;
}

View File

@@ -109,17 +109,17 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
DEFAULT_CSS = """
DataTable {
background: $surface;
color: $text-surface;
color: $text;
}
DataTable > .datatable--header {
text-style: bold;
background: $primary;
color: $text-primary;
color: $text;
}
DataTable > .datatable--fixed {
text-style: bold;
background: $primary;
color: $text-primary;
color: $text;
}
DataTable > .datatable--odd-row {
@@ -132,7 +132,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
DataTable > .datatable--cursor {
background: $secondary;
color: $text-secondary;
color: $text;
}
.-dark-mode DataTable > .datatable--even-row {
@@ -557,7 +557,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
def _scroll_cursor_in_to_view(self, animate: bool = False) -> None:
region = self._get_cell_region(self.cursor_row, self.cursor_column)
spacing = self._get_cell_border()
spacing = self._get_cell_border() + self.scrollbar_gutter
self.scroll_to_region(region, animate=animate, spacing=spacing)
def on_click(self, event: events.Click) -> None:

View File

@@ -74,7 +74,6 @@ class DirectoryTree(TreeControl[DirEntry]):
label.stylize("bold")
icon = "📂" if expanded else "📁"
else:
icon = "📄"
label.highlight_regex(r"\..*$", "italic")

View File

@@ -16,25 +16,22 @@ class Footer(Widget):
DEFAULT_CSS = """
Footer {
background: $accent;
color: $text-accent;
color: $text;
dock: bottom;
height: 1;
}
Footer > .footer--highlight {
background: $accent-darken-1;
color: $text-accent-darken-1;
}
Footer > .footer--highlight-key {
background: $secondary;
color: $text-secondary;
text-style: bold;
}
Footer > .footer--key {
text-style: bold;
background: $accent-darken-2;
color: $text-accent-darken-2;
}
"""

View File

@@ -34,7 +34,7 @@ class HeaderClock(Widget):
width: 10;
padding: 0 1;
background: $secondary-background-lighten-1;
color: $text-secondary-background;
color: $text;
text-opacity: 85%;
content-align: center middle;
}
@@ -76,7 +76,7 @@ class Header(Widget):
dock: top;
width: 100%;
background: $secondary-background;
color: $text-secondary-background;
color: $text;
height: 1;
}
Header.tall {

View File

@@ -165,7 +165,7 @@ class TreeControl(Generic[NodeDataType], Static, can_focus=True):
DEFAULT_CSS = """
TreeControl {
background: $surface;
color: $text-surface;
color: $text;
height: auto;
width: 100%;
}
@@ -177,7 +177,6 @@ class TreeControl(Generic[NodeDataType], Static, can_focus=True):
TreeControl > .tree--guides-highlight {
color: $success;
text-style: uu;
}
TreeControl > .tree--guides-cursor {
@@ -186,12 +185,12 @@ class TreeControl(Generic[NodeDataType], Static, can_focus=True):
}
TreeControl > .tree--labels {
color: $text-panel;
color: $text;
}
TreeControl > .tree--cursor {
background: $secondary;
color: $text-secondary;
color: $text;
}
"""

View File

@@ -37,7 +37,7 @@ class Welcome(Static):
Welcome Container {
padding: 1;
background: $panel;
color: $text-panel;
color: $text;
}
Welcome #text {

View File

@@ -97,7 +97,9 @@ class TestVariableReferenceSubstitution:
path="",
code=css,
location=(0, 4),
referenced_by=ReferencedBy(name="x", location=(0, 28), length=2),
referenced_by=ReferencedBy(
name="x", location=(0, 28), length=2, code=css
),
),
Token(
name="declaration_end",
@@ -191,7 +193,9 @@ class TestVariableReferenceSubstitution:
path="",
code=css,
location=(0, 3),
referenced_by=ReferencedBy(name="x", location=(0, 27), length=2),
referenced_by=ReferencedBy(
name="x", location=(0, 27), length=2, code=css
),
),
Token(
name="declaration_end",
@@ -273,7 +277,9 @@ class TestVariableReferenceSubstitution:
path="",
code=css,
location=(0, 4),
referenced_by=ReferencedBy(name="x", location=(1, 4), length=2),
referenced_by=ReferencedBy(
name="x", location=(1, 4), length=2, code=css
),
),
Token(
name="variable_value_end",
@@ -337,7 +343,9 @@ class TestVariableReferenceSubstitution:
path="",
code=css,
location=(0, 4),
referenced_by=ReferencedBy(name="y", location=(2, 17), length=2),
referenced_by=ReferencedBy(
name="y", location=(2, 17), length=2, code=css
),
),
Token(
name="whitespace",
@@ -446,7 +454,9 @@ class TestVariableReferenceSubstitution:
path="",
code=css,
location=(0, 4),
referenced_by=ReferencedBy(name="x", location=(1, 6), length=2),
referenced_by=ReferencedBy(
name="x", location=(1, 6), length=2, code=css
),
),
Token(
name="whitespace",
@@ -454,7 +464,9 @@ class TestVariableReferenceSubstitution:
path="",
code=css,
location=(0, 5),
referenced_by=ReferencedBy(name="x", location=(1, 6), length=2),
referenced_by=ReferencedBy(
name="x", location=(1, 6), length=2, code=css
),
),
Token(
name="number",
@@ -462,7 +474,9 @@ class TestVariableReferenceSubstitution:
path="",
code=css,
location=(0, 6),
referenced_by=ReferencedBy(name="x", location=(1, 6), length=2),
referenced_by=ReferencedBy(
name="x", location=(1, 6), length=2, code=css
),
),
Token(
name="whitespace",
@@ -542,7 +556,9 @@ class TestVariableReferenceSubstitution:
path="",
code=css,
location=(1, 4),
referenced_by=ReferencedBy(name="y", location=(2, 17), length=2),
referenced_by=ReferencedBy(
name="y", location=(2, 17), length=2, code=css
),
),
Token(
name="whitespace",
@@ -550,7 +566,9 @@ class TestVariableReferenceSubstitution:
path="",
code=css,
location=(1, 5),
referenced_by=ReferencedBy(name="y", location=(2, 17), length=2),
referenced_by=ReferencedBy(
name="y", location=(2, 17), length=2, code=css
),
),
Token(
name="number",
@@ -558,7 +576,9 @@ class TestVariableReferenceSubstitution:
path="",
code=css,
location=(0, 4),
referenced_by=ReferencedBy(name="y", location=(2, 17), length=2),
referenced_by=ReferencedBy(
name="y", location=(2, 17), length=2, code=css
),
),
Token(
name="whitespace",
@@ -566,7 +586,9 @@ class TestVariableReferenceSubstitution:
path="",
code=css,
location=(0, 5),
referenced_by=ReferencedBy(name="y", location=(2, 17), length=2),
referenced_by=ReferencedBy(
name="y", location=(2, 17), length=2, code=css
),
),
Token(
name="number",
@@ -574,7 +596,9 @@ class TestVariableReferenceSubstitution:
path="",
code=css,
location=(0, 6),
referenced_by=ReferencedBy(name="y", location=(2, 17), length=2),
referenced_by=ReferencedBy(
name="y", location=(2, 17), length=2, code=css
),
),
Token(
name="whitespace",
@@ -582,7 +606,9 @@ class TestVariableReferenceSubstitution:
path="",
code=css,
location=(1, 8),
referenced_by=ReferencedBy(name="y", location=(2, 17), length=2),
referenced_by=ReferencedBy(
name="y", location=(2, 17), length=2, code=css
),
),
Token(
name="number",
@@ -590,7 +616,9 @@ class TestVariableReferenceSubstitution:
path="",
code=css,
location=(1, 9),
referenced_by=ReferencedBy(name="y", location=(2, 17), length=2),
referenced_by=ReferencedBy(
name="y", location=(2, 17), length=2, code=css
),
),
Token(
name="whitespace",
@@ -715,7 +743,9 @@ class TestVariableReferenceSubstitution:
path="",
code=css,
location=(0, 4),
referenced_by=ReferencedBy(name="x", location=(1, 20), length=2),
referenced_by=ReferencedBy(
name="x", location=(1, 20), length=2, code=css
),
),
Token(
name="declaration_end",
@@ -845,7 +875,9 @@ class TestVariableReferenceSubstitution:
path="",
code=css,
location=(0, 7),
referenced_by=ReferencedBy(name="x", location=(0, 26), length=2),
referenced_by=ReferencedBy(
name="x", location=(0, 26), length=2, code=css
),
),
Token(
name="declaration_set_end",
@@ -1134,7 +1166,9 @@ class TestParsePadding:
class TestParseTextAlign:
@pytest.mark.parametrize("valid_align", ["left", "start", "center", "right", "end", "justify"])
@pytest.mark.parametrize(
"valid_align", ["left", "start", "center", "right", "end", "justify"]
)
def test_text_align(self, valid_align):
css = f"#foo {{ text-align: {valid_align} }}"
stylesheet = Stylesheet()

View File

@@ -189,8 +189,6 @@ async def test_composition_of_vertical_container_with_children(
"outer",
"hkey",
"vkey",
"tall",
"wide",
]
],
),