summaryrefslogtreecommitdiffstats
path: root/contrib/python
diff options
context:
space:
mode:
authorrobot-piglet <[email protected]>2026-05-13 08:51:44 +0300
committerrobot-piglet <[email protected]>2026-05-13 09:43:18 +0300
commit21b994f3cab88fade95b9dabe9b1b491627b822d (patch)
treee27d9725b1f4f23d6e137c5c115715c1070600df /contrib/python
parent2e7284b4e24ef8749490a6bb96e6edb645520af1 (diff)
Intermediate changes
commit_hash:9dd0a391400eb50723299039cdf1b64398309ddd
Diffstat (limited to 'contrib/python')
-rw-r--r--contrib/python/allure-pytest/.dist-info/METADATA4
-rw-r--r--contrib/python/allure-pytest/allure_pytest/listener.py54
-rw-r--r--contrib/python/allure-pytest/allure_pytest/plugin.py40
-rw-r--r--contrib/python/allure-pytest/allure_pytest/stash.py2
-rw-r--r--contrib/python/allure-pytest/allure_pytest/utils.py20
-rw-r--r--contrib/python/allure-pytest/ya.make2
-rw-r--r--contrib/python/allure-python-commons/.dist-info/METADATA2
-rw-r--r--contrib/python/allure-python-commons/allure/__init__.py50
-rw-r--r--contrib/python/allure-python-commons/allure_commons/__init__.py16
-rw-r--r--contrib/python/allure-python-commons/allure_commons/_allure.py45
-rw-r--r--contrib/python/allure-python-commons/allure_commons/_core.py2
-rw-r--r--contrib/python/allure-python-commons/allure_commons/_hooks.py16
-rw-r--r--contrib/python/allure-python-commons/allure_commons/lifecycle.py52
-rw-r--r--contrib/python/allure-python-commons/allure_commons/logger.py16
-rw-r--r--contrib/python/allure-python-commons/allure_commons/mapping.py4
-rw-r--r--contrib/python/allure-python-commons/allure_commons/model2.py37
-rw-r--r--contrib/python/allure-python-commons/allure_commons/reporter.py40
-rw-r--r--contrib/python/allure-python-commons/allure_commons/types.py50
-rw-r--r--contrib/python/allure-python-commons/allure_commons/utils.py14
-rw-r--r--contrib/python/allure-python-commons/ya.make2
-rw-r--r--contrib/python/textual/.dist-info/METADATA4
-rw-r--r--contrib/python/textual/textual/__init__.py65
-rw-r--r--contrib/python/textual/textual/__main__.py13
-rw-r--r--contrib/python/textual/textual/_animator.py12
-rw-r--r--contrib/python/textual/textual/_arrange.py2
-rw-r--r--contrib/python/textual/textual/_compat.py73
-rw-r--r--contrib/python/textual/textual/_compositor.py2
-rw-r--r--contrib/python/textual/textual/_extrema.py64
-rw-r--r--contrib/python/textual/textual/_markup_playground.py62
-rw-r--r--contrib/python/textual/textual/_parser.py2
-rw-r--r--contrib/python/textual/textual/_styles_cache.py5
-rw-r--r--contrib/python/textual/textual/_xterm_parser.py26
-rw-r--r--contrib/python/textual/textual/app.py185
-rw-r--r--contrib/python/textual/textual/canvas.py6
-rw-r--r--contrib/python/textual/textual/case.py2
-rw-r--r--contrib/python/textual/textual/color.py42
-rw-r--r--contrib/python/textual/textual/constants.py13
-rw-r--r--contrib/python/textual/textual/containers.py32
-rw-r--r--contrib/python/textual/textual/content.py6
-rw-r--r--contrib/python/textual/textual/css/_style_properties.py2
-rw-r--r--contrib/python/textual/textual/css/constants.py2
-rw-r--r--contrib/python/textual/textual/css/parse.py4
-rw-r--r--contrib/python/textual/textual/css/styles.py7
-rw-r--r--contrib/python/textual/textual/css/stylesheet.py4
-rw-r--r--contrib/python/textual/textual/css/tokenizer.py24
-rw-r--r--contrib/python/textual/textual/demo/home.py2
-rw-r--r--contrib/python/textual/textual/document/_document.py5
-rw-r--r--contrib/python/textual/textual/dom.py26
-rw-r--r--contrib/python/textual/textual/filter.py19
-rw-r--r--contrib/python/textual/textual/fuzzy.py2
-rw-r--r--contrib/python/textual/textual/geometry.py8
-rw-r--r--contrib/python/textual/textual/getters.py188
-rw-r--r--contrib/python/textual/textual/keys.py12
-rw-r--r--contrib/python/textual/textual/layout.py7
-rw-r--r--contrib/python/textual/textual/markup.py77
-rw-r--r--contrib/python/textual/textual/message_pump.py24
-rw-r--r--contrib/python/textual/textual/notifications.py3
-rw-r--r--contrib/python/textual/textual/reactive.py16
-rw-r--r--contrib/python/textual/textual/renderables/blank.py44
-rw-r--r--contrib/python/textual/textual/renderables/text_opacity.py6
-rw-r--r--contrib/python/textual/textual/renderables/tint.py16
-rw-r--r--contrib/python/textual/textual/screen.py59
-rw-r--r--contrib/python/textual/textual/signal.py16
-rw-r--r--contrib/python/textual/textual/timer.py13
-rw-r--r--contrib/python/textual/textual/visual.py2
-rw-r--r--contrib/python/textual/textual/widget.py185
-rw-r--r--contrib/python/textual/textual/widgets/__init__.py3
-rw-r--r--contrib/python/textual/textual/widgets/__init__.pyi1
-rw-r--r--contrib/python/textual/textual/widgets/_button.py12
-rw-r--r--contrib/python/textual/textual/widgets/_collapsible.py34
-rw-r--r--contrib/python/textual/textual/widgets/_input.py57
-rw-r--r--contrib/python/textual/textual/widgets/_markdown.py31
-rw-r--r--contrib/python/textual/textual/widgets/_masked_input.py2
-rw-r--r--contrib/python/textual/textual/widgets/_option_list.py41
-rw-r--r--contrib/python/textual/textual/widgets/_radio_set.py18
-rw-r--r--contrib/python/textual/textual/widgets/_select.py26
-rw-r--r--contrib/python/textual/textual/widgets/_selection_list.py4
-rw-r--r--contrib/python/textual/textual/widgets/_static.py19
-rw-r--r--contrib/python/textual/textual/widgets/_text_area.py240
-rw-r--r--contrib/python/textual/textual/widgets/_toast.py39
-rw-r--r--contrib/python/textual/textual/widgets/_toggle_button.py11
-rw-r--r--contrib/python/textual/textual/widgets/collapsible.py3
-rw-r--r--contrib/python/textual/textual/worker.py4
-rw-r--r--contrib/python/textual/ya.make6
84 files changed, 1859 insertions, 549 deletions
diff --git a/contrib/python/allure-pytest/.dist-info/METADATA b/contrib/python/allure-pytest/.dist-info/METADATA
index fe65aa63e02..e8fa3f60a59 100644
--- a/contrib/python/allure-pytest/.dist-info/METADATA
+++ b/contrib/python/allure-pytest/.dist-info/METADATA
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: allure-pytest
-Version: 2.15.3
+Version: 2.16.0
Summary: Allure pytest integration
Home-page: https://allurereport.org/
Author: Qameta Software Inc., Stanislav Seliverstov
@@ -25,7 +25,7 @@ Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Description-Content-Type: text/markdown
Requires-Dist: pytest>=4.5.0
-Requires-Dist: allure-python-commons==2.15.3
+Requires-Dist: allure-python-commons==2.16.0
Dynamic: author
Dynamic: author-email
Dynamic: classifier
diff --git a/contrib/python/allure-pytest/allure_pytest/listener.py b/contrib/python/allure-pytest/allure_pytest/listener.py
index 42b7ff49176..10ec29df23a 100644
--- a/contrib/python/allure-pytest/allure_pytest/listener.py
+++ b/contrib/python/allure-pytest/allure_pytest/listener.py
@@ -149,14 +149,14 @@ class AllureListener:
self.__apply_default_suites(item, test_result)
test_result.labels.append(Label(name=LabelType.HOST, value=self._host))
test_result.labels.append(Label(name=LabelType.THREAD, value=self._thread))
- test_result.labels.append(Label(name=LabelType.FRAMEWORK, value='pytest'))
+ test_result.labels.append(Label(name=LabelType.FRAMEWORK, value="pytest"))
test_result.labels.append(Label(name=LabelType.LANGUAGE, value=platform_label()))
- test_result.labels.append(Label(name='package', value=allure_package(item)))
+ test_result.labels.append(Label(name="package", value=allure_package(item)))
test_result.links.extend([Link(link_type, url, name) for link_type, url, name in allure_links(item)])
@pytest.hookimpl(hookwrapper=True)
def pytest_fixture_setup(self, fixturedef, request):
- fixture_name = getattr(fixturedef.func, '__allure_display_name__', fixturedef.argname)
+ fixture_name = getattr(fixturedef.func, "__allure_display_name__", fixturedef.argname)
container_uuid = self._cache.get(fixturedef)
@@ -178,16 +178,16 @@ class AllureListener:
status=get_outcome_status(outcome),
statusDetails=get_outcome_status_details(outcome))
- finalizers = getattr(fixturedef, '_finalizers', [])
+ finalizers = getattr(fixturedef, "_finalizers", [])
for index, finalizer in enumerate(finalizers):
finalizer_name = getattr(finalizer, "__name__", index)
- name = f'{fixture_name}::{finalizer_name}'
+ name = f"{fixture_name}::{finalizer_name}"
finalizers[index] = allure_commons.fixture(finalizer, parent_uuid=container_uuid, name=name)
@pytest.hookimpl(hookwrapper=True)
def pytest_fixture_post_finalizer(self, fixturedef):
yield
- if hasattr(fixturedef, 'cached_result') and self._cache.get(fixturedef):
+ if hasattr(fixturedef, "cached_result") and self._cache.get(fixturedef):
container_uuid = self._cache.pop(fixturedef)
self.allure_logger.stop_group(container_uuid, stop=now())
@@ -203,9 +203,9 @@ class AllureListener:
if call.excinfo:
message = call.excinfo.exconly()
- if hasattr(report, 'wasxfail'):
+ if hasattr(report, "wasxfail"):
reason = report.wasxfail
- message = (f'XFAIL {reason}' if reason else 'XFAIL') + '\n\n' + message
+ message = (f"XFAIL {reason}" if reason else "XFAIL") + "\n\n" + message
trace = report.longreprtext
status_details = StatusDetails(
message=message,
@@ -215,21 +215,21 @@ class AllureListener:
if (status != Status.SKIPPED and _exception_brokes_test(exception)):
status = Status.BROKEN
- if status == Status.PASSED and hasattr(report, 'wasxfail'):
+ if status == Status.PASSED and hasattr(report, "wasxfail"):
reason = report.wasxfail
- message = f'XPASS {reason}' if reason else 'XPASS'
+ message = f"XPASS {reason}" if reason else "XPASS"
status_details = StatusDetails(message=message)
- if report.when == 'setup':
+ if report.when == "setup":
test_result.status = status
test_result.statusDetails = status_details
- if report.when == 'call':
+ if report.when == "call":
if test_result.status == Status.PASSED:
test_result.status = status
test_result.statusDetails = status_details
- if report.when == 'teardown':
+ if report.when == "teardown":
if status in (Status.FAILED, Status.BROKEN) and test_result.status == Status.PASSED:
test_result.status = status
test_result.statusDetails = status_details
@@ -258,6 +258,30 @@ class AllureListener:
self.allure_logger.attach_file(uuid4(), source, name=name, attachment_type=attachment_type, extension=extension)
@allure_commons.hookimpl
+ def global_attach_data(self, body, name, attachment_type, extension):
+ self.allure_logger.global_attach_data(
+ uuid4(),
+ body,
+ name=name,
+ attachment_type=attachment_type,
+ extension=extension,
+ )
+
+ @allure_commons.hookimpl
+ def global_attach_file(self, source, name, attachment_type, extension):
+ self.allure_logger.global_attach_file(
+ uuid4(),
+ source,
+ name=name,
+ attachment_type=attachment_type,
+ extension=extension,
+ )
+
+ @allure_commons.hookimpl
+ def global_error(self, message, trace):
+ self.allure_logger.global_error(message=message, trace=trace)
+
+ @allure_commons.hookimpl
def add_title(self, test_title):
test_result = self.allure_logger.get_test(None)
if test_result:
@@ -310,11 +334,11 @@ class AllureListener:
@staticmethod
def __get_pytest_params(item):
- return item.callspec.params if hasattr(item, 'callspec') else {}
+ return item.callspec.params if hasattr(item, "callspec") else {}
@staticmethod
def __get_pytest_param_id(item):
- return item.callspec.id if hasattr(item, 'callspec') else None
+ return item.callspec.id if hasattr(item, "callspec") else None
def __apply_default_suites(self, item, test_result):
default_suites = allure_suite_labels(item)
diff --git a/contrib/python/allure-pytest/allure_pytest/plugin.py b/contrib/python/allure-pytest/allure_pytest/plugin.py
index 2771722ffcc..9efc358b583 100644
--- a/contrib/python/allure-pytest/allure_pytest/plugin.py
+++ b/contrib/python/allure-pytest/allure_pytest/plugin.py
@@ -17,24 +17,24 @@ from allure_pytest.utils import ALLURE_LABEL_MARK, ALLURE_LINK_MARK
def pytest_addoption(parser):
- parser.getgroup("reporting").addoption('--alluredir',
+ parser.getgroup("reporting").addoption("--alluredir",
action="store",
dest="allure_report_dir",
metavar="DIR",
default=None,
help="Generate Allure report in the specified directory (may not exist)")
- parser.getgroup("reporting").addoption('--clean-alluredir',
+ parser.getgroup("reporting").addoption("--clean-alluredir",
action="store_true",
dest="clean_alluredir",
help="Clean alluredir folder if it exists")
- parser.getgroup("reporting").addoption('--allure-no-capture',
+ parser.getgroup("reporting").addoption("--allure-no-capture",
action="store_false",
dest="attach_capture",
help="Do not attach pytest captured logging/stdout/stderr to report")
- parser.getgroup("reporting").addoption('--inversion',
+ parser.getgroup("reporting").addoption("--inversion",
action="store",
dest="inversion",
default=False,
@@ -42,21 +42,21 @@ def pytest_addoption(parser):
def label_type(type_name, legal_values=set()):
def a_label_type(string):
- atoms = set(string.split(','))
+ atoms = set(string.split(","))
if type_name is LabelType.SEVERITY:
if not atoms <= legal_values:
- raise argparse.ArgumentTypeError('Illegal {} values: {}, only [{}] are allowed'.format(
+ raise argparse.ArgumentTypeError("Illegal {} values: {}, only [{}] are allowed".format(
type_name,
- ', '.join(atoms - legal_values),
- ', '.join(legal_values)
+ ", ".join(atoms - legal_values),
+ ", ".join(legal_values)
))
return set((type_name, allure.severity_level(atom)) for atom in atoms)
return set((type_name, atom) for atom in atoms)
return a_label_type
severities = [x.value for x in list(allure.severity_level)]
- formatted_severities = ', '.join(severities)
- parser.getgroup("general").addoption('--allure-severities',
+ formatted_severities = ", ".join(severities)
+ parser.getgroup("general").addoption("--allure-severities",
action="store",
dest="allure_severities",
metavar="SEVERITIES_SET",
@@ -66,7 +66,7 @@ def pytest_addoption(parser):
Tests only with these severities will be run.
Possible values are: {formatted_severities}.""")
- parser.getgroup("general").addoption('--allure-epics',
+ parser.getgroup("general").addoption("--allure-epics",
action="store",
dest="allure_epics",
metavar="EPICS_SET",
@@ -75,7 +75,7 @@ def pytest_addoption(parser):
help="""Comma-separated list of epic names.
Run tests that have at least one of the specified feature labels.""")
- parser.getgroup("general").addoption('--allure-features',
+ parser.getgroup("general").addoption("--allure-features",
action="store",
dest="allure_features",
metavar="FEATURES_SET",
@@ -84,7 +84,7 @@ def pytest_addoption(parser):
help="""Comma-separated list of feature names.
Run tests that have at least one of the specified feature labels.""")
- parser.getgroup("general").addoption('--allure-stories',
+ parser.getgroup("general").addoption("--allure-stories",
action="store",
dest="allure_stories",
metavar="STORIES_SET",
@@ -93,7 +93,7 @@ def pytest_addoption(parser):
help="""Comma-separated list of story names.
Run tests that have at least one of the specified story labels.""")
- parser.getgroup("general").addoption('--allure-ids',
+ parser.getgroup("general").addoption("--allure-ids",
action="store",
dest="allure_ids",
metavar="IDS_SET",
@@ -107,7 +107,7 @@ def pytest_addoption(parser):
atoms = set(values.split(","))
return [(type_name, atom) for atom in atoms]
- parser.getgroup("general").addoption('--allure-label',
+ parser.getgroup("general").addoption("--allure-label",
action="append",
dest="allure_labels",
metavar="LABELS_SET",
@@ -117,15 +117,15 @@ def pytest_addoption(parser):
"Run tests that have at least one of the specified labels.""")
def link_pattern(string):
- pattern = string.split(':', 1)
+ pattern = string.split(":", 1)
if not pattern[0]:
- raise argparse.ArgumentTypeError('Link type is mandatory.')
+ raise argparse.ArgumentTypeError("Link type is mandatory.")
if len(pattern) != 2:
- raise argparse.ArgumentTypeError('Link pattern is mandatory')
+ raise argparse.ArgumentTypeError("Link pattern is mandatory")
return pattern
- parser.getgroup("general").addoption('--allure-link-pattern',
+ parser.getgroup("general").addoption("--allure-link-pattern",
action="append",
dest="allure_link_pattern",
metavar="LINK_TYPE:LINK_PATTERN",
@@ -160,7 +160,7 @@ def pytest_configure(config):
if report_dir:
report_dir = os.path.abspath(report_dir)
test_listener = AllureListener(config)
- config.pluginmanager.register(test_listener, 'allure_listener')
+ config.pluginmanager.register(test_listener, "allure_listener")
allure_commons.plugin_manager.register(test_listener)
config.add_cleanup(cleanup_factory(test_listener))
diff --git a/contrib/python/allure-pytest/allure_pytest/stash.py b/contrib/python/allure-pytest/allure_pytest/stash.py
index 31d9302b802..83e31b577ac 100644
--- a/contrib/python/allure-pytest/allure_pytest/stash.py
+++ b/contrib/python/allure-pytest/allure_pytest/stash.py
@@ -1,7 +1,7 @@
import pytest
from functools import wraps
-HAS_STASH = hasattr(pytest, 'StashKey')
+HAS_STASH = hasattr(pytest, "StashKey")
def create_stashkey_safe():
diff --git a/contrib/python/allure-pytest/allure_pytest/utils.py b/contrib/python/allure-pytest/allure_pytest/utils.py
index 56594a09503..31ffb63f978 100644
--- a/contrib/python/allure-pytest/allure_pytest/utils.py
+++ b/contrib/python/allure-pytest/allure_pytest/utils.py
@@ -7,10 +7,10 @@ from allure_commons.model2 import StatusDetails
from allure_commons.types import LabelType
from allure_pytest.stash import stashed
-ALLURE_DESCRIPTION_MARK = 'allure_description'
-ALLURE_DESCRIPTION_HTML_MARK = 'allure_description_html'
-ALLURE_LABEL_MARK = 'allure_label'
-ALLURE_LINK_MARK = 'allure_link'
+ALLURE_DESCRIPTION_MARK = "allure_description"
+ALLURE_DESCRIPTION_HTML_MARK = "allure_description_html"
+ALLURE_LABEL_MARK = "allure_label"
+ALLURE_LINK_MARK = "allure_link"
ALLURE_UNIQUE_LABELS = [
LabelType.SEVERITY,
LabelType.FRAMEWORK,
@@ -34,11 +34,11 @@ class ParsedPytestNodeId:
def __init__(self, nodeid):
filepath, *class_names, function_segment = ensure_len(nodeid.split("::"), 2)
self.filepath = filepath
- self.path_segments = filepath.split('/')
+ self.path_segments = filepath.split("/")
*parent_dirs, filename = ensure_len(self.path_segments, 1)
- self.parent_package = '.'.join(parent_dirs)
+ self.parent_package = ".".join(parent_dirs)
self.module = filename.rsplit(".", 1)[0]
- self.package = '.'.join(filter(None, [self.parent_package, self.module]))
+ self.package = ".".join(filter(None, [self.parent_package, self.module]))
self.class_names = class_names
self.test_function = function_segment.split("[", 1)[0]
@@ -65,7 +65,7 @@ def allure_description(item):
description = get_marker_value(item, ALLURE_DESCRIPTION_MARK)
if description:
return description
- elif hasattr(item, 'function'):
+ elif hasattr(item, "function"):
return item.function.__doc__
@@ -103,7 +103,7 @@ def allure_links(item):
def format_allure_link(config, url, link_type):
- pattern = dict(config.option.allure_link_pattern).get(link_type, '{}')
+ pattern = dict(config.option.allure_link_pattern).get(link_type, "{}")
return pattern.format(url)
@@ -203,7 +203,7 @@ def get_status_details(exception_type, exception, exception_traceback):
def get_pytest_report_status(pytest_report):
- pytest_statuses = ('failed', 'passed', 'skipped')
+ pytest_statuses = ("failed", "passed", "skipped")
statuses = (Status.FAILED, Status.PASSED, Status.SKIPPED)
for pytest_status, status in zip(pytest_statuses, statuses):
if getattr(pytest_report, pytest_status):
diff --git a/contrib/python/allure-pytest/ya.make b/contrib/python/allure-pytest/ya.make
index 7881df4ad6c..55eb3a44500 100644
--- a/contrib/python/allure-pytest/ya.make
+++ b/contrib/python/allure-pytest/ya.make
@@ -2,7 +2,7 @@
PY3_LIBRARY()
-VERSION(2.15.3)
+VERSION(2.16.0)
LICENSE(Apache-2.0)
diff --git a/contrib/python/allure-python-commons/.dist-info/METADATA b/contrib/python/allure-python-commons/.dist-info/METADATA
index 4d9e390bc7c..ec525d816a0 100644
--- a/contrib/python/allure-python-commons/.dist-info/METADATA
+++ b/contrib/python/allure-python-commons/.dist-info/METADATA
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: allure-python-commons
-Version: 2.15.3
+Version: 2.16.0
Summary: Contains the API for end users as well as helper functions and classes to build Allure adapters for Python test frameworks
Home-page: https://allurereport.org/
Author: Qameta Software Inc., Stanislav Seliverstov
diff --git a/contrib/python/allure-python-commons/allure/__init__.py b/contrib/python/allure-python-commons/allure/__init__.py
index c30329a6e8a..6fe2f270a4e 100644
--- a/contrib/python/allure-python-commons/allure/__init__.py
+++ b/contrib/python/allure-python-commons/allure/__init__.py
@@ -10,6 +10,8 @@ from allure_commons._allure import link, issue, testcase
from allure_commons._allure import Dynamic as dynamic
from allure_commons._allure import step
from allure_commons._allure import attach
+from allure_commons._allure import global_attach
+from allure_commons._allure import global_error
from allure_commons._allure import manual
from allure_commons.types import Severity as severity_level
from allure_commons.types import AttachmentType as attachment_type
@@ -17,27 +19,29 @@ from allure_commons.types import ParameterMode as parameter_mode
__all__ = [
- 'title',
- 'description',
- 'description_html',
- 'label',
- 'severity',
- 'suite',
- 'parent_suite',
- 'sub_suite',
- 'tag',
- 'id',
- 'epic',
- 'feature',
- 'story',
- 'link',
- 'issue',
- 'testcase',
- 'manual',
- 'step',
- 'dynamic',
- 'severity_level',
- 'attach',
- 'attachment_type',
- 'parameter_mode'
+ "title",
+ "description",
+ "description_html",
+ "label",
+ "severity",
+ "suite",
+ "parent_suite",
+ "sub_suite",
+ "tag",
+ "id",
+ "epic",
+ "feature",
+ "story",
+ "link",
+ "issue",
+ "testcase",
+ "manual",
+ "step",
+ "dynamic",
+ "severity_level",
+ "attach",
+ "global_attach",
+ "global_error",
+ "attachment_type",
+ "parameter_mode"
]
diff --git a/contrib/python/allure-python-commons/allure_commons/__init__.py b/contrib/python/allure-python-commons/allure_commons/__init__.py
index 111c2d06c28..e480aceab69 100644
--- a/contrib/python/allure-python-commons/allure_commons/__init__.py
+++ b/contrib/python/allure-python-commons/allure_commons/__init__.py
@@ -1,12 +1,12 @@
-from allure_commons._hooks import hookimpl # noqa: F401
-from allure_commons._core import plugin_manager # noqa: F401
-from allure_commons._allure import fixture # noqa: F401
-from allure_commons._allure import test # noqa: F401
+from allure_commons._hooks import hookimpl
+from allure_commons._core import plugin_manager
+from allure_commons._allure import fixture
+from allure_commons._allure import test
__all__ = [
- 'hookimpl',
- 'plugin_manager',
- 'fixture',
- 'test'
+ "hookimpl",
+ "plugin_manager",
+ "fixture",
+ "test"
]
diff --git a/contrib/python/allure-python-commons/allure_commons/_allure.py b/contrib/python/allure-python-commons/allure_commons/_allure.py
index b7bbe2a5e07..607e1cb873b 100644
--- a/contrib/python/allure-python-commons/allure_commons/_allure.py
+++ b/contrib/python/allure-python-commons/allure_commons/_allure.py
@@ -4,6 +4,7 @@ from typing import Any, Callable, TypeVar, Union, overload
from allure_commons._core import plugin_manager
from allure_commons.types import LabelType, LinkType, ParameterMode
from allure_commons.utils import uuid4
+from allure_commons.utils import format_exception, format_traceback
from allure_commons.utils import func_parameters, represent
_TFunc = TypeVar("_TFunc", bound=Callable[..., Any])
@@ -125,7 +126,7 @@ class Dynamic:
Dynamic.label(LabelType.TAG, *tags)
@staticmethod
- def id(id): # noqa: A003,A002
+ def id(id): # noqa: A002
Dynamic.label(LabelType.ID, id)
@staticmethod
@@ -216,6 +217,48 @@ class Attach:
attach = Attach()
+class GlobalAttach:
+
+ def __call__(self, body, name=None, attachment_type=None, extension=None):
+ plugin_manager.hook.global_attach_data(
+ body=body,
+ name=name,
+ attachment_type=attachment_type,
+ extension=extension,
+ )
+
+ def file(self, source, name=None, attachment_type=None, extension=None):
+ plugin_manager.hook.global_attach_file(
+ source=source,
+ name=name,
+ attachment_type=attachment_type,
+ extension=extension,
+ )
+
+
+global_attach = GlobalAttach()
+
+
+@overload
+def global_error(value: BaseException) -> None:
+ ...
+
+
+@overload
+def global_error(value: str, trace: Union[str, None] = None) -> None:
+ ...
+
+
+def global_error(value, trace=None):
+ message = None
+ if isinstance(value, BaseException):
+ message = format_exception(type(value), value)
+ trace = format_traceback(value.__traceback__)
+ else:
+ message = value
+ plugin_manager.hook.global_error(message=message, trace=trace)
+
+
class fixture:
def __init__(self, fixture_function, parent_uuid=None, name=None):
self._fixture_function = fixture_function
diff --git a/contrib/python/allure-python-commons/allure_commons/_core.py b/contrib/python/allure-python-commons/allure_commons/_core.py
index 40d9deafb78..8687891cf75 100644
--- a/contrib/python/allure-python-commons/allure_commons/_core.py
+++ b/contrib/python/allure-python-commons/allure_commons/_core.py
@@ -8,7 +8,7 @@ class MetaPluginManager(type):
@staticmethod
def get_plugin_manager():
if not MetaPluginManager._plugin_manager:
- MetaPluginManager._plugin_manager = PluginManager('allure')
+ MetaPluginManager._plugin_manager = PluginManager("allure")
MetaPluginManager._plugin_manager.add_hookspecs(_hooks.AllureUserHooks)
MetaPluginManager._plugin_manager.add_hookspecs(_hooks.AllureDeveloperHooks)
diff --git a/contrib/python/allure-python-commons/allure_commons/_hooks.py b/contrib/python/allure-python-commons/allure_commons/_hooks.py
index 0ff19a27d3a..84e916d900b 100644
--- a/contrib/python/allure-python-commons/allure_commons/_hooks.py
+++ b/contrib/python/allure-python-commons/allure_commons/_hooks.py
@@ -66,6 +66,18 @@ class AllureUserHooks:
def attach_file(self, source, name, attachment_type, extension):
""" attach file """
+ @hookspec
+ def global_attach_data(self, body, name, attachment_type, extension):
+ """ attach global data """
+
+ @hookspec
+ def global_attach_file(self, source, name, attachment_type, extension):
+ """ attach global file """
+
+ @hookspec
+ def global_error(self, message, trace):
+ """ global error """
+
class AllureDeveloperHooks:
@@ -100,3 +112,7 @@ class AllureDeveloperHooks:
@hookspec
def report_attached_data(self, body, file_name):
""" reporting """
+
+ @hookspec
+ def report_globals(self, globals_item):
+ """ reporting """
diff --git a/contrib/python/allure-python-commons/allure_commons/lifecycle.py b/contrib/python/allure-python-commons/allure_commons/lifecycle.py
index 2e730e2e433..e2c2251ebcf 100644
--- a/contrib/python/allure-python-commons/allure_commons/lifecycle.py
+++ b/contrib/python/allure-python-commons/allure_commons/lifecycle.py
@@ -4,6 +4,7 @@ from allure_commons._core import plugin_manager
from allure_commons.model2 import TestResultContainer
from allure_commons.model2 import TestResult
from allure_commons.model2 import Attachment, ATTACHMENT_PATTERN
+from allure_commons.model2 import GlobalAttachment, GlobalError, Globals
from allure_commons.model2 import TestStepResult
from allure_commons.model2 import ExecutableItem
from allure_commons.model2 import TestBeforeResult
@@ -124,14 +125,11 @@ class AllureLifecycle:
fixture.stop = now()
def _attach(self, uuid, name=None, attachment_type=None, extension=None, parent_uuid=None):
- mime_type = attachment_type
- extension = extension if extension else 'attach'
-
- if type(attachment_type) is AttachmentType:
- extension = attachment_type.extension
- mime_type = attachment_type.mime_type
-
- file_name = ATTACHMENT_PATTERN.format(prefix=uuid, ext=extension)
+ file_name, mime_type = self.__resolve_attachment_filename_and_type(
+ uuid,
+ attachment_type=attachment_type,
+ extension=extension,
+ )
attachment = Attachment(source=file_name, name=name, type=mime_type)
last_uuid = parent_uuid if parent_uuid else self._last_item_uuid(ExecutableItem)
self._items[last_uuid].attachments.append(attachment)
@@ -147,3 +145,41 @@ class AllureLifecycle:
file_name = self._attach(uuid, name=name, attachment_type=attachment_type,
extension=extension, parent_uuid=parent_uuid)
plugin_manager.hook.report_attached_data(body=body, file_name=file_name)
+
+ def global_attach_file(self, uuid, source, name=None, attachment_type=None, extension=None):
+ file_name, mime_type = self.__resolve_attachment_filename_and_type(
+ uuid,
+ attachment_type=attachment_type,
+ extension=extension,
+ )
+ plugin_manager.hook.report_attached_file(source=source, file_name=file_name)
+ plugin_manager.hook.report_globals(globals_item=Globals(attachments=[
+ GlobalAttachment(source=file_name, name=name, type=mime_type, timestamp=now())
+ ]))
+
+ def global_attach_data(self, uuid, body, name=None, attachment_type=None, extension=None):
+ file_name, mime_type = self.__resolve_attachment_filename_and_type(
+ uuid,
+ attachment_type=attachment_type,
+ extension=extension,
+ )
+ plugin_manager.hook.report_attached_data(body=body, file_name=file_name)
+ plugin_manager.hook.report_globals(globals_item=Globals(attachments=[
+ GlobalAttachment(source=file_name, name=name, type=mime_type, timestamp=now())
+ ]))
+
+ def global_error(self, message=None, trace=None):
+ plugin_manager.hook.report_globals(globals_item=Globals(errors=[
+ GlobalError(message=message, trace=trace, timestamp=now())
+ ]))
+
+ def __resolve_attachment_filename_and_type(self, uuid, attachment_type=None, extension=None):
+ mime_type = attachment_type
+ extension = extension if extension else "attach"
+
+ if type(attachment_type) is AttachmentType:
+ extension = attachment_type.extension
+ mime_type = attachment_type.mime_type
+
+ file_name = ATTACHMENT_PATTERN.format(prefix=uuid, ext=extension)
+ return file_name, mime_type
diff --git a/contrib/python/allure-python-commons/allure_commons/logger.py b/contrib/python/allure-python-commons/allure_commons/logger.py
index 55f956f2507..4345bc77db0 100644
--- a/contrib/python/allure-python-commons/allure_commons/logger.py
+++ b/contrib/python/allure-python-commons/allure_commons/logger.py
@@ -22,7 +22,7 @@ class AllureFileLogger:
indent = INDENT if os.environ.get("ALLURE_INDENT_OUTPUT") else None
filename = item.file_pattern.format(prefix=uuid.uuid4())
data = asdict(item, filter=lambda _, v: v or v is False)
- with io.open(self._report_dir / filename, 'w', encoding='utf8') as json_file:
+ with io.open(self._report_dir / filename, "w", encoding="utf8") as json_file:
json.dump(data, json_file, indent=indent, ensure_ascii=False)
@hookimpl
@@ -41,12 +41,16 @@ class AllureFileLogger:
@hookimpl
def report_attached_data(self, body, file_name):
destination = self._report_dir / file_name
- with open(destination, 'wb') as attached_file:
+ with open(destination, "wb") as attached_file:
if isinstance(body, str):
- attached_file.write(body.encode('utf-8'))
+ attached_file.write(body.encode("utf-8"))
else:
attached_file.write(body)
+ @hookimpl
+ def report_globals(self, globals_item):
+ self._report_item(globals_item)
+
class AllureMemoryLogger:
@@ -54,6 +58,7 @@ class AllureMemoryLogger:
self.test_cases = []
self.test_containers = []
self.attachments = {}
+ self.globals = []
@hookimpl
def report_result(self, result):
@@ -72,3 +77,8 @@ class AllureMemoryLogger:
@hookimpl
def report_attached_data(self, body, file_name):
self.attachments[file_name] = body
+
+ @hookimpl
+ def report_globals(self, globals_item):
+ data = asdict(globals_item, filter=lambda _, v: v or v is False)
+ self.globals.append(data)
diff --git a/contrib/python/allure-python-commons/allure_commons/mapping.py b/contrib/python/allure-python-commons/allure_commons/mapping.py
index 737d3390a46..0cd099f0c3b 100644
--- a/contrib/python/allure-python-commons/allure_commons/mapping.py
+++ b/contrib/python/allure-python-commons/allure_commons/mapping.py
@@ -20,7 +20,7 @@ def allure_tag_sep(tag):
def __is(kind, t):
- return kind in [v for k, v in t.__dict__.items() if not k.startswith('__')]
+ return kind in [v for k, v in t.__dict__.items() if not k.startswith("__")]
def parse_tag(tag, issue_pattern=None, link_pattern=None):
@@ -51,7 +51,7 @@ def parse_tag(tag, issue_pattern=None, link_pattern=None):
"""
sep = allure_tag_sep(tag)
schema, value = islice(chain(tag.split(sep, 1), [None]), 2)
- prefix, kind, name = islice(chain(schema.split('.'), [None], [None]), 3)
+ prefix, kind, name = islice(chain(schema.split("."), [None], [None]), 3)
if tag in [severity for severity in Severity]:
return Label(name=LabelType.SEVERITY, value=tag)
diff --git a/contrib/python/allure-python-commons/allure_commons/model2.py b/contrib/python/allure-python-commons/allure_commons/model2.py
index d8591598533..cd069b176fd 100644
--- a/contrib/python/allure-python-commons/allure_commons/model2.py
+++ b/contrib/python/allure-python-commons/allure_commons/model2.py
@@ -4,7 +4,8 @@ from attr import Factory
TEST_GROUP_PATTERN = "{prefix}-container.json"
TEST_CASE_PATTERN = "{prefix}-result.json"
-ATTACHMENT_PATTERN = '{prefix}-attachment.{ext}'
+ATTACHMENT_PATTERN = "{prefix}-attachment.{ext}"
+GLOBALS_PATTERN = "{prefix}-globals.json"
INDENT = 4
@@ -54,7 +55,7 @@ class TestResult(ExecutableItem):
@attrs
class TestStepResult(ExecutableItem):
- id = attrib(default=None) # noqa: A003
+ id = attrib(default=None)
@attrs
@@ -83,7 +84,7 @@ class Label:
@attrs
class Link:
- type = attrib(default=None) # noqa: A003
+ type = attrib(default=None)
url = attrib(default=None)
name = attrib(default=None)
@@ -100,12 +101,30 @@ class StatusDetails:
class Attachment:
name = attrib(default=None)
source = attrib(default=None)
- type = attrib(default=None) # noqa: A003
+ type = attrib(default=None)
+
+
+@attrs
+class GlobalAttachment(Attachment):
+ timestamp = attrib(default=None)
+
+
+@attrs
+class GlobalError(StatusDetails):
+ timestamp = attrib(default=None)
+
+
+@attrs
+class Globals:
+ file_pattern = GLOBALS_PATTERN
+
+ attachments = attrib(default=Factory(list))
+ errors = attrib(default=Factory(list))
class Status:
- FAILED = 'failed'
- BROKEN = 'broken'
- PASSED = 'passed'
- SKIPPED = 'skipped'
- UNKNOWN = 'unknown'
+ FAILED = "failed"
+ BROKEN = "broken"
+ PASSED = "passed"
+ SKIPPED = "skipped"
+ UNKNOWN = "unknown"
diff --git a/contrib/python/allure-python-commons/allure_commons/reporter.py b/contrib/python/allure-python-commons/allure_commons/reporter.py
index 2e1f4a89d3a..7e7b7594e77 100644
--- a/contrib/python/allure-python-commons/allure_commons/reporter.py
+++ b/contrib/python/allure-python-commons/allure_commons/reporter.py
@@ -5,6 +5,7 @@ from allure_commons.types import AttachmentType
from allure_commons.model2 import ExecutableItem
from allure_commons.model2 import TestResult
from allure_commons.model2 import Attachment, ATTACHMENT_PATTERN
+from allure_commons.model2 import GlobalAttachment, GlobalError, Globals
from allure_commons.utils import now
from allure_commons._core import plugin_manager
@@ -140,14 +141,7 @@ class AllureReporter:
self._items.pop(uuid)
def _attach(self, uuid, name=None, attachment_type=None, extension=None, parent_uuid=None):
- mime_type = attachment_type
- extension = extension if extension else 'attach'
-
- if type(attachment_type) is AttachmentType:
- extension = attachment_type.extension
- mime_type = attachment_type.mime_type
-
- file_name = ATTACHMENT_PATTERN.format(prefix=uuid, ext=extension)
+ file_name, mime_type = self.__resolve_attachment_filename_and_type(uuid, attachment_type, extension)
attachment = Attachment(source=file_name, name=name, type=mime_type)
last_uuid = parent_uuid if parent_uuid else self._last_executable()
self._items[last_uuid].attachments.append(attachment)
@@ -163,3 +157,33 @@ class AllureReporter:
file_name = self._attach(uuid, name=name, attachment_type=attachment_type,
extension=extension, parent_uuid=parent_uuid)
plugin_manager.hook.report_attached_data(body=body, file_name=file_name)
+
+ def global_attach_file(self, uuid, source, name=None, attachment_type=None, extension=None):
+ file_name, mime_type = self.__resolve_attachment_filename_and_type(uuid, attachment_type, extension)
+ plugin_manager.hook.report_attached_file(source=source, file_name=file_name)
+ plugin_manager.hook.report_globals(globals_item=Globals(attachments=[
+ GlobalAttachment(source=file_name, name=name, type=mime_type, timestamp=now())
+ ]))
+
+ def global_attach_data(self, uuid, body, name=None, attachment_type=None, extension=None):
+ file_name, mime_type = self.__resolve_attachment_filename_and_type(uuid, attachment_type, extension)
+ plugin_manager.hook.report_attached_data(body=body, file_name=file_name)
+ plugin_manager.hook.report_globals(globals_item=Globals(attachments=[
+ GlobalAttachment(source=file_name, name=name, type=mime_type, timestamp=now())
+ ]))
+
+ def global_error(self, message=None, trace=None):
+ plugin_manager.hook.report_globals(globals_item=Globals(errors=[
+ GlobalError(message=message, trace=trace, timestamp=now())
+ ]))
+
+ def __resolve_attachment_filename_and_type(self, uuid, attachment_type=None, extension=None):
+ mime_type = attachment_type
+ extension = extension if extension else "attach"
+
+ if type(attachment_type) is AttachmentType:
+ extension = attachment_type.extension
+ mime_type = attachment_type.mime_type
+
+ file_name = ATTACHMENT_PATTERN.format(prefix=uuid, ext=extension)
+ return file_name, mime_type
diff --git a/contrib/python/allure-python-commons/allure_commons/types.py b/contrib/python/allure-python-commons/allure_commons/types.py
index 1db7fd512f7..43b8d5edfda 100644
--- a/contrib/python/allure-python-commons/allure_commons/types.py
+++ b/contrib/python/allure-python-commons/allure_commons/types.py
@@ -1,37 +1,37 @@
from enum import Enum
-ALLURE_UNIQUE_LABELS = ['severity', 'thread', 'host']
+ALLURE_UNIQUE_LABELS = ["severity", "thread", "host"]
class Severity(str, Enum):
- BLOCKER = 'blocker'
- CRITICAL = 'critical'
- NORMAL = 'normal'
- MINOR = 'minor'
- TRIVIAL = 'trivial'
+ BLOCKER = "blocker"
+ CRITICAL = "critical"
+ NORMAL = "normal"
+ MINOR = "minor"
+ TRIVIAL = "trivial"
class LinkType:
- LINK = 'link'
- ISSUE = 'issue'
- TEST_CASE = 'tms'
+ LINK = "link"
+ ISSUE = "issue"
+ TEST_CASE = "tms"
class LabelType(str):
- EPIC = 'epic'
- FEATURE = 'feature'
- STORY = 'story'
- PARENT_SUITE = 'parentSuite'
- SUITE = 'suite'
- SUB_SUITE = 'subSuite'
- SEVERITY = 'severity'
- THREAD = 'thread'
- HOST = 'host'
- TAG = 'tag'
- ID = 'as_id'
- FRAMEWORK = 'framework'
- LANGUAGE = 'language'
- MANUAL = 'ALLURE_MANUAL'
+ EPIC = "epic"
+ FEATURE = "feature"
+ STORY = "story"
+ PARENT_SUITE = "parentSuite"
+ SUITE = "suite"
+ SUB_SUITE = "subSuite"
+ SEVERITY = "severity"
+ THREAD = "thread"
+ HOST = "host"
+ TAG = "tag"
+ ID = "as_id"
+ FRAMEWORK = "framework"
+ LANGUAGE = "language"
+ MANUAL = "ALLURE_MANUAL"
class AttachmentType(Enum):
@@ -67,6 +67,6 @@ class AttachmentType(Enum):
class ParameterMode(Enum):
- HIDDEN = 'hidden'
- MASKED = 'masked'
+ HIDDEN = "hidden"
+ MASKED = "masked"
DEFAULT = None
diff --git a/contrib/python/allure-python-commons/allure_commons/utils.py b/contrib/python/allure-python-commons/allure_commons/utils.py
index 5ba0d3775b7..c50cb7ecbd3 100644
--- a/contrib/python/allure-python-commons/allure_commons/utils.py
+++ b/contrib/python/allure-python-commons/allure_commons/utils.py
@@ -21,7 +21,7 @@ def md5(*args):
if not isinstance(arg, bytes):
if not isinstance(arg, str):
arg = repr(arg)
- arg = arg.encode('utf-8')
+ arg = arg.encode("utf-8")
m.update(arg)
return m.hexdigest()
@@ -37,11 +37,11 @@ def now():
def platform_label():
major_version, *_ = platform.python_version_tuple()
implementation = platform.python_implementation().lower()
- return f'{implementation}{major_version}'
+ return f"{implementation}{major_version}"
def thread_tag():
- return '{0}-{1}'.format(os.getpid(), threading.current_thread().name)
+ return "{0}-{1}".format(os.getpid(), threading.current_thread().name)
def host_tag():
@@ -241,7 +241,7 @@ def func_parameters(func, *args, **kwargs):
varargs = args[len(arg_spec.args):]
parameters.update({arg_spec.varargs: varargs} if varargs else {})
- if arg_spec.args and arg_spec.args[0] in ['cls', 'self']:
+ if arg_spec.args and arg_spec.args[0] in ["cls", "self"]:
args_dict.pop(arg_spec.args[0], None)
if kwargs:
@@ -269,7 +269,7 @@ def func_parameters(func, *args, **kwargs):
def format_traceback(exc_traceback):
- return ''.join(traceback.format_tb(exc_traceback)) if exc_traceback else None
+ return "".join(traceback.format_tb(exc_traceback)) if exc_traceback else None
def format_exception(etype, value):
@@ -320,7 +320,7 @@ def format_exception(etype, value):
... format_exception(etype, e) # doctest: +ELLIPSIS
"AssertionError: \\nExpected:...but:..."
"""
- return '\n'.join(format_exception_only(etype, value)) if etype or value else None
+ return "\n".join(format_exception_only(etype, value)) if etype or value else None
def get_testplan():
@@ -328,7 +328,7 @@ def get_testplan():
file_path = os.environ.get("ALLURE_TESTPLAN_PATH")
if file_path and os.path.exists(file_path):
- with open(file_path, 'r') as plan_file:
+ with open(file_path, "r") as plan_file:
plan = json.load(plan_file)
planned_tests = plan.get("tests", [])
diff --git a/contrib/python/allure-python-commons/ya.make b/contrib/python/allure-python-commons/ya.make
index 39bfee9f10e..4aa8c9ae74f 100644
--- a/contrib/python/allure-python-commons/ya.make
+++ b/contrib/python/allure-python-commons/ya.make
@@ -2,7 +2,7 @@
PY3_LIBRARY()
-VERSION(2.15.3)
+VERSION(2.16.0)
LICENSE(Apache-2.0)
diff --git a/contrib/python/textual/.dist-info/METADATA b/contrib/python/textual/.dist-info/METADATA
index a03aa2700b6..cdad74316a5 100644
--- a/contrib/python/textual/.dist-info/METADATA
+++ b/contrib/python/textual/.dist-info/METADATA
@@ -1,6 +1,6 @@
Metadata-Version: 2.1
Name: textual
-Version: 3.0.1
+Version: 3.7.1
Summary: Modern Text User Interface framework
Home-page: https://github.com/Textualize/textual
License: MIT
@@ -38,7 +38,7 @@ Requires-Dist: tree-sitter-json (>=0.24.0) ; (python_version >= "3.9") and (extr
Requires-Dist: tree-sitter-markdown (>=0.3.0) ; (python_version >= "3.9") and (extra == "syntax")
Requires-Dist: tree-sitter-python (>=0.23.0) ; (python_version >= "3.9") and (extra == "syntax")
Requires-Dist: tree-sitter-regex (>=0.24.0) ; (python_version >= "3.9") and (extra == "syntax")
-Requires-Dist: tree-sitter-rust (>=0.23.0) ; (python_version >= "3.9") and (extra == "syntax")
+Requires-Dist: tree-sitter-rust (>=0.23.0,<=0.23.2) ; (python_version >= "3.9") and (extra == "syntax")
Requires-Dist: tree-sitter-sql (>=0.3.0,<0.3.8) ; (python_version >= "3.9") and (extra == "syntax")
Requires-Dist: tree-sitter-toml (>=0.6.0) ; (python_version >= "3.9") and (extra == "syntax")
Requires-Dist: tree-sitter-xml (>=0.7.0) ; (python_version >= "3.9") and (extra == "syntax")
diff --git a/contrib/python/textual/textual/__init__.py b/contrib/python/textual/textual/__init__.py
index 7ffd7a931a8..f0b587248b4 100644
--- a/contrib/python/textual/textual/__init__.py
+++ b/contrib/python/textual/textual/__init__.py
@@ -8,6 +8,7 @@ Exposes some commonly used symbols.
from __future__ import annotations
import inspect
+import weakref
from typing import TYPE_CHECKING, Callable
import rich.repr
@@ -35,6 +36,8 @@ LogCallable: TypeAlias = "Callable"
if TYPE_CHECKING:
from importlib.metadata import version
+ from textual.app import App
+
__version__ = version("textual")
"""The version of Textual."""
@@ -62,10 +65,17 @@ class Logger:
log_callable: LogCallable | None,
group: LogGroup = LogGroup.INFO,
verbosity: LogVerbosity = LogVerbosity.NORMAL,
+ app: App | None = None,
) -> None:
self._log = log_callable
self._group = group
self._verbosity = verbosity
+ self._app = None if app is None else weakref.ref(app)
+
+ @property
+ def app(self) -> App | None:
+ """The associated application, or `None` if there isn't one."""
+ return None if self._app is None else self._app()
def __rich_repr__(self) -> rich.repr.Result:
yield self._group, LogGroup.INFO
@@ -82,13 +92,20 @@ class Logger:
with open(constants.LOG_FILE, "a") as log_file:
print(output, file=log_file)
- try:
- app = active_app.get()
- except LookupError:
- print_args = (*args, *[f"{key}={value!r}" for key, value in kwargs.items()])
- print(*print_args)
- return
- if app.devtools is None or not app.devtools.is_connected:
+
+ app = self.app
+ if app is None:
+ try:
+ app = active_app.get()
+ except LookupError:
+ if constants.DEBUG:
+ print_args = (
+ *args,
+ *[f"{key}={value!r}" for key, value in kwargs.items()],
+ )
+ print(*print_args)
+ return
+ if not app._is_devtools_connected:
return
current_frame = inspect.currentframe()
@@ -108,8 +125,12 @@ class Logger:
)
except LoggerError:
# If there is not active app, try printing
- print_args = (*args, *[f"{key}={value!r}" for key, value in kwargs.items()])
- print(*print_args)
+ if constants.DEBUG:
+ print_args = (
+ *args,
+ *[f"{key}={value!r}" for key, value in kwargs.items()],
+ )
+ print(*print_args)
def verbosity(self, verbose: bool) -> Logger:
"""Get a new logger with selective verbosity.
@@ -121,52 +142,52 @@ class Logger:
New logger.
"""
verbosity = LogVerbosity.HIGH if verbose else LogVerbosity.NORMAL
- return Logger(self._log, self._group, verbosity)
+ return Logger(self._log, self._group, verbosity, app=self.app)
@property
def verbose(self) -> Logger:
"""A verbose logger."""
- return Logger(self._log, self._group, LogVerbosity.HIGH)
+ return Logger(self._log, self._group, LogVerbosity.HIGH, app=self.app)
@property
def event(self) -> Logger:
"""Logs events."""
- return Logger(self._log, LogGroup.EVENT)
+ return Logger(self._log, LogGroup.EVENT, app=self.app)
@property
def debug(self) -> Logger:
"""Logs debug messages."""
- return Logger(self._log, LogGroup.DEBUG)
+ return Logger(self._log, LogGroup.DEBUG, app=self.app)
@property
def info(self) -> Logger:
"""Logs information."""
- return Logger(self._log, LogGroup.INFO)
+ return Logger(self._log, LogGroup.INFO, app=self.app)
@property
def warning(self) -> Logger:
"""Logs warnings."""
- return Logger(self._log, LogGroup.WARNING)
+ return Logger(self._log, LogGroup.WARNING, app=self.app)
@property
def error(self) -> Logger:
"""Logs errors."""
- return Logger(self._log, LogGroup.ERROR)
+ return Logger(self._log, LogGroup.ERROR, app=self.app)
@property
def system(self) -> Logger:
"""Logs system information."""
- return Logger(self._log, LogGroup.SYSTEM)
+ return Logger(self._log, LogGroup.SYSTEM, app=self.app)
@property
def logging(self) -> Logger:
"""Logs from stdlib logging module."""
- return Logger(self._log, LogGroup.LOGGING)
+ return Logger(self._log, LogGroup.LOGGING, app=self.app)
@property
def worker(self) -> Logger:
"""Logs worker information."""
- return Logger(self._log, LogGroup.WORKER)
+ return Logger(self._log, LogGroup.WORKER, app=self.app)
log = Logger(None)
@@ -177,4 +198,10 @@ Example:
from textual import log
log(locals())
```
+
+!!! note
+ This logger will only work if there is an active app in the current thread.
+ Use `app.log` to write logs from a thread without an active app.
+
+
"""
diff --git a/contrib/python/textual/textual/__main__.py b/contrib/python/textual/textual/__main__.py
index 9da832d9682..9d5d1b26d95 100644
--- a/contrib/python/textual/textual/__main__.py
+++ b/contrib/python/textual/textual/__main__.py
@@ -1,5 +1,18 @@
+from rich import print
+from rich.panel import Panel
+
from textual.demo.demo_app import DemoApp
if __name__ == "__main__":
app = DemoApp()
app.run()
+ print(
+ Panel.fit(
+ "[b magenta]Hope you liked the demo![/]\n\n"
+ "Please consider sponsoring me if you get value from my work.\n\n"
+ "Even the price of a ☕ can brighten my day!\n\n"
+ "https://github.com/sponsors/willmcgugan",
+ border_style="red",
+ title="Consider sponsoring",
+ )
+ )
diff --git a/contrib/python/textual/textual/_animator.py b/contrib/python/textual/textual/_animator.py
index 6d13c288c20..75927ec9a42 100644
--- a/contrib/python/textual/textual/_animator.py
+++ b/contrib/python/textual/textual/_animator.py
@@ -10,6 +10,7 @@ from typing_extensions import Protocol, runtime_checkable
from textual import _time
from textual._callback import invoke
+from textual._compat import cached_property
from textual._easing import DEFAULT_EASING, EASING
from textual._types import AnimationLevel, CallbackType
from textual.timer import Timer
@@ -242,11 +243,16 @@ class Animator:
callback=self,
pause=True,
)
+
+ @cached_property
+ def _idle_event(self) -> asyncio.Event:
"""The timer that runs the animator."""
- self._idle_event = asyncio.Event()
+ return asyncio.Event()
+
+ @cached_property
+ def _complete_event(self) -> asyncio.Event:
"""Flag if no animations are currently taking place."""
- self._complete_event = asyncio.Event()
- """Flag if no animations are currently taking place and none are scheduled."""
+ return asyncio.Event()
async def start(self) -> None:
"""Start the animator task."""
diff --git a/contrib/python/textual/textual/_arrange.py b/contrib/python/textual/textual/_arrange.py
index 22fd7c90e73..4bfb227acaa 100644
--- a/contrib/python/textual/textual/_arrange.py
+++ b/contrib/python/textual/textual/_arrange.py
@@ -102,7 +102,7 @@ def arrange(
container_width, container_height = dock_region.size
placement_offset += styles._align_size(
bounding_region.size,
- Size(
+ widget._extrema.apply_dimensions(
0 if styles.is_auto_width else container_width,
0 if styles.is_auto_height else container_height,
),
diff --git a/contrib/python/textual/textual/_compat.py b/contrib/python/textual/textual/_compat.py
new file mode 100644
index 00000000000..32d7d7d1362
--- /dev/null
+++ b/contrib/python/textual/textual/_compat.py
@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+import sys
+from typing import Any, Generic, TypeVar, overload
+
+if sys.version_info >= (3, 12):
+ from functools import cached_property
+else:
+ # based on the code from Python 3.14:
+ # https://github.com/python/cpython/blob/
+ # 5507eff19c757a908a2ff29dfe423e35595fda00/Lib/functools.py#L1089-L1138
+ # Copyright (C) 2006 Python Software Foundation.
+ # vendored under the PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 because
+ # prior to Python 3.12 cached_property used a threading.Lock, which makes
+ # it very slow.
+ _T_co = TypeVar("_T_co", covariant=True)
+ _NOT_FOUND = object()
+
+ class cached_property(Generic[_T_co]):
+ def __init__(self, func: Callable[[Any, _T_co]]) -> None:
+ self.func = func
+ self.attrname = None
+ self.__doc__ = func.__doc__
+ self.__module__ = func.__module__
+
+ def __set_name__(self, owner: type[any], name: str) -> None:
+ if self.attrname is None:
+ self.attrname = name
+ elif name != self.attrname:
+ raise TypeError(
+ "Cannot assign the same cached_property to two different names "
+ f"({self.attrname!r} and {name!r})."
+ )
+
+ @overload
+ def __get__(self, instance: None, owner: type[Any] | None = None) -> Self: ...
+
+ @overload
+ def __get__(
+ self, instance: object, owner: type[Any] | None = None
+ ) -> _T_co: ...
+
+ def __get__(
+ self, instance: object, owner: type[Any] | None = None
+ ) -> _T_co | Self:
+ if instance is None:
+ return self
+ if self.attrname is None:
+ raise TypeError(
+ "Cannot use cached_property instance without calling __set_name__ on it."
+ )
+ try:
+ cache = instance.__dict__
+ except (
+ AttributeError
+ ): # not all objects have __dict__ (e.g. class defines slots)
+ msg = (
+ f"No '__dict__' attribute on {type(instance).__name__!r} "
+ f"instance to cache {self.attrname!r} property."
+ )
+ raise TypeError(msg) from None
+ val = cache.get(self.attrname, _NOT_FOUND)
+ if val is _NOT_FOUND:
+ val = self.func(instance)
+ try:
+ cache[self.attrname] = val
+ except TypeError:
+ msg = (
+ f"The '__dict__' attribute on {type(instance).__name__!r} instance "
+ f"does not support item assignment for caching {self.attrname!r} property."
+ )
+ raise TypeError(msg) from None
+ return val
diff --git a/contrib/python/textual/textual/_compositor.py b/contrib/python/textual/textual/_compositor.py
index 76103ec47cc..245abf6ccbc 100644
--- a/contrib/python/textual/textual/_compositor.py
+++ b/contrib/python/textual/textual/_compositor.py
@@ -419,7 +419,7 @@ class Compositor:
resized_widgets = {
widget
for widget, (region, *_) in changes
- if (widget in common_widgets and old_map[widget].region[2:] != region[2:])
+ if (widget in common_widgets and old_map[widget].region.size != region.size)
}
return ReflowResult(
hidden=hidden_widgets,
diff --git a/contrib/python/textual/textual/_extrema.py b/contrib/python/textual/textual/_extrema.py
new file mode 100644
index 00000000000..d8e3d3bf2e3
--- /dev/null
+++ b/contrib/python/textual/textual/_extrema.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from fractions import Fraction
+from typing import NamedTuple
+
+from textual.geometry import Size
+
+
+class Extrema(NamedTuple):
+ """Specifies minimum and maximum dimensions."""
+
+ min_width: Fraction | None = None
+ max_width: Fraction | None = None
+ min_height: Fraction | None = None
+ max_height: Fraction | None = None
+
+ def apply_width(self, width: Fraction) -> Fraction:
+ """Apply width extrema.
+
+ Args:
+ width: Width value.
+
+ Returns:
+ Width, clamped between minimum and maximum.
+
+ """
+ min_width, max_width = self[:2]
+ if min_width is not None:
+ width = max(width, min_width)
+ if max_width is not None:
+ width = min(width, max_width)
+ return width
+
+ def apply_height(self, height: Fraction) -> Fraction:
+ """Apply height extrema.
+
+ Args:
+ height: Height value.
+
+ Returns:
+ Height, clamped between minimum and maximum.
+
+ """
+ min_height, max_height = self[2:]
+ if min_height is not None:
+ height = max(height, min_height)
+ if max_height is not None:
+ height = min(height, max_height)
+ return height
+
+ def apply_dimensions(self, width: int, height: int) -> Size:
+ """Apply extrema to integer dimensions.
+
+ Args:
+ width: Integer width.
+ height: Integer height.
+
+ Returns:
+ Size with extrema applied.
+ """
+ return Size(
+ int(self.apply_width(Fraction(width))),
+ int(self.apply_height(Fraction(height))),
+ )
diff --git a/contrib/python/textual/textual/_markup_playground.py b/contrib/python/textual/textual/_markup_playground.py
index 720e2d68d4b..f45fcabcba2 100644
--- a/contrib/python/textual/textual/_markup_playground.py
+++ b/contrib/python/textual/textual/_markup_playground.py
@@ -4,25 +4,21 @@ from textual import containers, events, on
from textual.app import App, ComposeResult
from textual.content import Content
from textual.reactive import reactive
-from textual.widgets import Static, TextArea
+from textual.widgets import Footer, Pretty, Static, TextArea
class MarkupPlayground(App):
TITLE = "Markup Playground"
CSS = """
- Screen {
- & > * {
- margin: 0 1;
- height: 1fr;
- }
+ Screen {
layout: vertical;
#editor {
- width: 2fr;
+ width: 1fr;
height: 1fr;
border: tab $foreground 50%;
padding: 1;
- margin: 1 1 0 0;
+ margin: 1 0 0 0;
&:focus {
border: tab $primary;
}
@@ -48,28 +44,57 @@ class MarkupPlayground(App):
}
overflow-y: auto;
}
- #results {
-
+ #results {
padding: 1 1;
+ width: 1fr;
+ }
+ #spans-container {
+ border: tab $success;
+ overflow-y: auto;
+ margin: 0 0 0 1;
+ }
+ #spans {
+ padding: 1 1;
+ width: 1fr;
+ }
+ HorizontalGroup {
+ height: 1fr;
}
}
"""
AUTO_FOCUS = "#editor"
+ BINDINGS = [
+ ("f1", "toggle('show_variables')", "Variables"),
+ ("f2", "toggle('show_spans')", "Spans"),
+ ]
variables: reactive[dict[str, object]] = reactive({})
+ show_variables = reactive(True)
+ show_spans = reactive(False)
+
def compose(self) -> ComposeResult:
with containers.HorizontalGroup():
- yield (editor := TextArea(id="editor"))
+ yield (editor := TextArea(id="editor", soft_wrap=False))
yield (variables := TextArea("", id="variables", language="json"))
editor.border_title = "Markup"
variables.border_title = "Variables (JSON)"
- with containers.VerticalScroll(
- id="results-container", can_focus=False
- ) as container:
- yield Static(id="results")
- container.border_title = "Output"
+ with containers.HorizontalGroup():
+ with containers.VerticalScroll(id="results-container") as container:
+ yield Static(id="results")
+ container.border_title = "Output"
+ with containers.VerticalScroll(id="spans-container") as container:
+ yield Pretty([], id="spans")
+ container.border_title = "Spans"
+
+ yield Footer()
+
+ def watch_show_variables(self, show_variables: bool) -> None:
+ self.query_one("#variables").display = show_variables
+
+ def watch_show_spans(self, show_spans: bool) -> None:
+ self.query_one("#spans-container").display = show_spans
@on(TextArea.Changed, "#editor")
def on_markup_changed(self, event: TextArea.Changed) -> None:
@@ -78,13 +103,16 @@ class MarkupPlayground(App):
def update_markup(self) -> None:
results = self.query_one("#results", Static)
editor = self.query_one("#editor", TextArea)
+ spans = self.query_one("#spans", Pretty)
try:
content = Content.from_markup(editor.text, **self.variables)
results.update(content)
- except Exception as error:
+ spans.update(content.spans)
+ except Exception:
from rich.traceback import Traceback
results.update(Traceback())
+ spans.update([])
self.query_one("#results-container").add_class("-error").scroll_end(
animate=False
diff --git a/contrib/python/textual/textual/_parser.py b/contrib/python/textual/textual/_parser.py
index 4820c7da18b..8b1bc982c66 100644
--- a/contrib/python/textual/textual/_parser.py
+++ b/contrib/python/textual/textual/_parser.py
@@ -83,7 +83,7 @@ class Parser(Generic[T]):
if not data:
self._eof = True
try:
- self._gen.throw(EOFError())
+ self._gen.throw(ParseEOF())
except StopIteration:
pass
while tokens:
diff --git a/contrib/python/textual/textual/_styles_cache.py b/contrib/python/textual/textual/_styles_cache.py
index 20d6e801581..7897e883d93 100644
--- a/contrib/python/textual/textual/_styles_cache.py
+++ b/contrib/python/textual/textual/_styles_cache.py
@@ -202,6 +202,7 @@ class StylesCache:
crop: Region to crop to.
filters: Additional post-processing for the segments.
opacity: Widget opacity.
+ ansi_theme: Theme for ANSI colors.
Returns:
Rendered lines.
@@ -350,7 +351,9 @@ class StylesCache:
ansi_theme = DEFAULT_TERMINAL_THEME
if styles.tint.a:
- segments = Tint.process_segments(segments, styles.tint, ansi_theme)
+ segments = Tint.process_segments(
+ segments, styles.tint, ansi_theme, background
+ )
if opacity != 1.0:
segments = _apply_opacity(segments, base_background, opacity)
return segments
diff --git a/contrib/python/textual/textual/_xterm_parser.py b/contrib/python/textual/textual/_xterm_parser.py
index 4cc21fecc58..431a4866e7d 100644
--- a/contrib/python/textual/textual/_xterm_parser.py
+++ b/contrib/python/textual/textual/_xterm_parser.py
@@ -9,7 +9,7 @@ from typing_extensions import Final
from textual import constants, events, messages
from textual._ansi_sequences import ANSI_SEQUENCES_KEYS, IGNORE_SEQUENCE
from textual._keyboard_protocol import FUNCTIONAL_KEYS
-from textual._parser import Parser, ParseTimeout, Peek1, Read1, TokenCallback
+from textual._parser import ParseEOF, Parser, ParseTimeout, Peek1, Read1, TokenCallback
from textual.keys import KEY_NAME_REPLACEMENTS, Keys, _character_to_key
from textual.message import Message
@@ -18,7 +18,7 @@ from textual.message import Message
# to be unsuccessful?
_MAX_SEQUENCE_SEARCH_THRESHOLD = 32
-_re_mouse_event = re.compile("^" + re.escape("\x1b[") + r"(<?[\d;]+[mM]|M...)\Z")
+_re_mouse_event = re.compile("^" + re.escape("\x1b[") + r"(<?[-?\d;]+[mM]|M...)\Z")
_re_terminal_mode_response = re.compile(
"^" + re.escape("\x1b[") + r"\?(?P<mode_id>\d+);(?P<setting_parameter>\d)\$y"
)
@@ -50,7 +50,7 @@ IS_ITERM = (
class XTermParser(Parser[Message]):
- _re_sgr_mouse = re.compile(r"\x1b\[<(\d+);(\d+);(\d+)([Mm])")
+ _re_sgr_mouse = re.compile(r"\x1b\[<(-?\d+);(-?\d+);(-?\d+)([Mm])")
def __init__(self, debug: bool = False) -> None:
self.last_x = 0.0
@@ -78,6 +78,9 @@ class XTermParser(Parser[Message]):
buttons = int(_buttons)
x = float(int(_x) - 1)
y = float(int(_y) - 1)
+ if x < 0 or y < 0:
+ # TODO: Workaround for Ghostty erroneous negative coordinate bug
+ return None
if (
self.mouse_pixels
and self.terminal_pixel_size is not None
@@ -187,7 +190,7 @@ class XTermParser(Parser[Message]):
try:
character = yield read1()
- except EOFError:
+ except ParseEOF:
return
if bracketed_paste:
@@ -216,7 +219,7 @@ class XTermParser(Parser[Message]):
except ParseTimeout:
send_escape()
break
- except EOFError:
+ except ParseEOF:
send_escape()
return
@@ -260,14 +263,11 @@ class XTermParser(Parser[Message]):
# Check cursor position report
cursor_position_match = _re_cursor_position.match(sequence)
if cursor_position_match is not None:
- row, column = cursor_position_match.groups()
- # Cursor position report conflicts with f3 key
- # If it is a keypress, "row" will be 1, so ignore
- if int(row) != 1:
- x = int(column) - 1
- y = int(row) - 1
- on_token(events.CursorPosition(x, y))
- break
+ row, column = map(int, cursor_position_match.groups())
+ x = int(column) - 1
+ y = int(row) - 1
+ on_token(events.CursorPosition(x, y))
+ break
# Was it a pressed key event that we received?
key_events = list(sequence_to_key_events(sequence))
diff --git a/contrib/python/textual/textual/app.py b/contrib/python/textual/textual/app.py
index 94bafee479b..01996615ff6 100644
--- a/contrib/python/textual/textual/app.py
+++ b/contrib/python/textual/textual/app.py
@@ -18,7 +18,7 @@ import sys
import threading
import uuid
import warnings
-from asyncio import Task, create_task
+from asyncio import AbstractEventLoop, Task, create_task
from concurrent.futures import Future
from contextlib import (
asynccontextmanager,
@@ -74,6 +74,7 @@ from textual._animator import DEFAULT_EASING, Animatable, Animator, EasingFuncti
from textual._ansi_sequences import SYNC_END, SYNC_START
from textual._ansi_theme import ALABASTER, MONOKAI
from textual._callback import invoke
+from textual._compat import cached_property
from textual._compose import compose
from textual._compositor import CompositorUpdate
from textual._context import active_app, active_message_pump
@@ -107,6 +108,7 @@ from textual.keys import (
REPLACED_KEYS,
_character_to_key,
_get_unicode_name_from_key,
+ _normalize_key_list,
format_key,
)
from textual.messages import CallbackType, Prune
@@ -481,6 +483,31 @@ class App(Generic[ReturnType], DOMNode):
SUSPENDED_SCREEN_CLASS: ClassVar[str] = ""
"""Class to apply to suspended screens, or empty string for no class."""
+ HORIZONTAL_BREAKPOINTS: ClassVar[list[tuple[int, str]]] | None = []
+ """List of horizontal breakpoints for responsive classes.
+
+ This allows for styles to be responsive to the dimensions of the terminal.
+ For instance, you might want to show less information, or fewer columns on a narrow displays -- or more information when the terminal is sized wider than usual.
+
+ A breakpoint consists of a tuple containing the minimum width where the class should applied, and the name of the class to set.
+
+ Note that only one class name is set, and if you should avoid having more than one breakpoint set for the same size.
+
+ Example:
+ ```python
+ # Up to 80 cells wide, the app has the class "-normal"
+ # 80 - 119 cells wide, the app has the class "-wide"
+ # 120 cells or wider, the app has the class "-very-wide"
+ HORIZONTAL_BREAKPOINTS = [(0, "-normal"), (80, "-wide"), (120, "-very-wide")]
+ ```
+
+ """
+ VERTICAL_BREAKPOINTS: ClassVar[list[tuple[int, str]]] | None = []
+ """List of vertical breakpoints for responsive classes.
+
+ Contents are the same as [`HORIZONTAL_BREAKPOINTS`][textual.app.App.HORIZONTAL_BREAKPOINTS], but the integer is compared to the height, rather than the width.
+ """
+
_PSEUDO_CLASSES: ClassVar[dict[str, Callable[[App[Any]], bool]]] = {
"focus": lambda app: app.app_focus,
"blur": lambda app: not app.app_focus,
@@ -538,7 +565,7 @@ class App(Generic[ReturnType], DOMNode):
CssPathError: When the supplied CSS path(s) are an unexpected type.
"""
self._start_time = perf_counter()
- super().__init__()
+ super().__init__(classes=self.DEFAULT_CLASSES)
self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", ""))
self._registered_themes: dict[str, Theme] = {}
@@ -622,9 +649,6 @@ class App(Generic[ReturnType], DOMNode):
"""The unhandled exception which is leading to the app shutting down,
or None if the app is still running with no unhandled exceptions."""
- self._exception_event: asyncio.Event = asyncio.Event()
- """An event that will be set when the first exception is encountered."""
-
self.title = (
self.TITLE if self.TITLE is not None else f"{self.__class__.__name__}"
)
@@ -658,7 +682,7 @@ class App(Generic[ReturnType], DOMNode):
will be ignored.
"""
- self._logger = Logger(self._log)
+ self._logger = Logger(self._log, app=self)
self._css_has_errors = False
@@ -767,8 +791,8 @@ class App(Generic[ReturnType], DOMNode):
perform work after the app has resumed.
"""
- self.set_class(self.current_theme.dark, "-dark-mode")
- self.set_class(not self.current_theme.dark, "-light-mode")
+ self.set_class(self.current_theme.dark, "-dark-mode", update=False)
+ self.set_class(not self.current_theme.dark, "-light-mode", update=False)
self.animation_level: AnimationLevel = constants.TEXTUAL_ANIMATIONS
"""Determines what type of animations the app will display.
@@ -818,6 +842,16 @@ class App(Generic[ReturnType], DOMNode):
)
)
+ @property
+ def _is_devtools_connected(self) -> bool:
+ """Is the app connected to the devtools?"""
+ return self.devtools is not None and self.devtools.is_connected
+
+ @cached_property
+ def _exception_event(self) -> asyncio.Event:
+ """An event that will be set when the first exception is encountered."""
+ return asyncio.Event()
+
def __init_subclass__(cls, *args, **kwargs) -> None:
for variable_name, screen_collection in (
("SCREENS", cls.SCREENS),
@@ -836,6 +870,17 @@ class App(Generic[ReturnType], DOMNode):
return super().__init_subclass__(*args, **kwargs)
+ def _thread_init(self):
+ """Initialize threading primitives for the current thread.
+
+ https://github.com/Textualize/textual/issues/5845
+
+ """
+ self._message_queue
+ self._mounted_event
+ self._exception_event
+ self._thread_id = threading.get_ident()
+
def _get_dom_base(self) -> DOMNode:
"""When querying from the app, we want to query the default screen."""
return self.default_screen
@@ -2033,7 +2078,6 @@ class App(Generic[ReturnType], DOMNode):
from textual.pilot import Pilot
app = self
-
auto_pilot_task: Task | None = None
if auto_pilot is None and constants.PRESS:
@@ -2066,27 +2110,30 @@ class App(Generic[ReturnType], DOMNode):
run_auto_pilot(auto_pilot, pilot), name=repr(pilot)
)
- try:
- app._loop = asyncio.get_running_loop()
- app._thread_id = threading.get_ident()
+ self._thread_init()
- await app._process_messages(
- ready_callback=None if auto_pilot is None else app_ready,
- headless=headless,
- inline=inline,
- inline_no_clear=inline_no_clear,
- mouse=mouse,
- terminal_size=size,
- )
- finally:
+ app._loop = asyncio.get_running_loop()
+ with app._context():
try:
- if auto_pilot_task is not None:
- await auto_pilot_task
+ await app._process_messages(
+ ready_callback=None if auto_pilot is None else app_ready,
+ headless=headless,
+ inline=inline,
+ inline_no_clear=inline_no_clear,
+ mouse=mouse,
+ terminal_size=size,
+ )
finally:
try:
- await asyncio.shield(app._shutdown())
- except asyncio.CancelledError:
- pass
+ if auto_pilot_task is not None:
+ await auto_pilot_task
+ finally:
+ try:
+ await asyncio.shield(app._shutdown())
+ except asyncio.CancelledError:
+ pass
+ app._loop = None
+ app._thread_id = 0
return app.return_value
@@ -2099,6 +2146,7 @@ class App(Generic[ReturnType], DOMNode):
mouse: bool = True,
size: tuple[int, int] | None = None,
auto_pilot: AutopilotCallbackType | None = None,
+ loop: AbstractEventLoop | None = None,
) -> ReturnType | None:
"""Run the app.
@@ -2110,37 +2158,40 @@ class App(Generic[ReturnType], DOMNode):
size: Force terminal size to `(WIDTH, HEIGHT)`,
or None to auto-detect.
auto_pilot: An auto pilot coroutine.
-
+ loop: Asyncio loop instance, or `None` to use default.
Returns:
App return value.
"""
- async def run_app() -> None:
+ async def run_app() -> ReturnType | None:
"""Run the app."""
- self._loop = asyncio.get_running_loop()
- self._thread_id = threading.get_ident()
- with self._context():
- try:
- await self.run_async(
- headless=headless,
- inline=inline,
- inline_no_clear=inline_no_clear,
- mouse=mouse,
- size=size,
- auto_pilot=auto_pilot,
- )
- finally:
- self._loop = None
- self._thread_id = 0
+ return await self.run_async(
+ headless=headless,
+ inline=inline,
+ inline_no_clear=inline_no_clear,
+ mouse=mouse,
+ size=size,
+ auto_pilot=auto_pilot,
+ )
- if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED:
- # N.B. This doesn't work with Python<3.10, as we end up with 2 event loops:
- asyncio.run(run_app())
- else:
- # However, this works with Python<3.10:
- event_loop = asyncio.get_event_loop()
- event_loop.run_until_complete(run_app())
- return self.return_value
+ if loop is None:
+ if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED:
+ # N.B. This does work with Python<3.10, but global Locks, Events, etc
+ # eagerly bind the event loop, and result in Future bound to wrong
+ # loop errors.
+ return asyncio.run(run_app())
+ try:
+ global_loop = asyncio.get_event_loop()
+ except RuntimeError:
+ # the global event loop may have been destroyed by someone running
+ # asyncio.run(), or asyncio.set_event_loop(None), in which case
+ # we need to use asyncio.run() also. (We run this outside the
+ # context of an exception handler)
+ pass
+ else:
+ return global_loop.run_until_complete(run_app())
+ return asyncio.run(run_app())
+ return loop.run_until_complete(run_app())
async def _on_css_change(self) -> None:
"""Callback for the file monitor, called when CSS files change."""
@@ -2629,6 +2680,7 @@ class App(Generic[ReturnType], DOMNode):
if not self.is_screen_installed(screen) and all(
screen not in stack for stack in self._screen_stacks.values()
):
+ self.capture_mouse(None)
await screen.remove()
self.log.system(f"{screen} REMOVED")
return screen
@@ -2685,6 +2737,7 @@ class App(Generic[ReturnType], DOMNode):
else:
future = loop.create_future()
+ self.app.capture_mouse(None)
if self._screen_stack:
self.screen.post_message(events.ScreenSuspend())
self.screen.refresh()
@@ -2753,6 +2806,7 @@ class App(Generic[ReturnType], DOMNode):
self.log.system(f"Screen {screen} is already current.")
return AwaitComplete.nothing()
+ self.app.capture_mouse(None)
top_screen = self._screen_stack.pop()
top_screen._pop_result_callback()
@@ -3086,6 +3140,9 @@ class App(Generic[ReturnType], DOMNode):
terminal_size: tuple[int, int] | None = None,
message_hook: Callable[[Message], None] | None = None,
) -> None:
+
+ self._thread_init()
+
async def app_prelude() -> bool:
"""Work required before running the app.
@@ -3697,6 +3754,13 @@ class App(Generic[ReturnType], DOMNode):
)
return
+ @classmethod
+ def _normalize_keymap(cls, keymap: Keymap) -> Keymap:
+ """Normalizes the keys in a keymap, so they use long form, i.e. "question_mark" rather than "?"."""
+ return {
+ binding_id: _normalize_key_list(keys) for binding_id, keys in keymap.items()
+ }
+
def set_keymap(self, keymap: Keymap) -> None:
"""Set the keymap, a mapping of binding IDs to key strings.
@@ -3709,7 +3773,9 @@ class App(Generic[ReturnType], DOMNode):
Args:
keymap: A mapping of binding IDs to key strings.
"""
- self._keymap = keymap
+
+ self._keymap = self._normalize_keymap(keymap)
+ self.refresh_bindings()
def update_keymap(self, keymap: Keymap) -> None:
"""Update the App's keymap, merging with `keymap`.
@@ -3720,7 +3786,9 @@ class App(Generic[ReturnType], DOMNode):
Args:
keymap: A mapping of binding IDs to key strings.
"""
- self._keymap = {**self._keymap, **keymap}
+
+ self._keymap = {**self._keymap, **self._normalize_keymap(keymap)}
+ self.refresh_bindings()
def handle_bindings_clash(
self, clashed_bindings: set[Binding], node: DOMNode
@@ -4278,6 +4346,13 @@ class App(Generic[ReturnType], DOMNode):
# Update the toast rack.
self.call_later(toast_rack.show, self._notifications)
+ def clear_selection(self) -> None:
+ """Clear text selection on the active screen."""
+ try:
+ self.screen.clear_selection()
+ except NoScreen:
+ pass
+
def notify(
self,
message: str,
@@ -4285,6 +4360,7 @@ class App(Generic[ReturnType], DOMNode):
title: str = "",
severity: SeverityLevel = "information",
timeout: float | None = None,
+ markup: bool = True,
) -> None:
"""Create a notification.
@@ -4298,6 +4374,7 @@ class App(Generic[ReturnType], DOMNode):
title: The title for the notification.
severity: The severity of the notification.
timeout: The timeout (in seconds) for the notification, or `None` for default.
+ markup: Render the message as content markup?
The `notify` method is used to create an application-wide
notification, shown in a [`Toast`][textual.widgets._toast.Toast],
@@ -4334,7 +4411,7 @@ class App(Generic[ReturnType], DOMNode):
"""
if timeout is None:
timeout = self.NOTIFICATION_TIMEOUT
- notification = Notification(message, title, severity, timeout)
+ notification = Notification(message, title, severity, timeout, markup=markup)
self.post_message(Notify(notification))
def _on_notify(self, event: Notify) -> None:
diff --git a/contrib/python/textual/textual/canvas.py b/contrib/python/textual/textual/canvas.py
index 641ee072d14..d783e1d1b2c 100644
--- a/contrib/python/textual/textual/canvas.py
+++ b/contrib/python/textual/textual/canvas.py
@@ -8,6 +8,7 @@ A Canvas class used to render keylines.
from __future__ import annotations
+import sys
from array import array
from collections import defaultdict
from dataclasses import dataclass
@@ -157,7 +158,10 @@ class Canvas:
self._width = width
self._height = height
blank_line = " " * width
- self.lines: list[array[str]] = [array("u", blank_line) for _ in range(height)]
+ array_type_code = "w" if sys.version_info >= (3, 13) else "u"
+ self.lines: list[array[str]] = [
+ array(array_type_code, blank_line) for _ in range(height)
+ ]
self.box: list[defaultdict[int, Quad]] = [
defaultdict(lambda: (0, 0, 0, 0)) for _ in range(height)
]
diff --git a/contrib/python/textual/textual/case.py b/contrib/python/textual/textual/case.py
index e92dfa3fb33..e091f7aef2f 100644
--- a/contrib/python/textual/textual/case.py
+++ b/contrib/python/textual/textual/case.py
@@ -11,7 +11,7 @@ def camel_to_snake(
name: A symbol name, such as a class name.
Returns:
- Name in camel case.
+ Name in snake case.
"""
def repl(match: Match[str]) -> str:
diff --git a/contrib/python/textual/textual/color.py b/contrib/python/textual/textual/color.py
index fe73b069438..69dc0c6da42 100644
--- a/contrib/python/textual/textual/color.py
+++ b/contrib/python/textual/textual/color.py
@@ -31,7 +31,7 @@ output = table
from __future__ import annotations
import re
-from colorsys import hls_to_rgb, rgb_to_hls
+from colorsys import hls_to_rgb, hsv_to_rgb, rgb_to_hls, rgb_to_hsv
from functools import lru_cache
from operator import itemgetter
from typing import Callable, NamedTuple
@@ -53,7 +53,7 @@ _TRUECOLOR = ColorType.TRUECOLOR
class HSL(NamedTuple):
- """A color in HLS (Hue, Saturation, Lightness) format."""
+ """A color in HSL (Hue, Saturation, Lightness) format."""
h: float
"""Hue in range 0 to 1."""
@@ -82,7 +82,7 @@ class HSV(NamedTuple):
s: float
"""Saturation in range 0 to 1."""
v: float
- """Value un range 0 to 1."""
+ """Value in range 0 to 1."""
class Lab(NamedTuple):
@@ -199,12 +199,12 @@ class Color(NamedTuple):
@classmethod
def from_hsl(cls, h: float, s: float, l: float) -> Color:
- """Create a color from HLS components.
+ """Create a color from HSL components.
Args:
h: Hue.
- l: Lightness.
s: Saturation.
+ l: Lightness.
Returns:
A new color.
@@ -212,6 +212,21 @@ class Color(NamedTuple):
r, g, b = hls_to_rgb(h, l, s)
return cls(int(r * 255 + 0.5), int(g * 255 + 0.5), int(b * 255 + 0.5))
+ @classmethod
+ def from_hsv(cls, h: float, s: float, v: float) -> Color:
+ """Create a color from HSV components.
+
+ Args:
+ h: Hue.
+ s: Saturation.
+ v: Value.
+
+ Returns:
+ A new color.
+ """
+ r, g, b = hsv_to_rgb(h, s, v)
+ return cls(int(r * 255 + 0.5), int(g * 255 + 0.5), int(b * 255 + 0.5))
+
@property
def inverse(self) -> Color:
"""The inverse of this color.
@@ -287,6 +302,19 @@ class Color(NamedTuple):
return HSL(h, s, l)
@property
+ def hsv(self) -> HSV:
+ """This color in HSV format.
+
+ HSV color is an alternative way of representing a color, which can be used in certain color calculations.
+
+ Returns:
+ Color encoded in HSV format.
+ """
+ r, g, b = self.normalized
+ h, s, v = rgb_to_hsv(r, g, b)
+ return HSV(h, s, v)
+
+ @property
def brightness(self) -> float:
"""The human perceptual brightness.
@@ -301,7 +329,7 @@ class Color(NamedTuple):
def hex(self) -> str:
"""The color in CSS hex form, with 6 digits for RGB, and 8 digits for RGBA.
- For example, `"#46b3de"` for an RGB color, or `"#3342457f"` for a color with alpha.
+ For example, `"#46B3DE"` for an RGB color, or `"#3342457F"` for a color with alpha.
"""
r, g, b, a, ansi, _ = self.clamped
if ansi is not None:
@@ -316,7 +344,7 @@ class Color(NamedTuple):
def hex6(self) -> str:
"""The color in CSS hex form, with 6 digits for RGB. Alpha is ignored.
- For example, `"#46b3de"`.
+ For example, `"#46B3DE"`.
"""
r, g, b, _a, _, _ = self.clamped
return f"#{r:02X}{g:02X}{b:02X}"
diff --git a/contrib/python/textual/textual/constants.py b/contrib/python/textual/textual/constants.py
index cb348e482e3..feedbea6e96 100644
--- a/contrib/python/textual/textual/constants.py
+++ b/contrib/python/textual/textual/constants.py
@@ -27,7 +27,9 @@ def _get_environ_bool(name: str) -> bool:
return has_environ
-def _get_environ_int(name: str, default: int, minimum: int | None = None) -> int:
+def _get_environ_int(
+ name: str, default: int, minimum: int | None = None, maximum: int | None = None
+) -> int:
"""Retrieves an integer environment variable.
Args:
@@ -48,6 +50,8 @@ def _get_environ_int(name: str, default: int, minimum: int | None = None) -> int
return default
if minimum is not None:
return max(minimum, value)
+ if maximum is not None:
+ return min(maximum, value)
return value
@@ -159,5 +163,10 @@ Textual will use the first theme that exists.
"""
SMOOTH_SCROLL: Final[bool] = _get_environ_int("TEXTUAL_SMOOTH_SCROLL", 1) == 1
-"""Should smooth scrolling be enabled? set `TEXTUAL_SMOOTH_SCROLL=0` to disable smooth
+"""Should smooth scrolling be enabled? set `TEXTUAL_SMOOTH_SCROLL=0` to disable smooth scrolling.
"""
+
+DIM_FACTOR: Final[float] = (
+ _get_environ_int("TEXTUAL_DIM_FACTOR", 66, minimum=0, maximum=100) / 100
+)
+"""Percentage to use as opacity when converting ANSI 'dim' attribute to RGB."""
diff --git a/contrib/python/textual/textual/containers.py b/contrib/python/textual/textual/containers.py
index 4f6cb0bde9e..4478901d35c 100644
--- a/contrib/python/textual/textual/containers.py
+++ b/contrib/python/textual/textual/containers.py
@@ -29,7 +29,7 @@ class Container(Widget):
"""
-class ScrollableContainer(Widget, can_focus=True, inherit_bindings=False):
+class ScrollableContainer(Widget, can_focus=True):
"""A scrollable container with vertical layout, and auto scrollbars on both axis."""
# We don't typically want to maximize scrollable containers,
@@ -118,7 +118,7 @@ class ScrollableContainer(Widget, can_focus=True, inherit_bindings=False):
return self.can_maximize
-class Vertical(Widget, inherit_bindings=False):
+class Vertical(Widget):
"""An expanding container with vertical layout and no scrollbars."""
DEFAULT_CSS = """
@@ -131,7 +131,7 @@ class Vertical(Widget, inherit_bindings=False):
"""
-class VerticalGroup(Widget, inherit_bindings=False):
+class VerticalGroup(Widget):
"""A non-expanding container with vertical layout and no scrollbars."""
DEFAULT_CSS = """
@@ -156,7 +156,7 @@ class VerticalScroll(ScrollableContainer):
"""
-class Horizontal(Widget, inherit_bindings=False):
+class Horizontal(Widget):
"""An expanding container with horizontal layout and no scrollbars."""
DEFAULT_CSS = """
@@ -169,7 +169,7 @@ class Horizontal(Widget, inherit_bindings=False):
"""
-class HorizontalGroup(Widget, inherit_bindings=False):
+class HorizontalGroup(Widget):
"""A non-expanding container with horizontal layout and no scrollbars."""
DEFAULT_CSS = """
@@ -194,7 +194,7 @@ class HorizontalScroll(ScrollableContainer):
"""
-class Center(Widget, inherit_bindings=False):
+class Center(Widget):
"""A container which aligns children on the X axis."""
DEFAULT_CSS = """
@@ -206,7 +206,7 @@ class Center(Widget, inherit_bindings=False):
"""
-class Right(Widget, inherit_bindings=False):
+class Right(Widget):
"""A container which aligns children on the X axis."""
DEFAULT_CSS = """
@@ -218,7 +218,7 @@ class Right(Widget, inherit_bindings=False):
"""
-class Middle(Widget, inherit_bindings=False):
+class Middle(Widget):
"""A container which aligns children on the Y axis."""
DEFAULT_CSS = """
@@ -230,7 +230,19 @@ class Middle(Widget, inherit_bindings=False):
"""
-class Grid(Widget, inherit_bindings=False):
+class CenterMiddle(Widget):
+ """A container which aligns its children on both axis."""
+
+ DEFAULT_CSS = """
+ CenterMiddle {
+ align: center middle;
+ width: 1fr;
+ height: 1fr;
+ }
+ """
+
+
+class Grid(Widget):
"""A container with grid layout."""
DEFAULT_CSS = """
@@ -242,7 +254,7 @@ class Grid(Widget, inherit_bindings=False):
"""
-class ItemGrid(Widget, inherit_bindings=False):
+class ItemGrid(Widget):
"""A container with grid layout and automatic columns."""
DEFAULT_CSS = """
diff --git a/contrib/python/textual/textual/content.py b/contrib/python/textual/textual/content.py
index 08b45aec6f9..20d9480562c 100644
--- a/contrib/python/textual/textual/content.py
+++ b/contrib/python/textual/textual/content.py
@@ -145,7 +145,7 @@ class Content(Visual):
@cached_property
def markup(self) -> str:
- """Get Content markup to render this Text.
+ """Get content markup to render this Text.
Returns:
str: A string potentially creating markup tags.
@@ -215,7 +215,7 @@ class Content(Visual):
@classmethod
def from_markup(cls, markup: str | Content, **variables: object) -> Content:
- """Create content from Textual markup, optionally combined with template variables.
+ """Create content from markup, optionally combined with template variables.
If `markup` is already a Content instance, it will be returned unmodified.
@@ -228,7 +228,7 @@ class Content(Visual):
```
Args:
- markup: Textual markup, or Content.
+ markup: Content markup, or Content.
**variables: Optional template variables used
Returns:
diff --git a/contrib/python/textual/textual/css/_style_properties.py b/contrib/python/textual/textual/css/_style_properties.py
index 472e5fd26c4..7860eb32e06 100644
--- a/contrib/python/textual/textual/css/_style_properties.py
+++ b/contrib/python/textual/textual/css/_style_properties.py
@@ -991,7 +991,7 @@ class ColorProperty:
self.name, context="inline", error=error, value=token
),
)
- parsed_color = parsed_color.with_alpha(alpha)
+ parsed_color = parsed_color.multiply_alpha(alpha)
if obj.set_rule(self.name, parsed_color):
obj.refresh(children=True)
diff --git a/contrib/python/textual/textual/css/constants.py b/contrib/python/textual/textual/css/constants.py
index e2cd109dcd2..cb4f6958029 100644
--- a/contrib/python/textual/textual/css/constants.py
+++ b/contrib/python/textual/textual/css/constants.py
@@ -76,6 +76,8 @@ VALID_PSEUDO_CLASSES: Final = {
"nocolor",
"first-of-type",
"last-of-type",
+ "first-child",
+ "last-child",
"odd",
"even",
}
diff --git a/contrib/python/textual/textual/css/parse.py b/contrib/python/textual/textual/css/parse.py
index d0365c825f4..61b759253b4 100644
--- a/contrib/python/textual/textual/css/parse.py
+++ b/contrib/python/textual/textual/css/parse.py
@@ -17,7 +17,7 @@ from textual.css.model import (
)
from textual.css.styles import Styles
from textual.css.tokenize import Token, tokenize, tokenize_declarations, tokenize_values
-from textual.css.tokenizer import EOFError, ReferencedBy
+from textual.css.tokenizer import ReferencedBy, UnexpectedEnd
from textual.css.types import CSSLocation, Specificity3
from textual.suggestions import get_suggestion
@@ -66,7 +66,7 @@ def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]:
while True:
try:
token = next(tokens, None)
- except EOFError:
+ except UnexpectedEnd:
break
if token is None:
break
diff --git a/contrib/python/textual/textual/css/styles.py b/contrib/python/textual/textual/css/styles.py
index 03907bea4a9..b4d149aea20 100644
--- a/contrib/python/textual/textual/css/styles.py
+++ b/contrib/python/textual/textual/css/styles.py
@@ -572,7 +572,6 @@ class StylesBase:
yield getattr(self, key)
def items(self) -> Iterable[tuple[str, object]]:
- get_rule = self.get_rule
for key in RULE_NAMES:
yield (key, getattr(self, key))
@@ -1162,9 +1161,9 @@ class Styles(StylesBase):
if "min_height" in rules:
append_declaration("min-height", str(self.min_height))
if "max_width" in rules:
- append_declaration("max-width", str(self.min_width))
+ append_declaration("max-width", str(self.max_width))
if "max_height" in rules:
- append_declaration("max-height", str(self.min_height))
+ append_declaration("max-height", str(self.max_height))
if "transitions" in rules:
append_declaration(
"transition",
@@ -1347,7 +1346,7 @@ class RenderStyles(StylesBase):
@property
def gutter(self) -> Spacing:
- """Get space around widget.
+ """Get space around widget (padding + border)
Returns:
Space around widget content.
diff --git a/contrib/python/textual/textual/css/stylesheet.py b/contrib/python/textual/textual/css/stylesheet.py
index de1e98e84ef..cd22a27d942 100644
--- a/contrib/python/textual/textual/css/stylesheet.py
+++ b/contrib/python/textual/textual/css/stylesheet.py
@@ -459,6 +459,8 @@ class Stylesheet:
_EXCLUDE_PSEUDO_CLASSES_FROM_CACHE: Final[set[str]] = {
"first-of-type",
"last-of-type",
+ "first-child",
+ "last-child",
"odd",
"even",
"focus-within",
@@ -503,7 +505,7 @@ class Stylesheet:
node._has_hover_style = "hover" in all_pseudo_classes
node._has_focus_within = "focus-within" in all_pseudo_classes
node._has_order_style = not all_pseudo_classes.isdisjoint(
- {"first-of-type", "last-of-type"}
+ {"first-of-type", "last-of-type", "first-child", "last-child"}
)
node._has_odd_or_even = (
"odd" in all_pseudo_classes or "even" in all_pseudo_classes
diff --git a/contrib/python/textual/textual/css/tokenizer.py b/contrib/python/textual/textual/css/tokenizer.py
index 770b3379ff5..3b31b3df150 100644
--- a/contrib/python/textual/textual/css/tokenizer.py
+++ b/contrib/python/textual/textual/css/tokenizer.py
@@ -102,8 +102,8 @@ class TokenError(Exception):
return Group(*errors)
-class EOFError(TokenError):
- """Indicates that the CSS ended prematurely."""
+class UnexpectedEnd(TokenError):
+ """Indicates that the text being tokenized ended prematurely."""
@rich.repr.auto
@@ -231,7 +231,7 @@ class Tokenizer:
expect: Expect object which describes which tokens may be read.
Raises:
- EOFError: If there is an unexpected end of file.
+ UnexpectedEnd: If there is an unexpected end of file.
TokenError: If there is an error with the token.
Returns:
@@ -251,11 +251,15 @@ class Tokenizer:
None,
)
else:
- raise EOFError(
+ raise UnexpectedEnd(
self.read_from,
self.code,
(line_no + 1, col_no + 1),
- "Unexpected end of file; did you forget a '}' ?",
+ (
+ "Unexpected end of file; did you forget a '}' ?"
+ if expect._expect_semicolon
+ else "Unexpected end of text"
+ ),
)
line = self.lines[line_no]
preceding_text: str = ""
@@ -348,7 +352,7 @@ class Tokenizer:
expect: Expect object describing the expected token.
Raises:
- EOFError: If end of file is reached.
+ UnexpectedEndOfText: If end of file is reached.
Returns:
A new token.
@@ -358,11 +362,15 @@ class Tokenizer:
while True:
if line_no >= len(self.lines):
- raise EOFError(
+ raise UnexpectedEnd(
self.read_from,
self.code,
(line_no, col_no),
- "Unexpected end of file; did you forget a '}' ?",
+ (
+ "Unexpected end of file; did you forget a '}' ?"
+ if expect._expect_semicolon
+ else "Unexpected end of markup"
+ ),
)
line = self.lines[line_no]
match = expect.search(line, col_no)
diff --git a/contrib/python/textual/textual/demo/home.py b/contrib/python/textual/textual/demo/home.py
index 7a8cb836e62..3c15d010cd1 100644
--- a/contrib/python/textual/textual/demo/home.py
+++ b/contrib/python/textual/textual/demo/home.py
@@ -66,7 +66,7 @@ A modern Python API from the developer of [Rich](https://github.com/Textualize/r
```python
# Start building!
-from textual import App, ComposeResult
+from textual.app import App, ComposeResult
from textual.widgets import Label
class MyApp(App):
diff --git a/contrib/python/textual/textual/document/_document.py b/contrib/python/textual/textual/document/_document.py
index 47e87eb09b3..92d5a12a991 100644
--- a/contrib/python/textual/textual/document/_document.py
+++ b/contrib/python/textual/textual/document/_document.py
@@ -466,3 +466,8 @@ class Selection(NamedTuple):
"""Return True if the selection has 0 width, i.e. it's just a cursor."""
start, end = self
return start == end
+
+ def contains_line(self, y: int) -> bool:
+ """Check if the given line is within the selection."""
+ top, bottom = sorted((self.start[0], self.end[0]))
+ return y >= top and y <= bottom
diff --git a/contrib/python/textual/textual/dom.py b/contrib/python/textual/textual/dom.py
index 00b5595c659..c91f6cfc0b3 100644
--- a/contrib/python/textual/textual/dom.py
+++ b/contrib/python/textual/textual/dom.py
@@ -224,7 +224,7 @@ class DOMNode(MessagePump):
self._has_hover_style: bool = False
self._has_focus_within: bool = False
self._has_order_style: bool = False
- """The node has an ordered dependent pseudo-style (`:odd`, `:even`, `:first-of-type`, `:last-of-type`)"""
+ """The node has an ordered dependent pseudo-style (`:odd`, `:even`, `:first-of-type`, `:last-of-type`, `:first-child`, `:last-child`)"""
self._has_odd_or_even: bool = False
"""The node has the pseudo class `odd` or `even`."""
self._reactive_connect: (
@@ -1484,7 +1484,7 @@ class DOMNode(MessagePump):
else:
cache_key = None
- for node in walk_depth_first(base_node, with_root=False):
+ for node in walk_breadth_first(base_node, with_root=False):
if not match(selector_set, node):
continue
if expect_type is not None and not isinstance(node, expect_type):
@@ -1555,7 +1555,7 @@ class DOMNode(MessagePump):
else:
cache_key = None
- children = walk_depth_first(base_node, with_root=False)
+ children = walk_breadth_first(base_node, with_root=False)
iter_children = iter(children)
for node in iter_children:
if not match(selector_set, node):
@@ -1665,6 +1665,17 @@ class DOMNode(MessagePump):
def set_class(self, add: bool, *class_names: str, update: bool = True) -> Self:
"""Add or remove class(es) based on a condition.
+ This can condense the four lines required to implement the equivalent branch into a single line.
+
+ Example:
+ ```python
+ #if foo:
+ # self.add_class("-foo")
+ #else:
+ # self.remove_class("-foo")
+ self.set_class(foo, "-foo")
+ ```
+
Args:
add: Add the classes if True, otherwise remove them.
update: Also update styles.
@@ -1673,9 +1684,9 @@ class DOMNode(MessagePump):
Self.
"""
if add:
- self.add_class(*class_names, update=update and self.is_attached)
+ self.add_class(*class_names, update=update)
else:
- self.remove_class(*class_names, update=update and self.is_attached)
+ self.remove_class(*class_names, update=update)
return self
def set_classes(self, classes: str | Iterable[str]) -> Self:
@@ -1696,6 +1707,8 @@ class DOMNode(MessagePump):
Should be called whenever CSS classes / pseudo classes change.
"""
+ if not self.is_attached:
+ return
try:
self.app.update_styles(self)
except NoActiveAppError:
@@ -1818,7 +1831,8 @@ class DOMNode(MessagePump):
See [actions](/guide/actions#dynamic-actions) for how to use this method.
"""
- self.screen.refresh_bindings()
+ if self._is_mounted:
+ self.screen.refresh_bindings()
async def action_toggle(self, attribute_name: str) -> None:
"""Toggle an attribute on the node.
diff --git a/contrib/python/textual/textual/filter.py b/contrib/python/textual/textual/filter.py
index b0fe2a2b9be..5a2984f381b 100644
--- a/contrib/python/textual/textual/filter.py
+++ b/contrib/python/textual/textual/filter.py
@@ -22,6 +22,7 @@ from rich.style import Style
from rich.terminal_theme import TerminalTheme
from textual.color import Color
+from textual.constants import DIM_FACTOR
class LineFilter(ABC):
@@ -125,7 +126,9 @@ NO_DIM = Style(dim=False)
@lru_cache(1024)
-def dim_color(background: RichColor, color: RichColor, factor: float) -> RichColor:
+def dim_color(
+ background: RichColor, color: RichColor, factor: float = DIM_FACTOR
+) -> RichColor:
"""Dim a color by blending towards the background
Args:
@@ -227,7 +230,7 @@ class ANSIToTruecolor(LineFilter):
super().__init__(enabled=enabled)
@lru_cache(1024)
- def truecolor_style(self, style: Style) -> Style:
+ def truecolor_style(self, style: Style, background: RichColor) -> Style:
"""Replace system colors with truecolor equivalent.
Args:
@@ -247,6 +250,10 @@ class ANSIToTruecolor(LineFilter):
bgcolor = RichColor.from_rgb(
*bgcolor.get_truecolor(terminal_theme, foreground=False)
)
+ # Convert dim style to RGB
+ if style.dim and color is not None:
+ color = dim_color(background, color)
+ style += NO_DIM
return style + Style.from_color(color, bgcolor)
@@ -263,10 +270,16 @@ class ANSIToTruecolor(LineFilter):
_Segment = Segment
truecolor_style = self.truecolor_style
+ background_rich_color = background.rich_color
+
return [
_Segment(
text,
- None if style is None else truecolor_style(style),
+ (
+ None
+ if style is None
+ else truecolor_style(style, background_rich_color)
+ ),
None,
)
for text, style, _ in segments
diff --git a/contrib/python/textual/textual/fuzzy.py b/contrib/python/textual/textual/fuzzy.py
index a5bc7431d4c..efdb4ed3b73 100644
--- a/contrib/python/textual/textual/fuzzy.py
+++ b/contrib/python/textual/textual/fuzzy.py
@@ -219,7 +219,7 @@ class Matcher:
candidate: The candidate string to match against the query.
Returns:
- A [rich.text.Text][`Text`] object with highlighted matches.
+ A [`Text`][rich.text.Text] object with highlighted matches.
"""
content = Content.from_markup(candidate)
score, offsets = self.fuzzy_search.match(self.query, candidate)
diff --git a/contrib/python/textual/textual/geometry.py b/contrib/python/textual/textual/geometry.py
index 9847052fa8b..3a7ab64d3ce 100644
--- a/contrib/python/textual/textual/geometry.py
+++ b/contrib/python/textual/textual/geometry.py
@@ -5,6 +5,7 @@ Functions and classes to manage terminal geometry (anything involving coordinate
from __future__ import annotations
+import os
from functools import lru_cache
from operator import attrgetter, itemgetter
from typing import (
@@ -1315,6 +1316,13 @@ class Spacing(NamedTuple):
)
+if not TYPE_CHECKING and os.environ.get("TEXTUAL_SPEEDUPS") == "1":
+ try:
+ from textual_speedups import Offset, Region, Size, Spacing
+ except ImportError:
+ pass
+
+
NULL_OFFSET: Final = Offset(0, 0)
"""An [offset][textual.geometry.Offset] constant for (0, 0)."""
diff --git a/contrib/python/textual/textual/getters.py b/contrib/python/textual/textual/getters.py
new file mode 100644
index 00000000000..f5b23590b0c
--- /dev/null
+++ b/contrib/python/textual/textual/getters.py
@@ -0,0 +1,188 @@
+"""
+Descriptors to define properties on your widget, screen, or App.
+
+"""
+
+from __future__ import annotations
+
+from typing import Generic, overload
+
+from textual.css.query import NoMatches, QueryType, WrongType
+from textual.dom import DOMNode
+from textual.widget import Widget
+
+
+class query_one(Generic[QueryType]):
+ """Create a query one property.
+
+ A query one property calls [Widget.query_one][textual.dom.DOMNode.query_one] when accessed, and returns
+ a widget. If the widget doesn't exist, then the property will raise the same exceptions as `Widget.query_one`.
+
+
+ Example:
+ ```python
+ from textual import getters
+
+ class MyScreen(screen):
+
+ # Note this is at the class level
+ output_log = getters.query_one("#output", RichLog)
+
+ def compose(self) -> ComposeResult:
+ with containers.Vertical():
+ yield RichLog(id="output")
+
+ def on_mount(self) -> None:
+ self.output_log.write("Screen started")
+ # Equivalent to the following line:
+ # self.query_one("#output", RichLog).write("Screen started")
+ ```
+
+ Args:
+ selector: A TCSS selector, e.g. "#mywidget". Or a widget type, i.e. `Input`.
+ expect_type: The type of the expected widget, e.g. `Input`, if the first argument is a selector.
+
+ """
+
+ selector: str
+ expect_type: type[Widget]
+
+ @overload
+ def __init__(self, selector: str) -> None:
+ """
+
+ Args:
+ selector: A TCSS selector, e.g. "#mywidget"
+ """
+ self.selector = selector
+ self.expect_type = Widget
+
+ @overload
+ def __init__(self, selector: type[QueryType]) -> None:
+ self.selector = selector.__name__
+ self.expect_type = selector
+
+ @overload
+ def __init__(self, selector: str, expect_type: type[QueryType]) -> None:
+ self.selector = selector
+ self.expect_type = expect_type
+
+ @overload
+ def __init__(self, selector: type[QueryType], expect_type: type[QueryType]) -> None:
+ self.selector = selector.__name__
+ self.expect_type = expect_type
+
+ def __init__(
+ self,
+ selector: str | type[QueryType],
+ expect_type: type[QueryType] | None = None,
+ ) -> None:
+ if expect_type is None:
+ self.expect_type = Widget
+ else:
+ self.expect_type = expect_type
+ if isinstance(selector, str):
+ self.selector = selector
+ else:
+ self.selector = selector.__name__
+ self.expect_type = selector
+
+ @overload
+ def __get__(
+ self: "query_one[QueryType]", obj: DOMNode, obj_type: type[DOMNode]
+ ) -> QueryType: ...
+
+ @overload
+ def __get__(
+ self: "query_one[QueryType]", obj: None, obj_type: type[DOMNode]
+ ) -> "query_one[QueryType]": ...
+
+ def __get__(
+ self: "query_one[QueryType]", obj: DOMNode | None, obj_type: type[DOMNode]
+ ) -> QueryType | Widget | "query_one":
+ """Get the widget matching the selector and/or type."""
+ if obj is None:
+ return self
+ query_node = obj.query_one(self.selector, self.expect_type)
+ return query_node
+
+
+class child_by_id(Generic[QueryType]):
+ """Create a child_by_id property, which returns the child with the given ID.
+
+ This is similar using [query_one][textual.getters.query_one] with an id selector, except that
+ only the immediate children are considered. It is also more efficient as it doesn't need to search the DOM.
+
+
+ Example:
+ ```python
+ from textual import getters
+
+ class MyScreen(screen):
+
+ # Note this is at the class level
+ output_log = getters.child_by_id("output", RichLog)
+
+ def compose(self) -> ComposeResult:
+ yield RichLog(id="output")
+
+ def on_mount(self) -> None:
+ self.output_log.write("Screen started")
+ ```
+
+ Args:
+ child_id: The `id` of the widget to get (not a selector).
+ expect_type: The type of the expected widget, e.g. `Input`.
+
+ """
+
+ child_id: str
+ expect_type: type[Widget]
+
+ @overload
+ def __init__(self, child_id: str) -> None:
+ self.child_id = child_id
+ self.expect_type = Widget
+
+ @overload
+ def __init__(self, child_id: str, expect_type: type[QueryType]) -> None:
+ self.child_id = child_id
+ self.expect_type = expect_type
+
+ def __init__(
+ self,
+ child_id: str,
+ expect_type: type[QueryType] | None = None,
+ ) -> None:
+ if expect_type is None:
+ self.expect_type = Widget
+ else:
+ self.expect_type = expect_type
+ self.child_id = child_id
+
+ @overload
+ def __get__(
+ self: "child_by_id[QueryType]", obj: DOMNode, obj_type: type[DOMNode]
+ ) -> QueryType: ...
+
+ @overload
+ def __get__(
+ self: "child_by_id[QueryType]", obj: None, obj_type: type[DOMNode]
+ ) -> "child_by_id[QueryType]": ...
+
+ def __get__(
+ self: "child_by_id[QueryType]", obj: DOMNode | None, obj_type: type[DOMNode]
+ ) -> QueryType | Widget | "child_by_id":
+ """Get the widget matching the selector and/or type."""
+ if obj is None:
+ return self
+ child = obj._nodes._get_by_id(self.child_id)
+ if child is None:
+ raise NoMatches(f"No child found with id={id!r}")
+ if not isinstance(child, self.expect_type):
+ if not isinstance(child, self.expect_type):
+ raise WrongType(
+ f"Child with id={id!r} is wrong type; expected {self.expect_type}, got"
+ f" {type(child)}"
+ )
+ return child
diff --git a/contrib/python/textual/textual/keys.py b/contrib/python/textual/textual/keys.py
index b4eb6236d03..09c7f17acb8 100644
--- a/contrib/python/textual/textual/keys.py
+++ b/contrib/python/textual/textual/keys.py
@@ -354,3 +354,15 @@ def _character_to_key(character: str) -> str:
key = character
key = KEY_NAME_REPLACEMENTS.get(key, key)
return key
+
+
+def _normalize_key_list(keys: str) -> str:
+ """Normalizes a comma separated list of keys.
+
+ Replaces single letter keys with full name.
+ """
+
+ keys_list = [key.strip() for key in keys.split(",")]
+ return ",".join(
+ _character_to_key(key) if len(key) == 1 else key for key in keys_list
+ )
diff --git a/contrib/python/textual/textual/layout.py b/contrib/python/textual/textual/layout.py
index 4f16787d456..3f93700f703 100644
--- a/contrib/python/textual/textual/layout.py
+++ b/contrib/python/textual/textual/layout.py
@@ -261,15 +261,12 @@ class Layout(ABC):
child.styles.is_dynamic_height for child in widget.displayed_children
):
# An exception for containers with all dynamic height widgets
- arrangement = widget._arrange(
- Size(width, container.height - widget.gutter.height)
- )
+ arrangement = widget._arrange(Size(width, container.height))
else:
arrangement = widget._arrange(Size(width, 0))
- height = arrangement.total_region.bottom
+ height = arrangement.total_region.height
else:
height = 0
-
return height
def render_keyline(self, container: Widget) -> StripRenderable:
diff --git a/contrib/python/textual/textual/markup.py b/contrib/python/textual/textual/markup.py
index 11ee5a132ff..60abb9f5cb8 100644
--- a/contrib/python/textual/textual/markup.py
+++ b/contrib/python/textual/textual/markup.py
@@ -1,6 +1,14 @@
+"""
+Utilities related to content markup.
+
+"""
+
from __future__ import annotations
+from operator import itemgetter
+
from textual.css.parse import substitute_references
+from textual.css.tokenizer import UnexpectedEnd
__all__ = ["MarkupError", "escape", "to_content"]
@@ -26,7 +34,7 @@ if TYPE_CHECKING:
class MarkupError(Exception):
- """An error occurred parsing Textual markup."""
+ """An error occurred parsing content markup."""
expect_markup_tag = (
@@ -40,8 +48,9 @@ expect_markup_tag = (
variable_ref=VARIABLE_REF,
whitespace=r"\s+",
)
- .expect_eof()
+ .expect_eof(True)
.expect_semicolon(False)
+ .extract_text(True)
)
expect_markup = Expect(
@@ -66,13 +75,13 @@ expect_markup_expression = (
double_string=r"\".*?\"",
single_string=r"'.*?'",
)
- .expect_eof()
+ .expect_eof(True)
.expect_semicolon(False)
)
class MarkupTokenizer(TokenizerState):
- """Tokenizes Textual markup."""
+ """Tokenizes content markup."""
EXPECT = expect_markup.expect_eof()
STATE_MAP = {
@@ -170,7 +179,7 @@ def escape(
def parse_style(style: str, variables: dict[str, str] | None = None) -> Style:
- """Parse an encoded style.
+ """Parse a style with substituted variables.
Args:
style: Style encoded in a string.
@@ -302,6 +311,10 @@ def to_content(
_rich_traceback_omit = True
try:
return _to_content(markup, style, template_variables)
+ except UnexpectedEnd:
+ raise MarkupError(
+ "Unexpected end of markup; are you missing a closing square bracket?"
+ ) from None
except Exception as error:
# Ensure all errors are wrapped in a MarkupError
raise MarkupError(str(error)) from None
@@ -362,14 +375,36 @@ def _to_content(
elif token_name == "open_tag":
tag_text = []
+ eof = False
+ contains_text = False
for token in iter_tokens:
if token.name == "end_tag":
break
+ elif token.name == "text":
+ contains_text = True
+ elif token.name == "eof":
+ eof = True
tag_text.append(token.value)
- opening_tag = "".join(tag_text).strip()
- style_stack.append(
- (position, opening_tag, normalize_markup_tag(opening_tag))
- )
+ if contains_text or eof:
+ # "tag" was unparsable
+ text_content = f"[{''.join(tag_text)}" + ("" if eof else "]")
+ text_append(text_content)
+ position += len(text_content)
+ else:
+ opening_tag = "".join(tag_text)
+
+ if not opening_tag.strip():
+ blank_tag = f"[{opening_tag}]"
+ text_append(blank_tag)
+ position += len(blank_tag)
+ else:
+ style_stack.append(
+ (
+ position,
+ opening_tag,
+ normalize_markup_tag(opening_tag.strip()),
+ )
+ )
elif token_name == "open_closing_tag":
tag_text = []
@@ -397,18 +432,26 @@ def _to_content(
if not style_stack:
raise MarkupError("auto closing tag ('[/]') has nothing to close")
open_position, tag_body, _ = style_stack.pop()
- spans.append(Span(open_position, position, tag_body))
+ if open_position != position:
+ spans.append(Span(open_position, position, tag_body))
content_text = "".join(text)
text_length = len(content_text)
- while style_stack:
- position, tag_body, _ = style_stack.pop()
- spans.append(Span(position, text_length, tag_body))
+ if style_stack and text_length:
+ spans.extend(
+ [
+ Span(position, text_length, tag_body)
+ for position, tag_body, _ in reversed(style_stack)
+ if position != text_length
+ ]
+ )
+ spans.reverse()
+ spans.sort(key=itemgetter(0)) # Zeroth item of Span is 'start' attribute
- if style:
- content = Content(content_text, [Span(0, len(content_text), style), *spans])
- else:
- content = Content(content_text, spans)
+ content = Content(
+ content_text,
+ [Span(0, text_length, style), *spans] if (style and text_length) else spans,
+ )
return content
diff --git a/contrib/python/textual/textual/message_pump.py b/contrib/python/textual/textual/message_pump.py
index 9ec49f90472..19e37e4a193 100644
--- a/contrib/python/textual/textual/message_pump.py
+++ b/contrib/python/textual/textual/message_pump.py
@@ -12,7 +12,7 @@ from __future__ import annotations
import asyncio
import threading
-from asyncio import CancelledError, Queue, QueueEmpty, Task, create_task
+from asyncio import CancelledError, QueueEmpty, Task, create_task
from contextlib import contextmanager
from functools import partial
from time import perf_counter
@@ -31,6 +31,7 @@ from weakref import WeakSet
from textual import Logger, events, log, messages
from textual._callback import invoke
+from textual._compat import cached_property
from textual._context import NoActiveAppError, active_app, active_message_pump
from textual._context import message_hook as message_hook_context_var
from textual._context import prevent_message_types_stack
@@ -114,7 +115,6 @@ class MessagePump(metaclass=_MessagePumpMeta):
"""Base class which supplies a message pump."""
def __init__(self, parent: MessagePump | None = None) -> None:
- self._message_queue: Queue[Message | None] = Queue()
self._parent = parent
self._running: bool = False
self._closing: bool = False
@@ -125,7 +125,6 @@ class MessagePump(metaclass=_MessagePumpMeta):
self._timers: WeakSet[Timer] = WeakSet()
self._last_idle: float = time()
self._max_idle: float | None = None
- self._mounted_event = asyncio.Event()
self._is_mounted = False
"""Having this explicit Boolean is an optimization.
@@ -143,6 +142,14 @@ class MessagePump(metaclass=_MessagePumpMeta):
"""
+ @cached_property
+ def _message_queue(self) -> asyncio.Queue[Message | None]:
+ return asyncio.Queue()
+
+ @cached_property
+ def _mounted_event(self) -> asyncio.Event:
+ return asyncio.Event()
+
@property
def _prevent_message_types_stack(self) -> list[set[type[Message]]]:
"""The stack that manages prevented messages."""
@@ -153,6 +160,15 @@ class MessagePump(metaclass=_MessagePumpMeta):
prevent_message_types_stack.set(stack)
return stack
+ def _thread_init(self):
+ """Initialize threading primitives for the current thread.
+
+ Require for Python3.8 https://github.com/Textualize/textual/issues/5845
+
+ """
+ self._message_queue
+ self._mounted_event
+
def _get_prevented_messages(self) -> set[type[Message]]:
"""A set of all the prevented message types."""
return self._prevent_message_types_stack[-1]
@@ -496,6 +512,8 @@ class MessagePump(metaclass=_MessagePumpMeta):
def _start_messages(self) -> None:
"""Start messages task."""
+ self._thread_init()
+
if self.app._running:
self._task = create_task(
self._process_messages(), name=f"message pump {self}"
diff --git a/contrib/python/textual/textual/notifications.py b/contrib/python/textual/textual/notifications.py
index 0687260ebf2..d721590071c 100644
--- a/contrib/python/textual/textual/notifications.py
+++ b/contrib/python/textual/textual/notifications.py
@@ -39,6 +39,9 @@ class Notification:
timeout: float = 5
"""The timeout (in seconds) for the notification."""
+ markup: bool = False
+ """Render the notification message as content markup?"""
+
raised_at: float = field(default_factory=time)
"""The time when the notification was raised (in Unix time)."""
diff --git a/contrib/python/textual/textual/reactive.py b/contrib/python/textual/textual/reactive.py
index 9b1e75d41ff..1480cf70a09 100644
--- a/contrib/python/textual/textual/reactive.py
+++ b/contrib/python/textual/textual/reactive.py
@@ -111,6 +111,7 @@ class Reactive(Generic[ReactiveType]):
compute: Run compute methods when attribute is changed.
recompose: Compose the widget again when the attribute changes.
bindings: Refresh bindings when the reactive changes.
+ toggle_class: An optional TCSS classname(s) to toggle based on the truthiness of the value.
"""
_reactives: ClassVar[dict[str, object]] = {}
@@ -126,6 +127,7 @@ class Reactive(Generic[ReactiveType]):
compute: bool = True,
recompose: bool = False,
bindings: bool = False,
+ toggle_class: str | None = None,
) -> None:
self._default = default
self._layout = layout
@@ -135,6 +137,7 @@ class Reactive(Generic[ReactiveType]):
self._run_compute = compute
self._recompose = recompose
self._bindings = bindings
+ self._toggle_class = toggle_class
self._owner: Type[MessageTarget] | None = None
self.name: str
@@ -175,6 +178,7 @@ class Reactive(Generic[ReactiveType]):
name: Name of attribute.
"""
_rich_traceback_omit = True
+
internal_name = f"_reactive_{name}"
if hasattr(obj, internal_name):
# Attribute already has a value
@@ -308,6 +312,11 @@ class Reactive(Generic[ReactiveType]):
public_validate_function = getattr(obj, f"validate_{name}", None)
if callable(public_validate_function):
value = public_validate_function(value)
+
+ # Toggle the classes using the value's truthiness
+ if (toggle_class := self._toggle_class) is not None:
+ obj.set_class(bool(value), *toggle_class.split())
+
# If the value has changed, or this is the first time setting the value
if always or self._always_update or current_value != value:
# Store the internal value
@@ -405,7 +414,9 @@ class reactive(Reactive[ReactiveType]):
repaint: Perform a repaint on change.
init: Call watchers on initialize (post mount).
always_update: Call watchers even when the new value equals the old value.
+ recompose: Compose the widget again when the attribute changes.
bindings: Refresh bindings when the reactive changes.
+ toggle_class: An optional TCSS classname(s) to toggle based on the truthiness of the value.
"""
def __init__(
@@ -418,6 +429,7 @@ class reactive(Reactive[ReactiveType]):
always_update: bool = False,
recompose: bool = False,
bindings: bool = False,
+ toggle_class: str | None = None,
) -> None:
super().__init__(
default,
@@ -427,6 +439,7 @@ class reactive(Reactive[ReactiveType]):
always_update=always_update,
recompose=recompose,
bindings=bindings,
+ toggle_class=toggle_class,
)
@@ -438,6 +451,7 @@ class var(Reactive[ReactiveType]):
init: Call watchers on initialize (post mount).
always_update: Call watchers even when the new value equals the old value.
bindings: Refresh bindings when the reactive changes.
+ toggle_class: An optional TCSS classname(s) to toggle based on the truthiness of the value.
"""
def __init__(
@@ -446,6 +460,7 @@ class var(Reactive[ReactiveType]):
init: bool = True,
always_update: bool = False,
bindings: bool = False,
+ toggle_class: str | None = None,
) -> None:
super().__init__(
default,
@@ -454,6 +469,7 @@ class var(Reactive[ReactiveType]):
init=init,
always_update=always_update,
bindings=bindings,
+ toggle_class=toggle_class,
)
diff --git a/contrib/python/textual/textual/renderables/blank.py b/contrib/python/textual/textual/renderables/blank.py
index 3c435d16ae5..a5ea61074e2 100644
--- a/contrib/python/textual/textual/renderables/blank.py
+++ b/contrib/python/textual/textual/renderables/blank.py
@@ -1,27 +1,39 @@
from __future__ import annotations
-from rich.console import Console, ConsoleOptions, RenderResult
-from rich.segment import Segment
-from rich.style import Style
+from rich.style import Style as RichStyle
from textual.color import Color
+from textual.content import Style
+from textual.css.styles import RulesMap
+from textual.selection import Selection
+from textual.strip import Strip
+from textual.visual import Visual
-class Blank:
+class Blank(Visual):
"""Draw solid background color."""
def __init__(self, color: Color | str = "transparent") -> None:
- background = Color.parse(color)
- self._style = Style.from_color(bgcolor=background.rich_color)
+ self._rich_style = RichStyle.from_color(bgcolor=Color.parse(color).rich_color)
- def __rich_console__(
- self, console: Console, options: ConsoleOptions
- ) -> RenderResult:
- width = options.max_width
- height = options.height or options.max_height
+ def visualize(self) -> Blank:
+ return self
- segment = Segment(" " * width, self._style)
- line = Segment.line()
- for _ in range(height):
- yield segment
- yield line
+ def get_optimal_width(self, rules: RulesMap, container_width: int) -> int:
+ return container_width
+
+ def get_height(self, rules: RulesMap, width: int) -> int:
+ return 1
+
+ def render_strips(
+ self,
+ rules: RulesMap,
+ width: int,
+ height: int | None,
+ style: Style,
+ selection: Selection | None = None,
+ selection_style: Style | None = None,
+ post_style: Style | None = None,
+ ) -> list[Strip]:
+ line_count = 1 if height is None else height
+ return [Strip.blank(width, self._rich_style)] * line_count
diff --git a/contrib/python/textual/textual/renderables/text_opacity.py b/contrib/python/textual/textual/renderables/text_opacity.py
index ee9e78ecbea..a2255ab7fc6 100644
--- a/contrib/python/textual/textual/renderables/text_opacity.py
+++ b/contrib/python/textual/textual/renderables/text_opacity.py
@@ -52,7 +52,10 @@ class TextOpacity:
@classmethod
def process_segments(
- cls, segments: Iterable[Segment], opacity: float, ansi_theme: TerminalTheme
+ cls,
+ segments: Iterable[Segment],
+ opacity: float,
+ ansi_theme: TerminalTheme,
) -> Iterable[Segment]:
"""Apply opacity to segments.
@@ -60,6 +63,7 @@ class TextOpacity:
segments: Incoming segments.
opacity: Opacity to apply.
ansi_theme: Terminal theme.
+ background: Color of background.
Returns:
Segments with applied opacity.
diff --git a/contrib/python/textual/textual/renderables/tint.py b/contrib/python/textual/textual/renderables/tint.py
index d8a96439bf5..6ad27926bce 100644
--- a/contrib/python/textual/textual/renderables/tint.py
+++ b/contrib/python/textual/textual/renderables/tint.py
@@ -7,7 +7,7 @@ from rich.segment import Segment
from rich.style import Style
from rich.terminal_theme import TerminalTheme
-from textual.color import Color
+from textual.color import TRANSPARENT, Color
from textual.filter import ANSIToTruecolor
@@ -30,7 +30,11 @@ class Tint:
@classmethod
def process_segments(
- cls, segments: Iterable[Segment], color: Color, ansi_theme: TerminalTheme
+ cls,
+ segments: Iterable[Segment],
+ color: Color,
+ ansi_theme: TerminalTheme,
+ background: Color = TRANSPARENT,
) -> Iterable[Segment]:
"""Apply tint to segments.
@@ -38,6 +42,7 @@ class Tint:
segments: Incoming segments.
color: Color of tint.
ansi_theme: The TerminalTheme defining how to map ansi colors to hex.
+ background: Background color.
Returns:
Segments with applied tint.
@@ -47,6 +52,7 @@ class Tint:
_Segment = Segment
truecolor_style = ANSIToTruecolor(ansi_theme).truecolor_style
+ background_rich_color = background.rich_color
NULL_STYLE = Style()
for segment in segments:
@@ -54,7 +60,11 @@ class Tint:
if control:
yield segment
else:
- style = truecolor_style(style) if style is not None else NULL_STYLE
+ style = (
+ truecolor_style(style, background_rich_color)
+ if style is not None
+ else NULL_STYLE
+ )
yield _Segment(
text,
(
diff --git a/contrib/python/textual/textual/screen.py b/contrib/python/textual/textual/screen.py
index 3178435c4ab..73369c6a466 100644
--- a/contrib/python/textual/textual/screen.py
+++ b/contrib/python/textual/textual/screen.py
@@ -196,6 +196,11 @@ class Screen(Generic[ScreenResultType], Widget):
you can set the [sub_title][textual.screen.Screen.sub_title] attribute.
"""
+ HORIZONTAL_BREAKPOINTS: ClassVar[list[tuple[int, str]]] | None = None
+ """Horizontal breakpoints, will override [App.HORIZONTAL_BREAKPOINTS][textual.app.App.HORIZONTAL_BREAKPOINTS] if not `None`."""
+ VERTICAL_BREAKPOINTS: ClassVar[list[tuple[int, str]]] | None = None
+ """Vertical breakpoints, will override [App.VERTICAL_BREAKPOINTS][textual.app.App.VERTICAL_BREAKPOINTS] if not `None`."""
+
focused: Reactive[Widget | None] = Reactive(None)
"""The focused [widget][textual.widget.Widget] or `None` for no focus.
To set focus, do not update this value directly. Use [set_focus][textual.screen.Screen.set_focus] instead."""
@@ -290,6 +295,11 @@ class Screen(Generic[ScreenResultType], Widget):
self.bindings_updated_signal: Signal[Screen] = Signal(self, "bindings_updated")
"""A signal published when the bindings have been updated"""
+ self.text_selection_started_signal: Signal[Screen] = Signal(
+ self, "selection_started"
+ )
+ """A signal published when text selection has started."""
+
self._css_update_count = -1
"""Track updates to CSS."""
@@ -1308,14 +1318,16 @@ class Screen(Generic[ScreenResultType], Widget):
def _screen_resized(self, size: Size) -> None:
"""Called by App when the screen is resized."""
- if self.stack_updates:
+ if self.stack_updates and self.is_attached:
self._refresh_layout(size)
def _on_screen_resume(self) -> None:
"""Screen has resumed."""
if self.app.SUSPENDED_SCREEN_CLASS:
self.remove_class(self.app.SUSPENDED_SCREEN_CLASS)
+
self.stack_updates += 1
+
self.app._refresh_notifications()
size = self.app.size
@@ -1330,10 +1342,11 @@ class Screen(Generic[ScreenResultType], Widget):
self.set_focus(widget)
break
- self._compositor_refresh()
- self.app.stylesheet.update(self)
- self._refresh_layout(size)
- self.refresh()
+ if self.is_attached:
+ self._compositor_refresh()
+ self.app.stylesheet.update(self)
+ self._refresh_layout(size)
+ self.refresh()
def _on_screen_suspend(self) -> None:
"""Screen has suspended."""
@@ -1349,6 +1362,41 @@ class Screen(Generic[ScreenResultType], Widget):
for screen in self.app._background_screens:
screen._screen_resized(event.size)
+ horizontal_breakpoints = (
+ self.app.HORIZONTAL_BREAKPOINTS
+ if self.HORIZONTAL_BREAKPOINTS is None
+ else self.HORIZONTAL_BREAKPOINTS
+ ) or []
+
+ vertical_breakpoints = (
+ self.app.VERTICAL_BREAKPOINTS
+ if self.VERTICAL_BREAKPOINTS is None
+ else self.VERTICAL_BREAKPOINTS
+ ) or []
+
+ width, height = event.size
+ if horizontal_breakpoints:
+ self._set_breakpoints(width, horizontal_breakpoints)
+ if vertical_breakpoints:
+ self._set_breakpoints(height, vertical_breakpoints)
+
+ def _set_breakpoints(
+ self, dimension: int, breakpoints: list[tuple[int, str]]
+ ) -> None:
+ """Set horizontal or vertical breakpoints.
+
+ Args:
+ dimension: Either the width or the height.
+ breakpoints: A list of breakpoints.
+
+ """
+ class_names = [class_name for _breakpoint, class_name in breakpoints]
+ self.remove_class(*class_names)
+ for breakpoint, class_name in sorted(breakpoints, reverse=True):
+ if dimension >= breakpoint:
+ self.add_class(class_name)
+ return
+
def _update_tooltip(self, widget: Widget) -> None:
"""Update the content of the tooltip."""
try:
@@ -1541,6 +1589,7 @@ class Screen(Generic[ScreenResultType], Widget):
):
self._selecting = True
if select_widget is not None and select_offset is not None:
+ self.text_selection_started_signal.publish(self)
self._select_start = (
select_widget,
event.screen_offset,
diff --git a/contrib/python/textual/textual/signal.py b/contrib/python/textual/textual/signal.py
index cc1be10b52c..eacb3fcf20b 100644
--- a/contrib/python/textual/textual/signal.py
+++ b/contrib/python/textual/textual/signal.py
@@ -3,8 +3,6 @@ Signals are a simple pub-sub mechanism.
DOMNodes can subscribe to a signal, which will invoke a callback when the signal is published.
-This is experimental for now, for internal use. It may be part of the public API in a future release.
-
"""
from __future__ import annotations
@@ -18,7 +16,7 @@ from textual import log
if TYPE_CHECKING:
from textual.dom import DOMNode
- from textual.message_pump import MessagePump
+
SignalT = TypeVar("SignalT")
SignalCallbackType = Union[
@@ -44,7 +42,7 @@ class Signal(Generic[SignalT]):
self._owner = ref(owner)
self._name = name
self._subscriptions: WeakKeyDictionary[
- MessagePump, list[SignalCallbackType]
+ DOMNode, list[SignalCallbackType[SignalT]]
] = WeakKeyDictionary()
def __rich_repr__(self) -> rich.repr.Result:
@@ -59,8 +57,8 @@ class Signal(Generic[SignalT]):
def subscribe(
self,
- node: MessagePump,
- callback: SignalCallbackType,
+ node: DOMNode,
+ callback: SignalCallbackType[SignalT],
immediate: bool = False,
) -> None:
"""Subscribe a node to this signal.
@@ -84,20 +82,20 @@ class Signal(Generic[SignalT]):
if immediate:
- def signal_callback(data: object) -> None:
+ def signal_callback(data: SignalT) -> None:
"""Invoke the callback immediately."""
callback(data)
else:
- def signal_callback(data: object) -> None:
+ def signal_callback(data: SignalT) -> None:
"""Post the callback to the node, to call at the next opertunity."""
node.call_next(callback, data)
callbacks = self._subscriptions.setdefault(node, [])
callbacks.append(signal_callback)
- def unsubscribe(self, node: MessagePump) -> None:
+ def unsubscribe(self, node: DOMNode) -> None:
"""Unsubscribe a node from this signal.
Args:
diff --git a/contrib/python/textual/textual/timer.py b/contrib/python/textual/textual/timer.py
index 998662a24e7..af657b3b698 100644
--- a/contrib/python/textual/textual/timer.py
+++ b/contrib/python/textual/textual/timer.py
@@ -15,6 +15,7 @@ from rich.repr import Result, rich_repr
from textual import _time, events
from textual._callback import invoke
+from textual._compat import cached_property
from textual._context import active_app
from textual._time import sleep
from textual._types import MessageTarget
@@ -62,11 +63,16 @@ class Timer:
self._callback = callback
self._repeat = repeat
self._skip = skip
- self._active = Event()
self._task: Task | None = None
self._reset: bool = False
- if not pause:
- self._active.set()
+ self._original_pause = pause
+
+ @cached_property
+ def _active(self) -> Event:
+ event = Event()
+ if not self._original_pause:
+ event.set()
+ return event
def __rich_repr__(self) -> Result:
yield self._interval
@@ -146,6 +152,7 @@ class Timer:
count = 0
_repeat = self._repeat
_interval = self._interval
+ self._active # Force instantiation in same thread
await self._active.wait()
start = _time.get_time()
diff --git a/contrib/python/textual/textual/visual.py b/contrib/python/textual/textual/visual.py
index edd20e31d42..e4d6087c597 100644
--- a/contrib/python/textual/textual/visual.py
+++ b/contrib/python/textual/textual/visual.py
@@ -86,7 +86,7 @@ def visualize(widget: Widget, obj: object, markup: bool = True) -> Visual:
return Content.from_markup(obj) if markup else Content(obj)
if is_renderable(obj):
- if isinstance(obj, Text) and widget.allow_select:
+ if isinstance(obj, Text):
return Content.from_rich_text(obj, console=widget.app.console)
# If its is a Rich renderable, wrap it with a RichVisual
diff --git a/contrib/python/textual/textual/widget.py b/contrib/python/textual/textual/widget.py
index 21214c0e465..cf0ec8a964f 100644
--- a/contrib/python/textual/textual/widget.py
+++ b/contrib/python/textual/textual/widget.py
@@ -54,6 +54,7 @@ from textual._context import NoActiveAppError
from textual._debug import get_caller_file_and_line
from textual._dispatch_key import dispatch_key
from textual._easing import DEFAULT_SCROLL_EASING
+from textual._extrema import Extrema
from textual._styles_cache import StylesCache
from textual._types import AnimationLevel
from textual.actions import SkipAction
@@ -377,7 +378,7 @@ class Widget(DOMNode):
"hover": lambda widget: widget.mouse_hover,
"focus": lambda widget: widget.has_focus,
"blur": lambda widget: not widget.has_focus,
- "can-focus": lambda widget: widget.can_focus,
+ "can-focus": lambda widget: widget.allow_focus(),
"disabled": lambda widget: widget.is_disabled,
"enabled": lambda widget: not widget.is_disabled,
"dark": lambda widget: widget.app.current_theme.dark,
@@ -388,6 +389,8 @@ class Widget(DOMNode):
"nocolor": lambda widget: widget.app.no_color,
"first-of-type": lambda widget: widget.first_of_type,
"last-of-type": lambda widget: widget.last_of_type,
+ "first-child": lambda widget: widget.first_child,
+ "last-child": lambda widget: widget.last_child,
"odd": lambda widget: widget.is_odd,
"even": lambda widget: widget.is_even,
} # type: ignore[assignment]
@@ -409,6 +412,7 @@ class Widget(DOMNode):
id: The ID of the widget in the DOM.
classes: The CSS classes for the widget.
disabled: Whether the widget is disabled or not.
+ markup: Enable content markup?
"""
self._render_markup = markup
_null_size = NULL_SIZE
@@ -498,12 +502,18 @@ class Widget(DOMNode):
"""Used to cache :first-of-type pseudoclass state."""
self._last_of_type: tuple[int, bool] = (-1, False)
"""Used to cache :last-of-type pseudoclass state."""
+ self._first_child: tuple[int, bool] = (-1, False)
+ """Used to cache :first-child pseudoclass state."""
+ self._last_child: tuple[int, bool] = (-1, False)
+ """Used to cache :last-child pseudoclass state."""
self._odd: tuple[int, bool] = (-1, False)
"""Used to cache :odd pseudoclass state."""
self._last_scroll_time = monotonic()
"""Time of last scroll."""
self._user_scroll_interrupt: bool = False
"""Has the user interrupted a scroll to end?"""
+ self._extrema = Extrema()
+ """Optional minimum and maximum values for width and height."""
@property
def is_mounted(self) -> bool:
@@ -849,6 +859,34 @@ class Widget(DOMNode):
return False
@property
+ def first_child(self) -> bool:
+ """Is this the first widget in its siblings?"""
+ parent = self.parent
+ if parent is None:
+ return True
+ # This pseudo class only changes when the parent's nodes._updates changes
+ if parent._nodes._updates == self._first_child[0]:
+ return self._first_child[1]
+ for node in parent._nodes:
+ self._first_child = (parent._nodes._updates, node is self)
+ return self._first_child[1]
+ return False
+
+ @property
+ def last_child(self) -> bool:
+ """Is this the last widget in its siblings?"""
+ parent = self.parent
+ if parent is None:
+ return True
+ # This pseudo class only changes when the parent's nodes._updates changes
+ if parent._nodes._updates == self._last_child[0]:
+ return self._last_child[1]
+ for node in reversed(parent._nodes):
+ self._last_child = (parent._nodes._updates, node is self)
+ return self._last_child[1]
+ return False
+
+ @property
def is_odd(self) -> bool:
"""Is this widget at an oddly numbered position within its siblings?"""
parent = self.parent
@@ -915,18 +953,15 @@ class Widget(DOMNode):
def set_loading(self, loading: bool) -> None:
"""Set or reset the loading state of this widget.
- A widget in a loading state will display a LoadingIndicator that obscures the widget.
+ A widget in a loading state will display a `LoadingIndicator` or a custom widget
+ set through overriding the `get_loading_widget` method.
Args:
loading: `True` to put the widget into a loading state, or `False` to reset the loading state.
-
- Returns:
- An optional awaitable.
"""
- LOADING_INDICATOR_CLASS = "-textual-loading-indicator"
if loading:
loading_indicator = self.get_loading_widget()
- loading_indicator.add_class(LOADING_INDICATOR_CLASS)
+ loading_indicator.add_class("-textual-loading-indicator")
self._cover(loading_indicator)
else:
self._uncover()
@@ -1300,7 +1335,7 @@ class Widget(DOMNode):
"""Update order related CSS"""
if before is not None or after is not None:
# If the new children aren't at the end.
- # we need to update both odd/even and first-of-type/last-of-type
+ # we need to update both odd/even, first-of-type/last-of-type and first-child/last-child
for child in children:
if child._has_order_style or child._has_odd_or_even:
child._update_styles()
@@ -1519,7 +1554,7 @@ class Widget(DOMNode):
content_width = Fraction(_content_width)
content_height = Fraction(_content_height)
is_border_box = styles.box_sizing == "border-box"
- gutter = styles.gutter
+ gutter = styles.gutter # Padding plus border
margin = styles.margin
is_auto_width = styles.width and styles.width.is_auto
@@ -1528,12 +1563,16 @@ class Widget(DOMNode):
# Container minus padding and border
content_container = container - gutter.totals
+ extrema = self._extrema = self._resolve_extrema(
+ container, viewport, width_fraction, height_fraction
+ )
+ min_width, max_width, min_height, max_height = extrema
+
if styles.width is None:
# No width specified, fill available space
content_width = Fraction(content_container.width - margin.width)
elif is_auto_width:
# When width is auto, we want enough space to always fit the content
-
content_width = Fraction(
self.get_content_width(content_container - margin.totals, viewport)
)
@@ -1555,28 +1594,17 @@ class Widget(DOMNode):
if is_border_box:
content_width -= gutter.width
- if styles.min_width is not None:
+ if min_width is not None:
# Restrict to minimum width, if set
- min_width = styles.min_width.resolve(
- container - margin.totals, viewport, width_fraction
- )
- if is_border_box:
- min_width -= gutter.width
content_width = max(content_width, min_width, Fraction(0))
- if styles.max_width is not None and not (
+ if max_width is not None and not (
container.width == 0
and not styles.max_width.is_cells
and self._parent is not None
and self._parent.styles.is_auto_width
):
# Restrict to maximum width, if set
- max_width = styles.max_width.resolve(
- container - margin.totals, viewport, width_fraction
- )
- if is_border_box:
- max_width -= gutter.width
-
content_width = min(content_width, max_width)
content_width = max(Fraction(0), content_width)
@@ -1590,7 +1618,11 @@ class Widget(DOMNode):
elif is_auto_height:
# Calculate dimensions based on content
content_height = Fraction(
- self.get_content_height(content_container, viewport, int(content_width))
+ self.get_content_height(
+ content_container - margin.totals,
+ viewport,
+ int(content_width),
+ )
)
if (
styles.overflow_y == "auto" and styles.scrollbar_gutter == "stable"
@@ -1610,31 +1642,19 @@ class Widget(DOMNode):
if is_border_box:
content_height -= gutter.height
- if styles.min_height is not None:
+ if min_height is not None:
# Restrict to minimum height, if set
- min_height = styles.min_height.resolve(
- container - margin.totals, viewport, height_fraction
- )
- if is_border_box:
- min_height -= gutter.height
content_height = max(content_height, min_height, Fraction(0))
- if styles.max_height is not None and not (
+ if max_height is not None and not (
container.height == 0
and not styles.max_height.is_cells
and self._parent is not None
and self._parent.styles.is_auto_height
):
- # Restrict maximum height, if set
- max_height = styles.max_height.resolve(
- container - margin.totals, viewport, height_fraction
- )
- if is_border_box:
- max_height -= gutter.height
content_height = min(content_height, max_height)
content_height = max(Fraction(0), content_height)
-
model = BoxModel(
content_width + gutter.width, content_height + gutter.height, margin
)
@@ -2122,7 +2142,7 @@ class Widget(DOMNode):
"""Can this widget currently be focused?"""
return (
not self.loading
- and self.can_focus
+ and self.allow_focus()
and self.visible
and not self._self_or_ancestors_disabled
)
@@ -2198,6 +2218,63 @@ class Widget(DOMNode):
return False
return True
+ def _resolve_extrema(
+ self,
+ container: Size,
+ viewport: Size,
+ width_fraction: Fraction,
+ height_fraction: Fraction,
+ ) -> Extrema:
+ """Resolve minimum and maximum values for width and height.
+
+ Args:
+ container: Size of outer widget.
+ viewport: Viewport size.
+ width_fraction: Size of 1fr width.
+ height_fraction: Size of 1fr height.
+
+ Returns:
+ Extrema object.
+ """
+
+ min_width: Fraction | None = None
+ max_width: Fraction | None = None
+ min_height: Fraction | None = None
+ max_height: Fraction | None = None
+
+ styles = self.styles
+ container -= styles.margin.totals
+ if styles.box_sizing == "border-box":
+ gutter_width, gutter_height = styles.gutter.totals
+ else:
+ gutter_width = gutter_height = 0
+
+ if styles.min_width is not None:
+ min_width = (
+ styles.min_width.resolve(container, viewport, width_fraction)
+ - gutter_width
+ )
+
+ if styles.max_width is not None:
+ max_width = (
+ styles.max_width.resolve(container, viewport, width_fraction)
+ - gutter_width
+ )
+ if styles.min_height is not None:
+ min_height = (
+ styles.min_height.resolve(container, viewport, height_fraction)
+ - gutter_height
+ )
+
+ if styles.max_height is not None:
+ max_height = (
+ styles.max_height.resolve(container, viewport, height_fraction)
+ - gutter_height
+ )
+
+ extrema = Extrema(min_width, max_width, min_height, max_height)
+ return extrema
+
def animate(
self,
attribute: str,
@@ -3827,13 +3904,11 @@ class Widget(DOMNode):
self.vertical_scrollbar.window_size = (
height - self.scrollbar_size_horizontal
)
- if self.vertical_scrollbar._repaint_required:
- self.vertical_scrollbar.refresh()
+ self.vertical_scrollbar.refresh()
if self.show_horizontal_scrollbar:
self.horizontal_scrollbar.window_virtual_size = virtual_size.width
self.horizontal_scrollbar.window_size = width - self.scrollbar_size_vertical
- if self.horizontal_scrollbar._repaint_required:
- self.horizontal_scrollbar.refresh()
+ self.horizontal_scrollbar.refresh()
self.scroll_x = self.validate_scroll_x(self.scroll_x)
self.scroll_y = self.validate_scroll_y(self.scroll_y)
@@ -4209,6 +4284,10 @@ class Widget(DOMNode):
else:
if self._scroll_required:
self._scroll_required = False
+ if self.styles.keyline[0] != "none":
+ # TODO: Feels like a hack
+ # Perhaps there should be an explicit mechanism for backgrounds to refresh when scrolled?
+ self._set_dirty()
screen.post_message(messages.UpdateScroll())
if self._repaint_required:
self._repaint_required = False
@@ -4266,7 +4345,8 @@ class Widget(DOMNode):
Mouse events will only be sent when the mouse is over the widget.
"""
- self.app.capture_mouse(None)
+ if self.app.mouse_captured is self:
+ self.app.capture_mouse(None)
def text_select_all(self) -> None:
"""Select the entire widget."""
@@ -4560,6 +4640,7 @@ class Widget(DOMNode):
title: str = "",
severity: SeverityLevel = "information",
timeout: float | None = None,
+ markup: bool = True,
) -> None:
"""Create a notification.
@@ -4572,6 +4653,7 @@ class Widget(DOMNode):
title: The title for the notification.
severity: The severity of the notification.
timeout: The timeout (in seconds) for the notification, or `None` for default.
+ markup: Render the message as content markup?
See [`App.notify`][textual.app.App.notify] for the full
documentation for this method.
@@ -4581,6 +4663,7 @@ class Widget(DOMNode):
message,
title=title,
severity=severity,
+ markup=markup,
)
else:
return self.app.notify(
@@ -4588,9 +4671,19 @@ class Widget(DOMNode):
title=title,
severity=severity,
timeout=timeout,
+ markup=markup,
)
def action_notify(
- self, message: str, title: str = "", severity: str = "information"
+ self,
+ message: str,
+ title: str = "",
+ severity: str = "information",
+ markup: bool = True,
) -> None:
- self.notify(message, title=title, severity=severity)
+ self.notify(
+ message,
+ title=title,
+ severity=severity,
+ markup=markup,
+ )
diff --git a/contrib/python/textual/textual/widgets/__init__.py b/contrib/python/textual/textual/widgets/__init__.py
index ffc861dad15..2dfc7d3f73b 100644
--- a/contrib/python/textual/textual/widgets/__init__.py
+++ b/contrib/python/textual/textual/widgets/__init__.py
@@ -12,7 +12,7 @@ if typing.TYPE_CHECKING:
from textual.widget import Widget
from textual.widgets._button import Button
from textual.widgets._checkbox import Checkbox
- from textual.widgets._collapsible import Collapsible
+ from textual.widgets._collapsible import Collapsible, CollapsibleTitle
from textual.widgets._content_switcher import ContentSwitcher
from textual.widgets._data_table import DataTable
from textual.widgets._digits import Digits
@@ -54,6 +54,7 @@ __all__ = [
"Button",
"Checkbox",
"Collapsible",
+ "CollapsibleTitle",
"ContentSwitcher",
"DataTable",
"Digits",
diff --git a/contrib/python/textual/textual/widgets/__init__.pyi b/contrib/python/textual/textual/widgets/__init__.pyi
index 907ae843b89..19e50cb424d 100644
--- a/contrib/python/textual/textual/widgets/__init__.pyi
+++ b/contrib/python/textual/textual/widgets/__init__.pyi
@@ -2,6 +2,7 @@
from ._button import Button as Button
from ._checkbox import Checkbox as Checkbox
from ._collapsible import Collapsible as Collapsible
+from ._collapsible import CollapsibleTitle as CollapsibleTitle
from ._content_switcher import ContentSwitcher as ContentSwitcher
from ._data_table import DataTable as DataTable
from ._digits import Digits as Digits
diff --git a/contrib/python/textual/textual/widgets/_button.py b/contrib/python/textual/textual/widgets/_button.py
index c11ff39fb09..df73e7f0d80 100644
--- a/contrib/python/textual/textual/widgets/_button.py
+++ b/contrib/python/textual/textual/widgets/_button.py
@@ -44,6 +44,8 @@ class Button(Widget, can_focus=True):
"""
+ ALLOW_SELECT = False
+
DEFAULT_CSS = """
Button {
width: auto;
@@ -59,6 +61,10 @@ class Button(Widget, can_focus=True):
text-style: bold;
line-pad: 1;
+ &.-textual-compact {
+ border: none !important;
+ }
+
&:disabled {
text-opacity: 0.6;
}
@@ -160,6 +166,9 @@ class Button(Widget, can_focus=True):
variant = reactive("default", init=False)
"""The variant name for the button."""
+ compact = reactive(False, toggle_class="-textual-compact")
+ """Make the button compact (without borders)."""
+
class Pressed(Message):
"""Event sent when a `Button` is pressed and there is no Button action.
@@ -191,6 +200,7 @@ class Button(Widget, can_focus=True):
disabled: bool = False,
tooltip: RenderableType | None = None,
action: str | None = None,
+ compact: bool = False,
):
"""Create a Button widget.
@@ -203,6 +213,7 @@ class Button(Widget, can_focus=True):
disabled: Whether the button is disabled or not.
tooltip: Optional tooltip.
action: Optional action to run when clicked.
+ compact: Enable compact button style.
"""
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
@@ -212,6 +223,7 @@ class Button(Widget, can_focus=True):
self.label = Content.from_text(label)
self.variant = variant
self.action = action
+ self.compact = compact
self.active_effect_duration = 0.2
"""Amount of time in seconds the button 'press' animation lasts."""
diff --git a/contrib/python/textual/textual/widgets/_collapsible.py b/contrib/python/textual/textual/widgets/_collapsible.py
index 80b9872a926..952cb49767a 100644
--- a/contrib/python/textual/textual/widgets/_collapsible.py
+++ b/contrib/python/textual/textual/widgets/_collapsible.py
@@ -4,6 +4,7 @@ from textual import events
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Container
+from textual.content import Content, ContentText
from textual.css.query import NoMatches
from textual.message import Message
from textual.reactive import reactive
@@ -21,7 +22,7 @@ class CollapsibleTitle(Static, can_focus=True):
CollapsibleTitle {
width: auto;
height: auto;
- padding: 0 1 0 1;
+ padding: 0 1;
text-style: $block-cursor-blurred-text-style;
color: $block-cursor-blurred-foreground;
@@ -47,12 +48,12 @@ class CollapsibleTitle(Static, can_focus=True):
"""
collapsed = reactive(True)
- label = reactive("Toggle")
+ label: reactive[ContentText] = reactive(Content("Toggle"))
def __init__(
self,
*,
- label: str,
+ label: ContentText,
collapsed_symbol: str,
expanded_symbol: str,
collapsed: bool,
@@ -60,10 +61,8 @@ class CollapsibleTitle(Static, can_focus=True):
super().__init__()
self.collapsed_symbol = collapsed_symbol
self.expanded_symbol = expanded_symbol
- self.label = label
+ self.label = Content.from_text(label)
self.collapsed = collapsed
- self._collapsed_label = f"{collapsed_symbol} {label}"
- self._expanded_label = f"{expanded_symbol} {label}"
class Toggle(Message):
"""Request toggle."""
@@ -77,19 +76,21 @@ class CollapsibleTitle(Static, can_focus=True):
"""Toggle the state of the parent collapsible."""
self.post_message(self.Toggle())
- def _watch_label(self, label: str) -> None:
- self._collapsed_label = f"{self.collapsed_symbol} {label}"
- self._expanded_label = f"{self.expanded_symbol} {label}"
+ def validate_label(self, label: ContentText) -> Content:
+ return Content.from_text(label)
+
+ def _update_label(self) -> None:
+ assert isinstance(self.label, Content)
if self.collapsed:
- self.update(self._collapsed_label)
+ self.update(Content.assemble(self.collapsed_symbol, " ", self.label))
else:
- self.update(self._expanded_label)
+ self.update(Content.assemble(self.expanded_symbol, " ", self.label))
+
+ def _watch_label(self) -> None:
+ self._update_label()
def _watch_collapsed(self, collapsed: bool) -> None:
- if collapsed:
- self.update(self._collapsed_label)
- else:
- self.update(self._expanded_label)
+ self._update_label()
class Collapsible(Widget):
@@ -226,7 +227,8 @@ class Collapsible(Widget):
def compose(self) -> ComposeResult:
yield self._title
- yield self.Contents(*self._contents_list)
+ with self.Contents():
+ yield from self._contents_list
def compose_add_child(self, widget: Widget) -> None:
"""When using the context manager compose syntax, we want to attach nodes to the contents.
diff --git a/contrib/python/textual/textual/widgets/_input.py b/contrib/python/textual/textual/widgets/_input.py
index 7b1f7a0a982..715ab0f5f08 100644
--- a/contrib/python/textual/textual/widgets/_input.py
+++ b/contrib/python/textual/textual/widgets/_input.py
@@ -12,6 +12,7 @@ from typing_extensions import Literal
from textual import events
from textual.expand_tabs import expand_tabs_inline
+from textual.screen import Screen
from textual.scroll_view import ScrollView
from textual.strip import Strip
@@ -176,9 +177,19 @@ class Input(ScrollView):
height: 3;
scrollbar-size-horizontal: 0;
+ &.-textual-compact {
+ border: none !important;
+ height: 1;
+ padding: 0;
+ &.-invalid {
+ background-tint: $error 20%;
+ }
+ }
+
&:focus {
- border: tall $border;
+ border: tall $border;
background-tint: $foreground 5%;
+
}
&>.input--cursor {
background: $input-cursor-background;
@@ -252,6 +263,8 @@ class Input(ScrollView):
"""The maximum length of the input, in characters."""
valid_empty = var(False)
"""Empty values should pass validation."""
+ compact = reactive(False, toggle_class="-textual-compact")
+ """Make the input compact (without borders)."""
@dataclass
class Changed(Message):
@@ -341,6 +354,7 @@ class Input(ScrollView):
classes: str | None = None,
disabled: bool = False,
tooltip: RenderableType | None = None,
+ compact: bool = False,
) -> None:
"""Initialise the `Input` widget.
@@ -365,6 +379,7 @@ class Input(ScrollView):
classes: Optional initial classes for the widget.
disabled: Whether the input is disabled or not.
tooltip: Optional tooltip.
+ compact: Enable compact style (without borders).
"""
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
@@ -430,6 +445,8 @@ class Input(ScrollView):
if tooltip is not None:
self.tooltip = tooltip
+ self.compact = compact
+
self.select_on_focus = select_on_focus
def _position_to_cell(self, position: int) -> int:
@@ -447,13 +464,18 @@ class Input(ScrollView):
def _cursor_offset(self) -> int:
"""The cell offset of the cursor."""
offset = self._position_to_cell(self.cursor_position)
- if self._cursor_at_end:
+ if self.cursor_at_end:
offset += 1
return offset
@property
- def _cursor_at_end(self) -> bool:
- """Flag to indicate if the cursor is at the end"""
+ def cursor_at_start(self) -> bool:
+ """Flag to indicate if the cursor is at the start."""
+ return self.cursor_position == 0
+
+ @property
+ def cursor_at_end(self) -> bool:
+ """Flag to indicate if the cursor is at the end."""
return self.cursor_position == len(self.value)
def check_consume_key(self, key: str, character: str | None) -> bool:
@@ -476,6 +498,7 @@ class Input(ScrollView):
return Selection(clamp(start, 0, value_length), clamp(end, 0, value_length))
def _watch_selection(self, selection: Selection) -> None:
+ self.app.clear_selection()
self.app.cursor_position = self.cursor_screen_offset
if not self._initial_value:
self.scroll_to_region(
@@ -495,7 +518,7 @@ class Input(ScrollView):
@property
def cursor_screen_offset(self) -> Offset:
- """The offset of the cursor of this input in screen-space. (x, y)/(column, row)"""
+ """The offset of the cursor of this input in screen-space. (x, y)/(column, row)."""
x, y, _width, _height = self.content_region
scroll_x, _ = self.scroll_offset
return Offset(x + self._cursor_offset - scroll_x, y)
@@ -518,6 +541,10 @@ class Input(ScrollView):
if self._initial_value:
self.cursor_position = len(self.value)
self._initial_value = False
+ else:
+ # Force a re-validation of the selection to ensure it accounts for
+ # the length of the new value
+ self.selection = self.selection
def _watch_valid_empty(self) -> None:
"""Repeat validation when valid_empty changes."""
@@ -618,7 +645,7 @@ class Input(ScrollView):
if self._cursor_visible:
cursor_style = self.get_component_rich_style("input--cursor")
cursor = self.cursor_position
- if not show_suggestion and self._cursor_at_end:
+ if not show_suggestion and self.cursor_at_end:
result.pad_right(1)
result.stylize(cursor_style, cursor, cursor + 1)
@@ -665,6 +692,13 @@ class Input(ScrollView):
self._cursor_visible = not self._cursor_visible
def _on_mount(self, event: Mount) -> None:
+ def text_selection_started(screen: Screen) -> None:
+ """Signal callback to unselect when arbitrary text selection starts."""
+ self.selection = Selection.cursor(self.cursor_position)
+
+ self.screen.text_selection_started_signal.subscribe(
+ self, text_selection_started, immediate=True
+ )
self._blink_timer = self.set_interval(
0.5,
self._toggle_cursor,
@@ -735,12 +769,19 @@ class Input(ScrollView):
self._selecting = True
self.capture_mouse()
- async def _on_mouse_up(self, event: events.MouseUp) -> None:
+ def _end_selecting(self) -> None:
+ """End selecting if it is currently active."""
if self._selecting:
self._selecting = False
self.release_mouse()
self._restart_blink()
+ async def _on_mouse_release(self, _event: events.MouseRelease) -> None:
+ self._end_selecting()
+
+ async def _on_mouse_up(self, _event: events.MouseUp) -> None:
+ self._end_selecting()
+
async def _on_mouse_move(self, event: events.MouseMove) -> None:
if self._selecting:
# As we drag the mouse, we update the end position of the selection,
@@ -819,7 +860,7 @@ class Input(ScrollView):
if select:
self.selection = Selection(start, end + 1)
else:
- if self._cursor_at_end and self._suggestion:
+ if self.cursor_at_end and self._suggestion:
self.value = self._suggestion
self.cursor_position = len(self.value)
else:
diff --git a/contrib/python/textual/textual/widgets/_markdown.py b/contrib/python/textual/textual/widgets/_markdown.py
index ad796b173f2..fdc32c01442 100644
--- a/contrib/python/textual/textual/widgets/_markdown.py
+++ b/contrib/python/textual/textual/widgets/_markdown.py
@@ -352,7 +352,7 @@ class MarkdownBlockQuote(MarkdownBlock):
DEFAULT_CSS = """
MarkdownBlockQuote {
background: $boost;
- border-left: outer $success-darken-2;
+ border-left: outer $primary 50%;
margin: 1 0;
padding: 0 1;
}
@@ -478,7 +478,7 @@ class MarkdownTableContent(Widget):
def render(self) -> Table:
table = Table(
expand=True,
- box=box.SIMPLE_HEAVY,
+ box=box.SIMPLE_HEAD,
style=self.rich_style,
header_style=self.get_component_rich_style("markdown-table--header"),
border_style=self.get_component_rich_style("markdown-table--lines"),
@@ -504,7 +504,10 @@ class MarkdownTable(MarkdownBlock):
DEFAULT_CSS = """
MarkdownTable {
width: 100%;
- background: $surface;
+ background: black 10%;
+ &:light {
+ background: white 30%;
+ }
}
"""
@@ -555,7 +558,7 @@ class MarkdownBullet(Widget):
DEFAULT_CSS = """
MarkdownBullet {
width: auto;
- color: $success;
+ color: $text;
text-style: bold;
&:light {
color: $secondary;
@@ -613,6 +616,11 @@ class MarkdownFence(MarkdownBlock):
height: auto;
max-height: 20;
color: rgb(210,210,210);
+ background: black 10%;
+
+ &:light {
+ background: white 30%;
+ }
}
MarkdownFence > * {
@@ -630,14 +638,19 @@ class MarkdownFence(MarkdownBlock):
else self._markdown.code_light_theme
)
+ def notify_style_update(self) -> None:
+ self.call_later(self._retheme)
+
def _block(self) -> Syntax:
+ _, background_color = self.background_colors
return Syntax(
self.code,
lexer=self.lexer,
word_wrap=False,
- indent_guides=True,
+ indent_guides=self._markdown.code_indent_guides,
padding=(1, 2),
theme=self.theme,
+ background_color=background_color.css,
)
def _on_mount(self, _: Mount) -> None:
@@ -722,6 +735,9 @@ class Markdown(Widget):
code_light_theme: reactive[str] = reactive("material-light")
"""The theme to use for code blocks when the App theme is light."""
+ code_indent_guides: reactive[bool] = reactive(True)
+ """Should code fences display indent guides?"""
+
def __init__(
self,
markdown: str | None = None,
@@ -1157,6 +1173,9 @@ class MarkdownViewer(VerticalScroll, can_focus=False, can_focus_children=True):
"""
show_table_of_contents = reactive(True)
+ """Show the table of contents?"""
+ code_indent_guides: reactive[bool] = reactive(True)
+ """Should code fences display indent guides?"""
top_block = reactive("")
navigator: var[Navigator] = var(Navigator)
@@ -1241,7 +1260,7 @@ class MarkdownViewer(VerticalScroll, can_focus=False, can_focus_children=True):
parser_factory=self._parser_factory, open_links=self._open_links
)
markdown.can_focus = True
- yield markdown
+ yield markdown.data_bind(MarkdownViewer.code_indent_guides)
yield MarkdownTableOfContents(markdown)
def _on_markdown_table_of_contents_updated(
diff --git a/contrib/python/textual/textual/widgets/_masked_input.py b/contrib/python/textual/textual/widgets/_masked_input.py
index 560258f3320..47bf61e445c 100644
--- a/contrib/python/textual/textual/widgets/_masked_input.py
+++ b/contrib/python/textual/textual/widgets/_masked_input.py
@@ -570,7 +570,7 @@ class MaskedInput(Input, can_focus=True):
result.stylize(style, index, index + 1)
if self._cursor_visible and self.has_focus:
- if self._cursor_at_end:
+ if self.cursor_at_end:
result.pad_right(1)
cursor_style = self.get_component_rich_style("input--cursor")
cursor = self.cursor_position
diff --git a/contrib/python/textual/textual/widgets/_option_list.py b/contrib/python/textual/textual/widgets/_option_list.py
index 21a5adead69..a63dca9726d 100644
--- a/contrib/python/textual/textual/widgets/_option_list.py
+++ b/contrib/python/textual/textual/widgets/_option_list.py
@@ -11,7 +11,7 @@ from textual._loop import loop_last
from textual.binding import Binding, BindingType
from textual.cache import LRUCache
from textual.css.styles import RulesMap
-from textual.geometry import Region, Size, clamp
+from textual.geometry import Region, Size, Spacing, clamp
from textual.message import Message
from textual.reactive import reactive
from textual.scroll_view import ScrollView
@@ -139,6 +139,13 @@ class OptionList(ScrollView, can_focus=True):
border: tall $border-blurred;
padding: 0 1;
background: $surface;
+ &.-textual-compact {
+ border: none !important;
+ padding: 0;
+ & > .option-list--option {
+ padding: 0;
+ }
+ }
& > .option-list--option-highlighted {
color: $block-cursor-blurred-foreground;
background: $block-cursor-blurred-background;
@@ -165,7 +172,7 @@ class OptionList(ScrollView, can_focus=True):
}
& > .option-list--option-hover {
background: $block-hover-background;
- }
+ }
}
"""
@@ -192,6 +199,9 @@ class OptionList(ScrollView, can_focus=True):
_mouse_hovering_over: reactive[int | None] = reactive(None)
"""The index of the option under the mouse or `None`."""
+ compact: reactive[bool] = reactive(False, toggle_class="-textual-compact")
+ """Enable compact display?"""
+
class OptionMessage(Message):
"""Base class for all option messages."""
@@ -252,6 +262,7 @@ class OptionList(ScrollView, can_focus=True):
classes: str | None = None,
disabled: bool = False,
markup: bool = True,
+ compact: bool = False,
):
"""Initialize an OptionList.
@@ -261,10 +272,12 @@ class OptionList(ScrollView, can_focus=True):
id: The ID of the OptionList in the DOM.
classes: Initial CSS classes.
disabled: Disable the widget?
- markup: Strips should be rendered as Textual markup if `True`, or plain text if `False`.
+ markup: Strips should be rendered as content markup if `True`, or plain text if `False`.
+ compact: Enable compact style?
"""
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self._markup = markup
+ self.compact = compact
self._options: list[Option] = []
"""List of options."""
self._id_to_option: dict[str, Option] = {}
@@ -272,7 +285,7 @@ class OptionList(ScrollView, can_focus=True):
self._option_to_index: dict[Option, int] = {}
"""Maps an Option to it's index in self._options."""
- self._option_render_cache: LRUCache[tuple[Option, Style], list[Strip]]
+ self._option_render_cache: LRUCache[tuple[Option, Style, Spacing], list[Strip]]
self._option_render_cache = LRUCache(maxsize=1024 * 2)
"""Caches rendered options."""
@@ -311,6 +324,8 @@ class OptionList(ScrollView, can_focus=True):
self._option_to_index.clear()
self.highlighted = None
self.refresh()
+ self.scroll_to(0, 0, animate=False)
+ self._update_lines()
return self
def add_options(self, new_options: Iterable[OptionListContent]) -> Self:
@@ -358,6 +373,7 @@ class OptionList(ScrollView, can_focus=True):
self._id_to_option[option._id] = option
add_option(option)
if self.is_mounted:
+ self.refresh(layout=self.styles.auto_dimensions)
self._update_lines()
return self
@@ -534,7 +550,7 @@ class OptionList(ScrollView, can_focus=True):
del self._id_to_option[option._id]
del self._option_to_index[option]
self.highlighted = self.highlighted
- self.refresh()
+ self._clear_caches()
return self
def _pre_remove_option(self, option: Option, index: int) -> None:
@@ -656,7 +672,7 @@ class OptionList(ScrollView, can_focus=True):
self.refresh()
def notify_style_update(self) -> None:
- self._clear_caches()
+ self.refresh()
super().notify_style_update()
def _on_resize(self):
@@ -730,8 +746,8 @@ class OptionList(ScrollView, can_focus=True):
"""Get rendered option with a given style.
Args:
+ option: An option.
style: Style of render.
- index: Index of the option.
Returns:
A list of strips.
@@ -739,7 +755,7 @@ class OptionList(ScrollView, can_focus=True):
padding = self.get_component_styles("option-list--option").padding
render_width = self.scrollable_content_region.width
width = render_width - self._get_left_gutter_width()
- cache_key = (option, style)
+ cache_key = (option, style, padding)
if (strips := self._option_render_cache.get(cache_key)) is None:
visual = self._get_visual(option)
if padding:
@@ -759,8 +775,7 @@ class OptionList(ScrollView, can_focus=True):
def _update_lines(self) -> None:
"""Update internal structures when new lines are added."""
- if not self.options or not self.scrollable_content_region:
- # No options -- nothing to
+ if not self.scrollable_content_region:
return
line_cache = self._line_cache
@@ -783,8 +798,10 @@ class OptionList(ScrollView, can_focus=True):
)
last_divider = self.options and self.options[-1]._divider
- self.virtual_size = Size(width, len(lines) - (1 if last_divider else 0))
- self._scroll_update(self.virtual_size)
+ virtual_size = Size(width, len(lines) - (1 if last_divider else 0))
+ if virtual_size != self.virtual_size:
+ self.virtual_size = virtual_size
+ self._scroll_update(virtual_size)
def get_content_width(self, container: Size, viewport: Size) -> int:
"""Get maximum width of options."""
diff --git a/contrib/python/textual/textual/widgets/_radio_set.py b/contrib/python/textual/textual/widgets/_radio_set.py
index edb6eb75253..7fedbac00ec 100644
--- a/contrib/python/textual/textual/widgets/_radio_set.py
+++ b/contrib/python/textual/textual/widgets/_radio_set.py
@@ -12,7 +12,7 @@ from textual.binding import Binding, BindingType
from textual.containers import VerticalScroll
from textual.events import Click, Mount
from textual.message import Message
-from textual.reactive import var
+from textual.reactive import reactive, var
from textual.widgets._radio_button import RadioButton
@@ -32,14 +32,20 @@ class RadioSet(VerticalScroll, can_focus=True, can_focus_children=False):
RadioSet {
border: tall $border-blurred;
background: $surface;
- padding: 0 1;
+ padding: 0 1;
height: auto;
- width: auto;
+ width: 1fr;
+
+ &.-textual-compact {
+ border: none !important;
+ padding: 0;
+ }
& > RadioButton {
background: transparent;
border: none;
padding: 0;
+ width: 1fr;
& > .toggle--button {
color: $panel-darken-2;
@@ -87,6 +93,9 @@ class RadioSet(VerticalScroll, can_focus=True, can_focus_children=False):
_selected: var[int | None] = var[Optional[int]](None)
"""The index of the currently-selected radio button."""
+ compact: reactive[bool] = reactive(False, toggle_class="-textual-compact")
+ """Enable compact display?"""
+
@rich.repr.auto
class Changed(Message):
"""Posted when the pressed button in the set changes.
@@ -133,6 +142,7 @@ class RadioSet(VerticalScroll, can_focus=True, can_focus_children=False):
classes: str | None = None,
disabled: bool = False,
tooltip: RenderableType | None = None,
+ compact: bool = False,
) -> None:
"""Initialise the radio set.
@@ -143,6 +153,7 @@ class RadioSet(VerticalScroll, can_focus=True, can_focus_children=False):
classes: The CSS classes of the radio set.
disabled: Whether the radio set is disabled or not.
tooltip: Optional tooltip.
+ compact: Enable compact radio set style
Note:
When a `str` label is provided, a
@@ -163,6 +174,7 @@ class RadioSet(VerticalScroll, can_focus=True, can_focus_children=False):
)
if tooltip is not None:
self.tooltip = tooltip
+ self.compact = compact
def _on_mount(self, _: Mount) -> None:
"""Perform some processing once mounted in the DOM."""
diff --git a/contrib/python/textual/textual/widgets/_select.py b/contrib/python/textual/textual/widgets/_select.py
index 5ba4df571a4..ac7dac6ee5f 100644
--- a/contrib/python/textual/textual/widgets/_select.py
+++ b/contrib/python/textual/textual/widgets/_select.py
@@ -12,7 +12,7 @@ from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches
from textual.message import Message
-from textual.reactive import var
+from textual.reactive import reactive, var
from textual.timer import Timer
from textual.widgets import Static
from textual.widgets._option_list import Option, OptionList
@@ -185,6 +185,10 @@ class SelectCurrent(Horizontal):
height: auto;
padding: 0 2;
+ &.-textual-compact {
+ border: none !important;
+ }
+
&:ansi {
border: tall ansi_blue;
color: ansi_default;
@@ -286,6 +290,13 @@ class Select(Generic[SelectType], Vertical, can_focus=True):
Select {
height: auto;
color: $foreground;
+
+ &.-textual-compact {
+ & > SelectCurrent {
+ padding: 0 1 0 0;
+ border: none !important;
+ }
+ }
.up-arrow {
display: none;
@@ -344,6 +355,9 @@ class Select(Generic[SelectType], Vertical, can_focus=True):
exception.
"""
+ compact = reactive(False, toggle_class="-textual-compact")
+ """Make the select compact (without borders)."""
+
@rich.repr.auto
class Changed(Message):
"""Posted when the select value was changed.
@@ -385,6 +399,7 @@ class Select(Generic[SelectType], Vertical, can_focus=True):
classes: str | None = None,
disabled: bool = False,
tooltip: RenderableType | None = None,
+ compact: bool = False,
):
"""Initialize the Select control.
@@ -404,6 +419,7 @@ class Select(Generic[SelectType], Vertical, can_focus=True):
classes: The CSS classes of the control.
disabled: Whether the control is disabled or not.
tooltip: Optional tooltip.
+ compact: Enable compact select (without borders).
Raises:
EmptySelectError: If no options are provided and `allow_blank` is `False`.
@@ -416,6 +432,7 @@ class Select(Generic[SelectType], Vertical, can_focus=True):
self._type_to_search = type_to_search
if tooltip is not None:
self.tooltip = tooltip
+ self.compact = compact
@classmethod
def from_values(
@@ -430,6 +447,7 @@ class Select(Generic[SelectType], Vertical, can_focus=True):
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
+ compact: bool = False,
) -> Select[SelectType]:
"""Initialize the Select control with values specified by an arbitrary iterable
@@ -450,6 +468,7 @@ class Select(Generic[SelectType], Vertical, can_focus=True):
id: The ID of the control in the DOM.
classes: The CSS classes of the control.
disabled: Whether the control is disabled or not.
+ compact: Enable compact style?
Returns:
A new Select widget with the provided values as options.
@@ -466,6 +485,7 @@ class Select(Generic[SelectType], Vertical, can_focus=True):
id=id,
classes=classes,
disabled=disabled,
+ compact=compact,
)
@property
@@ -587,7 +607,9 @@ class Select(Generic[SelectType], Vertical, can_focus=True):
def compose(self) -> ComposeResult:
"""Compose Select with overlay and current value."""
yield SelectCurrent(self.prompt)
- yield SelectOverlay(type_to_search=self._type_to_search)
+ yield SelectOverlay(type_to_search=self._type_to_search).data_bind(
+ compact=Select.compact
+ )
def _on_mount(self, _event: events.Mount) -> None:
"""Set initial values."""
diff --git a/contrib/python/textual/textual/widgets/_selection_list.py b/contrib/python/textual/textual/widgets/_selection_list.py
index 7fe2636f05d..50777c3accf 100644
--- a/contrib/python/textual/textual/widgets/_selection_list.py
+++ b/contrib/python/textual/textual/widgets/_selection_list.py
@@ -220,6 +220,7 @@ class SelectionList(Generic[SelectionType], OptionList):
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
+ compact: bool = False,
):
"""Initialise the selection list.
@@ -229,7 +230,9 @@ class SelectionList(Generic[SelectionType], OptionList):
id: The ID of the selection list in the DOM.
classes: The CSS classes of the selection list.
disabled: Whether the selection list is disabled or not.
+ compact: Enable a compact style?
"""
+
self._selected: dict[SelectionType, None] = {}
"""Tracking of which values are selected."""
self._send_messages = False
@@ -240,6 +243,7 @@ class SelectionList(Generic[SelectionType], OptionList):
}
"""Keeps track of which value relates to which option."""
super().__init__(*options, name=name, id=id, classes=classes, disabled=disabled)
+ self.compact = compact
@property
def selected(self) -> list[SelectionType]:
diff --git a/contrib/python/textual/textual/widgets/_static.py b/contrib/python/textual/textual/widgets/_static.py
index 097d2cb165d..b3e96f4688f 100644
--- a/contrib/python/textual/textual/widgets/_static.py
+++ b/contrib/python/textual/textual/widgets/_static.py
@@ -2,32 +2,13 @@ from __future__ import annotations
from typing import TYPE_CHECKING
-from rich.protocol import is_renderable
-
if TYPE_CHECKING:
from textual.app import RenderResult
-from textual.errors import RenderError
from textual.visual import Visual, VisualType, visualize
from textual.widget import Widget
-def _check_renderable(renderable: object):
- """Check if a renderable conforms to the Rich Console protocol
- (https://rich.readthedocs.io/en/latest/protocol.html)
-
- Args:
- renderable: A potentially renderable object.
-
- Raises:
- RenderError: If the object can not be rendered.
- """
- if not is_renderable(renderable) and not hasattr(renderable, "visualize"):
- raise RenderError(
- f"unable to render {renderable.__class__.__name__!r} type; must be a str, Text, Rich renderable oor Textual Visual instance"
- )
-
-
class Static(Widget, inherit_bindings=False):
"""A widget to display simple static content, or use as a base class for more complex widgets.
diff --git a/contrib/python/textual/textual/widgets/_text_area.py b/contrib/python/textual/textual/widgets/_text_area.py
index 542ff28cd1b..771f4415810 100644
--- a/contrib/python/textual/textual/widgets/_text_area.py
+++ b/contrib/python/textual/textual/widgets/_text_area.py
@@ -9,12 +9,14 @@ from pathlib import Path
from typing import TYPE_CHECKING, ClassVar, Iterable, Optional, Sequence, Tuple
from rich.console import RenderableType
+from rich.segment import Segment
from rich.style import Style
from rich.text import Text
from typing_extensions import Literal
from textual._text_area_theme import TextAreaTheme
from textual._tree_sitter import TREE_SITTER, get_language
+from textual.cache import LRUCache
from textual.color import Color
from textual.document._document import (
Document,
@@ -33,6 +35,7 @@ from textual.document._syntax_aware_document import (
)
from textual.document._wrapped_document import WrappedDocument
from textual.expand_tabs import expand_tabs_inline, expand_text_tabs_from_widths
+from textual.screen import Screen
if TYPE_CHECKING:
from tree_sitter import Language, Query
@@ -112,6 +115,9 @@ TextArea {
padding: 0 1;
color: $foreground;
background: $surface;
+ &.-textual-compact {
+ border: none !important;
+ }
& .text-area--cursor {
text-style: $input-cursor-text-style;
}
@@ -166,7 +172,7 @@ TextArea {
&.-read-only .text-area--cursor {
background: $warning-darken-1;
}
- }
+ }
}
"""
@@ -367,6 +373,21 @@ TextArea {
The document can still be edited programmatically via the API.
"""
+ show_cursor: Reactive[bool] = reactive(True)
+ """Show the cursor in read only mode?
+
+ If `True`, the cursor will be visible when `read_only==True`.
+ If `False`, the cursor will be hidden when `read_only==True`, and the TextArea will
+ scroll like other containers.
+
+ """
+
+ compact: reactive[bool] = reactive(False, toggle_class="-textual-compact")
+ """Enable compact display?"""
+
+ highlight_cursor_line: reactive[bool] = reactive(True)
+ """Highlight the line under the cursor?"""
+
_cursor_visible: Reactive[bool] = reactive(False, repaint=False, init=False)
"""Indicates where the cursor is in the blink cycle. If it's currently
not visible due to blinking, this is False."""
@@ -411,6 +432,7 @@ TextArea {
soft_wrap: bool = True,
tab_behavior: Literal["focus", "indent"] = "focus",
read_only: bool = False,
+ show_cursor: bool = True,
show_line_numbers: bool = False,
line_number_start: int = 1,
max_checkpoints: int = 50,
@@ -419,6 +441,8 @@ TextArea {
classes: str | None = None,
disabled: bool = False,
tooltip: RenderableType | None = None,
+ compact: bool = False,
+ highlight_cursor_line: bool = True,
) -> None:
"""Construct a new `TextArea`.
@@ -429,6 +453,7 @@ TextArea {
soft_wrap: Enable soft wrapping.
tab_behavior: If 'focus', pressing tab will switch focus. If 'indent', pressing tab will insert a tab.
read_only: Enable read-only mode. This prevents edits using the keyboard.
+ show_cursor: Show the cursor in read only mode (no effect otherwise).
show_line_numbers: Show line numbers on the left edge.
line_number_start: What line number to start on.
max_checkpoints: The maximum number of undo history checkpoints to retain.
@@ -437,6 +462,8 @@ TextArea {
classes: One or more Textual CSS compatible class names separated by spaces.
disabled: True if the widget is disabled.
tooltip: Optional tooltip.
+ compact: Enable compact style (without borders).
+ highlight_cursor_line: Highlight the line under the cursor.
"""
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
@@ -491,6 +518,15 @@ TextArea {
self._cursor_offset = (0, 0)
"""The virtual offset of the cursor (not screen-space offset)."""
+ self.set_reactive(TextArea.soft_wrap, soft_wrap)
+ self.set_reactive(TextArea.read_only, read_only)
+ self.set_reactive(TextArea.show_cursor, show_cursor)
+ self.set_reactive(TextArea.show_line_numbers, show_line_numbers)
+ self.set_reactive(TextArea.line_number_start, line_number_start)
+ self.set_reactive(TextArea.highlight_cursor_line, highlight_cursor_line)
+
+ self._line_cache: LRUCache[tuple, Strip] = LRUCache(1024)
+
self._set_document(text, language)
self.language = language
@@ -501,16 +537,13 @@ TextArea {
reactive is set as a string, the watcher will update this attribute to the
corresponding `TextAreaTheme` object."""
- self.set_reactive(TextArea.soft_wrap, soft_wrap)
- self.set_reactive(TextArea.read_only, read_only)
- self.set_reactive(TextArea.show_line_numbers, show_line_numbers)
- self.set_reactive(TextArea.line_number_start, line_number_start)
-
self.tab_behavior = tab_behavior
if tooltip is not None:
self.tooltip = tooltip
+ self.compact = compact
+
@classmethod
def code_editor(
cls,
@@ -521,6 +554,7 @@ TextArea {
soft_wrap: bool = False,
tab_behavior: Literal["focus", "indent"] = "indent",
read_only: bool = False,
+ show_cursor: bool = True,
show_line_numbers: bool = True,
line_number_start: int = 1,
max_checkpoints: int = 50,
@@ -529,6 +563,8 @@ TextArea {
classes: str | None = None,
disabled: bool = False,
tooltip: RenderableType | None = None,
+ compact: bool = False,
+ highlight_cursor_line: bool = True,
) -> TextArea:
"""Construct a new `TextArea` with sensible defaults for editing code.
@@ -541,6 +577,8 @@ TextArea {
theme: The theme to use.
soft_wrap: Enable soft wrapping.
tab_behavior: If 'focus', pressing tab will switch focus. If 'indent', pressing tab will insert a tab.
+ read_only: Enable read-only mode. This prevents edits using the keyboard.
+ show_cursor: Show the cursor in read only mode (no effect otherwise).
show_line_numbers: Show line numbers on the left edge.
line_number_start: What line number to start on.
name: The name of the `TextArea` widget.
@@ -548,6 +586,8 @@ TextArea {
classes: One or more Textual CSS compatible class names separated by spaces.
disabled: True if the widget is disabled.
tooltip: Optional tooltip
+ compact: Enable compact style (without borders).
+ highlight_cursor_line: Highlight the line under the cursor.
"""
return cls(
text,
@@ -556,6 +596,7 @@ TextArea {
soft_wrap=soft_wrap,
tab_behavior=tab_behavior,
read_only=read_only,
+ show_cursor=show_cursor,
show_line_numbers=show_line_numbers,
line_number_start=line_number_start,
max_checkpoints=max_checkpoints,
@@ -564,6 +605,8 @@ TextArea {
classes=classes,
disabled=disabled,
tooltip=tooltip,
+ compact=compact,
+ highlight_cursor_line=highlight_cursor_line,
)
@staticmethod
@@ -587,6 +630,9 @@ TextArea {
return highlight_query
+ def notify_style_update(self) -> None:
+ self._line_cache.clear()
+
def check_consume_key(self, key: str, character: str | None = None) -> bool:
"""Check if the widget may consume the given key.
@@ -610,6 +656,7 @@ TextArea {
def _build_highlight_map(self) -> None:
"""Query the tree for ranges to highlights, and update the internal highlights mapping."""
+ self._line_cache.clear()
highlights = self._highlights
highlights.clear()
if not self._highlight_query:
@@ -657,6 +704,8 @@ TextArea {
if not self.is_mounted:
return
+ self.app.clear_selection()
+
cursor_location = selection.end
self.scroll_cursor_visible()
@@ -1005,11 +1054,12 @@ TextArea {
width, _ = self.scrollable_content_region.size
cursor_width = 1
if self.soft_wrap:
- return width - self.gutter_width - cursor_width
+ return max(0, width - self.gutter_width - cursor_width)
return 0
def _rewrap_and_refresh_virtual_size(self) -> None:
self.wrapped_document.wrap(self.wrap_width, tab_width=self.indent_width)
+ self._line_cache.clear()
self._refresh_size()
@property
@@ -1067,6 +1117,24 @@ TextArea {
width, height = self.document.get_size(self.indent_width)
self.virtual_size = Size(width + self.gutter_width + 1, height)
+ @property
+ def _draw_cursor(self) -> bool:
+ """Draw the cursor?"""
+ if self.read_only:
+ # If we are in read only mode, we don't want the cursor to blink
+ return self.show_cursor and self.has_focus
+ draw_cursor = (
+ self.has_focus
+ and not self.cursor_blink
+ or (self.cursor_blink and self._cursor_visible)
+ )
+ return draw_cursor
+
+ @property
+ def _has_cursor(self) -> bool:
+ """Is there a usable cursor?"""
+ return not (self.read_only and not self.show_cursor)
+
def get_line(self, line_index: int) -> Text:
"""Retrieve the line at the given line index.
@@ -1080,7 +1148,13 @@ TextArea {
A `rich.Text` object containing the requested line.
"""
line_string = self.document.get_line(line_index)
- return Text(line_string, end="")
+ return Text(line_string, end="", no_wrap=True)
+
+ def render_lines(self, crop: Region) -> list[Strip]:
+ theme = self._theme
+ if theme:
+ theme.apply_css(self)
+ return super().render_lines(crop)
def render_line(self, y: int) -> Strip:
"""Render a single line of the TextArea. Called by Textual.
@@ -1091,9 +1165,51 @@ TextArea {
Returns:
A rendered line.
"""
+ scroll_x, scroll_y = self.scroll_offset
+ absolute_y = scroll_y + y
+ selection = self.selection
+ cache_key = (
+ self.size,
+ scroll_x,
+ absolute_y,
+ (
+ selection
+ if selection.contains_line(absolute_y) or self.soft_wrap
+ else selection.end[0] == absolute_y
+ ),
+ (
+ selection.end
+ if (
+ self._cursor_visible
+ and self.cursor_blink
+ and absolute_y == selection.end[0]
+ )
+ else None
+ ),
+ self.theme,
+ self._matching_bracket_location,
+ self.match_cursor_bracket,
+ self.soft_wrap,
+ self.show_line_numbers,
+ self.read_only,
+ self.show_cursor,
+ )
+ if (cached_line := self._line_cache.get(cache_key)) is not None:
+ return cached_line
+ line = self._render_line(y)
+ self._line_cache[cache_key] = line
+ return line
+
+ def _render_line(self, y: int) -> Strip:
+ """Render a single line of the TextArea. Called by Textual.
+
+ Args:
+ y: Y Coordinate of line relative to the widget region.
+
+ Returns:
+ A rendered line.
+ """
theme = self._theme
- if theme:
- theme.apply_css(self)
wrapped_document = self.wrapped_document
scroll_x, scroll_y = self.scroll_offset
@@ -1132,8 +1248,13 @@ TextArea {
selection_top_row, selection_top_column = selection_top
selection_bottom_row, selection_bottom_column = selection_bottom
- cursor_line_style = theme.cursor_line_style if theme else None
- if cursor_line_style and cursor_row == line_index:
+ highlight_cursor_line = self.highlight_cursor_line and self._has_cursor
+ cursor_line_style = (
+ theme.cursor_line_style if (theme and highlight_cursor_line) else None
+ )
+ has_cursor = self._has_cursor
+
+ if has_cursor and cursor_line_style and cursor_row == line_index:
line.stylize(cursor_line_style)
# Selection styling
@@ -1144,7 +1265,8 @@ TextArea {
if selection_style:
if line_character_count == 0 and line_index != cursor_row:
# A simple highlight to show empty lines are included in the selection
- line = Text("▌", end="", style=Style(color=selection_style.bgcolor))
+ line.plain = "▌"
+ line.stylize(Style(color=selection_style.bgcolor))
else:
if line_index == selection_top_row == selection_bottom_row:
# Selection within a single line
@@ -1185,15 +1307,14 @@ TextArea {
matching_bracket = self._matching_bracket_location
match_cursor_bracket = self.match_cursor_bracket
draw_matched_brackets = (
- match_cursor_bracket and matching_bracket is not None and start == end
+ has_cursor
+ and match_cursor_bracket
+ and matching_bracket is not None
+ and start == end
)
if cursor_row == line_index:
- draw_cursor = (
- self.has_focus
- and not self.cursor_blink
- or (self.cursor_blink and self._cursor_visible)
- )
+ draw_cursor = self._draw_cursor
if draw_matched_brackets:
matching_bracket_style = theme.bracket_matching_style if theme else None
if matching_bracket_style:
@@ -1225,7 +1346,7 @@ TextArea {
# Build the gutter text for this line
gutter_width = self.gutter_width
if self.show_line_numbers:
- if cursor_row == line_index:
+ if cursor_row == line_index and highlight_cursor_line:
gutter_style = theme.cursor_line_gutter_style
else:
gutter_style = theme.gutter_style
@@ -1234,13 +1355,11 @@ TextArea {
gutter_content = (
str(line_index + self.line_number_start) if section_offset == 0 else ""
)
- gutter = Text(
- f"{gutter_content:>{gutter_width_no_margin}} ",
- style=gutter_style or "",
- end="",
- )
+ gutter = [
+ Segment(f"{gutter_content:>{gutter_width_no_margin}} ", gutter_style)
+ ]
else:
- gutter = Text("", end="")
+ gutter = []
# TODO: Lets not apply the division each time through render_line.
# We should cache sections with the edit counts.
@@ -1275,28 +1394,21 @@ TextArea {
else max(virtual_width, self.region.size.width)
)
target_width = base_width - self.gutter_width
- console = self.app.console
- gutter_segments = console.render(gutter)
-
- text_segments = list(
- console.render(line, console.options.update_width(target_width))
- )
-
- gutter_strip = Strip(gutter_segments, cell_length=gutter_width)
- text_strip = Strip(text_segments)
# Crop the line to show only the visible part (some may be scrolled out of view)
+ console = self.app.console
+ text_strip = Strip(line.render(console), cell_length=line.cell_len)
if not self.soft_wrap:
text_strip = text_strip.crop(scroll_x, scroll_x + virtual_width)
# Stylize the line the cursor is currently on.
- if cursor_row == line_index:
+ if cursor_row == line_index and self.highlight_cursor_line:
line_style = cursor_line_style
else:
line_style = theme.base_style if theme else None
text_strip = text_strip.extend_cell_length(target_width, line_style)
- strip = Strip.join([gutter_strip, text_strip]).simplify()
+ strip = Strip.join([Strip(gutter, cell_length=gutter_width), text_strip])
return strip.apply_style(
theme.base_style
@@ -1486,7 +1598,9 @@ TextArea {
async def _on_key(self, event: events.Key) -> None:
"""Handle key presses which correspond to document inserts."""
+
self._restart_blink()
+
if self.read_only:
return
@@ -1569,9 +1683,17 @@ TextArea {
return gutter_width
def _on_mount(self, event: events.Mount) -> None:
+
+ def text_selection_started(screen: Screen) -> None:
+ """Signal callback to unselect when arbitrary text selection starts."""
+ self.selection = Selection(self.cursor_location, self.cursor_location)
+
+ self.screen.text_selection_started_signal.subscribe(
+ self, text_selection_started, immediate=True
+ )
+
# When `app.theme` reactive is changed, reset the theme to clear cached styles.
self.watch(self.app, "theme", self._app_theme_changed, init=False)
-
self.blink_timer = self.set_interval(
0.5,
self._toggle_cursor_blink_visible,
@@ -1608,7 +1730,7 @@ TextArea {
# Capture the mouse so that if the cursor moves outside the
# TextArea widget while selecting, the widget still scrolls.
self.capture_mouse()
- self._pause_blink(visible=True)
+ self._pause_blink(visible=False)
self.history.checkpoint()
async def _on_mouse_move(self, event: events.MouseMove) -> None:
@@ -1689,6 +1811,8 @@ TextArea {
Returns:
The offset that was scrolled to bring the cursor into view.
"""
+ if not self._has_cursor:
+ return Offset(0, 0)
self._recompute_cursor_offset()
x, y = self._cursor_offset
@@ -1718,6 +1842,8 @@ TextArea {
so that we jump back to the same width the next time we move to a row
that is wide enough.
"""
+ if not self._has_cursor:
+ return
if select:
start, _end = self.selection
self.selection = Selection(start, location)
@@ -1862,6 +1988,9 @@ TextArea {
Args:
select: If True, select the text while moving.
"""
+ if not self._has_cursor:
+ self.scroll_left()
+ return
target = (
self.get_cursor_left_location()
if select or self.selection.is_empty
@@ -1887,6 +2016,9 @@ TextArea {
Args:
select: If True, select the text while moving.
"""
+ if not self._has_cursor:
+ self.scroll_right()
+ return
target = (
self.get_cursor_right_location()
if select or self.selection.is_empty
@@ -1908,6 +2040,9 @@ TextArea {
Args:
select: If True, select the text while moving.
"""
+ if not self._has_cursor:
+ self.scroll_down()
+ return
target = self.get_cursor_down_location()
self.move_cursor(target, record_width=False, select=select)
@@ -1925,6 +2060,9 @@ TextArea {
Args:
select: If True, select the text while moving.
"""
+ if not self._has_cursor:
+ self.scroll_up()
+ return
target = self.get_cursor_up_location()
self.move_cursor(target, record_width=False, select=select)
@@ -1938,6 +2076,9 @@ TextArea {
def action_cursor_line_end(self, select: bool = False) -> None:
"""Move the cursor to the end of the line."""
+ if not self._has_cursor:
+ self.scroll_end()
+ return
location = self.get_cursor_line_end_location()
self.move_cursor(location, select=select)
@@ -1951,6 +2092,9 @@ TextArea {
def action_cursor_line_start(self, select: bool = False) -> None:
"""Move the cursor to the start of the line."""
+ if not self._has_cursor:
+ self.scroll_home()
+ return
target = self.get_cursor_line_start_location(smart_home=True)
self.move_cursor(target, select=select)
@@ -1975,6 +2119,8 @@ TextArea {
Args:
select: Whether to select while moving the cursor.
"""
+ if not self.show_cursor:
+ return
if self.cursor_at_start_of_text:
return
target = self.get_cursor_word_left_location()
@@ -2000,7 +2146,8 @@ TextArea {
def action_cursor_word_right(self, select: bool = False) -> None:
"""Move the cursor right by a single word, skipping leading whitespace."""
-
+ if not self.show_cursor:
+ return
if self.cursor_at_end_of_text:
return
@@ -2035,6 +2182,9 @@ TextArea {
def action_cursor_page_up(self) -> None:
"""Move the cursor and scroll up one page."""
+ if not self.show_cursor:
+ self.scroll_page_up()
+ return
height = self.content_size.height
_, cursor_location = self.selection
target = self.navigator.get_location_at_y_offset(
@@ -2046,6 +2196,9 @@ TextArea {
def action_cursor_page_down(self) -> None:
"""Move the cursor and scroll down one page."""
+ if not self.show_cursor:
+ self.scroll_page_down()
+ return
height = self.content_size.height
_, cursor_location = self.selection
target = self.navigator.get_location_at_y_offset(
@@ -2203,6 +2356,9 @@ TextArea {
If there's a selection, then the selected range is deleted."""
+ if self.read_only:
+ return
+
selection = self.selection
start, end = selection
@@ -2215,6 +2371,8 @@ TextArea {
"""Deletes the character to the right of the cursor and keeps the cursor at the same location.
If there's a selection, then the selected range is deleted."""
+ if self.read_only:
+ return
selection = self.selection
start, end = selection
@@ -2226,6 +2384,8 @@ TextArea {
def action_delete_line(self) -> None:
"""Deletes the lines which intersect with the selection."""
+ if self.read_only:
+ return
self._delete_cursor_line()
def _delete_cursor_line(self) -> EditResult | None:
diff --git a/contrib/python/textual/textual/widgets/_toast.py b/contrib/python/textual/textual/widgets/_toast.py
index 8111a1538fa..c43ee09fcdf 100644
--- a/contrib/python/textual/textual/widgets/_toast.py
+++ b/contrib/python/textual/textual/widgets/_toast.py
@@ -2,16 +2,11 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, ClassVar
-
-from rich.text import Text
+from typing import ClassVar
from textual import on
-
-if TYPE_CHECKING:
- from textual.app import RenderResult
-
from textual.containers import Container
+from textual.content import Content
from textual.css.query import NoMatches
from textual.events import Click, Mount
from textual.notifications import Notification, Notifications
@@ -30,7 +25,7 @@ class ToastHolder(Container, inherit_css=False):
align-horizontal: right;
width: 1fr;
height: auto;
- visibility: hidden;
+ visibility: hidden;
}
"""
@@ -43,8 +38,8 @@ class Toast(Static, inherit_css=False):
width: 60;
max-width: 50%;
height: auto;
- visibility: visible;
margin-top: 1;
+ visibility: visible;
padding: 1 1;
background: $panel-lighten-1;
link-background: initial;
@@ -104,25 +99,27 @@ class Toast(Static, inherit_css=False):
self._notification = notification
self._timeout = notification.time_left
- def render(self) -> RenderResult:
+ def render(self) -> Content:
"""Render the toast's content.
Returns:
A Rich renderable for the title and content of the Toast.
"""
notification = self._notification
+
+ message_content = (
+ Content.from_markup(notification.message)
+ if notification.markup
+ else Content(notification.message)
+ )
+
if notification.title:
- header_style = self.get_component_rich_style("toast--title")
- notification_text = Text.assemble(
- (notification.title, header_style),
- "\n",
- Text.from_markup(notification.message),
+ header_style = self.get_visual_style("toast--title")
+ message_content = Content.assemble(
+ (notification.title, header_style), "\n", message_content
)
- else:
- notification_text = Text.assemble(
- Text.from_markup(notification.message),
- )
- return notification_text
+
+ return message_content
def _on_mount(self, _: Mount) -> None:
"""Set the time running once the toast is mounted."""
@@ -155,7 +152,7 @@ class ToastRack(Container, inherit_css=False):
visibility: hidden;
layout: vertical;
overflow-y: scroll;
- margin-bottom: 1;
+ margin-bottom: 1;
}
"""
DEFAULT_CLASSES = "-textual-system"
diff --git a/contrib/python/textual/textual/widgets/_toggle_button.py b/contrib/python/textual/textual/widgets/_toggle_button.py
index d36b386ccda..720e7c77d7b 100644
--- a/contrib/python/textual/textual/widgets/_toggle_button.py
+++ b/contrib/python/textual/textual/widgets/_toggle_button.py
@@ -61,6 +61,11 @@ class ToggleButton(Static, can_focus=True):
text-wrap: nowrap;
text-overflow: ellipsis;
+ &.-textual-compact {
+ border: none !important;
+ padding: 0;
+ }
+
& > .toggle--button {
color: $panel-darken-2;
background: $panel;
@@ -100,6 +105,9 @@ class ToggleButton(Static, can_focus=True):
value: reactive[bool] = reactive(False, init=False)
"""The value of the button. `True` for on, `False` for off."""
+ compact: reactive[bool] = reactive(False, toggle_class="-textual-compact")
+ """Enable compact display?"""
+
def __init__(
self,
label: ContentText = "",
@@ -111,6 +119,7 @@ class ToggleButton(Static, can_focus=True):
classes: str | None = None,
disabled: bool = False,
tooltip: RenderableType | None = None,
+ compact: bool = False,
) -> None:
"""Initialise the toggle.
@@ -123,6 +132,7 @@ class ToggleButton(Static, can_focus=True):
classes: The CSS classes of the toggle.
disabled: Whether the button is disabled or not.
tooltip: RenderableType | None = None,
+ compact: Show a compact button.
"""
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self._button_first = button_first
@@ -132,6 +142,7 @@ class ToggleButton(Static, can_focus=True):
self._label = self._make_label(label)
if tooltip is not None:
self.tooltip = tooltip
+ self.compact = compact
def _make_label(self, label: ContentText) -> Content:
"""Make label content.
diff --git a/contrib/python/textual/textual/widgets/collapsible.py b/contrib/python/textual/textual/widgets/collapsible.py
new file mode 100644
index 00000000000..a11131d7ee1
--- /dev/null
+++ b/contrib/python/textual/textual/widgets/collapsible.py
@@ -0,0 +1,3 @@
+from textual.widgets._collapsible import CollapsibleTitle
+
+__all__ = ["CollapsibleTitle"]
diff --git a/contrib/python/textual/textual/worker.py b/contrib/python/textual/textual/worker.py
index 582b242ced5..db50ecd3dfb 100644
--- a/contrib/python/textual/textual/worker.py
+++ b/contrib/python/textual/textual/worker.py
@@ -164,7 +164,9 @@ class Worker(Generic[ResultType]):
self._work = work
self.name = name
self.group = group
- self.description = description
+ self.description = (
+ description if len(description) <= 1000 else description[:1000] + "..."
+ )
self.exit_on_error = exit_on_error
self.cancelled_event: Event = Event()
"""A threading event set when the worker is cancelled."""
diff --git a/contrib/python/textual/ya.make b/contrib/python/textual/ya.make
index b8f5dd8bb08..93542062304 100644
--- a/contrib/python/textual/ya.make
+++ b/contrib/python/textual/ya.make
@@ -2,7 +2,7 @@
PY3_LIBRARY()
-VERSION(3.0.1)
+VERSION(3.7.1)
LICENSE(MIT)
@@ -39,6 +39,7 @@ PY_SRCS(
textual/_callback.py
textual/_cells.py
textual/_color_constants.py
+ textual/_compat.py
textual/_compose.py
textual/_compositor.py
textual/_context.py
@@ -48,6 +49,7 @@ PY_SRCS(
textual/_duration.py
textual/_easing.py
textual/_event_broker.py
+ textual/_extrema.py
textual/_files.py
textual/_immutable_sequence_view.py
textual/_import_app.py
@@ -156,6 +158,7 @@ PY_SRCS(
textual/filter.py
textual/fuzzy.py
textual/geometry.py
+ textual/getters.py
textual/keys.py
textual/layout.py
textual/layouts/__init__.py
@@ -251,6 +254,7 @@ PY_SRCS(
textual/widgets/_tree.py
textual/widgets/_welcome.py
textual/widgets/button.py
+ textual/widgets/collapsible.py
textual/widgets/data_table.py
textual/widgets/directory_tree.py
textual/widgets/input.py