diff options
| author | robot-piglet <[email protected]> | 2026-05-13 08:51:44 +0300 |
|---|---|---|
| committer | robot-piglet <[email protected]> | 2026-05-13 09:43:18 +0300 |
| commit | 21b994f3cab88fade95b9dabe9b1b491627b822d (patch) | |
| tree | e27d9725b1f4f23d6e137c5c115715c1070600df /contrib/python | |
| parent | 2e7284b4e24ef8749490a6bb96e6edb645520af1 (diff) | |
Intermediate changes
commit_hash:9dd0a391400eb50723299039cdf1b64398309ddd
Diffstat (limited to 'contrib/python')
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 |
