diff --git a/README.md b/README.md index a8dd8f1be..a1144eea6 100644 --- a/README.md +++ b/README.md @@ -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. + +
+ ๐ŸŽฌ 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. - -[![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](); 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: - -![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) +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. + +![calculator screenshot](./imgs/calculator.svg) +
+ +
+ ๐Ÿ“ท Code browser +
+ + This is [code_browser.py](./examples/code_browser.py) which demonstrates the directory tree widget. + +![code browser screenshot](./imgs/codebrowser.svg) + +
+ + +
+ ๐Ÿ“ท Stopwatch +
+ + This is the Stopwatch example from the tutorial. + +### Light theme + +![stopwatch light screenshot](./imgs/stopwatch_light.svg) + +### Dark theme + +![stopwatch dark screenshot](./imgs/stopwatch_dark.svg) + +
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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CalculatorApp + + + + + + + + + + + + +0 + + + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + AC  +/-  %  รท  +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + 7  8  9  ร—  +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + 4  5  6  -  +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + 1  2  3  +  +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + 0  .  =  +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CodeBrowser + + + + + + + + + + +โญ˜CodeBrowser โ€” ./calculator.py15:39:34 + +๐Ÿ“‚ .  9  +โ”œโ”€โ”€ ๐Ÿ“ .mypy_cache 10 classCalculatorApp(App):โ–†โ–† +โ”œโ”€โ”€ ๐Ÿ“ __pycache__ 11 โ”‚   """A working 'desktop' calculator.""" +โ”œโ”€โ”€ ๐Ÿ“„ README.md 12 โ”‚    +โ”œโ”€โ”€ ๐Ÿ“„ borders.py 13 โ”‚   numbers = var("0") +โ”œโ”€โ”€ ๐Ÿ“„ calculator.css 14 โ”‚   show_ac = var(True)โ–ƒโ–ƒ +โ”œโ”€โ”€ ๐Ÿ“„ calculator.py 15 โ”‚   left = var(Decimal("0")) +โ”œโ”€โ”€ ๐Ÿ“„ code_browser.css 16 โ”‚   right = var(Decimal("0")) +โ”œโ”€โ”€ ๐Ÿ“„ code_browser.py 17 โ”‚   value = var("") +โ””โ”€โ”€ ๐Ÿ“„ pride.py 18 โ”‚   operator = var("plus") + 19 โ”‚    + 20 โ”‚   KEY_MAP = { + 21 โ”‚   โ”‚   "+""plus", + 22 โ”‚   โ”‚   "-""minus", + 23 โ”‚   โ”‚   ".""point", + 24 โ”‚   โ”‚   "*""multiply", + 25 โ”‚   โ”‚   "/""divide", + 26 โ”‚   โ”‚   "_""plus-minus", + 27 โ”‚   โ”‚   "%""percent", + 28 โ”‚   โ”‚   "=""equals", + 29 โ”‚   } + 30 โ”‚    + 31 โ”‚   defwatch_numbers(self, value: str) ->None: + 32 โ”‚   โ”‚   """Called when numbers is updated.""" + 33 โ”‚   โ”‚   # Update the Numbers widget + F  Toggle Files  Q  Quit  + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + StopwatchApp + + + + + + + + + + +โญ˜StopwatchApp16:11:30 + + Start 00:00:00.00 Reset  +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + + + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + Stop 00:00:15.21 +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + + + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + Stop 00:00:13.96 +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–ƒโ–ƒ + + + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + Start 00:00:00.00 Reset  +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + + + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + Start 00:00:00.00 Reset  +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + + + D  Toggle dark mode  A  Add  R  Remove  + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + StopwatchApp + + + + + + + + + + +โญ˜StopwatchApp16:11:33 + + Start 00:00:00.00 Reset  +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + + + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + Stop 00:00:18.33 +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + + + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + Stop 00:00:17.08 +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–ƒโ–ƒ + + + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + Start 00:00:00.00 Reset  +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + + + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + Start 00:00:00.00 Reset  +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + + + D  Toggle dark mode  A  Add  R  Remove  + + + 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", ] ], ),