termcore 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- termcore/__init__.py +6 -0
- termcore/cli/__init__.py +3 -0
- termcore/cli/output.py +70 -0
- termcore/io/__init__.py +7 -0
- termcore/io/app_state_storage.py +185 -0
- termcore/io/database.py +655 -0
- termcore/io/errors.py +71 -0
- termcore/io/file.py +263 -0
- termcore/io/textfile.py +92 -0
- termcore/py.typed +0 -0
- termcore/tui/__init__.py +9 -0
- termcore/tui/custom_bindings.py +551 -0
- termcore/tui/custom_widgets/__init__.py +1 -0
- termcore/tui/custom_widgets/custom_checkbox.py +41 -0
- termcore/tui/custom_widgets/custom_data_table.py +247 -0
- termcore/tui/custom_widgets/custom_selection.py +80 -0
- termcore/tui/custom_widgets/multiline_footer.py +240 -0
- termcore/tui/question_screen.py +107 -0
- termcore/tui/theme_loader.py +495 -0
- termcore/tui/themes/__init__.py +2 -0
- termcore/tui/themes/classic-black-saturated/style.css +57 -0
- termcore/tui/themes/classic-black-saturated/theme.py +22 -0
- termcore/tui/themes/classic-black-v1/style.css +57 -0
- termcore/tui/themes/classic-black-v1/theme.py +22 -0
- termcore/tui/themes/classic-black-v2/style.css +57 -0
- termcore/tui/themes/classic-black-v2/theme.py +24 -0
- termcore/tui/themes/classic-blue/theme.py +21 -0
- termcore/tui/themes/compact-gray/style.tcss +175 -0
- termcore/tui/themes/compact-gray/theme.py +22 -0
- termcore/tui/themes/mnml-black/style.css +57 -0
- termcore/tui/themes/mnml-black/theme.py +21 -0
- termcore/tui/themes/mnml-deepblack/style.css +57 -0
- termcore/tui/themes/mnml-deepblack/theme.py +21 -0
- termcore/tui/themes/pure-amber/theme.py +18 -0
- termcore/tui/themes/pure-black/theme.py +18 -0
- termcore/tui/themes/pure-blue/theme.py +18 -0
- termcore/tui/themes/pure-green/theme.py +18 -0
- termcore/tui/themes/pure-sweet16/theme.py +20 -0
- termcore/tui/themes/xplore-black/theme.py +18 -0
- termcore/tui/themes/xplore-blue/theme.py +18 -0
- termcore/tui/themes/xplore-blue-muted/theme.py +18 -0
- termcore/tui/themes/xplore-teal/theme.py +18 -0
- termcore/util/__init__.py +10 -0
- termcore/util/datetime.py +173 -0
- termcore/util/debug.py +196 -0
- termcore/util/index.py +112 -0
- termcore/util/logger.py +36 -0
- termcore/util/singleton.py +46 -0
- termcore/util/string.py +263 -0
- termcore/util/validation.py +31 -0
- termcore/util/version.py +56 -0
- termcore-0.2.0.dist-info/METADATA +504 -0
- termcore-0.2.0.dist-info/RECORD +55 -0
- termcore-0.2.0.dist-info/WHEEL +4 -0
- termcore-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""Discovery, registration and stylesheet handling of Textual themes."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import ModuleType
|
|
9
|
+
from typing import cast
|
|
10
|
+
|
|
11
|
+
from textual.app import App
|
|
12
|
+
from textual.theme import Theme
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DEFAULT_CUSTOM_THEME_PREFIX",
|
|
16
|
+
"DEFAULT_TERMCORE_THEME_PREFIX",
|
|
17
|
+
"SCRIPT_DIR",
|
|
18
|
+
"STANDARD_THEMES_DIR",
|
|
19
|
+
"ThemeData",
|
|
20
|
+
"ThemeLoader",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
DEFAULT_TERMCORE_THEME_PREFIX = "TERMCORE_"
|
|
24
|
+
DEFAULT_CUSTOM_THEME_PREFIX = "CUSTOM_"
|
|
25
|
+
SCRIPT_DIR = Path(__file__).parent.parent
|
|
26
|
+
STANDARD_THEMES_DIR = SCRIPT_DIR / "tui/themes"
|
|
27
|
+
|
|
28
|
+
_logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class ThemeData:
|
|
33
|
+
"""Data class to hold information of a single theme."""
|
|
34
|
+
|
|
35
|
+
prefix: str
|
|
36
|
+
textual_theme: Theme
|
|
37
|
+
css_files: list[str] | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ThemeLoader:
|
|
41
|
+
"""
|
|
42
|
+
Loads and manages themes for Textual applications.
|
|
43
|
+
|
|
44
|
+
The themes are expected to be in subfolders of the theme folder, each
|
|
45
|
+
containing a `theme.py` file defining a `TEXTUAL_THEME` variable.
|
|
46
|
+
Additionally, any number of `.css` or `.tcss` files can be included in the
|
|
47
|
+
theme folder.
|
|
48
|
+
|
|
49
|
+
A theme is identified by the `name` of its `TEXTUAL_THEME` plus the prefix
|
|
50
|
+
it is registered under; the name of its folder carries no meaning and is
|
|
51
|
+
free to differ.
|
|
52
|
+
|
|
53
|
+
This class dynamically imports the theme modules, registers them and makes
|
|
54
|
+
them available for use in the application.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
_theme_folder: str | None
|
|
58
|
+
_termcore_theme_prefix: str
|
|
59
|
+
_custom_theme_prefix: str
|
|
60
|
+
_theme_names: list[str]
|
|
61
|
+
_theme_data: dict[str, ThemeData]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self, theme_folder: str | None = None,
|
|
66
|
+
termcore_theme_prefix: str = DEFAULT_TERMCORE_THEME_PREFIX,
|
|
67
|
+
custom_theme_prefix: str = DEFAULT_CUSTOM_THEME_PREFIX
|
|
68
|
+
) -> None:
|
|
69
|
+
"""
|
|
70
|
+
Loads the themes that ship with termcore, plus the given folder.
|
|
71
|
+
|
|
72
|
+
Use `ThemeLoader.custom_only` for a loader that leaves the bundled
|
|
73
|
+
themes out.
|
|
74
|
+
"""
|
|
75
|
+
self._configure(
|
|
76
|
+
theme_folder, termcore_theme_prefix, custom_theme_prefix
|
|
77
|
+
)
|
|
78
|
+
self._load_standard_themes()
|
|
79
|
+
self._load_custom_themes()
|
|
80
|
+
self._theme_names.sort()
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def custom_only(
|
|
84
|
+
cls, theme_folder: str,
|
|
85
|
+
termcore_theme_prefix: str = DEFAULT_TERMCORE_THEME_PREFIX,
|
|
86
|
+
custom_theme_prefix: str = DEFAULT_CUSTOM_THEME_PREFIX
|
|
87
|
+
) -> "ThemeLoader":
|
|
88
|
+
"""
|
|
89
|
+
Returns a loader for the given folder alone, without termcore's themes.
|
|
90
|
+
|
|
91
|
+
Parameters
|
|
92
|
+
----------
|
|
93
|
+
theme_folder : str
|
|
94
|
+
Path to the folder holding the theme directories.
|
|
95
|
+
termcore_theme_prefix : str, optional
|
|
96
|
+
Kept so that a later comparison against a bundled theme name
|
|
97
|
+
still resolves, even though none are loaded.
|
|
98
|
+
custom_theme_prefix : str, optional
|
|
99
|
+
Prefix the themes of this folder are registered under.
|
|
100
|
+
|
|
101
|
+
Returns
|
|
102
|
+
-------
|
|
103
|
+
ThemeLoader
|
|
104
|
+
A loader holding only the themes of `theme_folder`.
|
|
105
|
+
"""
|
|
106
|
+
# A factory of this very class, which is what SLF001 cannot tell
|
|
107
|
+
# apart from reaching into a foreign object.
|
|
108
|
+
loader = cls.__new__(cls)
|
|
109
|
+
loader._configure( # noqa: SLF001
|
|
110
|
+
theme_folder, termcore_theme_prefix, custom_theme_prefix
|
|
111
|
+
)
|
|
112
|
+
loader._load_custom_themes() # noqa: SLF001
|
|
113
|
+
loader._theme_names.sort() # noqa: SLF001
|
|
114
|
+
|
|
115
|
+
return loader
|
|
116
|
+
|
|
117
|
+
def _configure(
|
|
118
|
+
self, theme_folder: str | None,
|
|
119
|
+
termcore_theme_prefix: str,
|
|
120
|
+
custom_theme_prefix: str
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Stores the settings and starts from an empty registry."""
|
|
123
|
+
self._theme_folder = theme_folder
|
|
124
|
+
self._termcore_theme_prefix = termcore_theme_prefix
|
|
125
|
+
self._custom_theme_prefix = custom_theme_prefix
|
|
126
|
+
self._theme_names = []
|
|
127
|
+
self._theme_data = {}
|
|
128
|
+
|
|
129
|
+
def _load_standard_themes(self) -> None:
|
|
130
|
+
"""Loads and registers the themes that ship with termcore."""
|
|
131
|
+
self._load_folder(
|
|
132
|
+
self._termcore_theme_prefix, STANDARD_THEMES_DIR.resolve()
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def _load_custom_themes(self) -> None:
|
|
136
|
+
"""Loads and registers the themes of the configured folder, if any."""
|
|
137
|
+
if not self._theme_folder:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
self._load_folder(
|
|
141
|
+
self._custom_theme_prefix, Path(self._theme_folder).resolve()
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def _load_folder(self, prefix: str, theme_folder_path: Path) -> None:
|
|
145
|
+
"""Loads every theme directory below the given folder."""
|
|
146
|
+
if not theme_folder_path.is_dir():
|
|
147
|
+
_logger.warning(
|
|
148
|
+
"Theme folder %r not found. Skipping.", str(theme_folder_path)
|
|
149
|
+
)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
count_before = len(self._theme_names)
|
|
153
|
+
self._process_themes(prefix, theme_folder_path)
|
|
154
|
+
_logger.info(
|
|
155
|
+
"Found %d themes in %r",
|
|
156
|
+
len(self._theme_names) - count_before, str(theme_folder_path)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def _process_themes(self, prefix: str, theme_folder_path: Path) -> None:
|
|
160
|
+
"""Imports and registers themes from the given folder path."""
|
|
161
|
+
# Sorted, so that a refused duplicate is always the same one
|
|
162
|
+
for full_path in sorted(theme_folder_path.iterdir()):
|
|
163
|
+
if full_path.name.startswith((".", "_")) \
|
|
164
|
+
or not full_path.is_dir():
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
self._import_and_register_theme(prefix, full_path)
|
|
168
|
+
|
|
169
|
+
def _import_and_register_theme(
|
|
170
|
+
self, prefix: str, theme_folder_path: Path
|
|
171
|
+
) -> None:
|
|
172
|
+
"""
|
|
173
|
+
Imports a theme module and registers its theme.
|
|
174
|
+
|
|
175
|
+
Raises
|
|
176
|
+
------
|
|
177
|
+
Exception
|
|
178
|
+
If any error occurs while executing the theme module.
|
|
179
|
+
"""
|
|
180
|
+
folder_name = theme_folder_path.name
|
|
181
|
+
theme_file = theme_folder_path / "theme.py"
|
|
182
|
+
if not theme_file.is_file():
|
|
183
|
+
_logger.warning(
|
|
184
|
+
"Skipping theme folder %r (no theme.py)", folder_name
|
|
185
|
+
)
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
theme_module = self._import_theme_module(theme_file)
|
|
190
|
+
textual_theme = getattr(theme_module, "TEXTUAL_THEME", None)
|
|
191
|
+
|
|
192
|
+
# Abort if no TEXTUAL_THEME variable is defined
|
|
193
|
+
if not isinstance(textual_theme, Theme):
|
|
194
|
+
_logger.warning(
|
|
195
|
+
"Skipping theme folder %r (no TEXTUAL_THEME defined)",
|
|
196
|
+
folder_name
|
|
197
|
+
)
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
# Register the theme
|
|
201
|
+
css_files = self._get_css_files_for_theme(theme_folder_path)
|
|
202
|
+
self._save_theme_data(prefix, textual_theme, css_files)
|
|
203
|
+
except Exception:
|
|
204
|
+
_logger.exception("Error loading theme folder %r", folder_name)
|
|
205
|
+
|
|
206
|
+
def _import_theme_module(self, theme_file: Path) -> ModuleType:
|
|
207
|
+
"""
|
|
208
|
+
Imports a theme.py by path, leaving sys.path and sys.modules alone.
|
|
209
|
+
|
|
210
|
+
Loading by path is what keeps theme folders independent of each
|
|
211
|
+
other. Importing them as a package named after the folder made the
|
|
212
|
+
first folder loaded in a process shadow every later one of the same
|
|
213
|
+
name, so a second application lost the themes of the toolkit.
|
|
214
|
+
"""
|
|
215
|
+
spec = importlib.util.spec_from_file_location(
|
|
216
|
+
f"_termcore_theme_{theme_file.parent.name}", theme_file
|
|
217
|
+
)
|
|
218
|
+
if spec is None or spec.loader is None:
|
|
219
|
+
raise ImportError(f"Cannot load a theme module from {theme_file}")
|
|
220
|
+
|
|
221
|
+
theme_module = importlib.util.module_from_spec(spec)
|
|
222
|
+
spec.loader.exec_module(theme_module)
|
|
223
|
+
return theme_module
|
|
224
|
+
|
|
225
|
+
def _get_css_files_for_theme(self, theme_folder_path: Path) -> list[str]:
|
|
226
|
+
"""Generates a list of CSS files in the given folder."""
|
|
227
|
+
return [
|
|
228
|
+
str(path) for path in sorted(theme_folder_path.iterdir())
|
|
229
|
+
if path.suffix in (".css", ".tcss")
|
|
230
|
+
]
|
|
231
|
+
|
|
232
|
+
def _save_theme_data(
|
|
233
|
+
self, prefix: str,
|
|
234
|
+
theme_instance: Theme,
|
|
235
|
+
css_files: list[str] | None = None
|
|
236
|
+
) -> None:
|
|
237
|
+
"""
|
|
238
|
+
Stores the theme data and records its registered name.
|
|
239
|
+
|
|
240
|
+
The prefix is applied to a copy of the theme, so that the
|
|
241
|
+
`TEXTUAL_THEME` constant of the imported module keeps the name it was
|
|
242
|
+
written with and repeated registration cannot stack prefixes.
|
|
243
|
+
"""
|
|
244
|
+
registered_name = f"{prefix}{theme_instance.name}"
|
|
245
|
+
if registered_name in self._theme_data:
|
|
246
|
+
_logger.error(
|
|
247
|
+
"Duplicate theme %r, keeping the one that was loaded first",
|
|
248
|
+
registered_name
|
|
249
|
+
)
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
self._theme_names.append(registered_name)
|
|
253
|
+
self._theme_data[registered_name] = ThemeData(
|
|
254
|
+
prefix=prefix,
|
|
255
|
+
textual_theme=replace(
|
|
256
|
+
theme_instance,
|
|
257
|
+
name=registered_name,
|
|
258
|
+
variables=dict(theme_instance.variables)
|
|
259
|
+
),
|
|
260
|
+
css_files=css_files
|
|
261
|
+
)
|
|
262
|
+
_logger.info("Registered theme: %r", registered_name)
|
|
263
|
+
|
|
264
|
+
def get_previously_used_theme(
|
|
265
|
+
self, theme_config_file: Path, default_theme_name: str
|
|
266
|
+
) -> str:
|
|
267
|
+
"""
|
|
268
|
+
Returns the name of the previously used theme from the config file.
|
|
269
|
+
|
|
270
|
+
Parameters
|
|
271
|
+
----------
|
|
272
|
+
theme_config_file : Path
|
|
273
|
+
Path to the config file which contains the name of the theme.
|
|
274
|
+
default_theme_name : str
|
|
275
|
+
The default theme name to return if no previous theme is found.
|
|
276
|
+
|
|
277
|
+
Returns
|
|
278
|
+
-------
|
|
279
|
+
str
|
|
280
|
+
The name of the last used theme or the default name.
|
|
281
|
+
|
|
282
|
+
Raises
|
|
283
|
+
------
|
|
284
|
+
json.JSONDecodeError
|
|
285
|
+
If the config file contains invalid JSON.
|
|
286
|
+
IOError
|
|
287
|
+
If there's an error reading the config file.
|
|
288
|
+
"""
|
|
289
|
+
if theme_config_file.exists():
|
|
290
|
+
try:
|
|
291
|
+
with theme_config_file.open() as f:
|
|
292
|
+
config = cast("dict[str, str]", json.load(f))
|
|
293
|
+
if "theme" not in config:
|
|
294
|
+
_logger.warning(
|
|
295
|
+
"Invalid theme config format in %r",
|
|
296
|
+
str(theme_config_file)
|
|
297
|
+
)
|
|
298
|
+
return default_theme_name
|
|
299
|
+
return config["theme"]
|
|
300
|
+
except (OSError, json.JSONDecodeError):
|
|
301
|
+
return default_theme_name
|
|
302
|
+
return default_theme_name
|
|
303
|
+
|
|
304
|
+
def register_themes_in_textual_app(self, app: App[None]) -> None:
|
|
305
|
+
"""
|
|
306
|
+
Registers all loaded themes in the given Textual application.
|
|
307
|
+
|
|
308
|
+
Registration reads the stored themes without modifying them, so the
|
|
309
|
+
same loader can be registered in more than one application.
|
|
310
|
+
|
|
311
|
+
Parameters
|
|
312
|
+
----------
|
|
313
|
+
app : App
|
|
314
|
+
The instance of the Textual application.
|
|
315
|
+
"""
|
|
316
|
+
# Sort themes, first TERMCORE_THEME_PREFIX, then CUSTOM_THEME_PREFIX
|
|
317
|
+
self._theme_names.sort(key=self._registration_order)
|
|
318
|
+
|
|
319
|
+
# Loop through name list instead of dict to keep alphabetic order
|
|
320
|
+
for theme_name in self._theme_names:
|
|
321
|
+
app.register_theme(self._theme_data[theme_name].textual_theme)
|
|
322
|
+
|
|
323
|
+
def _registration_order(self, theme_name: str) -> tuple[int, str]:
|
|
324
|
+
"""Sorts termcore themes before custom ones, then alphabetically."""
|
|
325
|
+
is_custom = \
|
|
326
|
+
self._theme_data[theme_name].prefix != self._termcore_theme_prefix
|
|
327
|
+
return int(is_custom), theme_name
|
|
328
|
+
|
|
329
|
+
def set_previous_theme_in_textual_app(
|
|
330
|
+
self, app: App[None], default_theme_name: str, theme_config_file: Path
|
|
331
|
+
) -> None:
|
|
332
|
+
"""
|
|
333
|
+
Set the previously used theme in the given Textual application.
|
|
334
|
+
|
|
335
|
+
Parameters
|
|
336
|
+
----------
|
|
337
|
+
app : App
|
|
338
|
+
The instance of the Textual application.
|
|
339
|
+
default_theme_name : str
|
|
340
|
+
The default theme name to use if no previous theme is found.
|
|
341
|
+
theme_config_file : Path
|
|
342
|
+
Path to the config file containing the previous theme.
|
|
343
|
+
"""
|
|
344
|
+
theme_name = self.get_previously_used_theme(
|
|
345
|
+
theme_config_file, default_theme_name
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
# A theme removed between two runs is the ordinary case, and a caller
|
|
349
|
+
# that passes a default expects it to be used rather than ignored
|
|
350
|
+
if theme_name not in app.available_themes:
|
|
351
|
+
_logger.warning(
|
|
352
|
+
"Stored theme %r is not registered, falling back to %r",
|
|
353
|
+
theme_name, default_theme_name
|
|
354
|
+
)
|
|
355
|
+
theme_name = default_theme_name
|
|
356
|
+
|
|
357
|
+
if theme_name not in app.available_themes:
|
|
358
|
+
_logger.error(
|
|
359
|
+
"Default theme %r is not registered either, leaving the "
|
|
360
|
+
"theme as it is", theme_name
|
|
361
|
+
)
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
app.theme = theme_name
|
|
365
|
+
_logger.info("Set previous theme: %r", theme_name)
|
|
366
|
+
|
|
367
|
+
def save_theme_to_config(
|
|
368
|
+
self, theme_name: str, theme_config_file: Path
|
|
369
|
+
) -> None:
|
|
370
|
+
"""
|
|
371
|
+
Saves the name of the active theme in the config file.
|
|
372
|
+
|
|
373
|
+
Raises
|
|
374
|
+
------
|
|
375
|
+
IOError
|
|
376
|
+
If there's an error writing to the config file.
|
|
377
|
+
"""
|
|
378
|
+
try:
|
|
379
|
+
with theme_config_file.open("w") as f:
|
|
380
|
+
json.dump({"theme": theme_name}, f)
|
|
381
|
+
except OSError:
|
|
382
|
+
_logger.exception("Could not save theme config")
|
|
383
|
+
|
|
384
|
+
def load_theme_css(self, theme_name: str, app: App[None]) -> None:
|
|
385
|
+
"""
|
|
386
|
+
Loads the CSS files for the current theme.
|
|
387
|
+
|
|
388
|
+
A theme without a stylesheet and a theme this loader never registered
|
|
389
|
+
(one of Textual's built-ins, for instance) are both normal: the CSS of
|
|
390
|
+
the previous theme is removed and nothing is loaded in its place.
|
|
391
|
+
|
|
392
|
+
Parameters
|
|
393
|
+
----------
|
|
394
|
+
theme_name : str
|
|
395
|
+
The registered name of the theme, including its prefix.
|
|
396
|
+
app : App
|
|
397
|
+
The instance of the Textual application.
|
|
398
|
+
"""
|
|
399
|
+
# Remove CSS from previous theme
|
|
400
|
+
self._remove_all_theme_css(app)
|
|
401
|
+
|
|
402
|
+
theme_data = self._theme_data.get(theme_name)
|
|
403
|
+
css_files = theme_data.css_files if theme_data else None
|
|
404
|
+
if css_files:
|
|
405
|
+
self._read_css_files(css_files, app)
|
|
406
|
+
else:
|
|
407
|
+
_logger.debug("Theme %r has no stylesheet", theme_name)
|
|
408
|
+
|
|
409
|
+
self._apply_stylesheet(app)
|
|
410
|
+
|
|
411
|
+
def _read_css_files(self, css_files: list[str], app: App[None]) -> None:
|
|
412
|
+
"""Reads the given CSS files into the app's stylesheet."""
|
|
413
|
+
for css_file in css_files:
|
|
414
|
+
try:
|
|
415
|
+
app.stylesheet.read(css_file)
|
|
416
|
+
_logger.debug("Loaded CSS file: %r", css_file)
|
|
417
|
+
except Exception:
|
|
418
|
+
_logger.exception("Error loading CSS file %r", css_file)
|
|
419
|
+
|
|
420
|
+
def _apply_stylesheet(self, app: App[None]) -> None:
|
|
421
|
+
"""
|
|
422
|
+
Re-parses the stylesheet so that changes take effect.
|
|
423
|
+
|
|
424
|
+
A theme shipping invalid TCSS would otherwise take the whole
|
|
425
|
+
application down from inside this library. Keeping the previous
|
|
426
|
+
stylesheet is a defined state; the failure goes to the log.
|
|
427
|
+
"""
|
|
428
|
+
try:
|
|
429
|
+
app.stylesheet.reparse()
|
|
430
|
+
except Exception:
|
|
431
|
+
_logger.exception(
|
|
432
|
+
"Theme stylesheet could not be parsed, keeping the previous "
|
|
433
|
+
"one"
|
|
434
|
+
)
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
app.stylesheet.update(app.screen)
|
|
439
|
+
except Exception:
|
|
440
|
+
_logger.exception("Error updating stylesheet")
|
|
441
|
+
|
|
442
|
+
def _remove_all_theme_css(self, app: App[None]) -> None:
|
|
443
|
+
"""
|
|
444
|
+
Remove all CSS files that were loaded from a theme folder.
|
|
445
|
+
|
|
446
|
+
This is necessary when switching themes to avoid conflicts
|
|
447
|
+
between styles from different themes.
|
|
448
|
+
|
|
449
|
+
The files this loader actually loaded are the yardstick, not a fixed
|
|
450
|
+
directory - otherwise the stylesheet of a consumer's own theme folder
|
|
451
|
+
would never be removed.
|
|
452
|
+
|
|
453
|
+
Parameters
|
|
454
|
+
----------
|
|
455
|
+
app : App
|
|
456
|
+
The instance of the Textual application.
|
|
457
|
+
"""
|
|
458
|
+
theme_css_paths = {
|
|
459
|
+
Path(css_file).resolve()
|
|
460
|
+
for theme_data in self._theme_data.values()
|
|
461
|
+
for css_file in (theme_data.css_files or [])
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
for key in list(app.stylesheet.source.keys()):
|
|
465
|
+
path_str, _ = key
|
|
466
|
+
try:
|
|
467
|
+
css_path = Path(path_str).resolve()
|
|
468
|
+
except (OSError, ValueError):
|
|
469
|
+
# An unresolvable path is simply not one of ours
|
|
470
|
+
continue
|
|
471
|
+
|
|
472
|
+
if css_path in theme_css_paths:
|
|
473
|
+
_logger.debug("Removing CSS file: %r", path_str)
|
|
474
|
+
del app.stylesheet.source[key]
|
|
475
|
+
|
|
476
|
+
def change_to_next_or_previous_theme(
|
|
477
|
+
self, direction: int, app: App[None]
|
|
478
|
+
) -> None:
|
|
479
|
+
"""
|
|
480
|
+
Change to the next or previous theme in the list.
|
|
481
|
+
|
|
482
|
+
Cycling deliberately walks every theme the app knows, including
|
|
483
|
+
Textual's built-ins, which simply carry no termcore stylesheet.
|
|
484
|
+
|
|
485
|
+
Parameters
|
|
486
|
+
----------
|
|
487
|
+
direction : int
|
|
488
|
+
1 for next theme, -1 for previous theme.
|
|
489
|
+
app : App
|
|
490
|
+
The instance of the Textual application.
|
|
491
|
+
"""
|
|
492
|
+
themes = list(app.available_themes)
|
|
493
|
+
current_index = themes.index(app.theme)
|
|
494
|
+
next_index = (current_index + direction) % len(themes)
|
|
495
|
+
app.theme = themes[next_index]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
* {
|
|
2
|
+
scrollbar-background: $panel;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.text-area--cursor, .input--cursor {
|
|
6
|
+
color: #80e98c;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
ToggleButton, CustomCheckbox {
|
|
10
|
+
background: $panel-lighten-2;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
ToggleButton, SelectionList {
|
|
14
|
+
/* Not-selected items */
|
|
15
|
+
& > .toggle--button,
|
|
16
|
+
& > .selection-list--button, & > .selection-list--button-highlighted {
|
|
17
|
+
background: $secondary;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* Selected items */
|
|
21
|
+
&.-on > .toggle--button,
|
|
22
|
+
& > .selection-list--button-selected,
|
|
23
|
+
& > .selection-list--button-selected-highlighted {
|
|
24
|
+
background: $success-muted !important;
|
|
25
|
+
color: $success !important;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
RadioButton {
|
|
30
|
+
/* Not-selected items */
|
|
31
|
+
& > .toggle--button,
|
|
32
|
+
& > .selection-list--button, & > .selection-list--button-highlighted {
|
|
33
|
+
color: $secondary !important;
|
|
34
|
+
background: $secondary-muted !important;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* Selected items */
|
|
38
|
+
&.-on > .toggle--button,
|
|
39
|
+
& > .selection-list--button-selected,
|
|
40
|
+
& > .selection-list--button-selected-highlighted {
|
|
41
|
+
background: $success-muted !important;
|
|
42
|
+
color: $success !important;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
Switch {
|
|
47
|
+
& .switch--slider { color: $primary; background: $panel-darken-2 }
|
|
48
|
+
&.-on .switch--slider { color: $success }
|
|
49
|
+
&:hover {
|
|
50
|
+
& > .switch--slider { color: $primary-lighten-2 }
|
|
51
|
+
&.-on > .switch--slider { color: $success-lighten-2 }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
Switch {
|
|
56
|
+
& .switch--slider { background: $secondary; }
|
|
57
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from textual.theme import Theme
|
|
2
|
+
|
|
3
|
+
TEXTUAL_THEME = Theme(
|
|
4
|
+
name="classic-black-saturated",
|
|
5
|
+
primary="#5C9AC3",
|
|
6
|
+
secondary="#5B5B5B",
|
|
7
|
+
accent="#A9B7C6",
|
|
8
|
+
foreground="#E5E5E5",
|
|
9
|
+
background="#0D0E10",
|
|
10
|
+
surface="#1E2022",
|
|
11
|
+
panel="#101112",
|
|
12
|
+
boost="#016F60",
|
|
13
|
+
success="#84D667",
|
|
14
|
+
warning="#E6B863",
|
|
15
|
+
error="#DA5E57",
|
|
16
|
+
dark=True,
|
|
17
|
+
variables={
|
|
18
|
+
"block-hover-background": "#0c4d5e",
|
|
19
|
+
"footer-key-foreground": "#D2D05F",
|
|
20
|
+
"input-cursor-text-style": "reverse",
|
|
21
|
+
}
|
|
22
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
* {
|
|
2
|
+
scrollbar-background: $panel;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.text-area--cursor, .input--cursor {
|
|
6
|
+
color: #80e98c;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
ToggleButton, CustomCheckbox {
|
|
10
|
+
background: $panel-lighten-2;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
ToggleButton, SelectionList {
|
|
14
|
+
/* Not-selected items */
|
|
15
|
+
& > .toggle--button,
|
|
16
|
+
& > .selection-list--button, & > .selection-list--button-highlighted {
|
|
17
|
+
background: $secondary;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* Selected items */
|
|
21
|
+
&.-on > .toggle--button,
|
|
22
|
+
& > .selection-list--button-selected,
|
|
23
|
+
& > .selection-list--button-selected-highlighted {
|
|
24
|
+
background: $success-muted !important;
|
|
25
|
+
color: $success !important;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
RadioButton {
|
|
30
|
+
/* Not-selected items */
|
|
31
|
+
& > .toggle--button,
|
|
32
|
+
& > .selection-list--button, & > .selection-list--button-highlighted {
|
|
33
|
+
color: $secondary !important;
|
|
34
|
+
background: $secondary-muted !important;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* Selected items */
|
|
38
|
+
&.-on > .toggle--button,
|
|
39
|
+
& > .selection-list--button-selected,
|
|
40
|
+
& > .selection-list--button-selected-highlighted {
|
|
41
|
+
background: $success-muted !important;
|
|
42
|
+
color: $success !important;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
Switch {
|
|
47
|
+
& .switch--slider { color: $primary; background: $panel-darken-2 }
|
|
48
|
+
&.-on .switch--slider { color: $success }
|
|
49
|
+
&:hover {
|
|
50
|
+
& > .switch--slider { color: $primary-lighten-2 }
|
|
51
|
+
&.-on > .switch--slider { color: $success-lighten-2 }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
Switch {
|
|
56
|
+
& .switch--slider { background: $secondary; }
|
|
57
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from textual.theme import Theme
|
|
2
|
+
|
|
3
|
+
TEXTUAL_THEME = Theme(
|
|
4
|
+
name="classic-black-v1",
|
|
5
|
+
primary="#7CA6C2",
|
|
6
|
+
secondary="#5B5B5B",
|
|
7
|
+
accent="#A9B7C6",
|
|
8
|
+
foreground="#E5E5E5",
|
|
9
|
+
background="#151618",
|
|
10
|
+
surface="#1E2022",
|
|
11
|
+
panel="#242628",
|
|
12
|
+
boost="#016F60",
|
|
13
|
+
success="#A3C995",
|
|
14
|
+
warning="#E6C384",
|
|
15
|
+
error="#D67B76",
|
|
16
|
+
dark=True,
|
|
17
|
+
variables={
|
|
18
|
+
"block-hover-background": "#335D48",
|
|
19
|
+
"footer-key-foreground": "#D2D05F",
|
|
20
|
+
"input-cursor-text-style": "reverse",
|
|
21
|
+
}
|
|
22
|
+
)
|