diff --git a/README.md b/README.md
index a8dd8f1be..a1144eea6 100644
--- a/README.md
+++ b/README.md
@@ -1,374 +1,112 @@
# Textual
-
+
-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.
+
+
+ ๐ฌ Code browser
+
+
+ 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
+
+
-> โ **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.
-
-[](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](); 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.
+
+
+ ๐ฌ Easing reference
+
+
+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
+
+
+
+
+ ๐ฌ Borders reference
+
+
+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
+
+
+
+
## 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.
-
-You can see some of these examples in action in the [Developer Video Log](#developer-video-log).
-
-- [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
-
-## Building Textual applications
-
-_This guide is a work in progress_
-
-Let's look at the simplest Textual app which does _something_:
-
-```python
-from textual.app import App
-
-
-class Beeper(App):
- def on_key(self):
- self.console.bell()
-
-
-Beeper.run()
-```
-
-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.
-
-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_` if it exists.
-
-Let's look at a _slightly_ more interesting example:
-
-```python
-from textual.app import App
-
-
-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:
-
-
-
-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:
-
-
-
-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
-
-[](http://www.youtube.com/watch?v=zNW7U36GHlU)
-
-### Update 2 - Keyboard toggle
-
-[](http://www.youtube.com/watch?v=bTYeFOVNXDI)
-
-### Update 3 - New scrollbars and smooth scrolling
-
-[](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.
-
-[](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!
-
-[](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.
-
-[](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.
-
-[](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
-
-[](http://www.youtube.com/watch?v=J-dzzD6NQJ4)
+The Textual repository comes with a number of examples you can experiment with or use as a template for your own projects.
+
+
+ ๐ท Calculator
+
+
+This is [calculator.py](./examples/calculator.py) which demonstrates Textual grid layouts.
+
+
+
+
+
+ ๐ท Code browser
+
+
+ This is [code_browser.py](./examples/code_browser.py) which demonstrates the directory tree widget.
+
+
+
+
+
+
+
+ ๐ท Stopwatch
+
+
+ This is the Stopwatch example from the tutorial.
+
+### Light theme
+
+
+
+### Dark theme
+
+
+
+
diff --git a/docs/examples/basic.css b/docs/examples/basic.css
index 17e551f60..825d4e9b6 100644
--- a/docs/examples/basic.css
+++ b/docs/examples/basic.css
@@ -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;
diff --git a/docs/examples/tutorial/stopwatch.css b/docs/examples/tutorial/stopwatch.css
index 716d7957b..f96bb38cc 100644
--- a/docs/examples/tutorial/stopwatch.css
+++ b/docs/examples/tutorial/stopwatch.css
@@ -33,7 +33,7 @@ Button {
.started {
text-style: bold;
background: $success;
- color: $text-success;
+ color: $text;
}
.started TimeDisplay {
diff --git a/docs/examples/tutorial/stopwatch04.css b/docs/examples/tutorial/stopwatch04.css
index 716d7957b..f96bb38cc 100644
--- a/docs/examples/tutorial/stopwatch04.css
+++ b/docs/examples/tutorial/stopwatch04.css
@@ -33,7 +33,7 @@ Button {
.started {
text-style: bold;
background: $success;
- color: $text-success;
+ color: $text;
}
.started TimeDisplay {
diff --git a/e2e_tests/test_apps/basic.css b/e2e_tests/test_apps/basic.css
index b7eaaed81..88de0d535 100644
--- a/e2e_tests/test_apps/basic.css
+++ b/e2e_tests/test_apps/basic.css
@@ -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;
+#sidebar {
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;
diff --git a/e2e_tests/test_apps/basic.py b/e2e_tests/test_apps/basic.py
index e96cffcbd..a81cdbf46 100644
--- a/e2e_tests/test_apps/basic.py
+++ b/e2e_tests/test_apps/basic.py
@@ -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()
diff --git a/examples/calculator.css b/examples/calculator.css
index 4a1d15663..ce9458a10 100644
--- a/examples/calculator.css
+++ b/examples/calculator.css
@@ -24,7 +24,7 @@ Button {
padding: 0 1;
height: 100%;
background: $primary-lighten-2;
- color: $text-primary-lighten-2;
+ color: $text;
}
#number-0 {
diff --git a/examples/code_browser.css b/examples/code_browser.css
index 00f62979d..aca48c127 100644
--- a/examples/code_browser.css
+++ b/examples/code_browser.css
@@ -1,3 +1,7 @@
+Screen {
+ background: $surface-darken-1;
+}
+
#tree-view {
display: none;
scrollbar-gutter: stable;
@@ -9,16 +13,16 @@ CodeBrowser.-show-tree #tree-view {
dock: left;
height: 100%;
max-width: 50%;
- background: $surface;
+ background: $surface;
}
CodeBrowser{
- background: $surface-darken-1;
+ background: $background;
}
DirectoryTree {
+ padding-right: 1;
padding-right: 1;
-
}
#code {
diff --git a/examples/code_browser.py b/examples/code_browser.py
index ca2dac027..1b434d487 100644
--- a/examples/code_browser.py
+++ b/examples/code_browser.py
@@ -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
diff --git a/imgs/calculator.svg b/imgs/calculator.svg
new file mode 100644
index 000000000..dbe6a8841
--- /dev/null
+++ b/imgs/calculator.svg
@@ -0,0 +1,172 @@
+
diff --git a/imgs/codebrowser.svg b/imgs/codebrowser.svg
new file mode 100644
index 000000000..1181ec037
--- /dev/null
+++ b/imgs/codebrowser.svg
@@ -0,0 +1,193 @@
+
diff --git a/imgs/custom.gif b/imgs/custom.gif
deleted file mode 100644
index 57cdb6cf0..000000000
Binary files a/imgs/custom.gif and /dev/null differ
diff --git a/imgs/custom.png b/imgs/custom.png
deleted file mode 100644
index 7c35a4a4d..000000000
Binary files a/imgs/custom.png and /dev/null differ
diff --git a/imgs/stopwatch_dark.svg b/imgs/stopwatch_dark.svg
new file mode 100644
index 000000000..018fe69c3
--- /dev/null
+++ b/imgs/stopwatch_dark.svg
@@ -0,0 +1,199 @@
+
diff --git a/imgs/stopwatch_light.svg b/imgs/stopwatch_light.svg
new file mode 100644
index 000000000..f2652cea1
--- /dev/null
+++ b/imgs/stopwatch_light.svg
@@ -0,0 +1,199 @@
+
diff --git a/imgs/textual.png b/imgs/textual.png
index d8a9e5975..12d78571b 100644
Binary files a/imgs/textual.png and b/imgs/textual.png differ
diff --git a/imgs/widgets.png b/imgs/widgets.png
deleted file mode 100644
index bca927bbc..000000000
Binary files a/imgs/widgets.png and /dev/null differ
diff --git a/sandbox/darren/basic.css b/sandbox/darren/basic.css
index b04a978cb..ce284de80 100644
--- a/sandbox/darren/basic.css
+++ b/sandbox/darren/basic.css
@@ -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;
diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css
index 7c2ad4c39..88de0d535 100644
--- a/sandbox/will/basic.css
+++ b/sandbox/will/basic.css
@@ -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 {
@@ -52,9 +47,9 @@ DataTable {
height: 24;
}
-#sidebar {
- color: $text-panel;
+#sidebar {
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;
diff --git a/sandbox/will/calculator.css b/sandbox/will/calculator.css
index 4a1d15663..ce9458a10 100644
--- a/sandbox/will/calculator.css
+++ b/sandbox/will/calculator.css
@@ -24,7 +24,7 @@ Button {
padding: 0 1;
height: 100%;
background: $primary-lighten-2;
- color: $text-primary-lighten-2;
+ color: $text;
}
#number-0 {
diff --git a/sandbox/will/center2.py b/sandbox/will/center2.py
index d0191bf48..0ec20f61c 100644
--- a/sandbox/will/center2.py
+++ b/sandbox/will/center2.py
@@ -33,7 +33,7 @@ class CenterApp(App):
Static {
background: $panel;
- color: $text-panel;
+ color: $text;
content-align: center middle;
}
diff --git a/sandbox/will/design.css b/sandbox/will/design.css
new file mode 100644
index 000000000..323044432
--- /dev/null
+++ b/sandbox/will/design.css
@@ -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%;
+}
diff --git a/sandbox/will/design.py b/sandbox/will/design.py
new file mode 100644
index 000000000..48cb7e3ae
--- /dev/null
+++ b/sandbox/will/design.py
@@ -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()
diff --git a/sandbox/will/offset.css b/sandbox/will/offset.css
new file mode 100644
index 000000000..ddcb18e49
--- /dev/null
+++ b/sandbox/will/offset.css
@@ -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;
+}
diff --git a/sandbox/will/offset.py b/sandbox/will/offset.py
new file mode 100644
index 000000000..d4a9dfd20
--- /dev/null
+++ b/sandbox/will/offset.py
@@ -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()
diff --git a/src/textual/_border.py b/src/textual/_border.py
index 43d70f11d..7294e4fc0 100644
--- a/src/textual/_border.py
+++ b/src/textual/_border.py
@@ -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]),
),
)
diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py
index 0eeac1fe0..5632f50b5 100644
--- a/src/textual/_compositor.py
+++ b/src/textual/_compositor.py
@@ -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
diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py
index f11e865d8..ee7cc6d99 100644
--- a/src/textual/_styles_cache.py
+++ b/src/textual/_styles_cache.py
@@ -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:
diff --git a/src/textual/app.py b/src/textual/app.py
index 3068c0e84..f3fcd68a2 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -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()
diff --git a/src/textual/cli/previews/borders.py b/src/textual/cli/previews/borders.py
index 376bd79d4..d2d4123fe 100644
--- a/src/textual/cli/previews/borders.py
+++ b/src/textual/cli/previews/borders.py
@@ -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()
diff --git a/src/textual/cli/previews/easing.css b/src/textual/cli/previews/easing.css
index 7840c5590..3f602b656 100644
--- a/src/textual/cli/previews/easing.css
+++ b/src/textual/cli/previews/easing.css
@@ -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;
+}
diff --git a/src/textual/cli/previews/easing.py b/src/textual/cli/previews/easing.py
index 5d57099f4..a8703fce1 100644
--- a/src/textual/cli/previews/easing.py
+++ b/src/textual/cli/previews/easing.py
@@ -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"),
diff --git a/src/textual/color.py b/src/textual/color.py
index 0835a4c7e..1b7c48246 100644
--- a/src/textual/color.py
+++ b/src/textual/color.py
@@ -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)
diff --git a/src/textual/css/_help_renderables.py b/src/textual/css/_help_renderables.py
index b1ba7d24b..ca2307f22 100644
--- a/src/textual/css/_help_renderables.py
+++ b/src/textual/css/_help_renderables.py
@@ -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
diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py
index 28fc34db8..b8d6cef22 100644
--- a/src/textual/css/_style_properties.py
+++ b/src/textual/css/_style_properties.py
@@ -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"."""
diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py
index 5ac422579..b379e85d4 100644
--- a/src/textual/css/_styles_builder.py
+++ b/src/textual/css/_styles_builder.py
@@ -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
diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py
index 7ea876355..c9ba19fd2 100644
--- a/src/textual/css/parse.py
+++ b/src/textual/css/parse.py
@@ -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)
diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py
index 40f744513..64c636501 100644
--- a/src/textual/css/styles.py
+++ b/src/textual/css/styles.py
@@ -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()
diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py
index d696525a8..b5e964383 100644
--- a/src/textual/css/stylesheet.py
+++ b/src/textual/css/stylesheet.py
@@ -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
diff --git a/src/textual/css/tokenizer.py b/src/textual/css/tokenizer.py
index c7fb9183b..15dc90508 100644
--- a/src/textual/css/tokenizer.py
+++ b/src/textual/css/tokenizer.py
@@ -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):
diff --git a/src/textual/design.py b/src/textual/design.py
index bf9f049d6..9a3749b1d 100644
--- a/src/textual/design.py
+++ b/src/textual/design.py
@@ -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")
diff --git a/src/textual/dom.py b/src/textual/dom.py
index 87715ff5b..0df51234f 100644
--- a/src/textual/dom.py
+++ b/src/textual/dom.py
@@ -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
- color = styles.color
+ if styles.auto_color:
+ color = background.get_contrast_text(color.a)
+ else:
+ color = styles.color
+
return (base_background, base_color, background, color)
@property
diff --git a/src/textual/screen.py b/src/textual/screen.py
index a8e3d4092..eab12c4f1 100644
--- a/src/textual/screen.py
+++ b/src/textual/screen.py
@@ -33,6 +33,7 @@ class Screen(Widget):
Screen {
layout: vertical;
overflow-y: auto;
+ background: $surface;
}
"""
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 167fda8b1..ea5edc37f 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -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(
diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py
index 3b406be1b..f13c5294f 100644
--- a/src/textual/widgets/_button.py
+++ b/src/textual/widgets/_button.py
@@ -33,11 +33,10 @@ class Button(Widget, can_focus=True):
Button {
width: auto;
min-width: 16;
- width: auto;
height: 3;
background: $panel;
- color: $text-panel;
- border: none;
+ color: $text;
+ border: none;
border-top: tall $panel-lighten-2;
border-bottom: tall $panel-darken-3;
content-align: center middle;
@@ -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;
}
diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py
index 71a667c08..5bf59fd54 100644
--- a/src/textual/widgets/_data_table.py
+++ b/src/textual/widgets/_data_table.py
@@ -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:
diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py
index 9512641f1..3486e39c6 100644
--- a/src/textual/widgets/_directory_tree.py
+++ b/src/textual/widgets/_directory_tree.py
@@ -74,7 +74,6 @@ class DirectoryTree(TreeControl[DirEntry]):
label.stylize("bold")
icon = "๐" if expanded else "๐"
else:
-
icon = "๐"
label.highlight_regex(r"\..*$", "italic")
diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py
index 4cdd3390c..182782c13 100644
--- a/src/textual/widgets/_footer.py
+++ b/src/textual/widgets/_footer.py
@@ -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;
+ background: $accent-darken-1;
}
Footer > .footer--highlight-key {
- background: $secondary;
- color: $text-secondary;
+ background: $secondary;
text-style: bold;
}
Footer > .footer--key {
text-style: bold;
- background: $accent-darken-2;
- color: $text-accent-darken-2;
+ background: $accent-darken-2;
}
"""
diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py
index 6f4ba044e..34c99629d 100644
--- a/src/textual/widgets/_header.py
+++ b/src/textual/widgets/_header.py
@@ -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 {
diff --git a/src/textual/widgets/_tree_control.py b/src/textual/widgets/_tree_control.py
index c2b163d28..e7acede74 100644
--- a/src/textual/widgets/_tree_control.py
+++ b/src/textual/widgets/_tree_control.py
@@ -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%;
}
@@ -176,8 +176,7 @@ class TreeControl(Generic[NodeDataType], Static, can_focus=True):
TreeControl > .tree--guides-highlight {
color: $success;
- text-style: uu;
-
+ 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;
}
"""
diff --git a/src/textual/widgets/_welcome.py b/src/textual/widgets/_welcome.py
index c6edc07a4..4b21a8830 100644
--- a/src/textual/widgets/_welcome.py
+++ b/src/textual/widgets/_welcome.py
@@ -37,7 +37,7 @@ class Welcome(Static):
Welcome Container {
padding: 1;
background: $panel;
- color: $text-panel;
+ color: $text;
}
Welcome #text {
diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py
index 3a9e43181..fed5db430 100644
--- a/tests/css/test_parse.py
+++ b/tests/css/test_parse.py
@@ -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()
diff --git a/tests/test_integration_layout.py b/tests/test_integration_layout.py
index 46106dd5f..c95847ab0 100644
--- a/tests/test_integration_layout.py
+++ b/tests/test_integration_layout.py
@@ -189,8 +189,6 @@ async def test_composition_of_vertical_container_with_children(
"outer",
"hkey",
"vkey",
- "tall",
- "wide",
]
],
),