Support multiple CSS files (#1079)

* Support multiple CSS paths

* Update a type to match docstring

* Ensure the demo app still works

* Use absolute paths in tests to (hopefully) appease Windows

* Notes about CSS changes in guide/docstrings, small grammar/typos fixes

* Move snapshot apps into snapshot_tests dir, improve messaging in snapshot output, add test for multiple css files interacting with classvar CSS

* Ensure consistent snapshot naming cross-platform

* Use rpartition instead of partition in import_app

* Fix handling of import_app when colon in arg

* Support paths containing Windows drive names in import_app

* Add note on new relative paths in snap_compare

* Update docs/guide/CSS.md

Co-authored-by: Will McGugan <willmcgugan@gmail.com>

* Fix formatting

* Update CHANGELOG to mention CSS_PATH supporting a list

Co-authored-by: Will McGugan <willmcgugan@gmail.com>
This commit is contained in:
darrenburns
2022-11-01 17:13:25 +00:00
committed by GitHub
parent 0403cfdc98
commit bbd811d671
19 changed files with 425 additions and 130 deletions

View File

@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added ### Added
- Added `init` param to reactive.watch - Added `init` param to reactive.watch
- `CSS_PATH` can now be a list of CSS files https://github.com/Textualize/textual/pull/1079
## [0.3.0] - 2022-10-31 ## [0.3.0] - 2022-10-31

View File

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

View File

@@ -35,8 +35,13 @@ def import_app(import_name: str) -> App:
from textual.app import App, WINDOWS from textual.app import App, WINDOWS
import_name, *argv = shlex.split(import_name, posix=not WINDOWS) import_name, *argv = shlex.split(import_name, posix=not WINDOWS)
drive, import_name = os.path.splitdrive(import_name)
lib, _colon, name = import_name.partition(":") lib, _colon, name = import_name.partition(":")
if drive:
lib = os.path.join(drive, os.sep, lib)
if lib.endswith(".py"): if lib.endswith(".py"):
path = os.path.abspath(lib) path = os.path.abspath(lib)
sys.path.append(str(Path(path).parent)) sys.path.append(str(Path(path).parent))
@@ -62,7 +67,7 @@ def import_app(import_name: str) -> App:
except KeyError: except KeyError:
raise AppFail(f"App {name!r} not found in {lib!r}") raise AppFail(f"App {name!r} not found in {lib!r}")
else: else:
# Find a App class or instance that is *not* the base class # Find an App class or instance that is *not* the base class
apps = [ apps = [
value value
for value in global_vars.values() for value in global_vars.values()

View File

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

View File

@@ -25,6 +25,7 @@ from typing import (
TypeVar, TypeVar,
cast, cast,
Union, Union,
List,
) )
from weakref import WeakSet, WeakValueDictionary from weakref import WeakSet, WeakValueDictionary
@@ -126,6 +127,10 @@ class ScreenStackError(ScreenError):
"""Raised when attempting to pop the last screen from the stack.""" """Raised when attempting to pop the last screen from the stack."""
class CssPathError(Exception):
"""Raised when supplied CSS path(s) are invalid."""
ReturnType = TypeVar("ReturnType") ReturnType = TypeVar("ReturnType")
@@ -137,23 +142,27 @@ class _NullFile:
pass pass
CSSPathType = Union[str, PurePath, None] CSSPathType = Union[str, PurePath, List[Union[str, PurePath]], None]
@rich.repr.auto @rich.repr.auto
class App(Generic[ReturnType], DOMNode): class App(Generic[ReturnType], DOMNode):
"""The base class for Textual Applications. """The base class for Textual Applications.
Args: Args:
driver_class (Type[Driver] | None, optional): Driver class or ``None`` to auto-detect. Defaults to None. driver_class (Type[Driver] | None, optional): Driver class or ``None`` to auto-detect. Defaults to None.
css_path (str | PurePath | None, optional): Path to CSS or ``None`` for no CSS file. Defaults to None. css_path (str | PurePath | list[str | PurePath] | None, optional): Path to CSS or ``None`` for no CSS file.
Defaults to None. To load multiple CSS files, pass a list of strings or paths which will be loaded in order.
watch_css (bool, optional): Watch CSS for changes. Defaults to False. watch_css (bool, optional): Watch CSS for changes. Defaults to False.
Raises:
CssPathError: When the supplied CSS path(s) are an unexpected type.
""" """
# Inline CSS for quick scripts (generally css_path should be preferred.)
CSS = "" CSS = ""
"""Inline CSS, useful for quick scripts. This is loaded after CSS_PATH,
and therefore takes priority in the event of a specificity clash."""
# Default (lowest priority) CSS # Default (the lowest priority) CSS
DEFAULT_CSS = """ DEFAULT_CSS = """
App { App {
background: $background; background: $background;
@@ -227,15 +236,30 @@ class App(Generic[ReturnType], DOMNode):
self.stylesheet = Stylesheet(variables=self.get_css_variables()) self.stylesheet = Stylesheet(variables=self.get_css_variables())
self._require_stylesheet_update: set[DOMNode] = set() self._require_stylesheet_update: set[DOMNode] = set()
# We want the CSS path to be resolved from the location of the App subclass
css_path = css_path or self.CSS_PATH css_path = css_path or self.CSS_PATH
if css_path is not None: if css_path is not None:
# When value(s) are supplied for CSS_PATH, we normalise them to a list of Paths.
if isinstance(css_path, str): if isinstance(css_path, str):
css_path = Path(css_path) css_paths = [Path(css_path)]
css_path = _make_path_object_relative(css_path, self) if css_path else None elif isinstance(css_path, PurePath):
css_paths = [css_path]
elif isinstance(css_path, list):
css_paths = []
for path in css_path:
css_paths.append(Path(path) if isinstance(path, str) else path)
else:
raise CssPathError(
"Expected a str, Path or list[str | Path] for the CSS_PATH."
)
self.css_path = css_path # We want the CSS path to be resolved from the location of the App subclass
css_paths = [
_make_path_object_relative(css_path, self) for css_path in css_paths
]
else:
css_paths = []
self.css_path = css_paths
self._registry: WeakSet[DOMNode] = WeakSet() self._registry: WeakSet[DOMNode] = WeakSet()
self._installed_screens: WeakValueDictionary[ self._installed_screens: WeakValueDictionary[
@@ -774,15 +798,16 @@ class App(Generic[ReturnType], DOMNode):
async def _on_css_change(self) -> None: async def _on_css_change(self) -> None:
"""Called when the CSS changes (if watch_css is True).""" """Called when the CSS changes (if watch_css is True)."""
if self.css_path is not None: css_paths = self.css_path
if css_paths:
try: try:
time = perf_counter() time = perf_counter()
stylesheet = self.stylesheet.copy() stylesheet = self.stylesheet.copy()
stylesheet.read(self.css_path) stylesheet.read_all(css_paths)
stylesheet.parse() stylesheet.parse()
elapsed = (perf_counter() - time) * 1000 elapsed = (perf_counter() - time) * 1000
self.log.system( self.log.system(
f"<stylesheet> loaded {self.css_path!r} in {elapsed:.0f} ms" f"<stylesheet> loaded {len(css_paths)} CSS files in {elapsed:.0f} ms"
) )
except Exception as error: except Exception as error:
# TODO: Catch specific exceptions # TODO: Catch specific exceptions
@@ -1141,8 +1166,8 @@ class App(Generic[ReturnType], DOMNode):
self.log.system(features=self.features) self.log.system(features=self.features)
try: try:
if self.css_path is not None: if self.css_path:
self.stylesheet.read(self.css_path) self.stylesheet.read_all(self.css_path)
for path, css, tie_breaker in self.get_default_css(): for path, css, tie_breaker in self.get_default_css():
self.stylesheet.add_source( self.stylesheet.add_source(
css, path=path, is_default_css=True, tie_breaker=tie_breaker css, path=path, is_default_css=True, tie_breaker=tie_breaker

View File

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

View File

@@ -319,7 +319,7 @@ class DemoApp(App):
self.query_one(TextLog).write(renderable) self.query_one(TextLog).write(renderable)
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
example_css = "\n".join(Path(self.css_path).read_text().splitlines()[:50]) example_css = "\n".join(Path(self.css_path[0]).read_text().splitlines()[:50])
yield Container( yield Container(
Sidebar(classes="-hidden"), Sidebar(classes="-hidden"),
Header(show_clock=True), Header(show_clock=True),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,43 +1,38 @@
from typing import Type from __future__ import annotations
from pathlib import Path from pathlib import Path
import pytest
from textual.app import App from textual.app import App
class RelativePathObjectApp(App[None]): APP_DIR = Path(__file__).parent
class RelativePathObjectApp(App[None]):
CSS_PATH = Path("test.css") CSS_PATH = Path("test.css")
class RelativePathStrApp(App[None]):
class RelativePathStrApp(App[None]):
CSS_PATH = "test.css" CSS_PATH = "test.css"
class AbsolutePathObjectApp(App[None]):
class AbsolutePathObjectApp(App[None]):
CSS_PATH = Path("/tmp/test.css") CSS_PATH = Path("/tmp/test.css")
class AbsolutePathStrApp(App[None]):
class AbsolutePathStrApp(App[None]):
CSS_PATH = "/tmp/test.css" CSS_PATH = "/tmp/test.css"
def path_tester(obj_type: Type[App[None]], str_type: Type[App[None]], intended_result: Path) -> None:
assert isinstance(obj_type().css_path,Path), (
"CSS_PATH didn't stay as an object"
)
assert isinstance(str_type().css_path,Path), (
"CSS_PATH wasn't converted from str to Path"
)
assert obj_type().css_path == intended_result, (
"CSS_PATH doesn't match the intended result."
)
assert str_type().css_path == intended_result, (
"CSS_PATH doesn't match the intended result."
)
assert str_type().css_path == obj_type().css_path, (
"CSS_PATH str to Path conversion gave a different result"
)
def test_relative_path(): class ListPathApp(App[None]):
path_tester(RelativePathObjectApp, RelativePathStrApp, ((Path(__file__).absolute().parent ) / "test.css").absolute()) CSS_PATH = ["test.css", Path("/another/path.css")]
def test_absolute_path():
path_tester(AbsolutePathObjectApp, AbsolutePathStrApp, Path("/tmp/test.css").absolute()) @pytest.mark.parametrize("app,expected_css_path_attribute", [
(RelativePathObjectApp(), [APP_DIR / "test.css"]),
(RelativePathStrApp(), [APP_DIR / "test.css"]),
(AbsolutePathObjectApp(), [Path("/tmp/test.css")]),
(AbsolutePathStrApp(), [Path("/tmp/test.css")]),
(ListPathApp(), [APP_DIR / "test.css", Path("/another/path.css")]),
])
def test_css_paths_of_various_types(app, expected_css_path_attribute):
assert app.css_path == [path.absolute() for path in expected_css_path_attribute]