diff --git a/CHANGELOG.md b/CHANGELOG.md
index edafb2b82..e952920e3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added
- 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
diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md
index 4ca7785c3..8579d9a15 100644
--- a/docs/guide/CSS.md
+++ b/docs/guide/CSS.md
@@ -4,7 +4,7 @@ Textual uses CSS to apply style to widgets. If you have any exposure to web deve
## Stylesheets
-CSS stands for _Cascading Stylesheets_. A stylesheet is a list of styles and rules about how those styles should be applied to a web page. In the case of Textual, the stylesheet applies [styles](./styles.md) to widgets but otherwise it is the same idea.
+CSS stands for _Cascading Stylesheets_. A stylesheet is a list of styles and rules about how those styles should be applied to a web page. In the case of Textual, the stylesheet applies [styles](./styles.md) to widgets, but otherwise it is the same idea.
When Textual loads CSS it sets attributes on your widgets' `style` object. The effect is the same as if you had set attributes in Python.
@@ -48,7 +48,7 @@ Header {
}
```
-The lines inside the curly braces contains CSS _rules_, which consist of a rule name and rule value separated by a colon and ending in a semi-colon. Such rules are typically written one per line, but you could add additional rules as long as they are separated by semi-colons.
+The lines inside the curly braces contains CSS _rules_, which consist of a rule name and rule value separated by a colon and ending in a semicolon. Such rules are typically written one per line, but you could add additional rules as long as they are separated by semicolons.
The first rule in the above example reads `"dock: top;"`. The rule name is `dock` which tells Textual to place the widget on an edge of the screen. The text after the colon is `top` which tells Textual to dock to the _top_ of the screen. Other valid values for `dock` are "right", "bottom", or "left"; but "top" is most appropriate for a header.
@@ -93,7 +93,7 @@ This doesn't look much like a tree yet. Let's add a header and a footer to this
```{.textual path="docs/examples/guide/dom2.py"}
```
-With a header and a footer widget the DOM looks the this:
+With a header and a footer widget the DOM looks like this:
--8<-- "docs/images/dom2.excalidraw.svg"
@@ -132,7 +132,7 @@ Here's the output from this example:
```
-You may recognize some of the elements in the above screenshot, but it doesn't quite look like a dialog. This is because we haven't added a stylesheet.
+You may recognize some elements in the above screenshot, but it doesn't quite look like a dialog. This is because we haven't added a stylesheet.
## CSS files
@@ -142,7 +142,8 @@ To add a stylesheet set the `CSS_PATH` classvar to a relative path:
--8<-- "docs/examples/guide/dom4.py"
```
-You may have noticed that some of the constructors have additional keyword arguments: `id` and `classes`. These are used by the CSS to identify parts of the DOM. We will cover these in the next section.
+You may have noticed that some constructors have additional keyword arguments: `id` and `classes`.
+These are used by the CSS to identify parts of the DOM. We will cover these in the next section.
Here's the CSS file we are applying:
@@ -158,6 +159,10 @@ With the CSS in place, the output looks very different:
```
+### Using multiple CSS files
+
+You can also set the `CSS_PATH` class variable to a list of paths. Textual will combine the rules from all of the supplied paths.
+
### Why CSS?
It is reasonable to ask why use CSS at all? Python is a powerful and expressive language. Wouldn't it be easier to set styles in your `.py` files?
@@ -178,7 +183,7 @@ Being able to iterate on the design without restarting the application makes it
A selector is the text which precedes the curly braces in a set of rules. It tells Textual which widgets it should apply the rules to.
-Selectors can target a kind of widget or a very specific widget. For instance you could have a selector that modifies all buttons, or you could target an individual button used in one dialog. This gives you a lot of flexibility in customizing your user interface.
+Selectors can target a kind of widget or a very specific widget. For instance, you could have a selector that modifies all buttons, or you could target an individual button used in one dialog. This gives you a lot of flexibility in customizing your user interface.
Let's look at the selectors supported by Textual CSS.
@@ -201,7 +206,7 @@ Button {
}
```
-The type selector will also match a widget's base classes. Consequently a `Static` selector will also style the button because the `Button` Python class extends `Static`.
+The type selector will also match a widget's base classes. Consequently, a `Static` selector will also style the button because the `Button` Python class extends `Static`.
```sass
Static {
diff --git a/src/textual/_import_app.py b/src/textual/_import_app.py
index e65e7fc85..fcd39f9a6 100644
--- a/src/textual/_import_app.py
+++ b/src/textual/_import_app.py
@@ -35,8 +35,13 @@ def import_app(import_name: str) -> App:
from textual.app import App, WINDOWS
import_name, *argv = shlex.split(import_name, posix=not WINDOWS)
+ drive, import_name = os.path.splitdrive(import_name)
+
lib, _colon, name = import_name.partition(":")
+ if drive:
+ lib = os.path.join(drive, os.sep, lib)
+
if lib.endswith(".py"):
path = os.path.abspath(lib)
sys.path.append(str(Path(path).parent))
@@ -62,7 +67,7 @@ def import_app(import_name: str) -> App:
except KeyError:
raise AppFail(f"App {name!r} not found in {lib!r}")
else:
- # Find a App class or instance that is *not* the base class
+ # Find an App class or instance that is *not* the base class
apps = [
value
for value in global_vars.values()
diff --git a/src/textual/_path.py b/src/textual/_path.py
index 28a9ba1bc..860041a50 100644
--- a/src/textual/_path.py
+++ b/src/textual/_path.py
@@ -1,7 +1,6 @@
from __future__ import annotations
import inspect
-import sys
from pathlib import Path, PurePath
diff --git a/src/textual/app.py b/src/textual/app.py
index 7ea329ed4..d53495730 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -25,6 +25,7 @@ from typing import (
TypeVar,
cast,
Union,
+ List,
)
from weakref import WeakSet, WeakValueDictionary
@@ -126,6 +127,10 @@ class ScreenStackError(ScreenError):
"""Raised when attempting to pop the last screen from the stack."""
+class CssPathError(Exception):
+ """Raised when supplied CSS path(s) are invalid."""
+
+
ReturnType = TypeVar("ReturnType")
@@ -137,23 +142,27 @@ class _NullFile:
pass
-CSSPathType = Union[str, PurePath, None]
+CSSPathType = Union[str, PurePath, List[Union[str, PurePath]], None]
@rich.repr.auto
class App(Generic[ReturnType], DOMNode):
"""The base class for Textual Applications.
-
Args:
driver_class (Type[Driver] | None, optional): Driver class or ``None`` to auto-detect. Defaults to None.
- css_path (str | PurePath | None, optional): Path to CSS or ``None`` for no CSS file. Defaults to None.
+ css_path (str | PurePath | list[str | PurePath] | None, optional): Path to CSS or ``None`` for no CSS file.
+ Defaults to None. To load multiple CSS files, pass a list of strings or paths which will be loaded in order.
watch_css (bool, optional): Watch CSS for changes. Defaults to False.
+
+ Raises:
+ CssPathError: When the supplied CSS path(s) are an unexpected type.
"""
- # Inline CSS for quick scripts (generally css_path should be preferred.)
CSS = ""
+ """Inline CSS, useful for quick scripts. This is loaded after CSS_PATH,
+ and therefore takes priority in the event of a specificity clash."""
- # Default (lowest priority) CSS
+ # Default (the lowest priority) CSS
DEFAULT_CSS = """
App {
background: $background;
@@ -227,15 +236,30 @@ class App(Generic[ReturnType], DOMNode):
self.stylesheet = Stylesheet(variables=self.get_css_variables())
self._require_stylesheet_update: set[DOMNode] = set()
- # We want the CSS path to be resolved from the location of the App subclass
css_path = css_path or self.CSS_PATH
if css_path is not None:
+ # When value(s) are supplied for CSS_PATH, we normalise them to a list of Paths.
if isinstance(css_path, str):
- css_path = Path(css_path)
- css_path = _make_path_object_relative(css_path, self) if css_path else None
+ css_paths = [Path(css_path)]
+ elif isinstance(css_path, PurePath):
+ css_paths = [css_path]
+ elif isinstance(css_path, list):
+ css_paths = []
+ for path in css_path:
+ css_paths.append(Path(path) if isinstance(path, str) else path)
+ else:
+ raise CssPathError(
+ "Expected a str, Path or list[str | Path] for the CSS_PATH."
+ )
- self.css_path = css_path
+ # We want the CSS path to be resolved from the location of the App subclass
+ css_paths = [
+ _make_path_object_relative(css_path, self) for css_path in css_paths
+ ]
+ else:
+ css_paths = []
+ self.css_path = css_paths
self._registry: WeakSet[DOMNode] = WeakSet()
self._installed_screens: WeakValueDictionary[
@@ -774,15 +798,16 @@ class App(Generic[ReturnType], DOMNode):
async def _on_css_change(self) -> None:
"""Called when the CSS changes (if watch_css is True)."""
- if self.css_path is not None:
+ css_paths = self.css_path
+ if css_paths:
try:
time = perf_counter()
stylesheet = self.stylesheet.copy()
- stylesheet.read(self.css_path)
+ stylesheet.read_all(css_paths)
stylesheet.parse()
elapsed = (perf_counter() - time) * 1000
self.log.system(
- f" loaded {self.css_path!r} in {elapsed:.0f} ms"
+ f" loaded {len(css_paths)} CSS files in {elapsed:.0f} ms"
)
except Exception as error:
# TODO: Catch specific exceptions
@@ -1141,8 +1166,8 @@ class App(Generic[ReturnType], DOMNode):
self.log.system(features=self.features)
try:
- if self.css_path is not None:
- self.stylesheet.read(self.css_path)
+ if self.css_path:
+ self.stylesheet.read_all(self.css_path)
for path, css, tie_breaker in self.get_default_css():
self.stylesheet.add_source(
css, path=path, is_default_css=True, tie_breaker=tie_breaker
diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py
index 9d09835c6..b91273eb4 100644
--- a/src/textual/css/stylesheet.py
+++ b/src/textual/css/stylesheet.py
@@ -248,11 +248,24 @@ class Stylesheet:
with open(filename, "rt") as css_file:
css = css_file.read()
path = os.path.abspath(filename)
- except Exception as error:
+ except Exception:
raise StylesheetError(f"unable to read CSS file {filename!r}") from None
self.source[str(path)] = CssSource(css, False, 0)
self._require_parse = True
+ def read_all(self, paths: list[PurePath]) -> None:
+ """Read multiple CSS files, in order.
+
+ Args:
+ paths (list[PurePath]): The paths of the CSS files to read, in order.
+
+ Raises:
+ StylesheetError: If the CSS could not be read.
+ StylesheetParseError: If the CSS is invalid.
+ """
+ for path in paths:
+ self.read(path)
+
def add_source(
self,
css: str,
@@ -268,6 +281,7 @@ class Stylesheet:
Defaults to None.
is_default_css (bool): True if the CSS is defined in the Widget, False if the CSS is defined
in a user stylesheet.
+ tie_breaker (int): Integer representing the priority of this source.
Raises:
StylesheetError: If the CSS could not be read.
diff --git a/src/textual/demo.py b/src/textual/demo.py
index 1bcfe9b53..f3401ba7c 100644
--- a/src/textual/demo.py
+++ b/src/textual/demo.py
@@ -102,15 +102,15 @@ Here's an example of some CSS used in this app:
EXAMPLE_CSS = """\
Screen {
layers: base overlay notes;
- overflow: hidden;
+ overflow: hidden;
}
-Sidebar {
+Sidebar {
width: 40;
- background: $panel;
- transition: offset 500ms in_out_cubic;
+ background: $panel;
+ transition: offset 500ms in_out_cubic;
layer: overlay;
-
+
}
Sidebar.-hidden {
@@ -142,7 +142,7 @@ Build your own or use the builtin widgets.
- **DataTable** A spreadsheet-like widget for navigating data. Cells may contain text or Rich renderables.
- **TreeControl** An generic tree with expandable nodes.
- **DirectoryTree** A tree of file and folders.
-- *... many more planned ...*
+- *... many more planned ...*
"""
@@ -319,7 +319,7 @@ class DemoApp(App):
self.query_one(TextLog).write(renderable)
def compose(self) -> ComposeResult:
- example_css = "\n".join(Path(self.css_path).read_text().splitlines()[:50])
+ example_css = "\n".join(Path(self.css_path[0]).read_text().splitlines()[:50])
yield Container(
Sidebar(classes="-hidden"),
Header(show_clock=True),
diff --git a/src/textual/file_monitor.py b/src/textual/file_monitor.py
index 562682db8..778884865 100644
--- a/src/textual/file_monitor.py
+++ b/src/textual/file_monitor.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
import os
from pathlib import PurePath
from typing import Callable
@@ -9,20 +11,20 @@ from ._callback import invoke
@rich.repr.auto
class FileMonitor:
- """Monitors a file for changes and invokes a callback when it does."""
+ """Monitors files for changes and invokes a callback when it does."""
- def __init__(self, path: PurePath, callback: Callable) -> None:
- self.path = path
+ def __init__(self, paths: list[PurePath], callback: Callable) -> None:
+ self.paths = paths
self.callback = callback
- self._modified = self._get_modified()
+ self._modified = self._get_last_modified_time()
- def _get_modified(self) -> float:
- """Get the modified time for a file being watched."""
- return os.stat(self.path).st_mtime
+ def _get_last_modified_time(self) -> float:
+ """Get the most recent modified time out of all files being watched."""
+ return max(os.stat(path).st_mtime for path in self.paths)
def check(self) -> bool:
- """Check the monitored file. Return True if it was changed."""
- modified = self._get_modified()
+ """Check the monitored files. Return True if any were changed since the last modification time."""
+ modified = self._get_last_modified_time()
changed = modified != self._modified
self._modified = modified
return changed
@@ -32,5 +34,5 @@ class FileMonitor:
await self.on_change()
async def on_change(self) -> None:
- """Called when file changes."""
+ """Called when any of the monitored files change."""
await invoke(self.callback)
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index d9eb39417..51ad5531c 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -344,7 +344,7 @@
'''
# ---
-# name: test_css_property_snapshot[docs/examples/styles/align.py]
+# name: test_css_property[align.py]
'''
If you're happy with the content on the left,
+ save it to disk by running pytest with the --snapshot-update flag.
+
Unexpected?
+
+ Snapshots are named after the name of the test you call snap_compare in by default.
+
+ If you've renamed a test, the association between the snapshot and the test is lost,
+ and you'll need to run with --snapshot-update to associate the snapshot
+ with the new test name.
+
+
+
+ {% endif %}
+ {% if diff.snapshot != "" %}
- Historical snapshot
+
+ Historical snapshot
+
+ {% endif %}
-
{# Modal with debug info: #}
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():
- path_tester(RelativePathObjectApp, RelativePathStrApp, ((Path(__file__).absolute().parent ) / "test.css").absolute())
+class ListPathApp(App[None]):
+ 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]