diff options
| author | robot-piglet <[email protected]> | 2026-05-21 10:26:59 +0300 |
|---|---|---|
| committer | robot-piglet <[email protected]> | 2026-05-21 10:53:11 +0300 |
| commit | aeca43477ddb5a282ba7a542a6a21f75be021121 (patch) | |
| tree | 7581cdd39df10b899c157cd0bef0b7c16628b624 /contrib/python | |
| parent | 0efbfe19e335d27cc80b7e288308302d112b2d71 (diff) | |
Intermediate changes
commit_hash:d742dced2a9e031a656c468e4e03a662fb40593b
Diffstat (limited to 'contrib/python')
139 files changed, 7287 insertions, 1056 deletions
diff --git a/contrib/python/mdit-py-plugins/.dist-info/METADATA b/contrib/python/mdit-py-plugins/.dist-info/METADATA new file mode 100644 index 00000000000..e0025e24561 --- /dev/null +++ b/contrib/python/mdit-py-plugins/.dist-info/METADATA @@ -0,0 +1,59 @@ +Metadata-Version: 2.4 +Name: mdit-py-plugins +Version: 0.5.0 +Summary: Collection of plugins for markdown-it-py +Keywords: markdown,markdown-it,lexer,parser,development +Author-email: Chris Sewell <[email protected]> +Requires-Python: >=3.10 +Description-Content-Type: text/markdown +Classifier: Development Status :: 5 - Production/Stable +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: MIT License +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: Implementation :: CPython +Classifier: Programming Language :: Python :: Implementation :: PyPy +Classifier: Topic :: Software Development :: Libraries :: Python Modules +Classifier: Topic :: Text Processing :: Markup +License-File: LICENSE +Requires-Dist: markdown-it-py>=2.0.0,<5.0.0 +Requires-Dist: pre-commit ; extra == "code-style" +Requires-Dist: myst-parser ; extra == "rtd" +Requires-Dist: sphinx-book-theme ; extra == "rtd" +Requires-Dist: coverage ; extra == "testing" +Requires-Dist: pytest ; extra == "testing" +Requires-Dist: pytest-cov ; extra == "testing" +Requires-Dist: pytest-regressions ; extra == "testing" +Project-URL: Documentation, https://mdit-py-plugins.readthedocs.io +Project-URL: Homepage, https://github.com/executablebooks/mdit-py-plugins +Provides-Extra: code-style +Provides-Extra: rtd +Provides-Extra: testing + +# mdit-py-plugins + +[![Github-CI][github-ci]][github-link] +[![Coverage Status][codecov-badge]][codecov-link] +[![PyPI][pypi-badge]][pypi-link] +[![Conda][conda-badge]][conda-link] +[![Code style: black][black-badge]][black-link] + +Collection of core plugins for [markdown-it-py](https://github.com/executablebooks/markdown-it-py). + +[github-ci]: https://github.com/executablebooks/mdit-py-plugins/workflows/continuous-integration/badge.svg +[github-link]: https://github.com/executablebooks/mdit-py-plugins +[pypi-badge]: https://img.shields.io/pypi/v/mdit-py-plugins.svg +[pypi-link]: https://pypi.org/project/mdit-py-plugins +[conda-badge]: https://anaconda.org/conda-forge/mdit-py-plugins/badges/version.svg +[conda-link]: https://anaconda.org/conda-forge/mdit-py-plugins +[codecov-badge]: https://codecov.io/gh/executablebooks/mdit-py-plugins/branch/master/graph/badge.svg +[codecov-link]: https://codecov.io/gh/executablebooks/mdit-py-plugins +[black-badge]: https://img.shields.io/badge/code%20style-black-000000.svg +[black-link]: https://github.com/ambv/black +[install-badge]: https://img.shields.io/pypi/dw/mdit-py-plugins?label=pypi%20installs +[install-link]: https://pypistats.org/packages/mdit-py-plugins + diff --git a/contrib/python/mdit-py-plugins/LICENSE b/contrib/python/mdit-py-plugins/LICENSE new file mode 100644 index 00000000000..582ddf59e08 --- /dev/null +++ b/contrib/python/mdit-py-plugins/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 ExecutableBookProject + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/contrib/python/mdit-py-plugins/README.md b/contrib/python/mdit-py-plugins/README.md new file mode 100644 index 00000000000..c831a87b0e9 --- /dev/null +++ b/contrib/python/mdit-py-plugins/README.md @@ -0,0 +1,22 @@ +# mdit-py-plugins + +[![Github-CI][github-ci]][github-link] +[![Coverage Status][codecov-badge]][codecov-link] +[![PyPI][pypi-badge]][pypi-link] +[![Conda][conda-badge]][conda-link] +[![Code style: black][black-badge]][black-link] + +Collection of core plugins for [markdown-it-py](https://github.com/executablebooks/markdown-it-py). + +[github-ci]: https://github.com/executablebooks/mdit-py-plugins/workflows/continuous-integration/badge.svg +[github-link]: https://github.com/executablebooks/mdit-py-plugins +[pypi-badge]: https://img.shields.io/pypi/v/mdit-py-plugins.svg +[pypi-link]: https://pypi.org/project/mdit-py-plugins +[conda-badge]: https://anaconda.org/conda-forge/mdit-py-plugins/badges/version.svg +[conda-link]: https://anaconda.org/conda-forge/mdit-py-plugins +[codecov-badge]: https://codecov.io/gh/executablebooks/mdit-py-plugins/branch/master/graph/badge.svg +[codecov-link]: https://codecov.io/gh/executablebooks/mdit-py-plugins +[black-badge]: https://img.shields.io/badge/code%20style-black-000000.svg +[black-link]: https://github.com/ambv/black +[install-badge]: https://img.shields.io/pypi/dw/mdit-py-plugins?label=pypi%20installs +[install-link]: https://pypistats.org/packages/mdit-py-plugins diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/__init__.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/__init__.py new file mode 100644 index 00000000000..3d187266f14 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/__init__.py @@ -0,0 +1 @@ +__version__ = "0.5.0" diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/admon/LICENSE b/contrib/python/mdit-py-plugins/mdit_py_plugins/admon/LICENSE new file mode 100644 index 00000000000..eb4033e926f --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/admon/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin. +Copyright (c) 2018 jebbs +Copyright (c) 2021- commenthol + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/admon/__init__.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/admon/__init__.py new file mode 100644 index 00000000000..27e968d1a4e --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/admon/__init__.py @@ -0,0 +1,3 @@ +from .index import admon_plugin + +__all__ = ("admon_plugin",) diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/admon/index.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/admon/index.py new file mode 100644 index 00000000000..6e28940de5b --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/admon/index.py @@ -0,0 +1,230 @@ +# Process admonitions and pass to cb. + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from contextlib import suppress +import re +from typing import TYPE_CHECKING + +from markdown_it import MarkdownIt +from markdown_it.rules_block import StateBlock + +from mdit_py_plugins.utils import is_code_block + +if TYPE_CHECKING: + from markdown_it.renderer import RendererProtocol + from markdown_it.token import Token + from markdown_it.utils import EnvType, OptionsDict + + +def _get_multiple_tags(params: str) -> tuple[list[str], str]: + """Check for multiple tags when the title is double quoted.""" + re_tags = re.compile(r'^\s*(?P<tokens>[^"]+)\s+"(?P<title>.*)"\S*$') + match = re_tags.match(params) + if match: + tags = match["tokens"].strip().split(" ") + return [tag.lower() for tag in tags], match["title"] + raise ValueError("No match found for parameters") + + +def _get_tag(_params: str) -> tuple[list[str], str]: + """Separate the tag name from the admonition title.""" + params = _params.strip() + if not params: + return [""], "" + + with suppress(ValueError): + return _get_multiple_tags(params) + + tag, *_title = params.split(" ") + joined = " ".join(_title) + + title = "" + if not joined: + title = tag.title() + elif joined != '""': # Specifically check for no title + title = joined + return [tag.lower()], title + + +def _validate(params: str) -> bool: + """Validate the presence of the tag name after the marker.""" + tag = params.strip().split(" ", 1)[-1] or "" + return bool(tag) + + +MARKER_LEN = 3 # Regardless of extra characters, block indent stays the same +MARKERS = ("!!!", "???", "???+") +MARKER_CHARS = {_m[0] for _m in MARKERS} +MAX_MARKER_LEN = max(len(_m) for _m in MARKERS) + + +def _extra_classes(markup: str) -> list[str]: + """Return the list of additional classes based on the markup.""" + if markup.startswith("?"): + if markup.endswith("+"): + return ["is-collapsible collapsible-open"] + return ["is-collapsible collapsible-closed"] + return [] + + +def admonition(state: StateBlock, startLine: int, endLine: int, silent: bool) -> bool: + if is_code_block(state, startLine): + return False + + start = state.bMarks[startLine] + state.tShift[startLine] + maximum = state.eMarks[startLine] + + # Check out the first character quickly, which should filter out most of non-containers + if state.src[start] not in MARKER_CHARS: + return False + + # Check out the rest of the marker string + marker = "" + marker_len = MAX_MARKER_LEN + while marker_len > 0: + marker_pos = start + marker_len + markup = state.src[start:marker_pos] + if markup in MARKERS: + marker = markup + break + marker_len -= 1 + else: + return False + + params = state.src[marker_pos:maximum] + + if not _validate(params): + return False + + # Since start is found, we can report success here in validation mode + if silent: + return True + + old_parent = state.parentType + old_line_max = state.lineMax + old_indent = state.blkIndent + + blk_start = marker_pos + while blk_start < maximum and state.src[blk_start] == " ": + blk_start += 1 + + state.parentType = "admonition" + # Correct block indentation when extra marker characters are present + marker_alignment_correction = MARKER_LEN - len(marker) + state.blkIndent += blk_start - start + marker_alignment_correction + + was_empty = False + + # Search for the end of the block + next_line = startLine + while True: + next_line += 1 + if next_line >= endLine: + # unclosed block should be autoclosed by end of document. + # also block seems to be autoclosed by end of parent + break + pos = state.bMarks[next_line] + state.tShift[next_line] + maximum = state.eMarks[next_line] + is_empty = state.sCount[next_line] < state.blkIndent + + # two consecutive empty lines autoclose the block + if is_empty and was_empty: + break + was_empty = is_empty + + if pos < maximum and state.sCount[next_line] < state.blkIndent: + # non-empty line with negative indent should stop the block: + # - !!! + # test + break + + # this will prevent lazy continuations from ever going past our end marker + state.lineMax = next_line + + tags, title = _get_tag(params) + tag = tags[0] + + token = state.push("admonition_open", "div", 1) + token.markup = markup + token.block = True + token.attrs = {"class": " ".join(["admonition", *tags, *_extra_classes(markup)])} + token.meta = {"tag": tag} + token.content = title + token.info = params + token.map = [startLine, next_line] + + if title: + title_markup = f"{markup} {tag}" + token = state.push("admonition_title_open", "p", 1) + token.markup = title_markup + token.attrs = {"class": "admonition-title"} + token.map = [startLine, startLine + 1] + + token = state.push("inline", "", 0) + token.content = title + token.map = [startLine, startLine + 1] + token.children = [] + + token = state.push("admonition_title_close", "p", -1) + + state.md.block.tokenize(state, startLine + 1, next_line) + + token = state.push("admonition_close", "div", -1) + token.markup = markup + token.block = True + + state.parentType = old_parent + state.lineMax = old_line_max + state.blkIndent = old_indent + state.line = next_line + + return True + + +def admon_plugin(md: MarkdownIt, render: None | Callable[..., str] = None) -> None: + """Plugin to use + `python-markdown style admonitions + <https://python-markdown.github.io/extensions/admonition>`_. + + .. code-block:: md + + !!! note + *content* + + `And mkdocs-style collapsible blocks + <https://squidfunk.github.io/mkdocs-material/reference/admonitions/#collapsible-blocks>`_. + + .. code-block:: md + + ???+ note + *content* + + Note, this is ported from + `markdown-it-admon + <https://github.com/commenthol/markdown-it-admon>`_. + """ + + def renderDefault( + self: RendererProtocol, + tokens: Sequence[Token], + idx: int, + _options: OptionsDict, + env: EnvType, + ) -> str: + return self.renderToken(tokens, idx, _options, env) # type: ignore[attr-defined,no-any-return] + + render = render or renderDefault + + md.add_render_rule("admonition_open", render) + md.add_render_rule("admonition_close", render) + md.add_render_rule("admonition_title_open", render) + md.add_render_rule("admonition_title_close", render) + + md.block.ruler.before( + "fence", + "admonition", + admonition, + {"alt": ["paragraph", "reference", "blockquote", "list"]}, + ) diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/admon/port.yaml b/contrib/python/mdit-py-plugins/mdit_py_plugins/admon/port.yaml new file mode 100644 index 00000000000..d2835bcb857 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/admon/port.yaml @@ -0,0 +1,4 @@ +- package: markdown-it-admon + commit: 9820ba89415c464a3cc18a780f222a0ceb3e18bd + date: Jul 3, 2021 + version: 1.0.0 diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/amsmath/__init__.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/amsmath/__init__.py new file mode 100644 index 00000000000..bad1b211dfd --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/amsmath/__init__.py @@ -0,0 +1,155 @@ +"""An extension to capture amsmath latex environments.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +import re +from typing import TYPE_CHECKING + +from markdown_it import MarkdownIt +from markdown_it.common.utils import escapeHtml +from markdown_it.rules_block import StateBlock + +from mdit_py_plugins.utils import is_code_block + +if TYPE_CHECKING: + from markdown_it.renderer import RendererProtocol + from markdown_it.token import Token + from markdown_it.utils import EnvType, OptionsDict + +# Taken from amsmath version 2.1 +# http://anorien.csc.warwick.ac.uk/mirrors/CTAN/macros/latex/required/amsmath/amsldoc.pdf +ENVIRONMENTS = [ + # 3.2 single equation with an automatically gen-erated number + "equation", + # 3.3 variation equation, used for equations that dont fit on a single line + "multline", + # 3.5 a group of consecutive equations when there is no alignment desired among them + "gather", + # 3.6 Used for two or more equations when vertical alignment is desired + "align", + # allows the horizontal space between equationsto be explicitly specified. + "alignat", + # stretches the space betweenthe equation columns to the maximum possible width + "flalign", + # 4.1 The pmatrix, bmatrix, Bmatrix, vmatrix and Vmatrix have (respectively) + # (),[],{},||,and ‖‖ delimiters built in. + "matrix", + "pmatrix", + "bmatrix", + "Bmatrix", + "vmatrix", + "Vmatrix", + # eqnarray is another math environment, it is not part of amsmath, + # and note that it is better to use align or equation+split instead + "eqnarray", +] +# other "non-top-level" environments: + +# 3.4 the split environment is for single equations that are too long to fit on one line +# and hence must be split into multiple lines, +# it is intended for use only inside some other displayed equation structure, +# usually an equation, align, or gather environment + +# 3.7 variants gathered, aligned,and alignedat are provided +# whose total width is the actual width of the contents; +# thus they can be used as a component in a containing expression + +RE_OPEN = r"\\begin\{(" + "|".join(ENVIRONMENTS) + r")([\*]?)\}" + + +def amsmath_plugin( + md: MarkdownIt, *, renderer: Callable[[str], str] | None = None +) -> None: + """Parses TeX math equations, without any surrounding delimiters, + only for top-level `amsmath <https://ctan.org/pkg/amsmath>`__ environments: + + .. code-block:: latex + + \\begin{gather*} + a_1=b_1+c_1\\\\ + a_2=b_2+c_2-d_2+e_2 + \\end{gather*} + + :param renderer: Function to render content, by default escapes HTML + + """ + md.block.ruler.before( + "blockquote", + "amsmath", + amsmath_block, + {"alt": ["paragraph", "reference", "blockquote", "list", "footnote_def"]}, + ) + + _renderer = (lambda content: escapeHtml(content)) if renderer is None else renderer + + def render_amsmath_block( + self: RendererProtocol, + tokens: Sequence[Token], + idx: int, + options: OptionsDict, + env: EnvType, + ) -> str: + content = _renderer(str(tokens[idx].content)) + return f'<div class="math amsmath">\n{content}\n</div>\n' + + md.add_render_rule("amsmath", render_amsmath_block) + + +def amsmath_block( + state: StateBlock, startLine: int, endLine: int, silent: bool +) -> bool: + # note the code principally follows the logic in markdown_it/rules_block/fence.py, + # except that: + # (a) it allows for closing tag on same line as opening tag + # (b) it does not allow for opening tag without closing tag (i.e. no auto-closing) + + if is_code_block(state, startLine): + return False + + # does the first line contain the beginning of an amsmath environment + first_start = state.bMarks[startLine] + state.tShift[startLine] + first_end = state.eMarks[startLine] + first_text = state.src[first_start:first_end] + + if not (match_open := re.match(RE_OPEN, first_text)): + return False + + # construct the closing tag + environment = match_open.group(1) + numbered = match_open.group(2) + closing = rf"\end{{{match_open.group(1)}{match_open.group(2)}}}" + + # start looking for the closing tag, including the current line + nextLine = startLine - 1 + + while True: + nextLine += 1 + if nextLine >= endLine: + # reached the end of the block without finding the closing tag + return False + + next_start = state.bMarks[nextLine] + state.tShift[nextLine] + next_end = state.eMarks[nextLine] + if next_start < first_end and state.sCount[nextLine] < state.blkIndent: + # non-empty line with negative indent should stop the list: + # - \begin{align} + # test + return False + + if state.src[next_start:next_end].rstrip().endswith(closing): + # found the closing tag + break + + state.line = nextLine + 1 + + if not silent: + token = state.push("amsmath", "math", 0) + token.block = True + token.content = state.getLines( + startLine, state.line, state.sCount[startLine], False + ) + token.meta = {"environment": environment, "numbered": numbered} + token.map = [startLine, nextLine] + + return True diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/anchors/__init__.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/anchors/__init__.py new file mode 100644 index 00000000000..1d9cbb97803 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/anchors/__init__.py @@ -0,0 +1,3 @@ +from .index import anchors_plugin + +__all__ = ("anchors_plugin",) diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/anchors/index.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/anchors/index.py new file mode 100644 index 00000000000..66bf0aa7549 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/anchors/index.py @@ -0,0 +1,129 @@ +from collections.abc import Callable +import re + +from markdown_it import MarkdownIt +from markdown_it.rules_core import StateCore +from markdown_it.token import Token + + +def anchors_plugin( + md: MarkdownIt, + min_level: int = 1, + max_level: int = 2, + slug_func: Callable[[str], str] | None = None, + permalink: bool = False, + permalinkSymbol: str = "¶", + permalinkBefore: bool = False, + permalinkSpace: bool = True, +) -> None: + """Plugin for adding header anchors, based on + `markdown-it-anchor <https://github.com/valeriangalliat/markdown-it-anchor>`__ + + .. code-block:: md + + # Title String + + renders as: + + .. code-block:: html + + <h1 id="title-string">Title String <a class="header-anchor" href="#title-string">¶</a></h1> + + :param min_level: minimum header level to apply anchors + :param max_level: maximum header level to apply anchors + :param slug_func: function to convert title text to id slug. + :param permalink: Add a permalink next to the title + :param permalinkSymbol: the symbol to show + :param permalinkBefore: Add the permalink before the title, otherwise after + :param permalinkSpace: Add a space between the permalink and the title + + Note, the default slug function aims to mimic the GitHub Markdown format, see: + + - https://github.com/jch/html-pipeline/blob/master/lib/html/pipeline/toc_filter.rb + - https://gist.github.com/asabaylus/3071099 + + """ + selected_levels = list(range(min_level, max_level + 1)) + md.core.ruler.push( + "anchor", + _make_anchors_func( + selected_levels, + slug_func or slugify, + permalink, + permalinkSymbol, + permalinkBefore, + permalinkSpace, + ), + ) + + +def _make_anchors_func( + selected_levels: list[int], + slug_func: Callable[[str], str], + permalink: bool, + permalinkSymbol: str, + permalinkBefore: bool, + permalinkSpace: bool, +) -> Callable[[StateCore], None]: + def _anchor_func(state: StateCore) -> None: + slugs: set[str] = set() + for idx, token in enumerate(state.tokens): + if token.type != "heading_open": + continue + level = int(token.tag[1]) + if level not in selected_levels: + continue + inline_token = state.tokens[idx + 1] + assert inline_token.children is not None + title = "".join( + child.content + for child in inline_token.children + if child.type in ["text", "code_inline"] + ) + slug = unique_slug(slug_func(title), slugs) + token.attrSet("id", slug) + + if permalink: + link_open = Token( + "link_open", + "a", + 1, + ) + link_open.attrSet("class", "header-anchor") + link_open.attrSet("href", f"#{slug}") + link_tokens = [ + link_open, + Token("html_block", "", 0, content=permalinkSymbol), + Token("link_close", "a", -1), + ] + if permalinkBefore: + inline_token.children = ( + link_tokens + + ( + [Token("text", "", 0, content=" ")] + if permalinkSpace + else [] + ) + + inline_token.children + ) + else: + inline_token.children.extend( + ([Token("text", "", 0, content=" ")] if permalinkSpace else []) + + link_tokens + ) + + return _anchor_func + + +def slugify(title: str) -> str: + return re.sub(r"[^\w\u4e00-\u9fff\- ]", "", title.strip().lower().replace(" ", "-")) + + +def unique_slug(slug: str, slugs: set[str]) -> str: + uniq = slug + i = 1 + while uniq in slugs: + uniq = f"{slug}-{i}" + i += 1 + slugs.add(uniq) + return uniq diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/attrs/__init__.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/attrs/__init__.py new file mode 100644 index 00000000000..a2f48a9b0f0 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/attrs/__init__.py @@ -0,0 +1,3 @@ +from .index import attrs_block_plugin, attrs_plugin + +__all__ = ("attrs_block_plugin", "attrs_plugin") diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/attrs/index.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/attrs/index.py new file mode 100644 index 00000000000..9811ddc0f9e --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/attrs/index.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +from collections.abc import Sequence +from functools import partial +from typing import Any + +from markdown_it import MarkdownIt +from markdown_it.rules_block import StateBlock +from markdown_it.rules_core import StateCore +from markdown_it.rules_inline import StateInline +from markdown_it.token import Token + +from mdit_py_plugins.utils import is_code_block + +from .parse import ParseError, parse + + +def attrs_plugin( + md: MarkdownIt, + *, + after: Sequence[str] = ("image", "code_inline", "link_close", "span_close"), + spans: bool = False, + span_after: str = "link", + allowed: Sequence[str] | None = None, +) -> None: + """Parse inline attributes that immediately follow certain inline elements:: + + {#id .a b=c} + + This syntax is inspired by + `Djot spans + <https://htmlpreview.github.io/?https://github.com/jgm/djot/blob/master/doc/syntax.html#inline-attributes>`_. + + Inside the curly braces, the following syntax is possible: + + - `.foo` specifies foo as a class. + Multiple classes may be given in this way; they will be combined. + - `#foo` specifies foo as an identifier. + An element may have only one identifier; + if multiple identifiers are given, the last one is used. + - `key="value"` or `key=value` specifies a key-value attribute. + Quotes are not needed when the value consists entirely of + ASCII alphanumeric characters or `_` or `:` or `-`. + Backslash escapes may be used inside quoted values. + - `%` begins a comment, which ends with the next `%` or the end of the attribute (`}`). + + Multiple attribute blocks are merged. + + :param md: The MarkdownIt instance to modify. + :param after: The names of inline elements after which attributes may be specified. + This plugin does not support attributes after emphasis, strikethrough or text elements, + which all require post-parse processing. + :param spans: If True, also parse attributes after spans of text, encapsulated by `[]`. + Note Markdown link references take precedence over this syntax. + :param span_after: The name of an inline rule after which spans may be specified. + :param allowed: A list of allowed attribute names. + If not ``None``, any attributes not in this list will be removed + and placed in the token's meta under the key "insecure_attrs". + """ + + if spans: + md.inline.ruler.after(span_after, "span", _span_rule) + if after: + md.inline.ruler.push( + "attr", + partial( + _attr_inline_rule, + after=after, + allowed=None if allowed is None else set(allowed), + ), + ) + + +def attrs_block_plugin(md: MarkdownIt, *, allowed: Sequence[str] | None = None) -> None: + """Parse block attributes. + + Block attributes are attributes on a single line, with no other content. + They attach the specified attributes to the block below them:: + + {.a #b c=1} + A paragraph, that will be assigned the class ``a`` and the identifier ``b``. + + Attributes can be stacked, with classes accumulating and lower attributes overriding higher:: + + {#a .a c=1} + {#b .b c=2} + A paragraph, that will be assigned the class ``a b c``, and the identifier ``b``. + + This syntax is inspired by Djot block attributes. + + :param allowed: A list of allowed attribute names. + If not ``None``, any attributes not in this list will be removed + and placed in the token's meta under the key "insecure_attrs". + """ + md.block.ruler.before("fence", "attr", _attr_block_rule) + md.core.ruler.after( + "block", + "attr", + partial( + _attr_resolve_block_rule, allowed=None if allowed is None else set(allowed) + ), + ) + + +def _find_opening(tokens: Sequence[Token], index: int) -> int | None: + """Find the opening token index, if the token is closing.""" + if tokens[index].nesting != -1: + return index + level = 0 + while index >= 0: + level += tokens[index].nesting + if level == 0: + return index + index -= 1 + return None + + +def _span_rule(state: StateInline, silent: bool) -> bool: + if state.src[state.pos] != "[": + return False + + maximum = state.posMax + labelStart = state.pos + 1 + labelEnd = state.md.helpers.parseLinkLabel(state, state.pos, False) + + # parser failed to find ']', so it's not a valid span + if labelEnd < 0: + return False + + pos = labelEnd + 1 + + # check not at end of inline + if pos >= maximum: + return False + + try: + new_pos, attrs = parse(state.src[pos:]) + except ParseError: + return False + + pos += new_pos + 1 + + if not silent: + state.pos = labelStart + state.posMax = labelEnd + token = state.push("span_open", "span", 1) + token.attrs = attrs # type: ignore[assignment] + state.md.inline.tokenize(state) + token = state.push("span_close", "span", -1) + + state.pos = pos + state.posMax = maximum + return True + + +def _attr_inline_rule( + state: StateInline, + silent: bool, + after: Sequence[str], + *, + allowed: set[str] | None = None, +) -> bool: + if state.pending or not state.tokens: + return False + token = state.tokens[-1] + if token.type not in after: + return False + try: + new_pos, attrs = parse(state.src[state.pos :]) + except ParseError: + return False + token_index = _find_opening(state.tokens, len(state.tokens) - 1) + if token_index is None: + return False + state.pos += new_pos + 1 + if not silent: + attr_token = state.tokens[token_index] + if "class" in attrs and "class" in token.attrs: + attrs["class"] = f"{token.attrs['class']} {attrs['class']}" + _add_attrs(attr_token, attrs, allowed) + return True + + +def _attr_block_rule( + state: StateBlock, startLine: int, endLine: int, silent: bool +) -> bool: + """Find a block of attributes. + + The block must be a single line that begins with a `{`, after three or less spaces, + and end with a `}` followed by any number if spaces. + """ + if is_code_block(state, startLine): + return False + + pos = state.bMarks[startLine] + state.tShift[startLine] + maximum = state.eMarks[startLine] + + # if it doesn't start with a {, it's not an attribute block + if state.src[pos] != "{": + return False + + # find first non-space character from the right + while maximum > pos and state.src[maximum - 1] in (" ", "\t"): + maximum -= 1 + # if it doesn't end with a }, it's not an attribute block + if maximum <= pos: + return False + if state.src[maximum - 1] != "}": + return False + + try: + new_pos, attrs = parse(state.src[pos:maximum]) + except ParseError: + return False + + # if the block was resolved earlier than expected, it's not an attribute block + # TODO this was not working in some instances, so I disabled it + # if (maximum - 1) != new_pos: + # return False + + if silent: + return True + + token = state.push("attrs_block", "", 0) + token.attrs = attrs # type: ignore[assignment] + token.map = [startLine, startLine + 1] + + state.line = startLine + 1 + return True + + +def _attr_resolve_block_rule(state: StateCore, *, allowed: set[str] | None) -> None: + """Find attribute block then move its attributes to the next block.""" + i = 0 + len_tokens = len(state.tokens) + while i < len_tokens: + if state.tokens[i].type != "attrs_block": + i += 1 + continue + + if i + 1 < len_tokens: + next_token = state.tokens[i + 1] + + # classes are appended + if "class" in state.tokens[i].attrs and "class" in next_token.attrs: + state.tokens[i].attrs["class"] = ( + f"{state.tokens[i].attrs['class']} {next_token.attrs['class']}" + ) + + if next_token.type == "attrs_block": + # subsequent attribute blocks take precedence, when merging + for key, value in state.tokens[i].attrs.items(): + if key == "class" or key not in next_token.attrs: + next_token.attrs[key] = value + else: + _add_attrs(next_token, state.tokens[i].attrs, allowed) + + state.tokens.pop(i) + len_tokens -= 1 + + +def _add_attrs( + token: Token, + attrs: dict[str, Any], + allowed: set[str] | None, +) -> None: + """Add attributes to a token, skipping any disallowed attributes.""" + if allowed is not None and ( + disallowed := {k: v for k, v in attrs.items() if k not in allowed} + ): + token.meta["insecure_attrs"] = disallowed + attrs = {k: v for k, v in attrs.items() if k in allowed} + + # attributes takes precedence over existing attributes + token.attrs.update(attrs) diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/attrs/parse.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/attrs/parse.py new file mode 100644 index 00000000000..061574ffa6d --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/attrs/parse.py @@ -0,0 +1,257 @@ +"""Parser for attributes:: + + attributes { id = "foo", class = "bar baz", + key1 = "val1", key2 = "val2" } + +Adapted from: +https://github.com/jgm/djot/blob/fae7364b86bfce69bc6d5b5eede1f5196d845fd6/djot/attributes.lua#L1 + +syntax: + +attributes <- '{' whitespace* attribute (whitespace attribute)* whitespace* '}' +attribute <- identifier | class | keyval +identifier <- '#' name +class <- '.' name +name <- (nonspace, nonpunctuation other than ':', '_', '-')+ +keyval <- key '=' val +key <- (ASCII_ALPHANUM | ':' | '_' | '-')+ +val <- bareval | quotedval +bareval <- (ASCII_ALPHANUM | ':' | '_' | '-')+ +quotedval <- '"' ([^"] | '\"') '"' +""" + +from __future__ import annotations + +from collections.abc import Callable +from enum import Enum +import re + + +class State(Enum): + START = 0 + SCANNING = 1 + SCANNING_ID = 2 + SCANNING_CLASS = 3 + SCANNING_KEY = 4 + SCANNING_VALUE = 5 + SCANNING_BARE_VALUE = 6 + SCANNING_QUOTED_VALUE = 7 + SCANNING_COMMENT = 8 + SCANNING_ESCAPED = 9 + DONE = 10 + + +REGEX_SPACE = re.compile(r"\s") +REGEX_SPACE_PUNCTUATION = re.compile(r"[\s!\"#$%&'()*+,./;<=>?@[\]^`{|}~]") +REGEX_KEY_CHARACTERS = re.compile(r"[a-zA-Z\d_:-]") + + +class TokenState: + def __init__(self) -> None: + self._tokens: list[tuple[int, int, str]] = [] + self.start: int = 0 + + def set_start(self, start: int) -> None: + self.start = start + + def append(self, start: int, end: int, ttype: str) -> None: + self._tokens.append((start, end, ttype)) + + def compile(self, string: str) -> dict[str, str]: + """compile the tokens into a dictionary""" + attributes = {} + classes = [] + idx = 0 + while idx < len(self._tokens): + start, end, ttype = self._tokens[idx] + if ttype == "id": + attributes["id"] = string[start:end] + elif ttype == "class": + classes.append(string[start:end]) + elif ttype == "key": + key = string[start:end] + if idx + 1 < len(self._tokens): + start, end, ttype = self._tokens[idx + 1] + if ttype == "value": + if key == "class": + classes.append(string[start:end]) + else: + attributes[key] = string[start:end] + idx += 1 + idx += 1 + if classes: + attributes["class"] = " ".join(classes) + return attributes + + def __str__(self) -> str: + return str(self._tokens) + + def __repr__(self) -> str: + return repr(self._tokens) + + +class ParseError(Exception): + def __init__(self, msg: str, pos: int) -> None: + self.pos = pos + super().__init__(msg + f" at position {pos}") + + +def parse(string: str) -> tuple[int, dict[str, str]]: + """Parse attributes from start of string. + + :returns: (length of parsed string, dict of attributes) + """ + pos = 0 + state: State = State.START + tokens = TokenState() + while pos < len(string): + state = HANDLERS[state](string[pos], pos, tokens) + if state == State.DONE: + return pos, tokens.compile(string) + pos = pos + 1 + + return pos, tokens.compile(string) + + +def handle_start(char: str, pos: int, tokens: TokenState) -> State: + if char == "{": + return State.SCANNING + raise ParseError("Attributes must start with '{'", pos) + + +def handle_scanning(char: str, pos: int, tokens: TokenState) -> State: + if char == " " or char == "\t" or char == "\n" or char == "\r": + return State.SCANNING + if char == "}": + return State.DONE + if char == "#": + tokens.set_start(pos) + return State.SCANNING_ID + if char == "%": + tokens.set_start(pos) + return State.SCANNING_COMMENT + if char == ".": + tokens.set_start(pos) + return State.SCANNING_CLASS + if REGEX_KEY_CHARACTERS.fullmatch(char): + tokens.set_start(pos) + return State.SCANNING_KEY + + raise ParseError(f"Unexpected character whilst scanning: {char}", pos) + + +def handle_scanning_comment(char: str, pos: int, tokens: TokenState) -> State: + if char == "%": + return State.SCANNING + + return State.SCANNING_COMMENT + + +def handle_scanning_id(char: str, pos: int, tokens: TokenState) -> State: + if not REGEX_SPACE_PUNCTUATION.fullmatch(char): + return State.SCANNING_ID + + if char == "}": + if (pos - 1) > tokens.start: + tokens.append(tokens.start + 1, pos, "id") + return State.DONE + + if REGEX_SPACE.fullmatch(char): + if (pos - 1) > tokens.start: + tokens.append(tokens.start + 1, pos, "id") + return State.SCANNING + + raise ParseError(f"Unexpected character whilst scanning id: {char}", pos) + + +def handle_scanning_class(char: str, pos: int, tokens: TokenState) -> State: + if not REGEX_SPACE_PUNCTUATION.fullmatch(char): + return State.SCANNING_CLASS + + if char == "}": + if (pos - 1) > tokens.start: + tokens.append(tokens.start + 1, pos, "class") + return State.DONE + + if REGEX_SPACE.fullmatch(char): + if (pos - 1) > tokens.start: + tokens.append(tokens.start + 1, pos, "class") + return State.SCANNING + + raise ParseError(f"Unexpected character whilst scanning class: {char}", pos) + + +def handle_scanning_key(char: str, pos: int, tokens: TokenState) -> State: + if char == "=": + tokens.append(tokens.start, pos, "key") + return State.SCANNING_VALUE + + if REGEX_KEY_CHARACTERS.fullmatch(char): + return State.SCANNING_KEY + + raise ParseError(f"Unexpected character whilst scanning key: {char}", pos) + + +def handle_scanning_value(char: str, pos: int, tokens: TokenState) -> State: + if char == '"': + tokens.set_start(pos) + return State.SCANNING_QUOTED_VALUE + + if REGEX_KEY_CHARACTERS.fullmatch(char): + tokens.set_start(pos) + return State.SCANNING_BARE_VALUE + + raise ParseError(f"Unexpected character whilst scanning value: {char}", pos) + + +def handle_scanning_bare_value(char: str, pos: int, tokens: TokenState) -> State: + if REGEX_KEY_CHARACTERS.fullmatch(char): + return State.SCANNING_BARE_VALUE + + if char == "}": + tokens.append(tokens.start, pos, "value") + return State.DONE + + if REGEX_SPACE.fullmatch(char): + tokens.append(tokens.start, pos, "value") + return State.SCANNING + + raise ParseError(f"Unexpected character whilst scanning bare value: {char}", pos) + + +def handle_scanning_escaped(char: str, pos: int, tokens: TokenState) -> State: + return State.SCANNING_QUOTED_VALUE + + +def handle_scanning_quoted_value(char: str, pos: int, tokens: TokenState) -> State: + if char == '"': + tokens.append(tokens.start + 1, pos, "value") + return State.SCANNING + + if char == "\\": + return State.SCANNING_ESCAPED + + if char == "{" or char == "}": + raise ParseError( + f"Unexpected character whilst scanning quoted value: {char}", pos + ) + + if char == "\n": + tokens.append(tokens.start + 1, pos, "value") + return State.SCANNING_QUOTED_VALUE + + return State.SCANNING_QUOTED_VALUE + + +HANDLERS: dict[State, Callable[[str, int, TokenState], State]] = { + State.START: handle_start, + State.SCANNING: handle_scanning, + State.SCANNING_COMMENT: handle_scanning_comment, + State.SCANNING_ID: handle_scanning_id, + State.SCANNING_CLASS: handle_scanning_class, + State.SCANNING_KEY: handle_scanning_key, + State.SCANNING_VALUE: handle_scanning_value, + State.SCANNING_BARE_VALUE: handle_scanning_bare_value, + State.SCANNING_QUOTED_VALUE: handle_scanning_quoted_value, + State.SCANNING_ESCAPED: handle_scanning_escaped, +} diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/colon_fence.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/colon_fence.py new file mode 100644 index 00000000000..70c95edfbe7 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/colon_fence.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from markdown_it import MarkdownIt +from markdown_it.common.utils import escapeHtml, unescapeAll +from markdown_it.rules_block import StateBlock + +from mdit_py_plugins.utils import is_code_block + +if TYPE_CHECKING: + from markdown_it.renderer import RendererProtocol + from markdown_it.token import Token + from markdown_it.utils import EnvType, OptionsDict + + +def colon_fence_plugin(md: MarkdownIt) -> None: + """This plugin directly mimics regular fences, but with `:` colons. + + Example:: + + :::name + contained text + ::: + + """ + + md.block.ruler.before( + "fence", + "colon_fence", + _rule, + {"alt": ["paragraph", "reference", "blockquote", "list", "footnote_def"]}, + ) + md.add_render_rule("colon_fence", _render) + + +def _rule(state: StateBlock, startLine: int, endLine: int, silent: bool) -> bool: + if is_code_block(state, startLine): + return False + + haveEndMarker = False + pos = state.bMarks[startLine] + state.tShift[startLine] + maximum = state.eMarks[startLine] + + if pos + 3 > maximum: + return False + + marker = state.src[pos] + + if marker != ":": + return False + + # scan marker length + mem = pos + pos = _skipCharsStr(state, pos, marker) + + length = pos - mem + + if length < 3: + return False + + markup = state.src[mem:pos] + params = state.src[pos:maximum] + + # Since start is found, we can report success here in validation mode + if silent: + return True + + # search end of block + nextLine = startLine + + while True: + nextLine += 1 + if nextLine >= endLine: + # unclosed block should be autoclosed by end of document. + # also block seems to be autoclosed by end of parent + break + + pos = mem = state.bMarks[nextLine] + state.tShift[nextLine] + maximum = state.eMarks[nextLine] + + if pos < maximum and state.sCount[nextLine] < state.blkIndent: + # non-empty line with negative indent should stop the list: + # - ``` + # test + break + + if state.src[pos] != marker: + continue + + if is_code_block(state, nextLine): + continue + + pos = _skipCharsStr(state, pos, marker) + + # closing code fence must be at least as long as the opening one + if pos - mem < length: + continue + + # make sure tail has spaces only + pos = state.skipSpaces(pos) + + if pos < maximum: + continue + + haveEndMarker = True + # found! + break + + # If a fence has heading spaces, they should be removed from its inner block + length = state.sCount[startLine] + + state.line = nextLine + (1 if haveEndMarker else 0) + + token = state.push("colon_fence", "code", 0) + token.info = params + token.content = state.getLines(startLine + 1, nextLine, length, True) + token.markup = markup + token.map = [startLine, state.line] + + return True + + +def _skipCharsStr(state: StateBlock, pos: int, ch: str) -> int: + """Skip character string from given position.""" + # TODO this can be replaced with StateBlock.skipCharsStr in markdown-it-py 3.0.0 + while True: + try: + current = state.src[pos] + except IndexError: + break + if current != ch: + break + pos += 1 + return pos + + +def _render( + self: RendererProtocol, + tokens: Sequence[Token], + idx: int, + options: OptionsDict, + env: EnvType, +) -> str: + token = tokens[idx] + info = unescapeAll(token.info).strip() if token.info else "" + content = escapeHtml(token.content) + block_name = "" + + if info: + block_name = info.split()[0] + + return ( + "<pre><code" + + (f' class="block-{block_name}" ' if block_name else "") + + ">" + + content + + "</code></pre>\n" + ) diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/container/LICENSE b/contrib/python/mdit-py-plugins/mdit_py_plugins/container/LICENSE new file mode 100644 index 00000000000..e6c32306de1 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/container/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/container/README.md b/contrib/python/mdit-py-plugins/mdit_py_plugins/container/README.md new file mode 100644 index 00000000000..03868d78b91 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/container/README.md @@ -0,0 +1,95 @@ +# markdown-it-container + +[](https://travis-ci.org/markdown-it/markdown-it-container) +[](https://www.npmjs.org/package/markdown-it-container) +[](https://coveralls.io/r/markdown-it/markdown-it-container?branch=master) + +> Plugin for creating block-level custom containers for [markdown-it](https://github.com/markdown-it/markdown-it) markdown parser. + +__v2.+ requires `markdown-it` v5.+, see changelog.__ + +With this plugin you can create block containers like: + +``` +::: warning +*here be dragons* +::: +``` + +.... and specify how they should be rendered. If no renderer defined, `<div>` with +container name class will be created: + +```html +<div class="warning"> +<em>here be dragons</em> +</div> +``` + +Markup is the same as for [fenced code blocks](http://spec.commonmark.org/0.18/#fenced-code-blocks). +Difference is, that marker use another character and content is rendered as markdown markup. + + +## Installation + +node.js, browser: + +```bash +$ npm install markdown-it-container --save +$ bower install markdown-it-container --save +``` + + +## API + +```js +var md = require('markdown-it')() + .use(require('markdown-it-container'), name [, options]); +``` + +Params: + +- __name__ - container name (mandatory) +- __options:__ + - __validate__ - optional, function to validate tail after opening marker, should + return `true` on success. + - __render__ - optional, renderer function for opening/closing tokens. + - __marker__ - optional (`:`), character to use in delimiter. + + +## Example + +```js +var md = require('markdown-it')(); + +md.use(require('markdown-it-container'), 'spoiler', { + + validate: function(params) { + return params.trim().match(/^spoiler\s+(.*)$/); + }, + + render: function (tokens, idx) { + var m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/); + + if (tokens[idx].nesting === 1) { + // opening tag + return '<details><summary>' + md.utils.escapeHtml(m[1]) + '</summary>\n'; + + } else { + // closing tag + return '</details>\n'; + } + } +}); + +console.log(md.render('::: spoiler click me\n*content*\n:::\n')); + +// Output: +// +// <details><summary>click me</summary> +// <p><em>content</em></p> +// </details> +``` + +## License + +[MIT](https://github.com/markdown-it/markdown-it-container/blob/master/LICENSE) diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/container/__init__.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/container/__init__.py new file mode 100644 index 00000000000..32ae9cdb351 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/container/__init__.py @@ -0,0 +1,3 @@ +from .index import container_plugin + +__all__ = ("container_plugin",) diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/container/index.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/container/index.py new file mode 100644 index 00000000000..c6102687ae9 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/container/index.py @@ -0,0 +1,193 @@ +"""Process block-level custom containers.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from math import floor +from typing import TYPE_CHECKING, Any + +from markdown_it import MarkdownIt +from markdown_it.rules_block import StateBlock + +from mdit_py_plugins.utils import is_code_block + +if TYPE_CHECKING: + from markdown_it.renderer import RendererProtocol + from markdown_it.token import Token + from markdown_it.utils import EnvType, OptionsDict + + +def container_plugin( + md: MarkdownIt, + name: str, + marker: str = ":", + validate: None | Callable[[str, str], bool] = None, + render: None | Callable[..., str] = None, +) -> None: + """Plugin ported from + `markdown-it-container <https://github.com/markdown-it/markdown-it-container>`__. + + It is a plugin for creating block-level custom containers: + + .. code-block:: md + + :::: name + ::: name + *markdown* + ::: + :::: + + :param name: the name of the container to parse + :param marker: the marker character to use + :param validate: func(marker, param) -> bool, default matches against the name + :param render: render func + + """ + + def validateDefault(params: str, *args: Any) -> bool: + return params.strip().split(" ", 2)[0] == name + + def renderDefault( + self: RendererProtocol, + tokens: Sequence[Token], + idx: int, + _options: OptionsDict, + env: EnvType, + ) -> str: + # add a class to the opening tag + if tokens[idx].nesting == 1: + tokens[idx].attrJoin("class", name) + + return self.renderToken(tokens, idx, _options, env) # type: ignore[attr-defined,no-any-return] + + min_markers = 3 + marker_str = marker + marker_char = marker_str[0] + marker_len = len(marker_str) + validate = validate or validateDefault + render = render or renderDefault + + def container_func( + state: StateBlock, startLine: int, endLine: int, silent: bool + ) -> bool: + if is_code_block(state, startLine): + return False + + auto_closed = False + start = state.bMarks[startLine] + state.tShift[startLine] + maximum = state.eMarks[startLine] + + # Check out the first character quickly, + # this should filter out most of non-containers + if marker_char != state.src[start]: + return False + + # Check out the rest of the marker string + pos = start + 1 + while pos <= maximum: + try: + character = state.src[pos] + except IndexError: + break + if marker_str[(pos - start) % marker_len] != character: + break + pos += 1 + + marker_count = floor((pos - start) / marker_len) + if marker_count < min_markers: + return False + pos -= (pos - start) % marker_len + + markup = state.src[start:pos] + params = state.src[pos:maximum] + assert validate is not None + if not validate(params, markup): + return False + + # Since start is found, we can report success here in validation mode + if silent: + return True + + # Search for the end of the block + nextLine = startLine + + while True: + nextLine += 1 + if nextLine >= endLine: + # unclosed block should be autoclosed by end of document. + # also block seems to be autoclosed by end of parent + break + + start = state.bMarks[nextLine] + state.tShift[nextLine] + maximum = state.eMarks[nextLine] + + if start < maximum and state.sCount[nextLine] < state.blkIndent: + # non-empty line with negative indent should stop the list: + # - ``` + # test + break + + if marker_char != state.src[start]: + continue + + if is_code_block(state, nextLine): + continue + + pos = start + 1 + while pos <= maximum: + try: + character = state.src[pos] + except IndexError: + break + if marker_str[(pos - start) % marker_len] != character: + break + pos += 1 + + # closing code fence must be at least as long as the opening one + if floor((pos - start) / marker_len) < marker_count: + continue + + # make sure tail has spaces only + pos -= (pos - start) % marker_len + pos = state.skipSpaces(pos) + + if pos < maximum: + continue + + # found! + auto_closed = True + break + + old_parent = state.parentType + old_line_max = state.lineMax + state.parentType = "container" + + # this will prevent lazy continuations from ever going past our end marker + state.lineMax = nextLine + + token = state.push(f"container_{name}_open", "div", 1) + token.markup = markup + token.block = True + token.info = params + token.map = [startLine, nextLine] + + state.md.block.tokenize(state, startLine + 1, nextLine) + + token = state.push(f"container_{name}_close", "div", -1) + token.markup = state.src[start:pos] + token.block = True + + state.parentType = old_parent + state.lineMax = old_line_max + state.line = nextLine + (1 if auto_closed else 0) + + return True + + md.block.ruler.before( + "fence", + "container_" + name, + container_func, + {"alt": ["paragraph", "reference", "blockquote", "list"]}, + ) + md.add_render_rule(f"container_{name}_open", render) + md.add_render_rule(f"container_{name}_close", render) diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/container/port.yaml b/contrib/python/mdit-py-plugins/mdit_py_plugins/container/port.yaml new file mode 100644 index 00000000000..e47c1185ed0 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/container/port.yaml @@ -0,0 +1,5 @@ +- package: markdown-it-container + commit: adb3defde3a1c56015895b47ce4c6591b8b1e3a2 + date: Jun 2, 2020 + version: 3.0.0 + changes: diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/deflist/LICENSE b/contrib/python/mdit-py-plugins/mdit_py_plugins/deflist/LICENSE new file mode 100644 index 00000000000..2fd4e3dc74c --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/deflist/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2014-2015 Vitaly Puzrin, Alex Kocharin. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/deflist/README.md b/contrib/python/mdit-py-plugins/mdit_py_plugins/deflist/README.md new file mode 100644 index 00000000000..414157bcc24 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/deflist/README.md @@ -0,0 +1,38 @@ +# markdown-it-deflist + +[](https://travis-ci.org/markdown-it/markdown-it-deflist) +[](https://www.npmjs.org/package/markdown-it-deflist) +[](https://coveralls.io/r/markdown-it/markdown-it-deflist?branch=master) + +> Definition list (`<dl>`) tag plugin for [markdown-it](https://github.com/markdown-it/markdown-it) markdown parser. + +__v2.+ requires `markdown-it` v5.+, see changelog.__ + +Syntax is based on [pandoc definition lists](http://johnmacfarlane.net/pandoc/README.html#definition-lists). + + +## Install + +node.js, browser: + +```bash +npm install markdown-it-deflist --save +bower install markdown-it-deflist --save +``` + +## Use + +```js +var md = require('markdown-it')() + .use(require('markdown-it-deflist')); + +md.render(/*...*/); +``` + +_Differences in browser._ If you load script directly into the page, without +package system, module will add itself globally as `window.markdownitDeflist`. + + +## License + +[MIT](https://github.com/markdown-it/markdown-it-deflist/blob/master/LICENSE) diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/deflist/__init__.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/deflist/__init__.py new file mode 100644 index 00000000000..97008b8ad5e --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/deflist/__init__.py @@ -0,0 +1,3 @@ +from .index import deflist_plugin + +__all__ = ("deflist_plugin",) diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/deflist/index.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/deflist/index.py new file mode 100644 index 00000000000..f5d20d17a69 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/deflist/index.py @@ -0,0 +1,253 @@ +"""Process definition lists.""" + +from markdown_it import MarkdownIt +from markdown_it.rules_block import StateBlock + +from mdit_py_plugins.utils import is_code_block + + +def deflist_plugin(md: MarkdownIt) -> None: + """Plugin ported from + `markdown-it-deflist <https://github.com/markdown-it/markdown-it-deflist>`__. + + The syntax is based on + `pandoc definition lists <http://johnmacfarlane.net/pandoc/README.html#definition-lists>`__: + + .. code-block:: md + + Term 1 + : Definition 1 long form + + second paragraph + + Term 2 with *inline markup* + ~ Definition 2a compact style + ~ Definition 2b + + """ + + def skipMarker(state: StateBlock, line: int) -> int: + """Search `[:~][\n ]`, returns next pos after marker on success or -1 on fail.""" + start = state.bMarks[line] + state.tShift[line] + maximum = state.eMarks[line] + + if start >= maximum: + return -1 + + # Check bullet + marker = state.src[start] + start += 1 + if marker != "~" and marker != ":": + return -1 + + pos = state.skipSpaces(start) + + # require space after ":" + if start == pos: + return -1 + + # no empty definitions, e.g. " : " + if pos >= maximum: + return -1 + + return start + + def markTightParagraphs(state: StateBlock, idx: int) -> None: + level = state.level + 2 + + i = idx + 2 + l2 = len(state.tokens) - 2 + while i < l2: + if ( + state.tokens[i].level == level + and state.tokens[i].type == "paragraph_open" + ): + state.tokens[i + 2].hidden = True + state.tokens[i].hidden = True + i += 2 + i += 1 + + def deflist(state: StateBlock, startLine: int, endLine: int, silent: bool) -> bool: + if is_code_block(state, startLine): + return False + + if silent: + # quirk: validation mode validates a dd block only, not a whole deflist + if state.ddIndent < 0: + return False + return skipMarker(state, startLine) >= 0 + + nextLine = startLine + 1 + if nextLine >= endLine: + return False + + if state.isEmpty(nextLine): + nextLine += 1 + if nextLine >= endLine: + return False + + if state.sCount[nextLine] < state.blkIndent: + return False + contentStart = skipMarker(state, nextLine) + if contentStart < 0: + return False + + # Start list + listTokIdx = len(state.tokens) + tight = True + + token = state.push("dl_open", "dl", 1) + token.map = listLines = [startLine, 0] + + # Iterate list items + dtLine = startLine + ddLine = nextLine + + # One definition list can contain multiple DTs, + # and one DT can be followed by multiple DDs. + # + # Thus, there is two loops here, and label is + # needed to break out of the second one + # + break_outer = False + + while True: + prevEmptyEnd = False + + token = state.push("dt_open", "dt", 1) + token.map = [dtLine, dtLine] + + token = state.push("inline", "", 0) + token.map = [dtLine, dtLine] + token.content = state.getLines( + dtLine, dtLine + 1, state.blkIndent, False + ).strip() + token.children = [] + + token = state.push("dt_close", "dt", -1) + + while True: + token = state.push("dd_open", "dd", 1) + token.map = itemLines = [nextLine, 0] + + pos = contentStart + maximum = state.eMarks[ddLine] + offset = ( + state.sCount[ddLine] + + contentStart + - (state.bMarks[ddLine] + state.tShift[ddLine]) + ) + + while pos < maximum: + if state.src[pos] == "\t": + offset += 4 - offset % 4 + elif state.src[pos] == " ": + offset += 1 + else: + break + + pos += 1 + + contentStart = pos + + oldTight = state.tight + oldDDIndent = state.ddIndent + oldIndent = state.blkIndent + oldTShift = state.tShift[ddLine] + oldSCount = state.sCount[ddLine] + oldParentType = state.parentType + state.blkIndent = state.ddIndent = state.sCount[ddLine] + 2 + state.tShift[ddLine] = contentStart - state.bMarks[ddLine] + state.sCount[ddLine] = offset + state.tight = True + state.parentType = "deflist" + + state.md.block.tokenize(state, ddLine, endLine) + + # If any of list item is tight, mark list as tight + if not state.tight or prevEmptyEnd: + tight = False + + # Item become loose if finish with empty line, + # but we should filter last element, because it means list finish + prevEmptyEnd = (state.line - ddLine) > 1 and state.isEmpty( + state.line - 1 + ) + + state.tShift[ddLine] = oldTShift + state.sCount[ddLine] = oldSCount + state.tight = oldTight + state.parentType = oldParentType + state.blkIndent = oldIndent + state.ddIndent = oldDDIndent + + token = state.push("dd_close", "dd", -1) + + itemLines[1] = nextLine = state.line + + if nextLine >= endLine: + break_outer = True + break + + if state.sCount[nextLine] < state.blkIndent: + break_outer = True + break + + contentStart = skipMarker(state, nextLine) + if contentStart < 0: + break + + ddLine = nextLine + + # go to the next loop iteration: + # insert DD tag and repeat checking + + if break_outer: + break_outer = False + break + + if nextLine >= endLine: + break + dtLine = nextLine + + if state.isEmpty(dtLine): + break + if state.sCount[dtLine] < state.blkIndent: + break + + ddLine = dtLine + 1 + if ddLine >= endLine: + break + if state.isEmpty(ddLine): + ddLine += 1 + if ddLine >= endLine: + break + + if state.sCount[ddLine] < state.blkIndent: + break + contentStart = skipMarker(state, ddLine) + if contentStart < 0: + break + + # go to the next loop iteration: + # insert DT and DD tags and repeat checking + + # Finalise list + token = state.push("dl_close", "dl", -1) + + listLines[1] = nextLine + + state.line = nextLine + + # mark paragraphs tight if needed + if tight: + markTightParagraphs(state, listTokIdx) + + return True + + md.block.ruler.before( + "paragraph", + "deflist", + deflist, + {"alt": ["paragraph", "reference", "blockquote"]}, + ) diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/deflist/port.yaml b/contrib/python/mdit-py-plugins/mdit_py_plugins/deflist/port.yaml new file mode 100644 index 00000000000..203c7729ed4 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/deflist/port.yaml @@ -0,0 +1,5 @@ +- package: markdown-it-deflist + commit: 20db400948520308291da029a23b0751cb30f3a0 + date: July 12, 2017 + version: 2.0.3 + changes: diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/dollarmath/__init__.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/dollarmath/__init__.py new file mode 100644 index 00000000000..102b9253a95 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/dollarmath/__init__.py @@ -0,0 +1,3 @@ +from .index import dollarmath_plugin + +__all__ = ("dollarmath_plugin",) diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/dollarmath/index.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/dollarmath/index.py new file mode 100644 index 00000000000..32039ad577c --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/dollarmath/index.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +import re +from typing import TYPE_CHECKING, Any + +from markdown_it import MarkdownIt +from markdown_it.common.utils import escapeHtml, isWhiteSpace +from markdown_it.rules_block import StateBlock +from markdown_it.rules_inline import StateInline + +from mdit_py_plugins.utils import is_code_block + +if TYPE_CHECKING: + from markdown_it.renderer import RendererProtocol + from markdown_it.token import Token + from markdown_it.utils import EnvType, OptionsDict + + +def dollarmath_plugin( + md: MarkdownIt, + *, + allow_labels: bool = True, + allow_space: bool = True, + allow_digits: bool = True, + allow_blank_lines: bool = True, + double_inline: bool = False, + label_normalizer: Callable[[str], str] | None = None, + renderer: Callable[[str, dict[str, Any]], str] | None = None, + label_renderer: Callable[[str], str] | None = None, +) -> None: + """Plugin for parsing dollar enclosed math, + e.g. inline: ``$a=1$``, block: ``$$b=2$$`` + + This is an improved version of ``texmath``; it is more performant, + and handles ``\\`` escaping properly and allows for more configuration. + + :param allow_labels: Capture math blocks with label suffix, e.g. ``$$a=1$$ (eq1)`` + :param allow_space: Parse inline math when there is space + after/before the opening/closing ``$``, e.g. ``$ a $`` + :param allow_digits: Parse inline math when there is a digit + before/after the opening/closing ``$``, e.g. ``1$`` or ``$2``. + This is useful when also using currency. + :param allow_blank_lines: Allow blank lines inside ``$$``. Note that blank lines are + not allowed in LaTeX, executablebooks/markdown-it-dollarmath, or the Github or + StackExchange markdown dialects. Hoever, they have special semantics if used + within Sphinx `..math` admonitions, so are allowed for backwards-compatibility. + :param double_inline: Search for double-dollar math within inline contexts + :param label_normalizer: Function to normalize the label, + by default replaces whitespace with `-` + :param renderer: Function to render content: `(str, {"display_mode": bool}) -> str`, + by default escapes HTML + :param label_renderer: Function to render labels, by default creates anchor + + """ + if label_normalizer is None: + label_normalizer = lambda label: re.sub(r"\s+", "-", label) # noqa: E731 + + md.inline.ruler.before( + "escape", + "math_inline", + math_inline_dollar(allow_space, allow_digits, double_inline), + ) + md.block.ruler.before( + "fence", + "math_block", + math_block_dollar(allow_labels, label_normalizer, allow_blank_lines), + ) + + # TODO the current render rules are really just for testing + # would be good to allow "proper" math rendering, + # e.g. https://github.com/roniemartinez/latex2mathml + + _renderer = ( + (lambda content, _: escapeHtml(content)) if renderer is None else renderer + ) + + _label_renderer: Callable[[str], str] + if label_renderer is None: + _label_renderer = ( # noqa: E731 + lambda label: f'<a href="#{label}" class="mathlabel" title="Permalink to this equation">¶</a>' + ) + else: + _label_renderer = label_renderer + + def render_math_inline( + self: RendererProtocol, + tokens: Sequence[Token], + idx: int, + options: OptionsDict, + env: EnvType, + ) -> str: + content = _renderer(str(tokens[idx].content).strip(), {"display_mode": False}) + return f'<span class="math inline">{content}</span>' + + def render_math_inline_double( + self: RendererProtocol, + tokens: Sequence[Token], + idx: int, + options: OptionsDict, + env: EnvType, + ) -> str: + content = _renderer(str(tokens[idx].content).strip(), {"display_mode": True}) + return f'<div class="math inline">{content}</div>' + + def render_math_block( + self: RendererProtocol, + tokens: Sequence[Token], + idx: int, + options: OptionsDict, + env: EnvType, + ) -> str: + content = _renderer(str(tokens[idx].content).strip(), {"display_mode": True}) + return f'<div class="math block">\n{content}\n</div>\n' + + def render_math_block_label( + self: RendererProtocol, + tokens: Sequence[Token], + idx: int, + options: OptionsDict, + env: EnvType, + ) -> str: + content = _renderer(str(tokens[idx].content).strip(), {"display_mode": True}) + _id = tokens[idx].info + label = _label_renderer(tokens[idx].info) + return f'<div id="{_id}" class="math block">\n{label}\n{content}\n</div>\n' + + md.add_render_rule("math_inline", render_math_inline) + md.add_render_rule("math_inline_double", render_math_inline_double) + + md.add_render_rule("math_block", render_math_block) + md.add_render_rule("math_block_label", render_math_block_label) + + +def is_escaped(state: StateInline, back_pos: int, mod: int = 0) -> bool: + """Test if dollar is escaped.""" + # count how many \ are before the current position + backslashes = 0 + while back_pos >= 0: + back_pos = back_pos - 1 + if state.src[back_pos] == "\\": + backslashes += 1 + else: + break + + if not backslashes: + return False + + # if an odd number of \ then ignore + if (backslashes % 2) != mod: # noqa: SIM103 + return True + + return False + + +def math_inline_dollar( + allow_space: bool = True, allow_digits: bool = True, allow_double: bool = False +) -> Callable[[StateInline, bool], bool]: + """Generate inline dollar rule. + + :param allow_space: Parse inline math when there is space + after/before the opening/closing ``$``, e.g. ``$ a $`` + :param allow_digits: Parse inline math when there is a digit + before/after the opening/closing ``$``, e.g. ``1$`` or ``$2``. + This is useful when also using currency. + :param allow_double: Search for double-dollar math within inline contexts + + """ + + def _math_inline_dollar(state: StateInline, silent: bool) -> bool: + """Inline dollar rule. + + - Initial check: + - check if first character is a $ + - check if the first character is escaped + - check if the next character is a space (if not allow_space) + - check if the next character is a digit (if not allow_digits) + - Advance one, if allow_double + - Find closing (advance one, if allow_double) + - Check closing: + - check if the previous character is a space (if not allow_space) + - check if the next character is a digit (if not allow_digits) + - Check empty content + """ + + # TODO options: + # even/odd backslash escaping + + if state.src[state.pos] != "$": + return False + + if not allow_space: + # whitespace not allowed straight after opening $ + try: + if isWhiteSpace(ord(state.src[state.pos + 1])): + return False + except IndexError: + return False + + if not allow_digits: + # digit not allowed straight before opening $ + try: + if state.src[state.pos - 1].isdigit(): + return False + except IndexError: + pass + + if is_escaped(state, state.pos): + return False + + try: + is_double = allow_double and state.src[state.pos + 1] == "$" + except IndexError: + return False + + # find closing $ + pos = state.pos + 1 + (1 if is_double else 0) + found_closing = False + while not found_closing: + try: + end = state.src.index("$", pos) + except ValueError: + return False + + if is_escaped(state, end): + pos = end + 1 + continue + + try: + if is_double and state.src[end + 1] != "$": + pos = end + 1 + continue + except IndexError: + return False + + if is_double: + end += 1 + + found_closing = True + + if not found_closing: + return False + + if not allow_space: + # whitespace not allowed straight before closing $ + try: + if isWhiteSpace(ord(state.src[end - 1])): + return False + except IndexError: + return False + + if not allow_digits: + # digit not allowed straight after closing $ + try: + if state.src[end + 1].isdigit(): + return False + except IndexError: + pass + + text = ( + state.src[state.pos + 2 : end - 1] + if is_double + else state.src[state.pos + 1 : end] + ) + + # ignore empty + if not text: + return False + + if not silent: + token = state.push( + "math_inline_double" if is_double else "math_inline", "math", 0 + ) + token.content = text + token.markup = "$$" if is_double else "$" + + state.pos = end + 1 + + return True + + return _math_inline_dollar + + +# reversed end of block dollar equation, with equation label +DOLLAR_EQNO_REV = re.compile(r"^\s*\)([^)$\r\n]+?)\(\s*\${2}") + + +def math_block_dollar( + allow_labels: bool = True, + label_normalizer: Callable[[str], str] | None = None, + allow_blank_lines: bool = False, +) -> Callable[[StateBlock, int, int, bool], bool]: + """Generate block dollar rule.""" + + def _math_block_dollar( + state: StateBlock, startLine: int, endLine: int, silent: bool + ) -> bool: + # TODO internal backslash escaping + + if is_code_block(state, startLine): + return False + + haveEndMarker = False + startPos = state.bMarks[startLine] + state.tShift[startLine] + end = state.eMarks[startLine] + + if startPos + 2 > end: + return False + + if state.src[startPos] != "$" or state.src[startPos + 1] != "$": + return False + + # search for end of block + nextLine = startLine + label = None + + # search for end of block on same line + lineText = state.src[startPos:end] + if len(lineText.strip()) > 3: + if lineText.strip().endswith("$$"): + haveEndMarker = True + end = end - 2 - (len(lineText) - len(lineText.strip())) + elif allow_labels: + # reverse the line and match + eqnoMatch = DOLLAR_EQNO_REV.match(lineText[::-1]) + if eqnoMatch: + haveEndMarker = True + label = eqnoMatch.group(1)[::-1] + end = end - eqnoMatch.end() + + # search for end of block on subsequent line + if not haveEndMarker: + while True: + nextLine += 1 + if nextLine >= endLine: + break + + start = state.bMarks[nextLine] + state.tShift[nextLine] + end = state.eMarks[nextLine] + + lineText = state.src[start:end] + + if lineText.strip().endswith("$$"): + haveEndMarker = True + end = end - 2 - (len(lineText) - len(lineText.strip())) + break + if lineText.strip() == "" and not allow_blank_lines: + break # blank lines are not allowed within $$ + + # reverse the line and match + if allow_labels: + eqnoMatch = DOLLAR_EQNO_REV.match(lineText[::-1]) + if eqnoMatch: + haveEndMarker = True + label = eqnoMatch.group(1)[::-1] + end = end - eqnoMatch.end() + break + + if not haveEndMarker: + return False + + state.line = nextLine + (1 if haveEndMarker else 0) + + token = state.push("math_block_label" if label else "math_block", "math", 0) + token.block = True + token.content = state.src[startPos + 2 : end] + token.markup = "$$" + token.map = [startLine, state.line] + if label: + token.info = label if label_normalizer is None else label_normalizer(label) + + return True + + return _math_block_dollar diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/field_list/__init__.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/field_list/__init__.py new file mode 100644 index 00000000000..60745d74bba --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/field_list/__init__.py @@ -0,0 +1,255 @@ +"""Field list plugin""" + +from collections.abc import Iterator +from contextlib import contextmanager + +from markdown_it import MarkdownIt +from markdown_it.rules_block import StateBlock + +from mdit_py_plugins.utils import is_code_block + + +def fieldlist_plugin(md: MarkdownIt) -> None: + """Field lists are mappings from field names to field bodies, based on the + `reStructureText syntax + <https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#field-lists>`_. + + .. code-block:: md + + :name *markup*: + :name1: body content + :name2: paragraph 1 + + paragraph 2 + :name3: + paragraph 1 + + paragraph 2 + + A field name may consist of any characters except colons (":"). + Inline markup is parsed in field names. + + The field name is followed by whitespace and the field body. + The field body may be empty or contain multiple body elements. + + Since the field marker may be quite long, + the second and subsequent lines of the field body do not have to + line up with the first line, but they must be indented relative to the + field name marker, and they must line up with each other. + """ + md.block.ruler.before( + "paragraph", + "fieldlist", + _fieldlist_rule, + {"alt": ["paragraph", "reference", "blockquote"]}, + ) + + +def parseNameMarker(state: StateBlock, startLine: int) -> tuple[int, str]: + """Parse field name: `:name:` + + :returns: position after name marker, name text + """ + start = state.bMarks[startLine] + state.tShift[startLine] + pos = start + maximum = state.eMarks[startLine] + + # marker should have at least 3 chars (colon + character + colon) + if pos + 2 >= maximum: + return -1, "" + + # first character should be ':' + if state.src[pos] != ":": + return -1, "" + + # scan name length + name_length = 1 + found_close = False + for ch in state.src[pos + 1 :]: + if ch == "\n": + break + if ch == ":": + # TODO backslash escapes + found_close = True + break + name_length += 1 + + if not found_close: + return -1, "" + + # get name + name_text = state.src[pos + 1 : pos + name_length] + + # name should contain at least one character + if not name_text.strip(): + return -1, "" + + return pos + name_length + 1, name_text + + +@contextmanager +def set_parent_type(state: StateBlock, name: str) -> Iterator[None]: + """Temporarily set parent type to `name`""" + oldParentType = state.parentType + state.parentType = name + yield + state.parentType = oldParentType + + +def _fieldlist_rule( + state: StateBlock, startLine: int, endLine: int, silent: bool +) -> bool: + # adapted from markdown_it/rules_block/list.py::list_block + + if is_code_block(state, startLine): + return False + + posAfterName, name_text = parseNameMarker(state, startLine) + if posAfterName < 0: + return False + + # For validation mode we can terminate immediately + if silent: + return True + + # start field list + token = state.push("field_list_open", "dl", 1) + token.attrSet("class", "field-list") + token.map = listLines = [startLine, 0] + + # iterate list items + nextLine = startLine + + with set_parent_type(state, "fieldlist"): + while nextLine < endLine: + # create name tokens + token = state.push("fieldlist_name_open", "dt", 1) + token.map = [startLine, startLine] + token = state.push("inline", "", 0) + token.map = [startLine, startLine] + token.content = name_text + token.children = [] + token = state.push("fieldlist_name_close", "dt", -1) + + # set indent positions + pos = posAfterName + maximum: int = state.eMarks[nextLine] + first_line_body_indent = ( + state.sCount[nextLine] + + posAfterName + - (state.bMarks[startLine] + state.tShift[startLine]) + ) + + # find indent to start of body on first line + while pos < maximum: + ch = state.src[pos] + + if ch == "\t": + first_line_body_indent += ( + 4 - (first_line_body_indent + state.bsCount[nextLine]) % 4 + ) + elif ch == " ": + first_line_body_indent += 1 + else: + break + + pos += 1 + + contentStart = pos + + # to figure out the indent of the body, + # we look at all non-empty, indented lines and find the minimum indent + block_indent: int | None = None + _line = startLine + 1 + while _line < endLine: + # if start_of_content < end_of_content, then non-empty line + if (state.bMarks[_line] + state.tShift[_line]) < state.eMarks[_line]: + if state.tShift[_line] <= 0: + # the line has no indent, so it's the end of the field + break + block_indent = ( + state.tShift[_line] + if block_indent is None + else min(block_indent, state.tShift[_line]) + ) + + _line += 1 + + has_first_line = contentStart < maximum + if block_indent is None: # no body content + if not has_first_line: # noqa: SIM108 + # no body or first line, so just use default + block_indent = 2 + else: + # only a first line, so use it's indent + block_indent = first_line_body_indent + else: + block_indent = min(block_indent, first_line_body_indent) + + # Run subparser on the field body + token = state.push("fieldlist_body_open", "dd", 1) + token.map = [startLine, startLine] + + with temp_state_changes(state, startLine): + diff = 0 + if has_first_line and block_indent < first_line_body_indent: + # this is a hack to get the first line to render correctly + # we temporarily "shift" it to the left by the difference + # between the first line indent and the block indent + # and replace the "hole" left with space, + # so that src indexes still match + diff = first_line_body_indent - block_indent + state.src = ( + state.src[: contentStart - diff] + + " " * diff + + state.src[contentStart:] + ) + + state.tShift[startLine] = contentStart - diff - state.bMarks[startLine] + state.sCount[startLine] = first_line_body_indent - diff + state.blkIndent = block_indent + + state.md.block.tokenize(state, startLine, endLine) + + state.push("fieldlist_body_close", "dd", -1) + + nextLine = startLine = state.line + token.map[1] = nextLine + + if nextLine >= endLine: + break + + contentStart = state.bMarks[startLine] + + # Try to check if list is terminated or continued. + if state.sCount[nextLine] < state.blkIndent: + break + + if is_code_block(state, startLine): + break + + # get next field item + posAfterName, name_text = parseNameMarker(state, startLine) + if posAfterName < 0: + break + + # Finalize list + token = state.push("field_list_close", "dl", -1) + listLines[1] = nextLine + state.line = nextLine + + return True + + +@contextmanager +def temp_state_changes(state: StateBlock, startLine: int) -> Iterator[None]: + """Allow temporarily changing certain state attributes.""" + oldTShift = state.tShift[startLine] + oldSCount = state.sCount[startLine] + oldBlkIndent = state.blkIndent + oldSrc = state.src + yield + state.blkIndent = oldBlkIndent + state.tShift[startLine] = oldTShift + state.sCount[startLine] = oldSCount + state.src = oldSrc diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/footnote/LICENSE b/contrib/python/mdit-py-plugins/mdit_py_plugins/footnote/LICENSE new file mode 100644 index 00000000000..2fd4e3dc74c --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/footnote/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2014-2015 Vitaly Puzrin, Alex Kocharin. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/footnote/__init__.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/footnote/__init__.py new file mode 100644 index 00000000000..c172629f88b --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/footnote/__init__.py @@ -0,0 +1,3 @@ +from .index import footnote_plugin + +__all__ = ("footnote_plugin",) diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/footnote/index.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/footnote/index.py new file mode 100644 index 00000000000..67c0b676ee5 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/footnote/index.py @@ -0,0 +1,526 @@ +"""Process footnotes""" + +from __future__ import annotations + +from collections.abc import Sequence +from functools import partial +from typing import TYPE_CHECKING, TypedDict + +from markdown_it import MarkdownIt +from markdown_it.helpers import parseLinkLabel +from markdown_it.rules_block import StateBlock +from markdown_it.rules_core import StateCore +from markdown_it.rules_inline import StateInline +from markdown_it.token import Token + +from mdit_py_plugins.utils import is_code_block + +if TYPE_CHECKING: + from markdown_it.renderer import RendererProtocol + from markdown_it.utils import EnvType, OptionsDict + + +def footnote_plugin( + md: MarkdownIt, + *, + inline: bool = True, + move_to_end: bool = True, + always_match_refs: bool = False, +) -> None: + """Plugin ported from + `markdown-it-footnote <https://github.com/markdown-it/markdown-it-footnote>`__. + + It is based on the + `pandoc definition <http://johnmacfarlane.net/pandoc/README.html#footnotes>`__: + + .. code-block:: md + + Normal footnote: + + Here is a footnote reference,[^1] and another.[^longnote] + + [^1]: Here is the footnote. + + [^longnote]: Here's one with multiple blocks. + + Subsequent paragraphs are indented to show that they + belong to the previous footnote. + + :param inline: If True, also parse inline footnotes (^[...]). + :param move_to_end: If True, move footnote definitions to the end of the token stream. + :param always_match_refs: If True, match references, even if the footnote is not defined. + + """ + md.block.ruler.before( + "reference", "footnote_def", footnote_def, {"alt": ["paragraph", "reference"]} + ) + _footnote_ref = partial(footnote_ref, always_match=always_match_refs) + if inline: + md.inline.ruler.after("image", "footnote_inline", footnote_inline) + md.inline.ruler.after("footnote_inline", "footnote_ref", _footnote_ref) + else: + md.inline.ruler.after("image", "footnote_ref", _footnote_ref) + if move_to_end: + md.core.ruler.after("inline", "footnote_tail", footnote_tail) + + md.add_render_rule("footnote_ref", render_footnote_ref) + md.add_render_rule("footnote_block_open", render_footnote_block_open) + md.add_render_rule("footnote_block_close", render_footnote_block_close) + md.add_render_rule("footnote_open", render_footnote_open) + md.add_render_rule("footnote_close", render_footnote_close) + md.add_render_rule("footnote_anchor", render_footnote_anchor) + + # helpers (only used in other rules, no tokens are attached to those) + md.add_render_rule("footnote_caption", render_footnote_caption) + md.add_render_rule("footnote_anchor_name", render_footnote_anchor_name) + + +class _RefData(TypedDict, total=False): + # standard + label: str + count: int + # inline + content: str + tokens: list[Token] + + +class _FootnoteData(TypedDict): + refs: dict[str, int] + """A mapping of all footnote labels (prefixed with ``:``) to their ID (-1 if not yet set).""" + list: dict[int, _RefData] + """A mapping of all footnote IDs to their data.""" + + +def _data_from_env(env: EnvType) -> _FootnoteData: + footnotes = env.setdefault("footnotes", {}) + footnotes.setdefault("refs", {}) + footnotes.setdefault("list", {}) + return footnotes # type: ignore[no-any-return] + + +# ## RULES ## + + +def footnote_def(state: StateBlock, startLine: int, endLine: int, silent: bool) -> bool: + """Process footnote block definition""" + + if is_code_block(state, startLine): + return False + + start = state.bMarks[startLine] + state.tShift[startLine] + maximum = state.eMarks[startLine] + + # line should be at least 5 chars - "[^x]:" + if start + 4 > maximum: + return False + + if state.src[start] != "[": + return False + if state.src[start + 1] != "^": + return False + + pos = start + 2 + while pos < maximum: + if state.src[pos] == " ": + return False + if state.src[pos] == "]": + break + pos += 1 + + if pos == start + 2: # no empty footnote labels + return False + pos += 1 + if pos >= maximum or state.src[pos] != ":": + return False + if silent: + return True + pos += 1 + + label = state.src[start + 2 : pos - 2] + footnote_data = _data_from_env(state.env) + footnote_data["refs"][":" + label] = -1 + + open_token = Token("footnote_reference_open", "", 1) + open_token.meta = {"label": label} + open_token.level = state.level + state.level += 1 + state.tokens.append(open_token) + + oldBMark = state.bMarks[startLine] + oldTShift = state.tShift[startLine] + oldSCount = state.sCount[startLine] + oldParentType = state.parentType + + posAfterColon = pos + initial = offset = ( + state.sCount[startLine] + + pos + - (state.bMarks[startLine] + state.tShift[startLine]) + ) + + while pos < maximum: + ch = state.src[pos] + + if ch == "\t": + offset += 4 - offset % 4 + elif ch == " ": + offset += 1 + + else: + break + + pos += 1 + + state.tShift[startLine] = pos - posAfterColon + state.sCount[startLine] = offset - initial + + state.bMarks[startLine] = posAfterColon + state.blkIndent += 4 + state.parentType = "footnote" + + if state.sCount[startLine] < state.blkIndent: + state.sCount[startLine] += state.blkIndent + + state.md.block.tokenize(state, startLine, endLine) + + state.parentType = oldParentType + state.blkIndent -= 4 + state.tShift[startLine] = oldTShift + state.sCount[startLine] = oldSCount + state.bMarks[startLine] = oldBMark + + open_token.map = [startLine, state.line] + + token = Token("footnote_reference_close", "", -1) + state.level -= 1 + token.level = state.level + state.tokens.append(token) + + return True + + +def footnote_inline(state: StateInline, silent: bool) -> bool: + """Process inline footnotes (^[...])""" + + maximum = state.posMax + start = state.pos + + if start + 2 >= maximum: + return False + if state.src[start] != "^": + return False + if state.src[start + 1] != "[": + return False + + labelStart = start + 2 + labelEnd = parseLinkLabel(state, start + 1) + + # parser failed to find ']', so it's not a valid note + if labelEnd < 0: + return False + + # We found the end of the link, and know for a fact it's a valid link + # so all that's left to do is to call tokenizer. + # + if not silent: + refs = _data_from_env(state.env)["list"] + footnoteId = len(refs) + + tokens: list[Token] = [] + state.md.inline.parse( + state.src[labelStart:labelEnd], state.md, state.env, tokens + ) + + token = state.push("footnote_ref", "", 0) + token.meta = {"id": footnoteId} + + refs[footnoteId] = {"content": state.src[labelStart:labelEnd], "tokens": tokens} + + state.pos = labelEnd + 1 + state.posMax = maximum + return True + + +def footnote_ref( + state: StateInline, silent: bool, *, always_match: bool = False +) -> bool: + """Process footnote references ([^...])""" + + maximum = state.posMax + start = state.pos + + # should be at least 4 chars - "[^x]" + if start + 3 > maximum: + return False + + footnote_data = _data_from_env(state.env) + + if not (always_match or footnote_data["refs"]): + return False + if state.src[start] != "[": + return False + if state.src[start + 1] != "^": + return False + + pos = start + 2 + while pos < maximum: + if state.src[pos] in (" ", "\n"): + return False + if state.src[pos] == "]": + break + pos += 1 + + if pos == start + 2: # no empty footnote labels + return False + if pos >= maximum: + return False + pos += 1 + + label = state.src[start + 2 : pos - 1] + if ((":" + label) not in footnote_data["refs"]) and not always_match: + return False + + if not silent: + if footnote_data["refs"].get(":" + label, -1) < 0: + footnoteId = len(footnote_data["list"]) + footnote_data["list"][footnoteId] = {"label": label, "count": 0} + footnote_data["refs"][":" + label] = footnoteId + else: + footnoteId = footnote_data["refs"][":" + label] + + footnoteSubId = footnote_data["list"][footnoteId]["count"] + footnote_data["list"][footnoteId]["count"] += 1 + + token = state.push("footnote_ref", "", 0) + token.meta = {"id": footnoteId, "subId": footnoteSubId, "label": label} + + state.pos = pos + state.posMax = maximum + return True + + +def footnote_tail(state: StateCore) -> None: + """Post-processing step, to move footnote tokens to end of the token stream. + + Also removes un-referenced tokens. + """ + + insideRef = False + refTokens = {} + + if "footnotes" not in state.env: + return + + current: list[Token] = [] + tok_filter = [] + for tok in state.tokens: + if tok.type == "footnote_reference_open": + insideRef = True + current = [] + currentLabel = tok.meta["label"] + tok_filter.append(False) + continue + + if tok.type == "footnote_reference_close": + insideRef = False + # prepend ':' to avoid conflict with Object.prototype members + refTokens[":" + currentLabel] = current + tok_filter.append(False) + continue + + if insideRef: + current.append(tok) + + tok_filter.append(not insideRef) + + state.tokens = [t for t, f in zip(state.tokens, tok_filter, strict=False) if f] + + footnote_data = _data_from_env(state.env) + if not footnote_data["list"]: + return + + token = Token("footnote_block_open", "", 1) + state.tokens.append(token) + + for i, foot_note in footnote_data["list"].items(): + token = Token("footnote_open", "", 1) + token.meta = {"id": i, "label": foot_note.get("label", None)} + # TODO propagate line positions of original foot note + # (but don't store in token.map, because this is used for scroll syncing) + state.tokens.append(token) + + if "tokens" in foot_note: + tokens = [] + + token = Token("paragraph_open", "p", 1) + token.block = True + tokens.append(token) + + token = Token("inline", "", 0) + token.children = foot_note["tokens"] + token.content = foot_note["content"] + tokens.append(token) + + token = Token("paragraph_close", "p", -1) + token.block = True + tokens.append(token) + + elif "label" in foot_note: + tokens = refTokens.get(":" + foot_note["label"], []) + + state.tokens.extend(tokens) + if state.tokens[len(state.tokens) - 1].type == "paragraph_close": + lastParagraph: Token | None = state.tokens.pop() + else: + lastParagraph = None + + t = ( + foot_note["count"] + if (("count" in foot_note) and (foot_note["count"] > 0)) + else 1 + ) + j = 0 + while j < t: + token = Token("footnote_anchor", "", 0) + token.meta = {"id": i, "subId": j, "label": foot_note.get("label", None)} + state.tokens.append(token) + j += 1 + + if lastParagraph: + state.tokens.append(lastParagraph) + + token = Token("footnote_close", "", -1) + state.tokens.append(token) + + token = Token("footnote_block_close", "", -1) + state.tokens.append(token) + + +######################################## +# Renderer partials + + +def render_footnote_anchor_name( + self: RendererProtocol, + tokens: Sequence[Token], + idx: int, + options: OptionsDict, + env: EnvType, +) -> str: + n = str(tokens[idx].meta["id"] + 1) + prefix = "" + + doc_id = env.get("docId", None) + if isinstance(doc_id, str): + prefix = f"-{doc_id}-" + + return prefix + n + + +def render_footnote_caption( + self: RendererProtocol, + tokens: Sequence[Token], + idx: int, + options: OptionsDict, + env: EnvType, +) -> str: + n = str(tokens[idx].meta["id"] + 1) + + if tokens[idx].meta.get("subId", -1) > 0: + n += ":" + str(tokens[idx].meta["subId"]) + + return "[" + n + "]" + + +def render_footnote_ref( + self: RendererProtocol, + tokens: Sequence[Token], + idx: int, + options: OptionsDict, + env: EnvType, +) -> str: + ident: str = self.rules["footnote_anchor_name"](tokens, idx, options, env) # type: ignore[attr-defined] + caption: str = self.rules["footnote_caption"](tokens, idx, options, env) # type: ignore[attr-defined] + refid = ident + + if tokens[idx].meta.get("subId", -1) > 0: + refid += ":" + str(tokens[idx].meta["subId"]) + + return ( + '<sup class="footnote-ref"><a href="#fn' + + ident + + '" id="fnref' + + refid + + '">' + + caption + + "</a></sup>" + ) + + +def render_footnote_block_open( + self: RendererProtocol, + tokens: Sequence[Token], + idx: int, + options: OptionsDict, + env: EnvType, +) -> str: + return ( + ( + '<hr class="footnotes-sep" />\n' + if options.xhtmlOut + else '<hr class="footnotes-sep">\n' + ) + + '<section class="footnotes">\n' + + '<ol class="footnotes-list">\n' + ) + + +def render_footnote_block_close( + self: RendererProtocol, + tokens: Sequence[Token], + idx: int, + options: OptionsDict, + env: EnvType, +) -> str: + return "</ol>\n</section>\n" + + +def render_footnote_open( + self: RendererProtocol, + tokens: Sequence[Token], + idx: int, + options: OptionsDict, + env: EnvType, +) -> str: + ident: str = self.rules["footnote_anchor_name"](tokens, idx, options, env) # type: ignore[attr-defined] + + if tokens[idx].meta.get("subId", -1) > 0: + ident += ":" + tokens[idx].meta["subId"] + + return '<li id="fn' + ident + '" class="footnote-item">' + + +def render_footnote_close( + self: RendererProtocol, + tokens: Sequence[Token], + idx: int, + options: OptionsDict, + env: EnvType, +) -> str: + return "</li>\n" + + +def render_footnote_anchor( + self: RendererProtocol, + tokens: Sequence[Token], + idx: int, + options: OptionsDict, + env: EnvType, +) -> str: + ident: str = self.rules["footnote_anchor_name"](tokens, idx, options, env) # type: ignore[attr-defined] + + if tokens[idx].meta["subId"] > 0: + ident += ":" + str(tokens[idx].meta["subId"]) + + # ↩ with escape code to prevent display as Apple Emoji on iOS + return ' <a href="#fnref' + ident + '" class="footnote-backref">\u21a9\ufe0e</a>' diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/footnote/port.yaml b/contrib/python/mdit-py-plugins/mdit_py_plugins/footnote/port.yaml new file mode 100644 index 00000000000..722f5e440dc --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/footnote/port.yaml @@ -0,0 +1,4 @@ +- package: markdown-it-footnote + commit: cab6665ba39c6eb517cbbae3baeb549004bf740c + date: Jul 9, 2019 + version: 3.0.2 diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/front_matter/LICENSE b/contrib/python/mdit-py-plugins/mdit_py_plugins/front_matter/LICENSE new file mode 100644 index 00000000000..54c0b84195f --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/front_matter/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2016-2020 ParkSB. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/front_matter/__init__.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/front_matter/__init__.py new file mode 100644 index 00000000000..7475c01d27e --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/front_matter/__init__.py @@ -0,0 +1,3 @@ +from .index import front_matter_plugin + +__all__ = ("front_matter_plugin",) diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/front_matter/index.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/front_matter/index.py new file mode 100644 index 00000000000..1551d76eaa2 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/front_matter/index.py @@ -0,0 +1,128 @@ +"""Process front matter.""" + +from markdown_it import MarkdownIt +from markdown_it.rules_block import StateBlock + +from mdit_py_plugins.utils import is_code_block + + +def front_matter_plugin(md: MarkdownIt) -> None: + """Plugin ported from + `markdown-it-front-matter <https://github.com/ParkSB/markdown-it-front-matter>`__. + + It parses initial metadata, stored between opening/closing dashes: + + .. code-block:: md + + --- + valid-front-matter: true + --- + + """ + md.block.ruler.before( + "table", + "front_matter", + _front_matter_rule, + {"alt": ["paragraph", "reference", "blockquote", "list"]}, + ) + + +def _front_matter_rule( + state: StateBlock, startLine: int, endLine: int, silent: bool +) -> bool: + marker_chr = "-" + min_markers = 3 + + auto_closed = False + start = state.bMarks[startLine] + state.tShift[startLine] + maximum = state.eMarks[startLine] + src_len = len(state.src) + + # Check out the first character of the first line quickly, + # this should filter out non-front matter + if startLine != 0 or state.src[0] != marker_chr: + return False + + # Check out the rest of the marker string + # while pos <= 3 + pos = start + 1 + while pos <= maximum and pos < src_len: + if state.src[pos] != marker_chr: + break + pos += 1 + + marker_count = pos - start + + if marker_count < min_markers: + return False + + # Since start is found, we can report success here in validation mode + if silent: + return True + + # Search for the end of the block + nextLine = startLine + + while True: + nextLine += 1 + if nextLine >= endLine: + # unclosed block should be autoclosed by end of document. + return False + + if state.src[start:maximum] == "...": + break + + start = state.bMarks[nextLine] + state.tShift[nextLine] + maximum = state.eMarks[nextLine] + + if start < maximum and state.sCount[nextLine] < state.blkIndent: + # non-empty line with negative indent should stop the list: + # - ``` + # test + break + + if state.src[start] != marker_chr: + continue + + if is_code_block(state, nextLine): + continue + + pos = start + 1 + while pos < maximum: + if state.src[pos] != marker_chr: + break + pos += 1 + + # closing code fence must be at least as long as the opening one + if (pos - start) < marker_count: + continue + + # make sure tail has spaces only + pos = state.skipSpaces(pos) + + if pos < maximum: + continue + + # found! + auto_closed = True + break + + old_parent = state.parentType + old_line_max = state.lineMax + state.parentType = "container" + + # this will prevent lazy continuations from ever going past our end marker + state.lineMax = nextLine + + token = state.push("front_matter", "", 0) + token.hidden = True + token.markup = marker_chr * min_markers + token.content = state.src[state.bMarks[startLine + 1] : state.eMarks[nextLine - 1]] + token.block = True + + state.parentType = old_parent + state.lineMax = old_line_max + state.line = nextLine + (1 if auto_closed else 0) + token.map = [startLine, state.line] + + return True diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/front_matter/port.yaml b/contrib/python/mdit-py-plugins/mdit_py_plugins/front_matter/port.yaml new file mode 100644 index 00000000000..f7d145f10d3 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/front_matter/port.yaml @@ -0,0 +1,4 @@ +- package: markdown-it-front-matter + commit: b404f5d8fd536e7e9ddb276267ae0b6f76e9cf9d + date: Feb 7, 2020 + version: 0.2.1 diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/myst_blocks/__init__.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/myst_blocks/__init__.py new file mode 100644 index 00000000000..207afecca20 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/myst_blocks/__init__.py @@ -0,0 +1,3 @@ +from .index import myst_block_plugin + +__all__ = ("myst_block_plugin",) diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/myst_blocks/index.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/myst_blocks/index.py new file mode 100644 index 00000000000..11fc0fbdc21 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/myst_blocks/index.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +from collections.abc import Sequence +import itertools +from typing import TYPE_CHECKING + +from markdown_it import MarkdownIt +from markdown_it.common.utils import escapeHtml +from markdown_it.rules_block import StateBlock + +from mdit_py_plugins.utils import is_code_block + +if TYPE_CHECKING: + from markdown_it.renderer import RendererProtocol + from markdown_it.token import Token + from markdown_it.utils import EnvType, OptionsDict + + +def myst_block_plugin(md: MarkdownIt) -> None: + """Parse MyST targets (``(name)=``), blockquotes (``% comment``) and block breaks (``+++``).""" + md.block.ruler.before( + "blockquote", + "myst_line_comment", + line_comment, + {"alt": ["paragraph", "reference", "blockquote", "list", "footnote_def"]}, + ) + md.block.ruler.before( + "hr", + "myst_block_break", + block_break, + {"alt": ["paragraph", "reference", "blockquote", "list", "footnote_def"]}, + ) + md.block.ruler.before( + "hr", + "myst_target", + target, + {"alt": ["paragraph", "reference", "blockquote", "list", "footnote_def"]}, + ) + md.add_render_rule("myst_target", render_myst_target) + md.add_render_rule("myst_line_comment", render_myst_line_comment) + + +def line_comment(state: StateBlock, startLine: int, endLine: int, silent: bool) -> bool: + if is_code_block(state, startLine): + return False + + pos = state.bMarks[startLine] + state.tShift[startLine] + maximum = state.eMarks[startLine] + + if state.src[pos] != "%": + return False + + if silent: + return True + + token = state.push("myst_line_comment", "", 0) + token.attrSet("class", "myst-line-comment") + token.content = state.src[pos + 1 : maximum].rstrip() + token.markup = "%" + + # search end of block while appending lines to `token.content` + for nextLine in itertools.count(startLine + 1): + if nextLine >= endLine: + break + pos = state.bMarks[nextLine] + state.tShift[nextLine] + maximum = state.eMarks[nextLine] + + if state.src[pos] != "%": + break + token.content += "\n" + state.src[pos + 1 : maximum].rstrip() + + state.line = nextLine + token.map = [startLine, nextLine] + + return True + + +def block_break(state: StateBlock, startLine: int, endLine: int, silent: bool) -> bool: + if is_code_block(state, startLine): + return False + + pos = state.bMarks[startLine] + state.tShift[startLine] + maximum = state.eMarks[startLine] + + marker = state.src[pos] + pos += 1 + + # Check block marker + if marker != "+": + return False + + # markers can be mixed with spaces, but there should be at least 3 of them + + cnt = 1 + while pos < maximum: + ch = state.src[pos] + if ch != marker and ch not in ("\t", " "): + break + if ch == marker: + cnt += 1 + pos += 1 + + if cnt < 3: + return False + + if silent: + return True + + state.line = startLine + 1 + + token = state.push("myst_block_break", "hr", 0) + token.attrSet("class", "myst-block") + token.content = state.src[pos:maximum].strip() + token.map = [startLine, state.line] + token.markup = marker * cnt + + return True + + +def target(state: StateBlock, startLine: int, endLine: int, silent: bool) -> bool: + if is_code_block(state, startLine): + return False + + pos = state.bMarks[startLine] + state.tShift[startLine] + maximum = state.eMarks[startLine] + + text = state.src[pos:maximum].strip() + if not text.startswith("("): + return False + if not text.endswith(")="): + return False + if not text[1:-2]: + return False + + if silent: + return True + + state.line = startLine + 1 + + token = state.push("myst_target", "", 0) + token.attrSet("class", "myst-target") + token.content = text[1:-2] + token.map = [startLine, state.line] + + return True + + +def render_myst_target( + self: RendererProtocol, + tokens: Sequence[Token], + idx: int, + options: OptionsDict, + env: EnvType, +) -> str: + label = tokens[idx].content + class_name = "myst-target" + target = f'<a href="#{label}">({label})=</a>' + return f'<div class="{class_name}">{target}</div>' + + +def render_myst_line_comment( + self: RendererProtocol, + tokens: Sequence[Token], + idx: int, + options: OptionsDict, + env: EnvType, +) -> str: + # Strip leading whitespace from all lines + content = "\n".join(line.lstrip() for line in tokens[idx].content.split("\n")) + return f"<!-- {escapeHtml(content)} -->" diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/myst_role/__init__.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/myst_role/__init__.py new file mode 100644 index 00000000000..12e07eeea1b --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/myst_role/__init__.py @@ -0,0 +1,3 @@ +from .index import myst_role_plugin + +__all__ = ("myst_role_plugin",) diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/myst_role/index.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/myst_role/index.py new file mode 100644 index 00000000000..97a5eb9a8c6 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/myst_role/index.py @@ -0,0 +1,75 @@ +from collections.abc import Sequence +import re +from typing import TYPE_CHECKING + +from markdown_it import MarkdownIt +from markdown_it.common.utils import escapeHtml +from markdown_it.rules_inline import StateInline + +if TYPE_CHECKING: + from markdown_it.renderer import RendererProtocol + from markdown_it.token import Token + from markdown_it.utils import EnvType, OptionsDict + +VALID_NAME_PATTERN = re.compile(r"^\{([a-zA-Z0-9\_\-\+\:]+)\}") + + +def myst_role_plugin(md: MarkdownIt) -> None: + """Parse ``{role-name}`content```""" + md.inline.ruler.before("backticks", "myst_role", myst_role) + md.add_render_rule("myst_role", render_myst_role) + + +def myst_role(state: StateInline, silent: bool) -> bool: + # check name + match = VALID_NAME_PATTERN.match(state.src[state.pos :]) + if not match: + return False + name = match.group(1) + + # check for starting backslash escape + try: + if state.src[state.pos - 1] == "\\": + # escaped (this could be improved in the case of edge case '\\{') + return False + except IndexError: + pass + + # scan opening tick length + start = pos = state.pos + match.end() + try: + while state.src[pos] == "`": + pos += 1 + except IndexError: + return False + + tick_length = pos - start + if not tick_length: + return False + + # search for closing ticks + match = re.search("`" * tick_length, state.src[pos + 1 :]) + if not match: + return False + content = state.src[pos : pos + match.start() + 1].replace("\n", " ") + + if not silent: + token = state.push("myst_role", "", 0) + token.meta = {"name": name} + token.content = content + + state.pos = pos + match.end() + 1 + + return True + + +def render_myst_role( + self: "RendererProtocol", + tokens: Sequence["Token"], + idx: int, + options: "OptionsDict", + env: "EnvType", +) -> str: + token = tokens[idx] + name = token.meta.get("name", "unknown") + return f'<code class="myst role">{{{name}}}[{escapeHtml(token.content)}]</code>' diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/py.typed b/contrib/python/mdit-py-plugins/mdit_py_plugins/py.typed new file mode 100644 index 00000000000..7632ecf7754 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/subscript/__init__.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/subscript/__init__.py new file mode 100644 index 00000000000..cc41fa39f91 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/subscript/__init__.py @@ -0,0 +1,117 @@ +""" +Markdown-it-py plugin to introduce <sub> markup using ~subscript~. + +Ported from +https://github.com/markdown-it/markdown-it-sub/blob/master/index.mjs + +Originally ported during implementation of https://github.com/hasgeek/funnel/blob/main/funnel/utils/markdown/mdit_plugins/sub_tag.py +""" + +from __future__ import annotations + +from collections.abc import Sequence +import re + +from markdown_it import MarkdownIt +from markdown_it.renderer import RendererHTML +from markdown_it.rules_inline import StateInline +from markdown_it.token import Token +from markdown_it.utils import EnvType, OptionsDict + +__all__ = ["sub_plugin"] + +TILDE_CHAR = "~" + +WHITESPACE_RE = re.compile(r"(^|[^\\])(\\\\)*\s") +UNESCAPE_RE = re.compile(r'\\([ \\!"#$%&\'()*+,.\/:;<=>?@[\]^_`{|}~-])') + + +def tokenize(state: StateInline, silent: bool) -> bool: + """Parse a ~subscript~ token.""" + start = state.pos + ch = state.src[start] + maximum = state.posMax + found = False + + # Don't run any pairs in validation mode + if silent: + return False + + if ch != TILDE_CHAR: + return False + + if start + 2 >= maximum: + return False + + state.pos = start + 1 + + while state.pos < maximum: + if state.src[state.pos] == TILDE_CHAR: + found = True + break + state.md.inline.skipToken(state) + + if not found or start + 1 == state.pos: + state.pos = start + return False + + content = state.src[start + 1 : state.pos] + + # Don't allow unescaped spaces/newlines inside + if WHITESPACE_RE.search(content) is not None: + state.pos = start + return False + + # Found a valid pair, so update posMax and pos + state.posMax = state.pos + state.pos = start + 1 + + # Earlier we checked "not silent", but this implementation does not need it + token = state.push("sub_open", "sub", 1) + token.markup = TILDE_CHAR + + token = state.push("text", "", 0) + token.content = UNESCAPE_RE.sub(r"\1", content) + + token = state.push("sub_close", "sub", -1) + token.markup = TILDE_CHAR + + state.pos = state.posMax + 1 + state.posMax = maximum + return True + + +def sub_open( + renderer: RendererHTML, + tokens: Sequence[Token], + idx: int, + options: OptionsDict, + env: EnvType, +) -> str: + """Render the opening tag for a ~subscript~ token.""" + return "<sub>" + + +def sub_close( + renderer: RendererHTML, + tokens: Sequence[Token], + idx: int, + options: OptionsDict, + env: EnvType, +) -> str: + """Render the closing tag for a ~subscript~ token.""" + return "</sub>" + + +def sub_plugin(md: MarkdownIt) -> None: + """ + Markdown-it-py plugin to introduce <sub> markup using ~subscript~. + + Ported from + https://github.com/markdown-it/markdown-it-sub/blob/master/index.mjs + + Originally ported during implementation of https://github.com/hasgeek/funnel/blob/main/funnel/utils/markdown/mdit_plugins/sub_tag.py + """ + md.inline.ruler.after("emphasis", "sub", tokenize) + md.add_render_rule("sub_open", sub_open) + md.add_render_rule("sub_close", sub_close) diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/subscript/port.yaml b/contrib/python/mdit-py-plugins/mdit_py_plugins/subscript/port.yaml new file mode 100644 index 00000000000..896872b7a4a --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/subscript/port.yaml @@ -0,0 +1,9 @@ +- package: markdown-it-sub + commit: 422e93885b3c611234d602aa795f3d75a62cc93e + date: 5 Dec 2023 + version: 3.0.0 + changes: + - TODO - Some strikethrough and subscript combinations are not rendered + correctly in markdown-it either, but that can be fixed at a later stage, + perhaps in both markdown-it and markdown-it-py. + See `tests/fixtures/subscript_strikethrough.md` for examples. diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/substitution.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/substitution.py new file mode 100644 index 00000000000..3b37d44c6ab --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/substitution.py @@ -0,0 +1,111 @@ +from markdown_it import MarkdownIt +from markdown_it.rules_block import StateBlock +from markdown_it.rules_inline import StateInline + +from mdit_py_plugins.utils import is_code_block + + +def substitution_plugin( + md: MarkdownIt, start_delimiter: str = "{", end_delimiter: str = "}" +) -> None: + """A plugin to create substitution tokens. + + These, token should be handled by the renderer. + + Example:: + + {{ block }} + + a {{ inline }} b + + """ + + def _substitution_inline(state: StateInline, silent: bool) -> bool: + try: + if ( + state.src[state.pos] != start_delimiter + or state.src[state.pos + 1] != start_delimiter + ): + return False + except IndexError: + return False + + pos = state.pos + 2 + found_closing = False + while True: + try: + end = state.src.index(end_delimiter, pos) + except ValueError: + return False + try: + if state.src[end + 1] == end_delimiter: + found_closing = True + break + except IndexError: + return False + pos = end + 2 + + if not found_closing: + return False + + text = state.src[state.pos + 2 : end].strip() + state.pos = end + 2 + + if silent: + return True + + token = state.push("substitution_inline", "span", 0) + token.block = False + token.content = text + token.attrSet("class", "substitution") + token.attrSet("text", text) + token.markup = f"{start_delimiter}{end_delimiter}" + + return True + + def _substitution_block( + state: StateBlock, startLine: int, endLine: int, silent: bool + ) -> bool: + if is_code_block(state, startLine): + return False + + startPos = state.bMarks[startLine] + state.tShift[startLine] + end = state.eMarks[startLine] + + lineText = state.src[startPos:end].strip() + + try: + if ( + lineText[0] != start_delimiter + or lineText[1] != start_delimiter + or lineText[-1] != end_delimiter + or lineText[-2] != end_delimiter + or len(lineText) < 5 + ): + return False + except IndexError: + return False + + text = lineText[2:-2].strip() + + # special case if multiple on same line, e.g. {{a}}{{b}} + if (end_delimiter * 2) in text: + return False + + state.line = startLine + 1 + + if silent: + return True + + token = state.push("substitution_block", "div", 0) + token.block = True + token.content = text + token.attrSet("class", "substitution") + token.attrSet("text", text) + token.markup = f"{start_delimiter}{end_delimiter}" + token.map = [startLine, state.line] + + return True + + md.block.ruler.before("fence", "substitution_block", _substitution_block) + md.inline.ruler.before("escape", "substitution_inline", _substitution_inline) diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/tasklists/__init__.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/tasklists/__init__.py new file mode 100644 index 00000000000..d80f475892e --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/tasklists/__init__.py @@ -0,0 +1,151 @@ +"""Builds task/todo lists out of markdown lists with items starting with [ ] or [x]""" + +# Ported by Wolmar Nyberg Åkerström from https://github.com/revin/markdown-it-task-lists +# ISC License +# Copyright (c) 2016, Revin Guillen +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +from __future__ import annotations + +import re +from uuid import uuid4 + +from markdown_it import MarkdownIt +from markdown_it.rules_core import StateCore +from markdown_it.token import Token + +# Regex string to match a whitespace character, as specified in +# https://github.github.com/gfm/#whitespace-character +# (spec version 0.29-gfm (2019-04-06)) +_GFM_WHITESPACE_RE = r"[ \t\n\v\f\r]" + + +def tasklists_plugin( + md: MarkdownIt, + enabled: bool = False, + label: bool = False, + label_after: bool = False, +) -> None: + """Plugin for building task/todo lists out of markdown lists with items starting with [ ] or [x] + .. Nothing else + + For example:: + - [ ] An item that needs doing + - [x] An item that is complete + + The rendered HTML checkboxes are disabled; to change this, pass a truthy value into the enabled + property of the plugin options. + + :param enabled: True enables the rendered checkboxes + :param label: True wraps the rendered list items in a <label> element for UX purposes, + :param label_after: True adds the <label> element after the checkbox. + """ + disable_checkboxes = not enabled + use_label_wrapper = label + use_label_after = label_after + + def fcn(state: StateCore) -> None: + tokens = state.tokens + for i in range(2, len(tokens) - 1): + if is_todo_item(tokens, i): + todoify(tokens[i]) + tokens[i - 2].attrSet( + "class", + "task-list-item" + (" enabled" if not disable_checkboxes else ""), + ) + tokens[parent_token(tokens, i - 2)].attrSet( + "class", "contains-task-list" + ) + + md.core.ruler.after("inline", "github-tasklists", fcn) + + def parent_token(tokens: list[Token], index: int) -> int: + target_level = tokens[index].level - 1 + for i in range(1, index + 1): + if tokens[index - i].level == target_level: + return index - i + return -1 + + def is_todo_item(tokens: list[Token], index: int) -> bool: + return ( + is_inline(tokens[index]) + and is_paragraph(tokens[index - 1]) + and is_list_item(tokens[index - 2]) + and starts_with_todo_markdown(tokens[index]) + ) + + def todoify(token: Token) -> None: + assert token.children is not None + token.children.insert(0, make_checkbox(token)) + token.children[1].content = token.children[1].content[3:] + token.content = token.content[3:] + + if use_label_wrapper: + if use_label_after: + token.children.pop() + + # Replaced number generator from original plugin with uuid. + checklist_id = f"task-item-{uuid4()}" + token.children[0].content = ( + token.children[0].content[0:-1] + f' id="{checklist_id}">' + ) + token.children.append(after_label(token.content, checklist_id)) + else: + token.children.insert(0, begin_label()) + token.children.append(end_label()) + + def make_checkbox(token: Token) -> Token: + checkbox = Token("html_inline", "", 0) + disabled_attr = 'disabled="disabled"' if disable_checkboxes else "" + if token.content.startswith("[ ] "): + checkbox.content = ( + '<input class="task-list-item-checkbox" ' + f'{disabled_attr} type="checkbox">' + ) + elif token.content.startswith("[x] ") or token.content.startswith("[X] "): + checkbox.content = ( + '<input class="task-list-item-checkbox" checked="checked" ' + f'{disabled_attr} type="checkbox">' + ) + return checkbox + + def begin_label() -> Token: + token = Token("html_inline", "", 0) + token.content = "<label>" + return token + + def end_label() -> Token: + token = Token("html_inline", "", 0) + token.content = "</label>" + return token + + def after_label(content: str, checkbox_id: str) -> Token: + token = Token("html_inline", "", 0) + token.content = ( + f'<label class="task-list-item-label" for="{checkbox_id}">{content}</label>' + ) + token.attrs = {"for": checkbox_id} + return token + + def is_inline(token: Token) -> bool: + return token.type == "inline" + + def is_paragraph(token: Token) -> bool: + return token.type == "paragraph_open" + + def is_list_item(token: Token) -> bool: + return token.type == "list_item_open" + + def starts_with_todo_markdown(token: Token) -> bool: + # leading whitespace in a list item is already trimmed off by markdown-it + return re.match(rf"\[[ xX]]{_GFM_WHITESPACE_RE}+", token.content) is not None diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/tasklists/port.yaml b/contrib/python/mdit-py-plugins/mdit_py_plugins/tasklists/port.yaml new file mode 100644 index 00000000000..4ad6da59acd --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/tasklists/port.yaml @@ -0,0 +1,6 @@ +- package: markdown-it-task-lists + commit: 8233e000559fae5a6306009e55332a54a9d3f606 + date: 6 Mar 2018 + version: 2.1.1 + changes: + - Replaced number generator from original plugin with uuid diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/texmath/LICENSE b/contrib/python/mdit-py-plugins/mdit_py_plugins/texmath/LICENSE new file mode 100644 index 00000000000..b88387c65e8 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/texmath/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2013-17 Stefan Goessner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/texmath/README.md b/contrib/python/mdit-py-plugins/mdit_py_plugins/texmath/README.md new file mode 100644 index 00000000000..f79f33563eb --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/texmath/README.md @@ -0,0 +1,137 @@ +[](https://github.com/goessner/markdown-it-texmath/blob/master/licence.txt) +[](https://www.npmjs.com/package/markdown-it-texmath) +[](https://www.npmjs.com/package/markdown-it-texmath) + +# markdown-it-texmath + +Add TeX math equations to your Markdown documents rendered by [markdown-it](https://github.com/markdown-it/markdown-it) parser. [KaTeX](https://github.com/Khan/KaTeX) is used as a fast math renderer. + +## Features +Simplify the process of authoring markdown documents containing math formulas. +This extension is a comfortable tool for scientists, engineers and students with markdown as their first choice document format. + +* Macro support +* Simple formula numbering +* Inline math with tables, lists and blockquote. +* User setting delimiters: + * `'dollars'` (default) + * inline: `$...$` + * display: `$$...$$` + * display + equation number: `$$...$$ (1)` + * `'brackets'` + * inline: `\(...\)` + * display: `\[...\]` + * display + equation number: `\[...\] (1)` + * `'gitlab'` + * inline: ``$`...`$`` + * display: `` ```math ... ``` `` + * display + equation number: `` ```math ... ``` (1)`` + * `'julia'` + * inline: `$...$` or ``` ``...`` ``` + * display: `` ```math ... ``` `` + * display + equation number: `` ```math ... ``` (1)`` + * `'kramdown'` + * inline: ``$$...$$`` + * display: `$$...$$` + * display + equation number: `$$...$$ (1)` + +## Show me + +View a [test table](https://goessner.github.io/markdown-it-texmath/index.html). + +[try it out ...](https://goessner.github.io/markdown-it-texmath/markdown-it-texmath-demo.html) + +## Use with `node.js` + +Install the extension. Verify having `markdown-it` and `katex` already installed . +``` +npm install markdown-it-texmath +``` +Use it with JavaScript. +```js +let kt = require('katex'), + tm = require('markdown-it-texmath').use(kt), + md = require('markdown-it')().use(tm,{delimiters:'dollars',macros:{"\\RR": "\\mathbb{R}"}}); + +md.render('Euler\'s identity \(e^{i\pi}+1=0\) is a beautiful formula in $\\RR 2$.') +``` + +## Use in Browser +```html +<html> +<head> + <meta charset='utf-8'> + <link rel="stylesheet" href="katex.min.css"> + <link rel="stylesheet" href="texmath.css"> + <script src="markdown-it.min.js"></script> + <script src="katex.min.js"></script> + <script src="texmath.js"></script> +</head> +<body> + <div id="out"></div> + <script> + let md; + document.addEventListener("DOMContentLoaded", () => { + const tm = texmath.use(katex); + md = markdownit().use(tm,{delimiters:'dollars',macros:{"\\RR": "\\mathbb{R}"}}); + out.innerHTML = md.render('Euler\'s identity $e^{i\pi}+1=0$ is a beautiful formula in //RR 2.'); + }) + </script> +</body> +</html> +``` +## CDN + +Use following links for `texmath.js` and `texmath.css` +* `https://gitcdn.xyz/cdn/goessner/markdown-it-texmath/master/texmath.js` +* `https://gitcdn.xyz/cdn/goessner/markdown-it-texmath/master/texmath.css` + +## Dependencies + +* [`markdown-it`](https://github.com/markdown-it/markdown-it): Markdown parser done right. Fast and easy to extend. +* [`katex`](https://github.com/Khan/KaTeX): This is where credits for fast rendering TeX math in HTML go to. + +## ToDo + + nothing yet + +## FAQ + +* __`markdown-it-texmath` with React Native does not work, why ?__ + * `markdown-it-texmath` is using regular expressions with `y` [(sticky) property](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/sticky) and cannot avoid this. The use of the `y` flag in regular expressions means the plugin is not compatible with React Native (which as of now doesn't support it and throws an error `Invalid flags supplied to RegExp constructor`). + +## CHANGELOG + +### [0.6.0] on October 04, 2019 +* Add support for [Julia Markdown](https://docs.julialang.org/en/v1/stdlib/Markdown/) on [request](https://github.com/goessner/markdown-it-texmath/issues/15). + +### [0.5.5] on February 07, 2019 +* Remove [rendering bug with brackets delimiters](https://github.com/goessner/markdown-it-texmath/issues/9). + +### [0.5.4] on January 20, 2019 +* Remove pathological [bug within blockquotes](https://github.com/goessner/mdmath/issues/50). + +### [0.5.3] on November 11, 2018 +* Add support for Tex macros (https://katex.org/docs/supported.html#macros) . +* Bug with [brackets delimiters](https://github.com/goessner/markdown-it-texmath/issues/9) . + +### [0.5.2] on September 07, 2018 +* Add support for [Kramdown](https://kramdown.gettalong.org/) . + +### [0.5.0] on August 15, 2018 +* Fatal blockquote bug investigated. Implemented workaround to vscode bug, which has finally gone with vscode 1.26.0 . + +### [0.4.6] on January 05, 2018 +* Escaped underscore bug removed. + +### [0.4.5] on November 06, 2017 +* Backslash bug removed. + +### [0.4.4] on September 27, 2017 +* Modifying the `block` mode regular expression with `gitlab` delimiters, so removing the `newline` bug. + +## License + +`markdown-it-texmath` is licensed under the [MIT License](./license.txt) + + © [Stefan Gössner](https://github.com/goessner) diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/texmath/__init__.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/texmath/__init__.py new file mode 100644 index 00000000000..387a81dd80c --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/texmath/__init__.py @@ -0,0 +1,3 @@ +from .index import texmath_plugin + +__all__ = ("texmath_plugin",) diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/texmath/index.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/texmath/index.py new file mode 100644 index 00000000000..67372fa8f64 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/texmath/index.py @@ -0,0 +1,344 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +import re +from re import Match +from typing import TYPE_CHECKING, Any, TypedDict + +from markdown_it import MarkdownIt +from markdown_it.common.utils import charCodeAt + +if TYPE_CHECKING: + from markdown_it.renderer import RendererProtocol + from markdown_it.rules_block import StateBlock + from markdown_it.rules_inline import StateInline + from markdown_it.token import Token + from markdown_it.utils import EnvType, OptionsDict + + +def texmath_plugin( + md: MarkdownIt, delimiters: str = "dollars", macros: Any = None +) -> None: + """Plugin ported from + `markdown-it-texmath <https://github.com/goessner/markdown-it-texmath>`__. + + It parses TeX math equations set inside opening and closing delimiters: + + .. code-block:: md + + $\\alpha = \\frac{1}{2}$ + + :param delimiters: one of: brackets, dollars, gitlab, julia, kramdown + + """ + macros = macros or {} + + if delimiters in rules: + for rule_inline in rules[delimiters]["inline"]: + md.inline.ruler.before( + "escape", rule_inline["name"], make_inline_func(rule_inline) + ) + + def render_math_inline( + self: RendererProtocol, + tokens: Sequence[Token], + idx: int, + options: OptionsDict, + env: EnvType, + ) -> str: + return rule_inline["tmpl"].format( # noqa: B023 + render(tokens[idx].content, False, macros) + ) + + md.add_render_rule(rule_inline["name"], render_math_inline) + + for rule_block in rules[delimiters]["block"]: + md.block.ruler.before( + "fence", rule_block["name"], make_block_func(rule_block) + ) + + def render_math_block( + self: RendererProtocol, + tokens: Sequence[Token], + idx: int, + options: OptionsDict, + env: EnvType, + ) -> str: + return rule_block["tmpl"].format( # noqa: B023 + render(tokens[idx].content, True, macros), tokens[idx].info + ) + + md.add_render_rule(rule_block["name"], render_math_block) + + +class _RuleDictReqType(TypedDict): + name: str + rex: re.Pattern[str] + tmpl: str + tag: str + + +class RuleDictType(_RuleDictReqType, total=False): + # Note in Python 3.10+ could use Req annotation + pre: Any + post: Any + + +def applyRule( + rule: RuleDictType, string: str, begin: int, inBlockquote: bool +) -> None | Match[str]: + if not ( + string.startswith(rule["tag"], begin) + and (rule["pre"](string, begin) if "pre" in rule else True) + ): + return None + + match = rule["rex"].match(string[begin:]) + + if not match or match.start() != 0: + return None + + lastIndex = match.end() + begin - 1 + if "post" in rule and not ( + rule["post"](string, lastIndex) # valid post-condition + # remove evil blockquote bug (https:#github.com/goessner/mdmath/issues/50) + and (not inBlockquote or "\n" not in match.group(1)) + ): + return None + return match + + +def make_inline_func(rule: RuleDictType) -> Callable[[StateInline, bool], bool]: + def _func(state: StateInline, silent: bool) -> bool: + res = applyRule(rule, state.src, state.pos, False) + if res: + if not silent: + token = state.push(rule["name"], "math", 0) + token.content = res[1] # group 1 from regex .. + token.markup = rule["tag"] + + state.pos += res.end() + + return bool(res) + + return _func + + +def make_block_func(rule: RuleDictType) -> Callable[[StateBlock, int, int, bool], bool]: + def _func(state: StateBlock, begLine: int, endLine: int, silent: bool) -> bool: + begin = state.bMarks[begLine] + state.tShift[begLine] + res = applyRule(rule, state.src, begin, state.parentType == "blockquote") + if res: + if not silent: + token = state.push(rule["name"], "math", 0) + token.block = True + token.content = res[1] + token.info = res[len(res.groups())] + token.markup = rule["tag"] + + line = begLine + endpos = begin + res.end() - 1 + + while line < endLine: + if endpos >= state.bMarks[line] and endpos <= state.eMarks[line]: + # line for end of block math found ... + state.line = line + 1 + break + line += 1 + + return bool(res) + + return _func + + +def dollar_pre(src: str, beg: int) -> bool: + prv = charCodeAt(src[beg - 1], 0) if beg > 0 else False + return ( + (not prv) or (prv != 0x5C and (prv < 0x30 or prv > 0x39)) # no backslash, + ) # no decimal digit .. before opening '$' + + +def dollar_post(src: str, end: int) -> bool: + try: + nxt = src[end + 1] and charCodeAt(src[end + 1], 0) + except IndexError: + return True + return ( + (not nxt) or (nxt < 0x30) or (nxt > 0x39) + ) # no decimal digit .. after closing '$' + + +def render(tex: str, displayMode: bool, macros: Any) -> str: + return tex + # TODO better HTML renderer port for math + # try: + # res = katex.renderToString(tex,{throwOnError:False,displayMode,macros}) + # except: + # res = tex+": "+err.message.replace("<","<") + # return res + + +# def use(katex): # math renderer used ... +# texmath.katex = katex; # ... katex solely at current ... +# return texmath; +# } + + +# All regexes areg global (g) and sticky (y), see: +# https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/sticky + + +rules: dict[str, dict[str, list[RuleDictType]]] = { + "brackets": { + "inline": [ + { + "name": "math_inline", + "rex": re.compile(r"^\\\((.+?)\\\)", re.DOTALL), + "tmpl": "<eq>{0}</eq>", + "tag": "\\(", + } + ], + "block": [ + { + "name": "math_block_eqno", + "rex": re.compile( + r"^\\\[(((?!\\\]|\\\[)[\s\S])+?)\\\]\s*?\(([^)$\r\n]+?)\)", re.M + ), + "tmpl": '<section class="eqno"><eqn>{0}</eqn><span>({1})</span></section>', + "tag": "\\[", + }, + { + "name": "math_block", + "rex": re.compile(r"^\\\[([\s\S]+?)\\\]", re.M), + "tmpl": "<section>\n<eqn>{0}</eqn>\n</section>\n", + "tag": "\\[", + }, + ], + }, + "gitlab": { + "inline": [ + { + "name": "math_inline", + "rex": re.compile(r"^\$`(.+?)`\$"), + "tmpl": "<eq>{0}</eq>", + "tag": "$`", + } + ], + "block": [ + { + "name": "math_block_eqno", + "rex": re.compile( + r"^`{3}math\s+?([^`]+?)\s+?`{3}\s*?\(([^)$\r\n]+?)\)", re.M + ), + "tmpl": '<section class="eqno">\n<eqn>{0}</eqn><span>({1})</span>\n</section>\n', + "tag": "```math", + }, + { + "name": "math_block", + "rex": re.compile(r"^`{3}math\s+?([^`]+?)\s+?`{3}", re.M), + "tmpl": "<section>\n<eqn>{0}</eqn>\n</section>\n", + "tag": "```math", + }, + ], + }, + "julia": { + "inline": [ + { + "name": "math_inline", + "rex": re.compile(r"^`{2}([^`]+?)`{2}"), + "tmpl": "<eq>{0}</eq>", + "tag": "``", + }, + { + "name": "math_inline", + "rex": re.compile(r"^\$(\S[^$\r\n]*?[^\s\\]{1}?)\$"), + "tmpl": "<eq>{0}</eq>", + "tag": "$", + "pre": dollar_pre, + "post": dollar_post, + }, + { + "name": "math_single", + "rex": re.compile(r"^\$([^$\s\\]{1}?)\$"), + "tmpl": "<eq>{0}</eq>", + "tag": "$", + "pre": dollar_pre, + "post": dollar_post, + }, + ], + "block": [ + { + "name": "math_block_eqno", + "rex": re.compile( + r"^`{3}math\s+?([^`]+?)\s+?`{3}\s*?\(([^)$\r\n]+?)\)", re.M + ), + "tmpl": '<section class="eqno"><eqn>{0}</eqn><span>({1})</span></section>', + "tag": "```math", + }, + { + "name": "math_block", + "rex": re.compile(r"^`{3}math\s+?([^`]+?)\s+?`{3}", re.M), + "tmpl": "<section><eqn>{0}</eqn></section>", + "tag": "```math", + }, + ], + }, + "kramdown": { + "inline": [ + { + "name": "math_inline", + "rex": re.compile(r"^\${2}([^$\r\n]*?)\${2}"), + "tmpl": "<eq>{0}</eq>", + "tag": "$$", + } + ], + "block": [ + { + "name": "math_block_eqno", + "rex": re.compile(r"^\${2}([^$]*?)\${2}\s*?\(([^)$\r\n]+?)\)", re.M), + "tmpl": '<section class="eqno"><eqn>{0}</eqn><span>({1})</span></section>', + "tag": "$$", + }, + { + "name": "math_block", + "rex": re.compile(r"^\${2}([^$]*?)\${2}", re.M), + "tmpl": "<section><eqn>{0}</eqn></section>", + "tag": "$$", + }, + ], + }, + "dollars": { + "inline": [ + { + "name": "math_inline", + "rex": re.compile(r"^\$(\S[^$]*?[^\s\\]{1}?)\$"), + "tmpl": "<eq>{0}</eq>", + "tag": "$", + "pre": dollar_pre, + "post": dollar_post, + }, + { + "name": "math_single", + "rex": re.compile(r"^\$([^$\s\\]{1}?)\$"), + "tmpl": "<eq>{0}</eq>", + "tag": "$", + "pre": dollar_pre, + "post": dollar_post, + }, + ], + "block": [ + { + "name": "math_block_eqno", + "rex": re.compile(r"^\${2}([^$]*?)\${2}\s*?\(([^)$\r\n]+?)\)", re.M), + "tmpl": '<section class="eqno">\n<eqn>{0}</eqn><span>({1})</span>\n</section>\n', + "tag": "$$", + }, + { + "name": "math_block", + "rex": re.compile(r"^\${2}([^$]*?)\${2}", re.M), + "tmpl": "<section>\n<eqn>{0}</eqn>\n</section>\n", + "tag": "$$", + }, + ], + }, +} diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/texmath/port.yaml b/contrib/python/mdit-py-plugins/mdit_py_plugins/texmath/port.yaml new file mode 100644 index 00000000000..ba47ac83a46 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/texmath/port.yaml @@ -0,0 +1,7 @@ +- package: markdown-it-texmath + commit: 78c548829ce2ef85c73dc71e680d01e5ae41ffbf + date: Oct 4, 2019 + version: 0.6 + changes: | + both dollars/math_inline and brackets/math_inline regexes have been changed, + to allow (single) line breaks diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/utils.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/utils.py new file mode 100644 index 00000000000..bd8db4e6507 --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/utils.py @@ -0,0 +1,12 @@ +from markdown_it.rules_block import StateBlock + + +def is_code_block(state: StateBlock, line: int) -> bool: + """Check if the line is part of a code block, compat for markdown-it-py v2.""" + try: + # markdown-it-py v3+ + return state.is_code_block(line) + except AttributeError: + pass + + return (state.sCount[line] - state.blkIndent) >= 4 diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/wordcount/__init__.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/wordcount/__init__.py new file mode 100644 index 00000000000..bf658233cec --- /dev/null +++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/wordcount/__init__.py @@ -0,0 +1,58 @@ +from collections.abc import Callable +import string + +from markdown_it import MarkdownIt +from markdown_it.rules_core import StateCore + + +def basic_count(text: str) -> int: + """Split the string and ignore punctuation only elements.""" + return sum([el.strip(string.punctuation).isalpha() for el in text.split()]) + + +def wordcount_plugin( + md: MarkdownIt, + *, + per_minute: int = 200, + count_func: Callable[[str], int] = basic_count, + store_text: bool = False, +) -> None: + """Plugin for computing and storing the word count. + + Stores in the ``env`` e.g.:: + + env["wordcount"] = { + "words": 200 + "minutes": 1, + } + + If "wordcount" is already in the env, it will update it. + + :param per_minute: Words per minute reading speed + :param store_text: store all text under a "text" key, as a list of strings + """ + + def _word_count_rule(state: StateCore) -> None: + text: list[str] = [] + words = 0 + for token in state.tokens: + if token.type == "text": + words += count_func(token.content) + if store_text: + text.append(token.content) + elif token.type == "inline": + for child in token.children or (): + if child.type == "text": + words += count_func(child.content) + if store_text: + text.append(child.content) + + data = state.env.setdefault("wordcount", {}) + if store_text: + data.setdefault("text", []) + data["text"] += text + data.setdefault("words", 0) + data["words"] += words + data["minutes"] = int(round(data["words"] / per_minute)) # noqa: RUF046 + + md.core.ruler.push("wordcount", _word_count_rule) diff --git a/contrib/python/mdit-py-plugins/ya.make b/contrib/python/mdit-py-plugins/ya.make new file mode 100644 index 00000000000..2366034b699 --- /dev/null +++ b/contrib/python/mdit-py-plugins/ya.make @@ -0,0 +1,74 @@ +# Generated by devtools/yamaker (pypi). + +PY3_LIBRARY() + +VERSION(0.5.0) + +LICENSE(MIT) + +PEERDIR( + contrib/python/markdown-it-py +) + +NO_LINT() + +PY_SRCS( + TOP_LEVEL + mdit_py_plugins/__init__.py + mdit_py_plugins/admon/__init__.py + mdit_py_plugins/admon/index.py + mdit_py_plugins/amsmath/__init__.py + mdit_py_plugins/anchors/__init__.py + mdit_py_plugins/anchors/index.py + mdit_py_plugins/attrs/__init__.py + mdit_py_plugins/attrs/index.py + mdit_py_plugins/attrs/parse.py + mdit_py_plugins/colon_fence.py + mdit_py_plugins/container/__init__.py + mdit_py_plugins/container/index.py + mdit_py_plugins/deflist/__init__.py + mdit_py_plugins/deflist/index.py + mdit_py_plugins/dollarmath/__init__.py + mdit_py_plugins/dollarmath/index.py + mdit_py_plugins/field_list/__init__.py + mdit_py_plugins/footnote/__init__.py + mdit_py_plugins/footnote/index.py + mdit_py_plugins/front_matter/__init__.py + mdit_py_plugins/front_matter/index.py + mdit_py_plugins/myst_blocks/__init__.py + mdit_py_plugins/myst_blocks/index.py + mdit_py_plugins/myst_role/__init__.py + mdit_py_plugins/myst_role/index.py + mdit_py_plugins/subscript/__init__.py + mdit_py_plugins/substitution.py + mdit_py_plugins/tasklists/__init__.py + mdit_py_plugins/texmath/__init__.py + mdit_py_plugins/texmath/index.py + mdit_py_plugins/utils.py + mdit_py_plugins/wordcount/__init__.py +) + +RESOURCE_FILES( + PREFIX contrib/python/mdit-py-plugins/ + .dist-info/METADATA + mdit_py_plugins/admon/LICENSE + mdit_py_plugins/admon/port.yaml + mdit_py_plugins/container/LICENSE + mdit_py_plugins/container/README.md + mdit_py_plugins/container/port.yaml + mdit_py_plugins/deflist/LICENSE + mdit_py_plugins/deflist/README.md + mdit_py_plugins/deflist/port.yaml + mdit_py_plugins/footnote/LICENSE + mdit_py_plugins/footnote/port.yaml + mdit_py_plugins/front_matter/LICENSE + mdit_py_plugins/front_matter/port.yaml + mdit_py_plugins/py.typed + mdit_py_plugins/subscript/port.yaml + mdit_py_plugins/tasklists/port.yaml + mdit_py_plugins/texmath/LICENSE + mdit_py_plugins/texmath/README.md + mdit_py_plugins/texmath/port.yaml +) + +END() diff --git a/contrib/python/textual/.dist-info/METADATA b/contrib/python/textual/.dist-info/METADATA index 1e6603305a8..e46ee01d7f6 100644 --- a/contrib/python/textual/.dist-info/METADATA +++ b/contrib/python/textual/.dist-info/METADATA @@ -1,11 +1,11 @@ Metadata-Version: 2.3 Name: textual -Version: 5.3.0 +Version: 6.12.0 Summary: Modern Text User Interface framework License: MIT Author: Will McGugan Author-email: [email protected] -Requires-Python: >=3.8.1,<4.0.0 +Requires-Python: >=3.9,<4.0 Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: Console Classifier: Intended Audience :: Developers @@ -20,13 +20,14 @@ Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 -Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.14 Classifier: Typing :: Typed Provides-Extra: syntax -Requires-Dist: markdown-it-py[linkify,plugins] (>=2.1.0) +Requires-Dist: markdown-it-py[linkify] (>=2.1.0) +Requires-Dist: mdit-py-plugins Requires-Dist: platformdirs (>=3.6.0,<5) Requires-Dist: pygments (>=2.19.2,<3.0.0) -Requires-Dist: rich (>=13.3.3) +Requires-Dist: rich (>=14.2.0) Requires-Dist: tree-sitter (>=0.25.0) ; (python_version >= "3.10") and (extra == "syntax") Requires-Dist: tree-sitter-bash (>=0.23.0) ; (python_version >= "3.10") and (extra == "syntax") Requires-Dist: tree-sitter-css (>=0.23.0) ; (python_version >= "3.10") and (extra == "syntax") @@ -38,8 +39,8 @@ Requires-Dist: tree-sitter-json (>=0.24.0) ; (python_version >= "3.10") and (ext Requires-Dist: tree-sitter-markdown (>=0.3.0) ; (python_version >= "3.10") and (extra == "syntax") Requires-Dist: tree-sitter-python (>=0.23.0) ; (python_version >= "3.10") and (extra == "syntax") Requires-Dist: tree-sitter-regex (>=0.24.0) ; (python_version >= "3.10") and (extra == "syntax") -Requires-Dist: tree-sitter-rust (>=0.23.0,<=0.23.2) ; (python_version >= "3.10") and (extra == "syntax") -Requires-Dist: tree-sitter-sql (>=0.3.0,<0.3.8) ; (python_version >= "3.10") and (extra == "syntax") +Requires-Dist: tree-sitter-rust (>=0.23.0) ; (python_version >= "3.10") and (extra == "syntax") +Requires-Dist: tree-sitter-sql (>=0.3.11) ; (python_version >= "3.10") and (extra == "syntax") Requires-Dist: tree-sitter-toml (>=0.6.0) ; (python_version >= "3.10") and (extra == "syntax") Requires-Dist: tree-sitter-xml (>=0.7.0) ; (python_version >= "3.10") and (extra == "syntax") Requires-Dist: tree-sitter-yaml (>=0.6.0) ; (python_version >= "3.10") and (extra == "syntax") @@ -53,7 +54,7 @@ Description-Content-Type: text/markdown [](https://discord.gg/Enf6Z3qhVr) -[](https://pypi.org/project/textual/) +[](https://pypi.org/project/textual/) [](https://badge.fury.io/py/textual)  diff --git a/contrib/python/textual/README.md b/contrib/python/textual/README.md index 4eb7cdee557..68e6dd70089 100644 --- a/contrib/python/textual/README.md +++ b/contrib/python/textual/README.md @@ -1,7 +1,7 @@ [](https://discord.gg/Enf6Z3qhVr) -[](https://pypi.org/project/textual/) +[](https://pypi.org/project/textual/) [](https://badge.fury.io/py/textual)  diff --git a/contrib/python/textual/textual/__init__.py b/contrib/python/textual/textual/__init__.py index 65f917cff10..cb901f747ad 100644 --- a/contrib/python/textual/textual/__init__.py +++ b/contrib/python/textual/textual/__init__.py @@ -36,7 +36,7 @@ LogCallable: TypeAlias = "Callable" if TYPE_CHECKING: from importlib.metadata import version - from textual.app import App + from textual.app import App as _App __version__ = version("textual") """The version of Textual.""" @@ -65,7 +65,7 @@ class Logger: log_callable: LogCallable | None, group: LogGroup = LogGroup.INFO, verbosity: LogVerbosity = LogVerbosity.NORMAL, - app: App | None = None, + app: _App | None = None, ) -> None: self._log = log_callable self._group = group @@ -73,7 +73,7 @@ class Logger: self._app = None if app is None else weakref.ref(app) @property - def app(self) -> App | None: + 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() diff --git a/contrib/python/textual/textual/_arrange.py b/contrib/python/textual/textual/_arrange.py index ddab9b185c9..4cfd2448fb5 100644 --- a/contrib/python/textual/textual/_arrange.py +++ b/contrib/python/textual/textual/_arrange.py @@ -31,6 +31,11 @@ def _build_layers(widgets: Iterable[Widget]) -> Mapping[str, Sequence[Widget]]: return layers +_get_dock = attrgetter("styles.is_docked") +_get_split = attrgetter("styles.is_split") +_get_display = attrgetter("display") + + def arrange( widget: Widget, children: Sequence[Widget], @@ -48,24 +53,18 @@ def arrange( Returns: Widget arrangement information. """ - placements: list[WidgetPlacement] = [] - scroll_spacing = Spacing() - - get_dock = attrgetter("styles.is_docked") - get_split = attrgetter("styles.is_split") - get_display = attrgetter("styles.display") - + scroll_spacing = NULL_SPACING styles = widget.styles # Widgets which will be displayed - display_widgets = [child for child in children if get_display(child) != "none"] + display_widgets = list(filter(_get_display, children)) # Widgets organized into layers layers = _build_layers(display_widgets) for widgets in layers.values(): # Partition widgets into split widgets and non-split widgets - non_split_widgets, split_widgets = partition(get_split, widgets) + non_split_widgets, split_widgets = partition(_get_split, widgets) if split_widgets: _split_placements, dock_region = _arrange_split_widgets( split_widgets, size, viewport @@ -78,7 +77,7 @@ def arrange( # Partition widgets into "layout" widgets (those that appears in the normal 'flow' of the # document), and "dock" widgets which are positioned relative to an edge - layout_widgets, dock_widgets = partition(get_dock, non_split_widgets) + layout_widgets, dock_widgets = partition(_get_dock, non_split_widgets) # Arrange docked widgets if dock_widgets: @@ -94,8 +93,10 @@ def arrange( if layout_widgets: # Arrange layout widgets (i.e. not docked) - layout_placements = widget.layout.arrange( - widget, layout_widgets, dock_region.size, greedy=not optimal + layout_placements = widget.process_layout( + widget.layout.arrange( + widget, layout_widgets, dock_region.size, greedy=not optimal + ) ) scroll_spacing = scroll_spacing.grow_maximum(dock_spacing) placement_offset = dock_region.offset diff --git a/contrib/python/textual/textual/_border.py b/contrib/python/textual/textual/_border.py index d14b17be8e1..7fc18c777be 100644 --- a/contrib/python/textual/textual/_border.py +++ b/contrib/python/textual/textual/_border.py @@ -87,6 +87,11 @@ BORDER_CHARS: dict[ ("█", " ", "█"), ("█", "▄", "█"), ), + "block": ( + ("▄", "▄", "▄"), + ("█", " ", "█"), + ("▀", "▀", "▀"), + ), "hkey": ( ("▔", "▔", "▔"), (" ", " ", " "), @@ -190,6 +195,11 @@ BORDER_LOCATIONS: dict[ (0, 0, 0), (0, 0, 0), ), + "block": ( + (1, 1, 1), + (0, 0, 0), + (1, 1, 1), + ), "hkey": ( (0, 0, 0), (0, 0, 0), diff --git a/contrib/python/textual/textual/_compositor.py b/contrib/python/textual/textual/_compositor.py index 24d2ae7b192..73fea51cc1b 100644 --- a/contrib/python/textual/textual/_compositor.py +++ b/contrib/python/textual/textual/_compositor.py @@ -75,7 +75,7 @@ class CompositorUpdate: class LayoutUpdate(CompositorUpdate): """A renderable containing the result of a render for a given region.""" - def __init__(self, strips: list[Strip], region: Region) -> None: + def __init__(self, strips: list[Iterable[Strip]], region: Region) -> None: self.strips = strips self.region = region @@ -87,7 +87,8 @@ class LayoutUpdate(CompositorUpdate): move_to = Control.move_to for last, (y, line) in loop_last(enumerate(self.strips, self.region.y)): yield move_to(x, y).segment - yield from line + for strip in line: + yield from strip if not last: yield new_line @@ -102,11 +103,12 @@ class LayoutUpdate(CompositorUpdate): """ sequences: list[str] = [] append = sequences.append + extend = sequences.extend x = self.region.x move_to = Control.move_to for last, (y, line) in loop_last(enumerate(self.strips, self.region.y)): append(move_to(x, y).segment.text) - append(line.render(console)) + extend([strip.render(console) for strip in line]) if not last: append("\n") return "".join(sequences) @@ -239,7 +241,6 @@ class ChopsUpdate(CompositorUpdate): Returns: Raw data with escape sequences. """ - sequences: list[str] = [] append = sequences.append @@ -597,7 +598,7 @@ class Compositor: if widget.is_container: # Arrange the layout - arrange_result = widget._arrange(child_region.size) + arrange_result = widget.arrange(child_region.size) arranged_widgets = arrange_result.widgets widgets.update(arranged_widgets) @@ -688,13 +689,22 @@ class Compositor: arrange_result.scroll_spacing, ) layer_order -= 1 + else: + if widget._anchored and not widget._anchor_released: + new_scroll_y = widget.virtual_size.height - ( + widget.container_size.height + - widget.scrollbar_size_horizontal + ) + widget.scroll_y = new_scroll_y + widget.scroll_target_y = new_scroll_y + widget.vertical_scrollbar.position = new_scroll_y if visible: # Add any scrollbars if ( widget.show_vertical_scrollbar or widget.show_horizontal_scrollbar - ): + ) and styles.scrollbar_visibility == "visible": for chrome_widget, chrome_region in widget._arrange_scrollbars( container_region ): @@ -708,7 +718,7 @@ class Compositor: dock_gutter, ) - map[widget] = _MapGeometry( + map[widget._render_widget] = _MapGeometry( region, order, clip, @@ -848,9 +858,10 @@ class Compositor: Sequence of (WIDGET, REGION) tuples. """ contains = Region.contains - for widget, cropped_region, region in self.layers_visible[y]: - if contains(cropped_region, x, y) and widget.visible: - yield widget, region + if len(self.layers_visible) > y >= 0: + for widget, cropped_region, region in self.layers_visible[y]: + if contains(cropped_region, x, y) and widget.visible: + yield widget, region def get_style_at(self, x: int, y: int) -> Style: """Get the Style at the given cell or Style.null() @@ -924,8 +935,10 @@ class Compositor: offset_x = 0 offset_x2 = 0 + from rich.cells import get_character_cell_size + for segment in line: - end += len(segment.text) + end += segment.cell_length style = segment.style if style is not None and style._meta is not None: meta = style.meta @@ -934,11 +947,14 @@ class Compositor: offset_x2 = offset_x + len(segment.text) if x < end and x >= start: - if x == end - 1: - segment_offset = len(segment.text) - else: - first, _ = segment.split_cells(x - start) - segment_offset = len(first.text) + segment_cell_length = 0 + cell_cut = x - start + segment_offset = 0 + for character in segment.text: + if segment_cell_length >= cell_cut: + break + segment_cell_length += get_character_cell_size(character) + segment_offset += 1 return widget, ( None if offset_y is None @@ -1131,12 +1147,15 @@ class Compositor: self._dirty_regions.clear() crop = screen_region chops = self._render_chops(crop, lambda y: True) + render_strips: list[Iterable[Strip]] if simplify: + # Simplify is done when exporting to SVG + # It doesn't make things faster render_strips = [ - Strip.join(chop.values()).simplify().discard_meta() for chop in chops + [Strip.join(chop.values()).simplify().discard_meta()] for chop in chops ] else: - render_strips = [Strip.join(chop.values()) for chop in chops] + render_strips = [chop.values() for chop in chops] return LayoutUpdate(render_strips, screen_region) @@ -1179,7 +1198,7 @@ class Compositor: self, crop: Region, is_rendered_line: Callable[[int], bool], - ) -> Sequence[Mapping[int, Strip | None]]: + ) -> Sequence[Mapping[int, Strip]]: """Render update 'chops'. Args: @@ -1218,8 +1237,7 @@ class Compositor: for cut, strip in zip(final_cuts, cut_strips): if get_chops_line(cut) is None: chops_line[cut] = strip - - return chops + return cast("Sequence[Mapping[int, Strip]]", chops) def __rich__(self) -> StripRenderable: return StripRenderable(self.render_strips()) @@ -1245,8 +1263,7 @@ class Compositor: offset = region.offset intersection = clip.intersection for dirty_region in widget._exchange_repaint_regions(): - update_region = intersection(dirty_region.translate(offset)) - if update_region: + if update_region := intersection(dirty_region.translate(offset)): add_region(update_region) self._dirty_regions.update(regions) diff --git a/contrib/python/textual/textual/_keyboard_protocol.py b/contrib/python/textual/textual/_keyboard_protocol.py index cdd38a86cba..9b765411026 100644 --- a/contrib/python/textual/textual/_keyboard_protocol.py +++ b/contrib/python/textual/textual/_keyboard_protocol.py @@ -13,8 +13,10 @@ FUNCTIONAL_KEYS = { "5~": "pageup", "6~": "pagedown", "1H": "home", + "1~": "home", "7~": "home", "1F": "end", + "4~": "end", "8~": "end", "57358u": "caps_lock", "57359u": "scroll_lock", diff --git a/contrib/python/textual/textual/_node_list.py b/contrib/python/textual/textual/_node_list.py index 54c3dae026c..42b287cb26b 100644 --- a/contrib/python/textual/textual/_node_list.py +++ b/contrib/python/textual/textual/_node_list.py @@ -3,7 +3,7 @@ from __future__ import annotations import sys import weakref from operator import attrgetter -from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, Sequence, overload +from typing import TYPE_CHECKING, Any, Callable, Iterator, Sequence, overload import rich.repr @@ -14,6 +14,10 @@ if TYPE_CHECKING: from textual.widget import Widget +_display_getter = attrgetter("display") +_visible_getter = attrgetter("visible") + + class DuplicateIds(Exception): """Raised when attempting to add a widget with an id that already exists.""" @@ -41,6 +45,8 @@ class NodeList(Sequence["Widget"]): # The nodes in the list self._nodes: list[Widget] = [] self._nodes_set: set[Widget] = set() + self._displayed_nodes: tuple[int, list[Widget]] = (-1, []) + self._displayed_visible_nodes: tuple[int, list[Widget]] = (-1, []) # We cache widgets by their IDs too for a quick lookup # Note that only widgets with IDs are cached like this, so @@ -69,8 +75,6 @@ class NodeList(Sequence["Widget"]): """Mark the nodes as having been updated.""" self._updates += 1 node = None if self._parent is None else self._parent() - if node is None: - return while node is not None and (node := node._parent) is not None: node._nodes._updates += 1 @@ -187,18 +191,29 @@ class NodeList(Sequence["Widget"]): return reversed(self._nodes) @property - def displayed(self) -> Iterable[Widget]: + def displayed(self) -> Sequence[Widget]: """Just the nodes where `display==True`.""" - for node in self._nodes: - if node.display: - yield node + if self._displayed_nodes[0] != self._updates: + self._displayed_nodes = ( + self._updates, + list(filter(_display_getter, self._nodes)), + ) + return self._displayed_nodes[1] @property - def displayed_reverse(self) -> Iterable[Widget]: + def displayed_and_visible(self) -> Sequence[Widget]: + """Nodes with both `display==True` and `visible==True`.""" + if self._displayed_visible_nodes[0] != self._updates: + self._displayed_nodes = ( + self._updates, + list(filter(_visible_getter, self.displayed)), + ) + return self._displayed_nodes[1] + + @property + def displayed_reverse(self) -> Iterator[Widget]: """Just the nodes where `display==True`, in reverse order.""" - for node in reversed(self._nodes): - if node.display: - yield node + return filter(_display_getter, reversed(self._nodes)) if TYPE_CHECKING: @@ -211,9 +226,11 @@ class NodeList(Sequence["Widget"]): def __getitem__(self, index: int | slice) -> Widget | list[Widget]: return self._nodes[index] - def __getattr__(self, key: str) -> object: - if key in {"clear", "append", "pop", "insert", "remove", "extend"}: - raise ReadOnlyError( - "Widget.children is read-only: use Widget.mount(...) or Widget.remove(...) to add or remove widgets" - ) - raise AttributeError(key) + if not TYPE_CHECKING: + # This confused the type checker for some reason + def __getattr__(self, key: str) -> object: + if key in {"clear", "append", "pop", "insert", "remove", "extend"}: + raise ReadOnlyError( + "Widget.children is read-only: use Widget.mount(...) or Widget.remove(...) to add or remove widgets" + ) + raise AttributeError(key) diff --git a/contrib/python/textual/textual/_profile.py b/contrib/python/textual/textual/_profile.py index 3e880cf15c1..8324db9deee 100644 --- a/contrib/python/textual/textual/_profile.py +++ b/contrib/python/textual/textual/_profile.py @@ -10,10 +10,17 @@ from textual import log @contextlib.contextmanager -def timer(subject: str = "time") -> Generator[None, None, None]: - """print the elapsed time. (only used in debugging)""" +def timer(subject: str = "time", threshold: float = 0) -> Generator[None, None, None]: + """print the elapsed time. (only used in debugging). + + Args: + subject: Text shown in log. + threshold: Time in second after which the log is written. + + """ start = perf_counter() yield elapsed = perf_counter() - start - elapsed_ms = elapsed * 1000 - log(f"{subject} elapsed {elapsed_ms:.4f}ms") + if elapsed >= threshold: + elapsed_ms = elapsed * 1000 + log(f"{subject} elapsed {elapsed_ms:.4f}ms") diff --git a/contrib/python/textual/textual/_queue.py b/contrib/python/textual/textual/_queue.py new file mode 100644 index 00000000000..d01e96adb7e --- /dev/null +++ b/contrib/python/textual/textual/_queue.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import asyncio +from asyncio import Event +from collections import deque +from typing import Generic, TypeVar + +QueueType = TypeVar("QueueType") + + +class Queue(Generic[QueueType]): + """A cut-down version of asyncio.Queue + + This has just enough functionality to run the message pumps. + + """ + + def __init__(self) -> None: + self.values: deque[QueueType] = deque() + self.ready_event = Event() + + def put_nowait(self, value: QueueType) -> None: + self.values.append(value) + self.ready_event.set() + + def qsize(self) -> int: + return len(self.values) + + def empty(self) -> bool: + return not self.values + + def task_done(self) -> None: + pass + + async def get(self) -> QueueType: + if not self.ready_event.is_set(): + await self.ready_event.wait() + value = self.values.popleft() + if not self.values: + self.ready_event.clear() + return value + + def get_nowait(self) -> QueueType: + if not self.values: + raise asyncio.QueueEmpty() + value = self.values.popleft() + if not self.values: + self.ready_event.clear() + return value diff --git a/contrib/python/textual/textual/_segment_tools.py b/contrib/python/textual/textual/_segment_tools.py index de6a656a707..3a4a916b142 100644 --- a/contrib/python/textual/textual/_segment_tools.py +++ b/contrib/python/textual/textual/_segment_tools.py @@ -5,6 +5,7 @@ Tools for processing Segments, or lists of Segments. from __future__ import annotations import re +from functools import lru_cache from typing import Iterable from rich.segment import Segment @@ -15,6 +16,20 @@ from textual.css.types import AlignHorizontal, AlignVertical from textual.geometry import Size +@lru_cache(1024 * 8) +def make_blank(width, style: Style) -> Segment: + """Make a blank segment. + + Args: + width: Width of blank. + style: Style of blank. + + Returns: + A single segment + """ + return Segment(" " * width, style) + + class NoCellPositionForIndex(Exception): pass @@ -162,19 +177,19 @@ def line_pad( """ if pad_left and pad_right: return [ - Segment(" " * pad_left, style), + make_blank(pad_left, style), *segments, - Segment(" " * pad_right, style), + make_blank(pad_right, style), ] elif pad_left: return [ - Segment(" " * pad_left, style), + make_blank(pad_left, style), *segments, ] elif pad_right: return [ *segments, - Segment(" " * pad_right, style), + make_blank(pad_right, style), ] return list(segments) @@ -215,7 +230,7 @@ def align_lines( Returns: A list of blank lines. """ - return [[Segment(" " * width, style)]] * count + return [[make_blank(width, style)]] * count top_blank_lines = bottom_blank_lines = 0 vertical_excess_space = max(0, height - shape_height) diff --git a/contrib/python/textual/textual/_styles_cache.py b/contrib/python/textual/textual/_styles_cache.py index 7897e883d93..e11acc0bbdc 100644 --- a/contrib/python/textual/textual/_styles_cache.py +++ b/contrib/python/textual/textual/_styles_cache.py @@ -1,7 +1,6 @@ from __future__ import annotations from functools import lru_cache -from sys import intern from typing import TYPE_CHECKING, Callable, Iterable, Sequence import rich.repr @@ -14,7 +13,7 @@ from textual._ansi_theme import DEFAULT_TERMINAL_THEME from textual._border import get_box, render_border_label, render_row from textual._context import active_app from textual._opacity import _apply_opacity -from textual._segment_tools import apply_hatch, line_pad, line_trim +from textual._segment_tools import apply_hatch, line_pad, line_trim, make_blank from textual.color import TRANSPARENT, Color from textual.constants import DEBUG from textual.content import Content @@ -34,20 +33,6 @@ if TYPE_CHECKING: RenderLineCallback: TypeAlias = Callable[[int], Strip] -@lru_cache(1024 * 8) -def make_blank(width, style: RichStyle) -> Segment: - """Make a blank segment. - - Args: - width: Width of blank. - style: Style of blank. - - Returns: - A single segment - """ - return Segment(intern(" " * width), style) - - @rich.repr.auto(angular=True) class StylesCache: """Responsible for rendering CSS Styles and keeping a cache of rendered lines. @@ -105,6 +90,7 @@ class StylesCache: def clear(self) -> None: """Clear the styles cache (will cause the content to re-render).""" + self._cache.clear() self._dirty_lines.clear() @@ -118,11 +104,10 @@ class StylesCache: Returns: Rendered lines. """ - border_title = widget._border_title border_subtitle = widget._border_subtitle - base_background, background = widget._opacity_background_colors + base_background, background = widget.background_colors styles = widget.styles strips = self.render( styles, @@ -130,6 +115,7 @@ class StylesCache: base_background, background, widget.render_line, + widget.get_line_filters(), ( None if border_title is None @@ -149,7 +135,6 @@ class StylesCache: content_size=widget.content_region.size, padding=styles.padding, crop=crop, - filters=widget.app._filters, opacity=widget.opacity, ansi_theme=widget.app.ansi_theme, ) @@ -177,12 +162,12 @@ class StylesCache: base_background: Color, background: Color, render_content_line: RenderLineCallback, + filters: Sequence[LineFilter], border_title: tuple[Content, Color, Color, Style] | None, border_subtitle: tuple[Content, Color, Color, Style] | None, content_size: Size | None = None, padding: Spacing | None = None, crop: Region | None = None, - filters: Sequence[LineFilter] | None = None, opacity: float = 1.0, ansi_theme: TerminalTheme = DEFAULT_TERMINAL_THEME, ) -> list[Strip]: @@ -223,9 +208,7 @@ class StylesCache: is_dirty = self._dirty_lines.__contains__ render_line = self.render_line - apply_filters = ( - [] if filters is None else [filter for filter in filters if filter.enabled] - ) + for y in crop.line_range: if is_dirty(y) or y not in self._cache: strip = render_line( @@ -246,7 +229,7 @@ class StylesCache: else: strip = self._cache[y] - for filter in apply_filters: + for filter in filters: strip = strip.apply_filter(filter, background) if DEBUG: @@ -263,6 +246,16 @@ class StylesCache: return strips + @lru_cache(1024) + def get_inner_outer( + cls, base_background: Color, background: Color + ) -> tuple[Style, Style]: + """Get inner and outer background colors.""" + return ( + Style(background=base_background + background), + Style(background=base_background), + ) + def render_line( self, styles: StylesBase, @@ -319,9 +312,7 @@ class StylesCache: ) = styles.outline from_color = RichStyle.from_color - - inner = Style(background=(base_background + background)) - outer = Style(background=base_background) + inner, outer = self.get_inner_outer(base_background, background) def line_post(segments: Iterable[Segment]) -> Iterable[Segment]: """Apply effects to segments inside the border.""" @@ -343,7 +334,6 @@ class StylesCache: Returns: New list of segments """ - try: app = active_app.get() ansi_theme = app.ansi_theme @@ -361,7 +351,6 @@ class StylesCache: line: Iterable[Segment] # Draw top or bottom borders (A) if (border_top and y == 0) or (border_bottom and y == height - 1): - is_top = y == 0 border_color = base_background + ( border_top_color if is_top else border_bottom_color @@ -427,7 +416,7 @@ class StylesCache: elif (pad_top and y < gutter.top) or ( pad_bottom and y >= height - gutter.bottom ): - background_rich_style = from_color(bgcolor=background.rich_color) + background_rich_style = inner.rich_style left_style = Style( foreground=base_background + border_left_color.multiply_alpha(opacity) ) @@ -450,15 +439,12 @@ class StylesCache: content_y = y - gutter.top if content_y < content_height: line = render_content_line(y - gutter.top) - line = line.adjust_cell_length(content_width) + line = line.adjust_cell_length(content_width, inner.rich_style) else: - line = [make_blank(content_width, inner.rich_style)] - if inner: - line = Segment.apply_style(line, inner.rich_style) - if styles.text_opacity != 1.0: - line = TextOpacity.process_segments( - line, styles.text_opacity, ansi_theme - ) + line = Strip.blank(content_width, inner.rich_style) + + if (text_opacity := styles.text_opacity) != 1.0: + line = TextOpacity.process_segments(line, text_opacity, ansi_theme) line = line_post(line_pad(line, pad_left, pad_right, inner.rich_style)) if border_left or border_right: diff --git a/contrib/python/textual/textual/_xterm_parser.py b/contrib/python/textual/textual/_xterm_parser.py index 6203163ccb4..1b7f3e57d9a 100644 --- a/contrib/python/textual/textual/_xterm_parser.py +++ b/contrib/python/textual/textual/_xterm_parser.py @@ -163,20 +163,29 @@ class XTermParser(Parser[Message]): else: on_token(event) - def reissue_sequence_as_keys(reissue_sequence: str) -> None: + def reissue_sequence_as_keys( + reissue_sequence: str, process_alt: bool = False + ) -> None: """Called when an escape sequence hasn't been understood. Args: reissue_sequence: Key sequence to report to the app. """ + + alt = False + if reissue_sequence: self.debug_log("REISSUE", repr(reissue_sequence)) for character in reissue_sequence: - key_events = sequence_to_key_events(character) + if process_alt and character == ESC: + alt = True + continue + key_events = sequence_to_key_events(character, alt=alt) for event in key_events: - if event.key == "escape": + if event.key == "escape" and not process_alt: event = events.Key("circumflex_accent", "^") on_token(event) + alt = False while not self.is_eof: if not bracketed_paste and paste_buffer: @@ -211,23 +220,25 @@ class XTermParser(Parser[Message]): # # Could be the escape key was pressed OR the start of an escape sequence sequence: str = ESC - def send_escape() -> None: + def send_sequence(process_alt: bool = True) -> None: """Send escape key and reissue sequence.""" - on_token(events.Key("escape", "\x1b")) - reissue_sequence_as_keys(sequence[1:]) + if sequence == ESC: + on_token(events.Key("escape", "\x1b")) + else: + reissue_sequence_as_keys(sequence, process_alt=process_alt) while True: try: new_character = yield read1(constants.ESCAPE_DELAY) except ParseTimeout: - send_escape() + send_sequence() break except ParseEOF: - send_escape() + send_sequence() return if new_character == ESC: - send_escape() + send_sequence(process_alt=False) sequence = character continue else: @@ -313,7 +324,9 @@ class XTermParser(Parser[Message]): self._debug_log_file.close() self._debug_log_file = None - def _sequence_to_key_events(self, sequence: str) -> Iterable[events.Key]: + def _sequence_to_key_events( + self, sequence: str, alt: bool = False + ) -> Iterable[events.Key]: """Map a sequence of code points on to a sequence of keys. Args: @@ -342,7 +355,7 @@ class XTermParser(Parser[Message]): key_tokens.append(modifier) key_tokens.sort() - key_tokens.append(key) + key_tokens.append(key.lower()) yield events.Key( "+".join(key_tokens), sequence if len(sequence) == 1 else None ) @@ -376,7 +389,12 @@ class XTermParser(Parser[Message]): name = _character_to_key(sequence) else: name = sequence + name = KEY_NAME_REPLACEMENTS.get(name, name) + if len(name) == 1 and alt: + if name.isupper(): + name = f"shift+{name.lower()}" + name = f"alt+{name}" yield events.Key(name, sequence) except Exception: yield events.Key(sequence, sequence) diff --git a/contrib/python/textual/textual/app.py b/contrib/python/textual/textual/app.py index 89ac28df1ec..d0715627143 100644 --- a/contrib/python/textual/textual/app.py +++ b/contrib/python/textual/textual/app.py @@ -41,6 +41,7 @@ from typing import ( Generic, Iterable, Iterator, + Mapping, NamedTuple, Sequence, TextIO, @@ -94,6 +95,7 @@ from textual.await_remove import AwaitRemove from textual.binding import Binding, BindingsMap, BindingType, Keymap from textual.command import CommandListItem, CommandPalette, Provider, SimpleProvider from textual.compose import compose +from textual.content import Content from textual.css.errors import StylesheetError from textual.css.query import NoMatches from textual.css.stylesheet import RulesMap, Stylesheet @@ -516,7 +518,7 @@ class App(Generic[ReturnType], DOMNode): "inline": lambda app: app.is_inline, "ansi": lambda app: app.ansi_color, "nocolor": lambda app: app.no_color, - } # type: ignore[assignment] + } title: Reactive[str] = Reactive("", compute=False) """The title of the app, displayed in the header.""" @@ -559,7 +561,7 @@ class App(Generic[ReturnType], DOMNode): will be loaded in order. watch_css: Reload CSS if the files changed. This is set automatically if you are using `textual run` with the `dev` switch. - ansi_color: Allow ANSI colors if `True`, or convert ANSI colors to to RGB if `False`. + ansi_color: Allow ANSI colors if `True`, or convert ANSI colors to RGB if `False`. Raises: CssPathError: When the supplied CSS path(s) are an unexpected type. @@ -615,6 +617,9 @@ class App(Generic[ReturnType], DOMNode): self._sync_available = False self.mouse_over: Widget | None = None + """The widget directly under the mouse.""" + self.hover_over: Widget | None = None + """The first widget with a hover style under the mouse.""" self.mouse_captured: Widget | None = None self._driver: Driver | None = None self._exit_renderables: list[RenderableType] = [] @@ -813,6 +818,8 @@ class App(Generic[ReturnType], DOMNode): self._resize_event: events.Resize | None = None """A pending resize event, sent on idle.""" + self._size: Size | None = None + self._css_update_count: int = 0 """Incremented when CSS is invalidated.""" @@ -842,6 +849,14 @@ class App(Generic[ReturnType], DOMNode): ) ) + def get_line_filters(self) -> Sequence[LineFilter]: + """Get currently enabled line filters. + + Returns: + A list of [LineFilter][textual.filters.LineFilter] instances. + """ + return [filter for filter in self._filters if filter.enabled] + @property def _is_devtools_connected(self) -> bool: """Is the app connected to the devtools?""" @@ -963,6 +978,27 @@ class App(Generic[ReturnType], DOMNode): """ return self._clipboard + def format_title(self, title: str, sub_title: str) -> Content: + """Format the title for display. + + Args: + title: The title. + sub_title: The sub title. + + Returns: + Content instance with title and subtitle. + """ + title_content = Content(title) + sub_title_content = Content(sub_title) + if sub_title_content: + return Content.assemble( + title_content, + (" — ", "dim"), + sub_title_content.stylize("dim"), + ) + else: + return title_content + @contextmanager def batch_update(self) -> Generator[None, None, None]: """A context manager to suspend all repaints until the end of the batch.""" @@ -983,10 +1019,11 @@ class App(Generic[ReturnType], DOMNode): if not self._batch_count: self.check_idle() - def _delay_update(self, delay: float = 0.05) -> None: + def delay_update(self, delay: float = 0.05) -> None: """Delay updates for a short period of time. May be used to mask a brief transition. + Consider this method only if you aren't able to use `App.batch_update`. Args: delay: Delay before updating. @@ -999,7 +1036,7 @@ class App(Generic[ReturnType], DOMNode): if not self._batch_count: self.screen.refresh() - self.set_timer(delay, end_batch, name="_delay_update") + self.set_timer(delay, end_batch, name="delay_update") @contextmanager def _context(self) -> Generator[None, None, None]: @@ -1219,25 +1256,25 @@ class App(Generic[ReturnType], DOMNode): """ if not self.ansi_color: yield SystemCommand( - "Change theme", + "Theme", "Change the current theme", self.action_change_theme, ) yield SystemCommand( - "Quit the application", + "Quit", "Quit the application as soon as possible", self.action_quit, ) if screen.query("HelpPanel"): yield SystemCommand( - "Hide keys and help panel", + "Keys", "Hide the keys and widget help panel", self.action_hide_help_panel, ) else: yield SystemCommand( - "Show keys and help panel", + "Keys", "Show help for the focused widget and a summary of available keys", self.action_show_help_panel, ) @@ -1254,7 +1291,7 @@ class App(Generic[ReturnType], DOMNode): ) yield SystemCommand( - "Save screenshot", + "Screenshot", "Save an SVG 'screenshot' of the current screen", lambda: self.set_timer(0.1, self.deliver_screenshot), ) @@ -1524,12 +1561,22 @@ class App(Generic[ReturnType], DOMNode): Returns: Size of the terminal. """ + if self._size is not None: + return self._size if self._driver is not None and self._driver._size is not None: width, height = self._driver._size else: width, height = self.console.size return Size(width, height) + @property + def viewport_size(self) -> Size: + """Get the viewport size (size of the screen).""" + try: + return self.screen.size + except (ScreenStackError, NoScreen): + return self.size + def _get_inline_height(self) -> int: """Get the inline height (height when in inline mode). @@ -1777,7 +1824,7 @@ class App(Generic[ReturnType], DOMNode): ) -> str | None: """Deliver a screenshot of the app. - This with save the screenshot when running locally, or serve it when the app + This will save the screenshot when running locally, or serve it when the app is running in a web browser. Args: @@ -2052,7 +2099,7 @@ class App(Generic[ReturnType], DOMNode): # Launch the app in the "background" - app_task = create_task(run_app(app), name=f"run_test {app}") + self._task = app_task = create_task(run_app(app), name=f"run_test {app}") # Wait until the app has performed all startup routines. await app_ready_event.wait() @@ -2063,6 +2110,7 @@ class App(Generic[ReturnType], DOMNode): await pilot._wait_for_screen() yield pilot finally: + await asyncio.sleep(0) # Shutdown the app cleanly await app._shutdown() await app_task @@ -2131,7 +2179,9 @@ class App(Generic[ReturnType], DOMNode): self._thread_init() - app._loop = asyncio.get_running_loop() + loop = app._loop = asyncio.get_running_loop() + if hasattr(asyncio, "eager_task_factory"): + loop.set_task_factory(asyncio.eager_task_factory) with app._context(): try: await app._process_messages( @@ -2387,8 +2437,8 @@ class App(Generic[ReturnType], DOMNode): MountError: If there is a problem with the mount request. Note: - Only one of ``before`` or ``after`` can be provided. If both are - provided a ``MountError`` will be raised. + Only one of `before` or `after` can be provided. If both are + provided a `MountError` will be raised. """ return self.screen.mount(*widgets, before=before, after=after) @@ -2417,8 +2467,8 @@ class App(Generic[ReturnType], DOMNode): MountError: If there is a problem with the mount request. Note: - Only one of ``before`` or ``after`` can be provided. If both are - provided a ``MountError`` will be raised. + Only one of `before` or `after` can be provided. If both are + provided a `MountError` will be raised. """ return self.mount(*widgets, before=before, after=after) @@ -2768,6 +2818,7 @@ class App(Generic[ReturnType], DOMNode): next_screen._push_result_callback(message_pump, callback, future) self._load_screen_css(next_screen) + next_screen._update_auto_focus() self._screen_stack.append(next_screen) next_screen.post_message(events.ScreenResume()) self.log.system(f"{self.screen} is current (PUSHED)") @@ -2963,7 +3014,9 @@ class App(Generic[ReturnType], DOMNode): """ self.screen.set_focus(widget, scroll_visible) - def _set_mouse_over(self, widget: Widget | None) -> None: + def _set_mouse_over( + self, widget: Widget | None, hover_widget: Widget | None + ) -> None: """Called when the mouse is over another widget. Args: @@ -2985,6 +3038,18 @@ class App(Generic[ReturnType], DOMNode): finally: self.mouse_over = widget + current_hover_over = self.hover_over + if current_hover_over is not None: + current_hover_over.mouse_hover = False + + if hover_widget is not None: + hover_widget.mouse_hover = True + if hover_widget._has_hover_style: + hover_widget.update_node_styles() + if current_hover_over is not None and current_hover_over._has_hover_style: + current_hover_over.update_node_styles() + self.hover_over = hover_widget + def _update_mouse_over(self, screen: Screen) -> None: """Updates the mouse over after the next refresh. @@ -2999,12 +3064,16 @@ class App(Generic[ReturnType], DOMNode): async def check_mouse() -> None: """Check if the mouse over widget has changed.""" try: - widget, _ = screen.get_widget_at(*self.mouse_position) + hover_widgets = screen.get_hover_widgets_at(*self.mouse_position) except NoWidget: pass else: - if widget is not self.mouse_over: - self._set_mouse_over(widget) + mouse_over, hover_over = hover_widgets.widgets + if ( + mouse_over is not self.mouse_over + or hover_over is not self.hover_over + ): + self._set_mouse_over(mouse_over, hover_over) self.call_after_refresh(check_mouse) @@ -3159,7 +3228,6 @@ 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: @@ -3286,8 +3354,9 @@ class App(Generic[ReturnType], DOMNode): if self._driver.is_inline: cursor_x, cursor_y = self._previous_cursor_position self._driver.write( - Control.move(-cursor_x, -cursor_y + 1).segment.text + Control.move(-cursor_x, -cursor_y).segment.text ) + self._driver.flush() if inline_no_clear and not self.app._exit_renderables: console = Console() try: @@ -3296,9 +3365,7 @@ class App(Generic[ReturnType], DOMNode): console.print() else: self._driver.write( - Control.move( - -cursor_x, -self.INLINE_PADDING - 1 - ).segment.text + Control.move(0, -self.INLINE_PADDING).segment.text ) driver.stop_application_mode() @@ -3371,7 +3438,6 @@ class App(Generic[ReturnType], DOMNode): Args: parent: Parent node. child: The child widget to register. - widgets: The widget to register. before: A location to mount before. after: A location to mount after. """ @@ -3408,7 +3474,6 @@ class App(Generic[ReturnType], DOMNode): self._registry.add(child) child._attach(parent) child._post_register(self) - child._start_messages() def _register( self, @@ -3461,6 +3526,7 @@ class App(Generic[ReturnType], DOMNode): self._register(widget, *widget._nodes, cache=cache) for widget in new_widgets: apply_stylesheet(widget, cache=cache) + widget._start_messages() if not self._running: # If the app is not running, prevent awaiting of the widget tasks @@ -3596,8 +3662,9 @@ class App(Generic[ReturnType], DOMNode): stylesheet.reparse() stylesheet.update(self.app, animate=animate) try: - self.screen._refresh_layout(self.size) - self.screen._css_update_count = self._css_update_count + if self.screen.is_mounted: + self.screen._refresh_layout(self.size) + self.screen._css_update_count = self._css_update_count except ScreenError: pass # The other screens in the stack will need to know about some style @@ -3936,12 +4003,17 @@ class App(Generic[ReturnType], DOMNode): ) def _parse_action( - self, action: str | ActionParseResult, default_namespace: DOMNode + self, + action: str | ActionParseResult, + default_namespace: DOMNode, + namespaces: Mapping[str, DOMNode] | None = None, ) -> tuple[DOMNode, str, tuple[object, ...]]: """Parse an action. Args: action: An action string. + default_namespace: Namespace to user when none is supplied in the action. + namespaces: Mapping of namespaces. Raises: ActionError: If there are any errors parsing the action string. @@ -3954,8 +4026,10 @@ class App(Generic[ReturnType], DOMNode): else: destination, action_name, params = actions.parse(action) - action_target: DOMNode | None = None - if destination: + action_target: DOMNode | None = ( + None if namespaces is None else namespaces.get(destination) + ) + if destination and action_target is None: if destination not in self._action_targets: raise ActionError(f"Action namespace {destination} is not known") action_target = getattr(self, destination, None) @@ -3988,6 +4062,7 @@ class App(Generic[ReturnType], DOMNode): self, action: str | ActionParseResult, default_namespace: DOMNode | None = None, + namespaces: Mapping[str, DOMNode] | None = None, ) -> bool: """Perform an [action](/guide/actions). @@ -3997,12 +4072,13 @@ class App(Generic[ReturnType], DOMNode): action: Action encoded in a string. default_namespace: Namespace to use if not provided in the action, or None to use app. + namespaces: Mapping of namespaces. Returns: True if the event has been handled. """ action_target, action_name, params = self._parse_action( - action, self if default_namespace is None else default_namespace + action, self if default_namespace is None else default_namespace, namespaces ) if action_target.check_action(action_name, params): return await self._dispatch_action(action_target, action_name, params) @@ -4106,6 +4182,7 @@ class App(Generic[ReturnType], DOMNode): async def _on_resize(self, event: events.Resize) -> None: event.stop() + self._size = event.size self._resize_event = event async def _on_app_focus(self, event: events.AppFocus) -> None: @@ -4170,7 +4247,7 @@ class App(Generic[ReturnType], DOMNode): def _watch_app_focus(self, focus: bool) -> None: """Respond to changes in app focus.""" - self.screen._update_styles() + self.screen.update_node_styles() if focus: # If we've got a last-focused widget, if it still has a screen, # and if the screen is still the current screen and if nothing @@ -4504,9 +4581,11 @@ class App(Generic[ReturnType], DOMNode): # app, and we don't want to have the driver auto-restart # application mode when the application comes back to the # foreground, in this context. - with self._driver.no_automatic_restart(), redirect_stdout( - sys.__stdout__ - ), redirect_stderr(sys.__stderr__): + with ( + self._driver.no_automatic_restart(), + redirect_stdout(sys.__stdout__), + redirect_stderr(sys.__stderr__), + ): yield # We're done with the dev's code so resume application mode. self._driver.resume_application_mode() @@ -4752,7 +4831,7 @@ class App(Generic[ReturnType], DOMNode): self.notify("Saved screenshot", title="Screenshot") else: self.notify( - f"Saved screenshot to [green]{str(event.path)!r}", + f"Saved screenshot to [$text-success]{str(event.path)!r}", title="Screenshot", ) diff --git a/contrib/python/textual/textual/binding.py b/contrib/python/textual/textual/binding.py index 40afa6cf420..05e0161ab22 100644 --- a/contrib/python/textual/textual/binding.py +++ b/contrib/python/textual/textual/binding.py @@ -66,7 +66,7 @@ class Binding: key_display: str | None = None """How the key should be shown in footer. - If None, the display of the key will use the result of `App.get_key_display`. + If `None`, the display of the key will use the result of `App.get_key_display`. If overridden in a keymap then this value is ignored. """ @@ -84,6 +84,19 @@ class Binding: system: bool = False """Make this binding a system binding, which removes it from the key panel.""" + @dataclass(frozen=True) + class Group: + """A binding group causes the keys to be grouped under a single description.""" + + description: str = "" + """Description of the group.""" + + compact: bool = False + """Show keys in compact form (no spaces).""" + + group: Group | None = None + """Optional binding group (used to group related bindings in the footer).""" + def parse_key(self) -> tuple[list[str], str]: """Parse a key into a list of modifiers, and the actual key. @@ -151,6 +164,7 @@ class Binding: tooltip=binding.tooltip, id=binding.id, system=binding.system, + group=binding.group, ) diff --git a/contrib/python/textual/textual/command.py b/contrib/python/textual/textual/command.py index a05401e9997..c0d3c415ff6 100644 --- a/contrib/python/textual/textual/command.py +++ b/contrib/python/textual/textual/command.py @@ -1230,7 +1230,7 @@ class CommandPalette(SystemModalScreen[None]): # decide what to do with it (hopefully it'll run it). self._cancel_gather_commands() self.app.post_message(CommandPalette.Closed(option_selected=True)) - self.app._delay_update() + self.app.delay_update() self.dismiss() self.app.call_later(self._selected_command.command) diff --git a/contrib/python/textual/textual/containers.py b/contrib/python/textual/textual/containers.py index 4478901d35c..f60929b6602 100644 --- a/contrib/python/textual/textual/containers.py +++ b/contrib/python/textual/textual/containers.py @@ -267,6 +267,7 @@ class ItemGrid(Widget): stretch_height: reactive[bool] = reactive(True) min_column_width: reactive[int | None] = reactive(None, layout=True) + max_column_width: reactive[int | None] = reactive(None, layout=True) regular: reactive[bool] = reactive(False) def __init__( @@ -277,6 +278,7 @@ class ItemGrid(Widget): classes: str | None = None, disabled: bool = False, min_column_width: int | None = None, + max_column_width: int | None = None, stretch_height: bool = True, regular: bool = False, ) -> None: @@ -298,10 +300,12 @@ class ItemGrid(Widget): ) self.set_reactive(ItemGrid.stretch_height, stretch_height) self.set_reactive(ItemGrid.min_column_width, min_column_width) + self.set_reactive(ItemGrid.max_column_width, max_column_width) self.set_reactive(ItemGrid.regular, regular) def pre_layout(self, layout: Layout) -> None: if isinstance(layout, GridLayout): layout.stretch_height = self.stretch_height layout.min_column_width = self.min_column_width + layout.max_column_width = self.max_column_width layout.regular = self.regular diff --git a/contrib/python/textual/textual/content.py b/contrib/python/textual/textual/content.py index 490ec2011e9..3ceaa45c62b 100644 --- a/contrib/python/textual/textual/content.py +++ b/contrib/python/textual/textual/content.py @@ -106,6 +106,27 @@ class Span(NamedTuple): return Span(start, end + cells, style) return self + def _shift(self, distance: int) -> "Span": + """Shift a span a given distance. + + Note that the start offset is clamped to 0. + The end offset is not clamped, as it is assumed this has already been checked by the caller. + + Args: + distance: Number of characters to move. + + Returns: + New Span. + """ + if distance < 0: + start, end, style = self + return Span( + offset if (offset := start + distance) > 0 else 0, end + distance, style + ) + else: + start, end, style = self + return Span(start + distance, end + distance, style) + @rich.repr.auto @total_ordering @@ -126,6 +147,7 @@ class Content(Visual): text: str = "", spans: list[Span] | None = None, cell_length: int | None = None, + strip_control_codes: bool = True, ) -> None: """ Initialize a Content object. @@ -134,8 +156,12 @@ class Content(Visual): text: text content. spans: Optional list of spans. cell_length: Cell length of text if known, otherwise `None`. + strip_control_codes: Strip control codes that may break output? """ - self._text: str = _strip_control_codes(text) + + self._text: str = ( + _strip_control_codes(text) if strip_control_codes and text else text + ) self._spans: list[Span] = [] if spans is None else spans self._cell_length = cell_length self._optimal_width_cache: int | None = None @@ -147,10 +173,26 @@ class Content(Visual): self._split_cache: FIFOCache[tuple[str, bool, bool], list[Content]] | None = ( None ) + # If there are 1 or 0 spans, it can't be simplified further + self._simplified = len(self._spans) <= 1 def __str__(self) -> str: return self._text + @property + def _is_regular(self) -> bool: + """Check if the line is regular (spans.end > span.start for all spans). + + This is a debugging aid, and unlikely to be useful in your app. + + Returns: + `True` if the content is regular, `False` if it is not (and broken). + """ + for span in self.spans: + if span.end <= span.start: + return False + return True + @cached_property def markup(self) -> str: """Get content markup to render this Text. @@ -248,6 +290,8 @@ class Content(Visual): raise ValueError("A literal string is require to substitute variables.") return markup markup = _strip_control_codes(markup) + if "[" not in markup and not variables: + return Content(markup) from textual.markup import to_content content = to_content(markup, template_variables=variables or None) @@ -321,6 +365,7 @@ class Content(Visual): text: str, style: Style | str = "", cell_length: int | None = None, + strip_control_codes: bool = True, ) -> Content: """Create a Content instance from text and an optional style. @@ -328,21 +373,47 @@ class Content(Visual): text: String content. style: Desired style. cell_length: Cell length of text if known, otherwise `None`. + strip_control_codes: Strip control codes that may break output. Returns: New Content instance. """ if not text: - return Content("") - span_length = cell_len(text) if cell_length is None else cell_length + return EMPTY_CONTENT new_content = cls( - text, [Span(0, span_length, style)] if style else None, span_length + text, + [Span(0, len(text), style)] if style else None, + cell_length, + strip_control_codes=strip_control_codes, ) return new_content @classmethod + def blank(cls, width: int, style: Style | str | None = None) -> Content: + """Get a Content instance consisting of spaces. + + Args: + width: Width of blank content (number of spaces). + style: Style of blank. + + Returns: + Content instance. + """ + if not width: + return EMPTY_CONTENT + blank = cls( + " " * width, + [Span(0, width, style)] if style else None, + cell_length=width, + ) + return blank + + @classmethod def assemble( - cls, *parts: str | Content | tuple[str, str | Style], end: str = "" + cls, + *parts: str | Content | tuple[str, str | Style], + end: str = "", + strip_control_codes: bool = True, ) -> Content: """Construct new content from string, content, or tuples of (TEXT, STYLE). @@ -362,6 +433,7 @@ class Content(Visual): *parts: Parts to join to gether. A *part* may be a simple string, another Content instance, or tuple containing text and a style. end: Optional end to the Content. + strip_control_codes: Strip control codes that may break output. """ text: list[str] = [] spans: list[Span] = [] @@ -393,13 +465,16 @@ class Content(Visual): position += len(part.plain) if end: text_append(end) - return cls("".join(text), spans) + assembled_content = cls( + "".join(text), spans, strip_control_codes=strip_control_codes + ) + return assembled_content def simplify(self) -> Content: """Simplify spans by joining contiguous spans together. - This can produce faster renders but typically only worth it if you have appended a - large number of Content instances together. + This may produce faster renders if you have concatenated a large number of small pieces + of content with repeating styles. Note that this modifies the Content instance in-place, which might appear to violate the immutability constraints, but it will not change the rendered output, @@ -408,13 +483,12 @@ class Content(Visual): Returns: Self. """ - spans = self.spans - if not spans: + if not (spans := self._spans) or self._simplified: return self - last_span = Span(0, 0, Style()) + last_span = Span(-1, -1, "") new_spans: list[Span] = [] changed: bool = False - for span in self._spans: + for span in spans: if span.start == last_span.end and span.style == last_span.style: last_span = new_spans[-1] = Span(last_span.start, span.end, span.style) changed = True @@ -423,6 +497,25 @@ class Content(Visual): last_span = span if changed: self._spans[:] = new_spans + self._simplified = True + return self + + def add_spans(self, spans: Sequence[Span]) -> Content: + """Adds spans to this Content instance. + + Args: + spans: A sequence of spans. + + Returns: + A Content instance. + """ + if spans: + return Content( + self.plain, + [*self._spans, *spans], + self._cell_length, + strip_control_codes=False, + ) return self def __eq__(self, other: object) -> bool: @@ -696,7 +789,9 @@ class Content(Visual): @property def without_spans(self) -> Content: """The content with no spans""" - return Content(self.plain, [], self._cell_length) + if self._spans: + return Content(self.plain, [], self._cell_length, strip_control_codes=False) + return self @property def first_line(self) -> Content: @@ -715,6 +810,7 @@ class Content(Visual): for start, end, style in self._spans if end > offset >= start ], + strip_control_codes=False, ) return content @@ -723,8 +819,27 @@ class Content(Visual): else: start, stop, step = slice.indices(len(self.plain)) if step == 1: - lines = self.divide([start, stop]) - return lines[1] + if start == 0: + if stop >= len(self.plain): + return self + text = self.plain[:stop] + sliced_content = Content( + text, + self._trim_spans(text, self._spans), + strip_control_codes=False, + ) + else: + text = self.plain[start:stop] + spans = [ + span._shift(-start) + for span in self._spans + if span.end - start > 0 + ] + sliced_content = Content( + text, self._trim_spans(text, spans), strip_control_codes=False + ) + return sliced_content + else: # This would be a bit of work to implement efficiently # For now, its not required @@ -732,31 +847,31 @@ class Content(Visual): def __add__(self, other: Content | str) -> Content: if isinstance(other, str): - return Content(self._text + other, self._spans) + return Content(self._text + other, self._spans, strip_control_codes=False) if isinstance(other, Content): offset = len(self.plain) content = Content( self.plain + other.plain, - [ - *self._spans, - *[ + ( + self._spans + + [ Span(start + offset, end + offset, style) for start, end, style in other._spans - ], - ], + ] + ), ( - self.cell_length + other._cell_length - if other._cell_length is not None - else None + None + if self._cell_length is not None + else (self.cell_length + other.cell_length) ), ) return content return NotImplemented - def __radd__(self, other: Content | str) -> Content: - if not isinstance(other, (Content, str)): + def __radd__(self, other: str) -> Content: + if not isinstance(other, str): return NotImplemented - return self + other + return Content(other) + self @classmethod def _trim_spans(cls, text: str, spans: list[Span]) -> list[Span]: @@ -794,8 +909,9 @@ class Content(Visual): if self._cell_length is None else self._cell_length + cell_len(content) ), + strip_control_codes=False, ) - return Content("").join([self, content]) + return EMPTY_CONTENT.join([self, content]) def append_text(self, text: str, style: Style | str = "") -> Content: """Append text give as a string, with an optional style. @@ -829,12 +945,20 @@ class Content(Visual): """Iterate the lines, optionally inserting the separator.""" if self.plain: for last, line in loop_last(lines): - yield line if isinstance(line, Content) else Content(line) + yield ( + line + if isinstance(line, Content) + else Content(line, strip_control_codes=False) + ) if not last: yield self else: for line in lines: - yield line if isinstance(line, Content) else Content(line) + yield ( + line + if isinstance(line, Content) + else Content(line, strip_control_codes=False) + ) extend_text = text.extend extend_spans = spans.extend @@ -844,6 +968,8 @@ class Content(Visual): total_cell_length: int | None = self._cell_length for content in iter_content(): + if not content: + continue extend_text(content._text) extend_spans( _Span(offset + start, offset + end, style) @@ -860,6 +986,73 @@ class Content(Visual): return Content("".join(text), spans, total_cell_length) + def wrap( + self, width: int, *, align: TextAlign = "left", overflow: TextOverflow = "fold" + ) -> list[Content]: + """Wrap text so that it fits within the given dimensions. + + Note that Textual will automatically wrap Content in widgets. + This method is only required if you need some additional processing to lines. + + Args: + width: Maximum width of the line (in cells). + align: Alignment of lines. + overflow: Overflow of lines (what happens when the text doesn't fit). + + Returns: + A list of Content objects, one per line. + """ + lines = self._wrap_and_format(width, align, overflow) + content_lines = [line.content for line in lines] + return content_lines + + def fold(self, width: int) -> list[Content]: + """Fold this line into a list of lines which have a cell length no less than 2 and no greater than `width`. + + Folded lines may be 1 less than the width if it contains double width characters (which may + not be subdivided). + + Note that this method will not do any word wrapping. For that, see [wrap()][textual.content.Content.wrap]. + + Args: + width: Desired maximum width (in cells) + + Returns: + List of content instances. + """ + if not self: + return [self] + text = self.plain + lines: list[Content] = [] + position = 0 + width = max(width, 2) + while True: + snip = text[position : position + width] + if not snip: + break + snip_cell_length = cell_len(snip) + if snip_cell_length < width: + # last snip + lines.append(self[position : position + width]) + break + if snip_cell_length == width: + # Cell length is exactly width + lines.append(self[position : position + width]) + position += len(snip) + continue + # TODO: Can this be more efficient? + extra_cells = snip_cell_length - width + if start_snip := extra_cells // 2: + snip_cell_length -= cell_len(snip[-start_snip:]) + snip = snip[: len(snip) - start_snip] + while snip_cell_length > width: + snip_cell_length -= cell_len(snip[-1]) + snip = snip[:-1] + lines.append(self[position : position + len(snip)]) + position += len(snip) + + return lines + def get_style_at_offset(self, offset: int) -> Style: """Get the style of a character at give offset. @@ -906,13 +1099,16 @@ class Content(Visual): if pad and length < max_width: spaces = max_width - length text = f"{self.plain}{' ' * spaces}" + return Content(text, spans, max_width, strip_control_codes=False) elif length > max_width: if ellipsis and max_width: text = set_cell_size(self.plain, max_width - 1) + "…" else: text = set_cell_size(self.plain, max_width) spans = self._trim_spans(text, self._spans) - return Content(text, spans) + return Content(text, spans, max_width, strip_control_codes=False) + else: + return self def pad_left(self, count: int, character: str = " ") -> Content: """Pad the left with a given character. @@ -933,6 +1129,7 @@ class Content(Visual): text, spans, None if self._cell_length is None else self._cell_length + count, + strip_control_codes=False, ) return content @@ -958,6 +1155,7 @@ class Content(Visual): for span in self._spans ], None if self._cell_length is None else self._cell_length + count, + strip_control_codes=False, ) return self @@ -974,6 +1172,7 @@ class Content(Visual): f"{self.plain}{character * count}", self._spans, None if self._cell_length is None else self._cell_length + count, + strip_control_codes=False, ) return self @@ -1000,6 +1199,7 @@ class Content(Visual): text, spans, None if self._cell_length is None else self._cell_length + left + right, + strip_control_codes=False, ) return content @@ -1058,7 +1258,7 @@ class Content(Visual): ] text = self.plain[:-amount] length = None if self._cell_length is None else self._cell_length - amount - return Content(text, spans, length) + return Content(text, spans, length, strip_control_codes=False) def stylize( self, style: Style | str, start: int = 0, end: int | None = None @@ -1084,7 +1284,9 @@ class Content(Visual): return self return Content( self.plain, - [*self._spans, Span(start, length if length < end else end, style)], + self._spans + [Span(start, length if length < end else end, style)], + self._cell_length, + strip_control_codes=False, ) def stylize_before( @@ -1117,6 +1319,8 @@ class Content(Visual): return Content( self.plain, [Span(start, length if length < end else end, style), *self._spans], + self._cell_length, + strip_control_codes=False, ) def render( @@ -1139,7 +1343,6 @@ class Content(Visual): An iterable of string and styles, which make up the content. """ - if not self._spans: yield (self._text, base_style) if end: @@ -1437,6 +1640,9 @@ class Content(Visual): if "\t" not in self.plain: return self + if not self._spans: + return Content(self.plain.expandtabs(tab_size)) + new_text: list[Content] = [] append = new_text.append @@ -1449,7 +1655,7 @@ class Content(Visual): for part in parts: if part.plain.endswith("\t"): part = Content( - part._text[-1][:-1] + " ", part._spans, part._cell_length + part._text[:-1] + " ", part._spans, part._cell_length ) cell_position += part.cell_length tab_remainder = cell_position % tab_size @@ -1461,14 +1667,14 @@ class Content(Visual): cell_position += part.cell_length append(part) - content = Content("").join(new_text) + content = EMPTY_CONTENT.join(new_text) return content def highlight_regex( self, highlight_regex: re.Pattern[str] | str, *, - style: Style, + style: Style | str, maximum_highlights: int | None = None, ) -> Content: """Apply a style to text that matches a regular expression. @@ -1487,6 +1693,8 @@ class Content(Visual): plain = self.plain if isinstance(highlight_regex, str): re_highlight = re.compile(highlight_regex) + else: + re_highlight = highlight_regex count = 0 for match in re_highlight.finditer(plain): start, end = match.span() @@ -1497,7 +1705,7 @@ class Content(Visual): and (count := count + 1) >= maximum_highlights ): break - return Content(self._text, spans) + return Content(self._text, spans, cell_length=self._cell_length) class _FormattedLine: diff --git a/contrib/python/textual/textual/css/_style_properties.py b/contrib/python/textual/textual/css/_style_properties.py index edc591966e3..7f63cf152b8 100644 --- a/contrib/python/textual/textual/css/_style_properties.py +++ b/contrib/python/textual/textual/css/_style_properties.py @@ -667,8 +667,9 @@ class LayoutProperty: Args: obj: The Styles object. objtype: The Styles class. + Returns: - The ``Layout`` object. + The `Layout` object. """ return obj.get_rule(self.name) # type: ignore[return-value] @@ -677,7 +678,7 @@ class LayoutProperty: Args: obj: The Styles object. layout: The layout to use. You can supply the name of the layout - or a ``Layout`` object. + or a `Layout` object. """ from textual.layouts.factory import Layout # Prevents circular import @@ -687,19 +688,23 @@ class LayoutProperty: if layout is None: if obj.clear_rule("layout"): obj.refresh(layout=True, children=True) - elif isinstance(layout, Layout): - if obj.set_rule("layout", layout): - obj.refresh(layout=True, children=True) - else: - try: - layout_object = get_layout(layout) - except MissingLayout as error: - raise StyleValueError( - str(error), - help_text=layout_property_help_text(self.name, context="inline"), - ) - if obj.set_rule("layout", layout_object): - obj.refresh(layout=True, children=True) + return + + if isinstance(layout, Layout): + layout = layout.name + + if obj.layout is not None and obj.layout.name == layout: + return + + try: + layout_object = get_layout(layout) + except MissingLayout as error: + raise StyleValueError( + str(error), + help_text=layout_property_help_text(self.name, context="inline"), + ) + if obj.set_rule("layout", layout_object): + obj.refresh(layout=True, children=True) class OffsetProperty: @@ -841,6 +846,12 @@ class StringEnumProperty(Generic[EnumType]): children=self._refresh_children, parent=self._refresh_parent, ) + + if self._display: + node = obj.node + if node is not None and node.parent: + node._nodes.updated() + else: if value not in self._valid_values: raise StyleValueError( diff --git a/contrib/python/textual/textual/css/_styles_builder.py b/contrib/python/textual/textual/css/_styles_builder.py index c2234ecaae7..95c157e8585 100644 --- a/contrib/python/textual/textual/css/_styles_builder.py +++ b/contrib/python/textual/textual/css/_styles_builder.py @@ -52,6 +52,7 @@ from textual.css.constants import ( VALID_OVERLAY, VALID_POSITION, VALID_SCROLLBAR_GUTTER, + VALID_SCROLLBAR_VISIBILITY, VALID_STYLE_FLAGS, VALID_TEXT_ALIGN, VALID_TEXT_OVERFLOW, @@ -76,6 +77,7 @@ from textual.css.types import ( Display, EdgeType, Overflow, + ScrollbarVisibility, TextOverflow, TextWrap, Visibility, @@ -768,6 +770,13 @@ class StylesBuilder: process_scrollbar_background_hover = process_color process_scrollbar_background_active = process_color + def process_scrollbar_visibility(self, name: str, tokens: list[Token]) -> None: + """Process scrollbar visibility rules.""" + self.styles._rules["scrollbar_visibility"] = cast( + ScrollbarVisibility, + self._process_enum(name, tokens, VALID_SCROLLBAR_VISIBILITY), + ) + process_link_color = process_color process_link_background = process_color process_link_color_hover = process_color diff --git a/contrib/python/textual/textual/css/constants.py b/contrib/python/textual/textual/css/constants.py index 7928dfd90aa..46606d67097 100644 --- a/contrib/python/textual/textual/css/constants.py +++ b/contrib/python/textual/textual/css/constants.py @@ -24,6 +24,7 @@ VALID_BORDER: Final = { "tall", "tab", "thick", + "block", "vkey", "wide", } @@ -89,6 +90,7 @@ VALID_HATCH: Final = {"left", "right", "cross", "vertical", "horizontal"} VALID_TEXT_WRAP: Final = {"wrap", "nowrap"} VALID_TEXT_OVERFLOW: Final = {"clip", "fold", "ellipsis"} VALID_EXPAND: Final = {"greedy", "optimal"} +VALID_SCROLLBAR_VISIBILITY: Final = {"visible", "hidden"} HATCHES: Final = { "left": "╲", diff --git a/contrib/python/textual/textual/css/styles.py b/contrib/python/textual/textual/css/styles.py index b613e21c8f1..406e2a30584 100644 --- a/contrib/python/textual/textual/css/styles.py +++ b/contrib/python/textual/textual/css/styles.py @@ -48,6 +48,7 @@ from textual.css.constants import ( VALID_OVERLAY, VALID_POSITION, VALID_SCROLLBAR_GUTTER, + VALID_SCROLLBAR_VISIBILITY, VALID_TEXT_ALIGN, VALID_TEXT_OVERFLOW, VALID_TEXT_WRAP, @@ -153,11 +154,10 @@ class RulesMap(TypedDict, total=False): scrollbar_background: Color scrollbar_background_hover: Color scrollbar_background_active: Color - scrollbar_gutter: ScrollbarGutter - scrollbar_size_vertical: int scrollbar_size_horizontal: int + scrollbar_visibility: ScrollbarVisibility align_horizontal: AlignHorizontal align_vertical: AlignVertical @@ -242,6 +242,7 @@ class StylesBase: "scrollbar_background", "scrollbar_background_hover", "scrollbar_background_active", + "scrollbar_visibility", "link_color", "link_background", "link_color_hover", @@ -282,7 +283,7 @@ class StylesBase: """ layout = LayoutProperty() - """Set the layout of the widget, defining how it's children are laid out. + """Set the layout of the widget, defining how its children are laid out. Valid values are "grid", "stream", "horizontal", or "vertical" or None to clear any layout that was set at runtime. @@ -394,7 +395,7 @@ class StylesBase: transitions = TransitionsProperty() tint = ColorProperty("transparent") - """Set the tint of the widget. This allows you apply a opaque color above the widget. + """Set the tint of the widget. This allows you apply an opaque color above the widget. You can specify an opacity after a color e.g. "blue 10%" """ @@ -424,6 +425,10 @@ class StylesBase: """Set the width of the vertical scrollbar (measured in cells).""" scrollbar_size_horizontal = IntegerProperty(default=1, layout=True) """Set the height of the horizontal scrollbar (measured in cells).""" + scrollbar_visibility = StringEnumProperty( + VALID_SCROLLBAR_VISIBILITY, "visible", layout=True + ) + """Sets the visibility of the scrollbar.""" align_horizontal = StringEnumProperty( VALID_ALIGN_HORIZONTAL, "left", layout=True, refresh_children=True @@ -1153,6 +1158,8 @@ class Styles(StylesBase): append_declaration( "scrollbar-size-vertical", str(self.scrollbar_size_vertical) ) + if "scrollbar_visibility" in rules: + append_declaration("scrollbar-visibility", self.scrollbar_visibility) if "box_sizing" in rules: append_declaration("box-sizing", self.box_sizing) @@ -1463,7 +1470,6 @@ class RenderStyles(StylesBase): return any(inline_has_rule(name) or base_has_rule(name) for name in rule_names) def set_rule(self, rule_name: str, value: object | None) -> bool: - self._updates += 1 return self._inline_styles.set_rule(rule_name, value) def get_rule(self, rule_name: str, default: object = None) -> object: @@ -1473,7 +1479,6 @@ class RenderStyles(StylesBase): def clear_rule(self, rule_name: str) -> bool: """Clear a rule (from inline).""" - self._updates += 1 return self._inline_styles.clear_rule(rule_name) def get_rules(self) -> RulesMap: diff --git a/contrib/python/textual/textual/css/types.py b/contrib/python/textual/textual/css/types.py index d75b0c38abe..d2b24348088 100644 --- a/contrib/python/textual/textual/css/types.py +++ b/contrib/python/textual/textual/css/types.py @@ -16,6 +16,7 @@ EdgeType = Literal[ "round", "solid", "thick", + "block", "double", "dashed", "heavy", @@ -43,6 +44,7 @@ Position = Literal["relative", "absolute"] TextWrap = Literal["wrap", "nowrap"] TextOverflow = Literal["clip", "fold", "ellipsis"] Expand = Literal["greedy", "expand"] +ScrollbarVisibility = Literal["visible", "hidden"] Specificity3 = Tuple[int, int, int] Specificity6 = Tuple[int, int, int, int, int, int] diff --git a/contrib/python/textual/textual/demo/_project_data.py b/contrib/python/textual/textual/demo/_project_data.py new file mode 100644 index 00000000000..6dcb00c8f7b --- /dev/null +++ b/contrib/python/textual/textual/demo/_project_data.py @@ -0,0 +1,107 @@ +from dataclasses import dataclass + + +@dataclass +class ProjectInfo: + """Dataclass for storing project information.""" + + title: str + author: str + url: str + description: str + repo_url_part: str + + +PROJECTS = [ + ProjectInfo( + "Posting", + "Darren Burns", + "https://posting.sh/", + "Posting is an HTTP client, not unlike Postman and Insomnia. As a TUI application, it can be used over SSH and enables efficient keyboard-centric workflows. ", + "darrenburns/posting", + ), + ProjectInfo( + "Memray", + "Bloomberg", + "https://github.com/bloomberg/memray", + "Memray is a memory profiler for Python. It can track memory allocations in Python code, in native extension modules, and in the Python interpreter itself.", + "bloomberg/memray", + ), + ProjectInfo( + "Toolong", + "Will McGugan", + "https://github.com/Textualize/toolong", + "A terminal application to view, tail, merge, and search log files (plus JSONL).", + "Textualize/toolong", + ), + ProjectInfo( + "Dolphie", + "Charles Thompson", + "https://github.com/charles-001/dolphie", + "Your single pane of glass for real-time analytics into MySQL/MariaDB & ProxySQL", + "charles-001/dolphie", + ), + ProjectInfo( + "Harlequin", + "Ted Conbeer", + "https://harlequin.sh/", + "Portable, powerful, colorful. An easy, fast, and beautiful database client for the terminal.", + "tconbeer/harlequin", + ), + ProjectInfo( + "Elia", + "Darren Burns", + "https://github.com/darrenburns/elia", + "A snappy, keyboard-centric terminal user interface for interacting with large language models.", + "darrenburns/elia", + ), + ProjectInfo( + "Trogon", + "Textualize", + "https://github.com/Textualize/trogon", + "Auto-generate friendly terminal user interfaces for command line apps.", + "Textualize/trogon", + ), + ProjectInfo( + "TFTUI - The Terraform textual UI", + "Ido Avraham", + "https://github.com/idoavrah/terraform-tui", + "TFTUI is a powerful textual UI that empowers users to effortlessly view and interact with their Terraform state.", + "idoavrah/terraform-tui", + ), + ProjectInfo( + "RecoverPy", + "Pablo Lecolinet", + "https://github.com/PabloLec/RecoverPy", + "RecoverPy is a powerful tool that leverages your system capabilities to recover lost files.", + "PabloLec/RecoverPy", + ), + ProjectInfo( + "Frogmouth", + "Dave Pearson", + "https://github.com/Textualize/frogmouth", + "Frogmouth is a Markdown viewer / browser for your terminal, built with Textual.", + "Textualize/frogmouth", + ), + ProjectInfo( + "oterm", + "Yiorgis Gozadinos", + "https://github.com/ggozad/oterm", + "The text-based terminal client for Ollama.", + "ggozad/oterm", + ), + ProjectInfo( + "logmerger", + "Paul McGuire", + "https://github.com/ptmcg/logmerger", + "logmerger is a TUI for viewing a merged display of multiple log files, merged by timestamp.", + "ptmcg/logmerger", + ), + ProjectInfo( + "doit", + "Murli Tawari", + "https://github.com/dooit-org/dooit", + "A todo manager that you didn't ask for, but needed!", + "dooit-org/dooit", + ), +] diff --git a/contrib/python/textual/textual/demo/_project_stargazer_updater.py b/contrib/python/textual/textual/demo/_project_stargazer_updater.py new file mode 100644 index 00000000000..ab2d0f0c922 --- /dev/null +++ b/contrib/python/textual/textual/demo/_project_stargazer_updater.py @@ -0,0 +1,50 @@ +import httpx +import os +import json +from rich.console import Console + +# Not using the Absolute reference because +# I can't get python to run it. +from _project_data import PROJECTS + +console = Console() +error_console = Console(stderr=True, style="bold red") + + +def main() -> None: + STARS = {} + + for project in PROJECTS: + # get each repo + console.log(f"Checking {project.repo_url_part}") + response = httpx.get(f"https://api.github.com/repos/{project.repo_url_part}") + if response.status_code == 200: + # get stargazers + stargazers = response.json()["stargazers_count"] + if stargazers // 1000 != 0: + # humanize them + stargazers = f"{stargazers / 1000:.1f}k" + else: + stargazers = str(stargazers) + STARS[project.title] = stargazers + elif response.status_code == 403: + # gh api rate limited + error_console.log( + "GitHub has received too many requests and started rate limiting." + ) + exit(1) + else: + # any other reason + print( + f"GET https://api.github.com/repos/{project.repo_url_part} returned status code {response.status_code}" + ) + # replace + with open( + os.path.join(os.path.dirname(__file__), "_project_stars.py"), "w" + ) as file: + file.write("STARS = " + json.dumps(STARS, indent=4)) + console.log("Done!") + + +if __name__ == "__main__": + main() diff --git a/contrib/python/textual/textual/demo/_project_stars.py b/contrib/python/textual/textual/demo/_project_stars.py new file mode 100644 index 00000000000..d4b313ff6f0 --- /dev/null +++ b/contrib/python/textual/textual/demo/_project_stars.py @@ -0,0 +1,15 @@ +STARS = { + "Posting": "9.7k", + "Memray": "14.3k", + "Toolong": "3.5k", + "Dolphie": "872", + "Harlequin": "4.8k", + "Elia": "2.2k", + "Trogon": "2.7k", + "TFTUI - The Terraform textual UI": "1.2k", + "RecoverPy": "1.5k", + "Frogmouth": "2.9k", + "oterm": "2.1k", + "logmerger": "204", + "doit": "2.6k", +} diff --git a/contrib/python/textual/textual/demo/projects.py b/contrib/python/textual/textual/demo/projects.py index e264fd71434..dbf40eade69 100644 --- a/contrib/python/textual/textual/demo/projects.py +++ b/contrib/python/textual/textual/demo/projects.py @@ -8,18 +8,8 @@ from textual.binding import Binding from textual.containers import Center, Horizontal, ItemGrid, Vertical, VerticalScroll from textual.demo.page import PageScreen from textual.widgets import Footer, Label, Link, Markdown, Static - - -@dataclass -class ProjectInfo: - """Dataclass for storing project information.""" - - title: str - author: str - url: str - description: str - stars: str - +from textual.demo._project_stars import STARS +from textual.demo._project_data import PROJECTS, ProjectInfo PROJECTS_MD = """\ # Projects @@ -30,101 +20,6 @@ And many more still in development. See below for a small selection! """ -PROJECTS = [ - ProjectInfo( - "Posting", - "Darren Burns", - "https://posting.sh/", - """Posting is an HTTP client, not unlike Postman and Insomnia. As a TUI application, it can be used over SSH and enables efficient keyboard-centric workflows. """, - "6.0k", - ), - ProjectInfo( - "Memray", - "Bloomberg", - "https://github.com/bloomberg/memray", - """Memray is a memory profiler for Python. It can track memory allocations in Python code, in native extension modules, and in the Python interpreter itself.""", - "13.3k", - ), - ProjectInfo( - "Toolong", - "Will McGugan", - "https://github.com/Textualize/toolong", - """A terminal application to view, tail, merge, and search log files (plus JSONL).""", - "3.2k", - ), - ProjectInfo( - "Dolphie", - "Charles Thompson", - "https://github.com/charles-001/dolphie", - "Your single pane of glass for real-time analytics into MySQL/MariaDB & ProxySQL", - "649", - ), - ProjectInfo( - "Harlequin", - "Ted Conbeer", - "https://harlequin.sh/", - """Portable, powerful, colorful. An easy, fast, and beautiful database client for the terminal.""", - "3.7k", - ), - ProjectInfo( - "Elia", - "Darren Burns", - "https://github.com/darrenburns/elia", - """A snappy, keyboard-centric terminal user interface for interacting with large language models. -Chat with Claude 3, ChatGPT, and local models like Llama 3, Phi 3, Mistral and Gemma.""", - "1.8k", - ), - ProjectInfo( - "Trogon", - "Textualize", - "https://github.com/Textualize/trogon", - "Auto-generate friendly terminal user interfaces for command line apps.", - "2.5k", - ), - ProjectInfo( - "TFTUI - The Terraform textual UI", - "Ido Avraham", - "https://github.com/idoavrah/terraform-tui", - "TFTUI is a powerful textual UI that empowers users to effortlessly view and interact with their Terraform state.", - "1k", - ), - ProjectInfo( - "RecoverPy", - "Pablo Lecolinet", - "https://github.com/PabloLec/RecoverPy", - """RecoverPy is a powerful tool that leverages your system capabilities to recover lost files.""", - "1.3k", - ), - ProjectInfo( - "Frogmouth", - "Dave Pearson", - "https://github.com/Textualize/frogmouth", - """Frogmouth is a Markdown viewer / browser for your terminal, built with Textual.""", - "2.5k", - ), - ProjectInfo( - "oterm", - "Yiorgis Gozadinos", - "https://github.com/ggozad/oterm", - "The text-based terminal client for Ollama.", - "1k", - ), - ProjectInfo( - "logmerger", - "Paul McGuire", - "https://github.com/ptmcg/logmerger", - "logmerger is a TUI for viewing a merged display of multiple log files, merged by timestamp.", - "162", - ), - ProjectInfo( - "doit", - "Murli Tawari", - "https://github.com/kraanzu/dooit", - "A todo manager that you didn't ask for, but needed!", - "2.1k", - ), -] - class Project(Vertical, can_focus=True, can_focus_children=False): """Display project information and open repo links.""" @@ -133,16 +28,16 @@ class Project(Vertical, can_focus=True, can_focus_children=False): DEFAULT_CSS = """ Project { width: 1fr; - height: auto; + height: auto; padding: 0 1; border: tall transparent; box-sizing: border-box; - &:focus { + &:focus { border: tall $text-primary; background: $primary 20%; &.link { color: red !important; - } + } } #title { text-style: bold; width: 1fr; } #author { text-style: italic; } @@ -179,7 +74,7 @@ class Project(Vertical, can_focus=True, can_focus_children=False): info = self.project_info with Horizontal(classes="header"): yield Label(info.title, id="title") - yield Label(f"★ {info.stars}", classes="stars") + yield Label(f"★ {STARS[info.title]}", classes="stars") yield Label(info.author, id="author") yield Link(info.url, tooltip="Click to open project repository") yield Static(info.description, classes="description") @@ -197,18 +92,18 @@ class Project(Vertical, can_focus=True, can_focus_children=False): class ProjectsScreen(PageScreen): AUTO_FOCUS = None CSS = """ - ProjectsScreen { - align-horizontal: center; + ProjectsScreen { + align-horizontal: center; ItemGrid { margin: 2 4; padding: 1 2; background: $boost; width: 1fr; - height: auto; + height: auto; grid-gutter: 1 1; - grid-rows: auto; - keyline:thin $foreground 30%; - } + grid-rows: auto; + keyline:thin $foreground 30%; + } Markdown { margin: 0; padding: 0 2; max-width: 100; background: transparent; } } """ diff --git a/contrib/python/textual/textual/demo/widgets.py b/contrib/python/textual/textual/demo/widgets.py index e3732321b05..1a70f5a0625 100644 --- a/contrib/python/textual/textual/demo/widgets.py +++ b/contrib/python/textual/textual/demo/widgets.py @@ -587,7 +587,7 @@ class Switches(containers.VerticalGroup): SWITCHES_MD = """\ ## Switches -Functionally almost identical to a Checkbox, but more displays more prominently in the UI. +Functionally almost identical to a Checkbox, but displays more prominently in the UI. """ DEFAULT_CSS = """\ Switches { diff --git a/contrib/python/textual/textual/design.py b/contrib/python/textual/textual/design.py index 33d0e6c3e47..f72dd7158c8 100644 --- a/contrib/python/textual/textual/design.py +++ b/contrib/python/textual/textual/design.py @@ -251,7 +251,7 @@ class ColorSystem: "block-cursor-blurred-text-style", "none" ) colors["block-hover-background"] = get( - "block-hover-background", boost.with_alpha(0.05).hex + "block-hover-background", boost.with_alpha(0.1).hex ) # The border color for focused widgets which have a border. diff --git a/contrib/python/textual/textual/dom.py b/contrib/python/textual/textual/dom.py index 2dba90b015b..92dfbd5cd64 100644 --- a/contrib/python/textual/textual/dom.py +++ b/contrib/python/textual/textual/dom.py @@ -10,7 +10,6 @@ import re import threading from functools import lru_cache, partial from inspect import getfile -from operator import attrgetter from typing import ( TYPE_CHECKING, Any, @@ -126,7 +125,7 @@ class _ClassesDescriptor: class_names = set(classes) check_identifiers("class name", *class_names) obj._classes = class_names - obj._update_styles() + obj.update_node_styles() @rich.repr.auto @@ -180,7 +179,7 @@ class DOMNode(MessagePump): # Names of potential computed reactives _computes: ClassVar[frozenset[str]] - _PSEUDO_CLASSES: ClassVar[dict[str, Callable[[object], bool]]] = {} + _PSEUDO_CLASSES: ClassVar[dict[str, Callable[[App[Any]], bool]]] = {} """Pseudo class checks.""" def __init__( @@ -229,6 +228,7 @@ class DOMNode(MessagePump): ) = None self._pruning = False self._query_one_cache: LRUCache[QueryOneCacheKey, DOMNode] = LRUCache(1024) + self._trap_focus = False super().__init__() @@ -409,6 +409,24 @@ class DOMNode(MessagePump): return self._nodes @property + def displayed_children(self) -> Sequence[Widget]: + """The displayed children (where `node.display==True`). + + Returns: + A sequence of widgets. + """ + return self._nodes.displayed + + @property + def displayed_and_visible_children(self) -> Sequence[Widget]: + """The displayed children (where `node.display==True` and `node.visible==True`). + + Returns: + A sequence of widgets. + """ + return self._nodes.displayed_and_visible + + @property def is_empty(self) -> bool: """Are there no displayed children?""" return not any(child.display for child in self._nodes) @@ -458,6 +476,20 @@ class DOMNode(MessagePump): """The app's worker manager. Shortcut for `self.app.workers`.""" return self.app.workers + def trap_focus(self, trap_focus: bool = True) -> None: + """Trap the focus. + + When applied to a container, this will limit tab-to-focus to the children of that + container (once focus is within that container). + + This can be useful for widgets that act like modal dialogs, where you want to restrict + the user to the controls within the dialog. + + Args: + trap_focus: `True` to trap focus. `False` to restore default behavior. + """ + self._trap_focus = trap_focus + def run_worker( self, work: WorkType[ResultType], @@ -1102,7 +1134,7 @@ class DOMNode(MessagePump): def _get_title_style_information( self, background: Color ) -> tuple[Color, Color, VisualStyle]: - """Get a Visual Style object for for titles. + """Get a Visual Style object for titles. Args: background: The background color. @@ -1125,7 +1157,7 @@ class DOMNode(MessagePump): def _get_subtitle_style_information( self, background: Color ) -> tuple[Color, Color, VisualStyle]: - """Get a Rich Style object for for titles. + """Get a Rich Style object for subtitles. Args: background: The background color. @@ -1146,26 +1178,12 @@ class DOMNode(MessagePump): @property def background_colors(self) -> tuple[Color, Color]: - """The background color and the color of the parent's background. - - Returns: - `(<background color>, <color>)` - """ - base_background = background = BLACK - for node in reversed(self.ancestors_with_self): - styles = node.styles - base_background = background - background += styles.background.tint(styles.background_tint) - return (base_background, background) - - @property - def _opacity_background_colors(self) -> tuple[Color, Color]: """Background colors adjusted for opacity. Returns: `(<background color>, <color>)` """ - base_background = background = BLACK + base_background = background = Color(0, 0, 0, 0) opacity = 1.0 for node in reversed(self.ancestors_with_self): styles = node.styles @@ -1229,15 +1247,6 @@ class DOMNode(MessagePump): add_node(node) return cast("list[DOMNode]", nodes) - @property - def displayed_children(self) -> list[Widget]: - """The child nodes which will be displayed. - - Returns: - A list of nodes. - """ - return list(filter(attrgetter("display"), self._nodes)) - def watch( self, obj: DOMNode, @@ -1614,7 +1623,7 @@ class DOMNode(MessagePump): self, selector: str | type[QueryType], expect_type: type[QueryType] | None = None, - ) -> DOMNode | None: + ) -> DOMNode: """Get an ancestor which matches a query. Args: @@ -1724,13 +1733,11 @@ class DOMNode(MessagePump): self.classes = classes return self - def _update_styles(self) -> None: + def update_node_styles(self) -> None: """Request an update of this node's styles. - Should be called whenever CSS classes / pseudo classes change. + Called by Textual whenever CSS classes / pseudo classes change. """ - if not self.is_attached: - return try: self.app.update_styles(self) except NoActiveAppError: @@ -1752,7 +1759,7 @@ class DOMNode(MessagePump): if old_classes == self._classes: return self if update: - self._update_styles() + self.update_node_styles() return self def remove_class(self, *class_names: str, update: bool = True) -> Self: @@ -1771,7 +1778,7 @@ class DOMNode(MessagePump): if old_classes == self._classes: return self if update: - self._update_styles() + self.update_node_styles() return self def toggle_class(self, *class_names: str) -> Self: @@ -1788,7 +1795,7 @@ class DOMNode(MessagePump): self._classes.symmetric_difference_update(class_names) if old_classes == self._classes: return self - self._update_styles() + self.update_node_styles() return self def has_pseudo_class(self, class_name: str) -> bool: diff --git a/contrib/python/textual/textual/driver.py b/contrib/python/textual/textual/driver.py index 61978934132..4908b226828 100644 --- a/contrib/python/textual/textual/driver.py +++ b/contrib/python/textual/textual/driver.py @@ -234,7 +234,7 @@ class Driver(ABC): in the `Content-Type` header. mime_type: *web only* The MIME type of the file. This will be used to set the `Content-Type` header in the HTTP response. - name: A user-defined named which will be returned in [`DeliveryComplete`][textual.events.DeliveryComplete] + name: A user-defined name which will be returned in [`DeliveryComplete`][textual.events.DeliveryComplete] and [`DeliveryComplete`][textual.events.DeliveryComplete]. """ diff --git a/contrib/python/textual/textual/drivers/windows_driver.py b/contrib/python/textual/textual/drivers/windows_driver.py index 3a908afbe73..76fd9656522 100644 --- a/contrib/python/textual/textual/drivers/windows_driver.py +++ b/contrib/python/textual/textual/drivers/windows_driver.py @@ -95,8 +95,8 @@ class WindowsDriver(Driver): self.write("\x1b[?1049h") # Enable alt screen self._enable_mouse_support() self.write("\x1b[?25l") # Hide cursor - self.write("\033[?1003h") self.write("\033[?1004h") # Enable FocusIn/FocusOut. + self.write("\x1b[>1u") # https://sw.kovidgoyal.net/kitty/keyboard-protocol/ self.flush() self._enable_bracketed_paste() @@ -124,6 +124,10 @@ class WindowsDriver(Driver): self._disable_bracketed_paste() self.disable_input() + # Disable the Kitty keyboard protocol. This must be done before leaving + # the alt screen. https://sw.kovidgoyal.net/kitty/keyboard-protocol/ + self.write("\x1b[<u") + # Disable alt screen, show cursor self.write("\x1b[?1049l" + "\x1b[?25h") self.write("\033[?1004l") # Disable FocusIn/FocusOut. diff --git a/contrib/python/textual/textual/events.py b/contrib/python/textual/textual/events.py index 29271cb1aba..b1d79d8d341 100644 --- a/contrib/python/textual/textual/events.py +++ b/contrib/python/textual/textual/events.py @@ -976,3 +976,7 @@ class DeliveryFailed(Event, bubble=False): name: str | None = None """Optional name returned to the app to identify the download.""" + + +class TextSelected(Event, bubble=True): + """Sent from the screen when text is selected (Not Input and TextArea)""" diff --git a/contrib/python/textual/textual/filter.py b/contrib/python/textual/textual/filter.py index 9b37fb7e804..8f8e1086dd4 100644 --- a/contrib/python/textual/textual/filter.py +++ b/contrib/python/textual/textual/filter.py @@ -240,22 +240,27 @@ class ANSIToTruecolor(LineFilter): New style. """ terminal_theme = self._terminal_theme - color = style.color - if color is not None and color.triplet is None: - color = RichColor.from_rgb( - *color.get_truecolor(terminal_theme, foreground=True) - ) - bgcolor = style.bgcolor - if bgcolor is not None and bgcolor.triplet is None: - bgcolor = RichColor.from_rgb( - *bgcolor.get_truecolor(terminal_theme, foreground=False) + + changed = False + if (color := style.color) is not None: + if color.triplet is None: + color = RichColor.from_triplet( + color.get_truecolor(terminal_theme, foreground=True) + ) + changed = True + + if (bgcolor := style.bgcolor) is not None and bgcolor.triplet is None: + bgcolor = RichColor.from_triplet( + bgcolor.get_truecolor(terminal_theme, foreground=False) ) - # Convert dim style to RGB + changed = True + if style.dim and color is not None: - color = dim_color(background, color) + color = dim_color(background if bgcolor is None else bgcolor, color) style += NO_DIM + changed = True - return style + Style.from_color(color, bgcolor) + return style + Style.from_color(color, bgcolor) if changed else style def apply(self, segments: list[Segment], background: Color) -> list[Segment]: """Transform a list of segments. @@ -269,9 +274,7 @@ class ANSIToTruecolor(LineFilter): """ _Segment = Segment truecolor_style = self.truecolor_style - background_rich_color = background.rich_color - return [ _Segment( text, diff --git a/contrib/python/textual/textual/fuzzy.py b/contrib/python/textual/textual/fuzzy.py index efdb4ed3b73..34c518c8027 100644 --- a/contrib/python/textual/textual/fuzzy.py +++ b/contrib/python/textual/textual/fuzzy.py @@ -7,9 +7,10 @@ This class is used by the [command palette](/guide/command_palette) to match sea from __future__ import annotations +from functools import lru_cache from operator import itemgetter -from re import IGNORECASE, escape, finditer, search -from typing import Iterable, NamedTuple +from re import finditer +from typing import Iterable, Sequence import rich.repr @@ -18,60 +19,28 @@ from textual.content import Content from textual.visual import Style -class _Search(NamedTuple): - """Internal structure to keep track of a recursive search.""" - - candidate_offset: int = 0 - query_offset: int = 0 - offsets: tuple[int, ...] = () - - def branch(self, offset: int) -> tuple[_Search, _Search]: - """Branch this search when an offset is found. - - Args: - offset: Offset of a matching letter in the query. - - Returns: - A pair of search objects. - """ - _, query_offset, offsets = self - return ( - _Search(offset + 1, query_offset + 1, offsets + (offset,)), - _Search(offset + 1, query_offset, offsets), - ) - - @property - def groups(self) -> int: - """Number of groups in offsets.""" - groups = 1 - last_offset, *offsets = self.offsets - for offset in offsets: - if offset != last_offset + 1: - groups += 1 - last_offset = offset - return groups - - class FuzzySearch: """Performs a fuzzy search. Unlike a regex solution, this will finds all possible matches. """ - cache: LRUCache[tuple[str, str, bool], tuple[float, tuple[int, ...]]] = LRUCache( - 1024 * 4 - ) - - def __init__(self, case_sensitive: bool = False) -> None: + def __init__( + self, case_sensitive: bool = False, *, cache_size: int = 1024 * 4 + ) -> None: """Initialize fuzzy search. Args: case_sensitive: Is the match case sensitive? + cache_size: Number of queries to cache. """ self.case_sensitive = case_sensitive + self.cache: LRUCache[tuple[str, str], tuple[float, Sequence[int]]] = LRUCache( + cache_size + ) - def match(self, query: str, candidate: str) -> tuple[float, tuple[int, ...]]: + def match(self, query: str, candidate: str) -> tuple[float, Sequence[int]]: """Match against a query. Args: @@ -81,86 +50,105 @@ class FuzzySearch: Returns: A pair of (score, tuple of offsets). `(0, ())` for no result. """ - query_regex = ".*?".join(f"({escape(character)})" for character in query) - if not search( - query_regex, candidate, flags=0 if self.case_sensitive else IGNORECASE - ): - # Bail out early if there is no possibility of a match - return (0.0, ()) - cache_key = (query, candidate, self.case_sensitive) + cache_key = (query, candidate) if cache_key in self.cache: return self.cache[cache_key] - result = max( - self._match(query, candidate), key=itemgetter(0), default=(0.0, ()) - ) + default: tuple[float, Sequence[int]] = (0.0, []) + result = max(self._match(query, candidate), key=itemgetter(0), default=default) self.cache[cache_key] = result return result - def _match( - self, query: str, candidate: str - ) -> Iterable[tuple[float, tuple[int, ...]]]: - """Generator to do the matching. + @classmethod + @lru_cache(maxsize=1024) + def get_first_letters(cls, candidate: str) -> frozenset[int]: + return frozenset({match.start() for match in finditer(r"\w+", candidate)}) + + def score(self, candidate: str, positions: Sequence[int]) -> float: + """Score a search. Args: - query: Query to match. - candidate: Candidate to check against. + search: Search object. - Yields: - Pairs of score and tuple of offsets. + Returns: + Score. """ + first_letters = self.get_first_letters(candidate) + # This is a heuristic, and can be tweaked for better results + # Boost first letter matches + offset_count = len(positions) + score: float = offset_count + len(first_letters.intersection(positions)) + + groups = 1 + last_offset, *offsets = positions + for offset in offsets: + if offset != last_offset + 1: + groups += 1 + last_offset = offset + + # Boost to favor less groups + normalized_groups = (offset_count - (groups - 1)) / offset_count + score *= 1 + (normalized_groups * normalized_groups) + return score + + def _match( + self, query: str, candidate: str + ) -> Iterable[tuple[float, Sequence[int]]]: + letter_positions: list[list[int]] = [] + position = 0 + if not self.case_sensitive: - query = query.lower() candidate = candidate.lower() + query = query.lower() + score = self.score + if query in candidate: + # Quick exit when the query exists as a substring + query_location = candidate.find(query) + offsets = list(range(query_location, query_location + len(query))) + yield ( + score(candidate, offsets) * (2.0 if candidate == query else 1.5), + offsets, + ) + return - # We need this to give a bonus to first letters. - first_letters = {match.start() for match in finditer(r"\w+", candidate)} + for offset, letter in enumerate(query): + last_index = len(candidate) - offset + positions: list[int] = [] + letter_positions.append(positions) + index = position + while (location := candidate.find(letter, index)) != -1: + positions.append(location) + index = location + 1 + if index >= last_index: + break + if not positions: + yield (0.0, ()) + return + position = positions[0] + 1 - def score(search: _Search) -> float: - """Sore a search. + possible_offsets: list[list[int]] = [] + query_length = len(query) - Args: - search: Search object. + def get_offsets(offsets: list[int], positions_index: int) -> None: + """Recursively match offsets. - Returns: - Score. + Args: + offsets: A list of offsets. + positions_index: Index of query letter. """ - # This is a heuristic, and can be tweaked for better results - # Boost first letter matches - offset_count = len(search.offsets) - score: float = offset_count + len( - first_letters.intersection(search.offsets) - ) - # Boost to favor less groups - normalized_groups = (offset_count - (search.groups - 1)) / offset_count - score *= 1 + (normalized_groups * normalized_groups) - return score + for offset in letter_positions[positions_index]: + if not offsets or offset > offsets[-1]: + new_offsets = [*offsets, offset] + if len(new_offsets) == query_length: + possible_offsets.append(new_offsets) + else: + get_offsets(new_offsets, positions_index + 1) + + get_offsets([], 0) - stack: list[_Search] = [_Search()] - push = stack.append - pop = stack.pop - query_size = len(query) - find = candidate.find - # Limit the number of loops out of an abundance of caution. - # This should be hard to reach without contrived data. - remaining_loops = 10_000 - while stack and (remaining_loops := remaining_loops - 1): - search = pop() - offset = find(query[search.query_offset], search.candidate_offset) - if offset != -1: - if not set(candidate[search.candidate_offset :]).issuperset( - query[search.query_offset :] - ): - # Early out if there is not change of a match - continue - advance_branch, branch = search.branch(offset) - if advance_branch.query_offset == query_size: - yield score(advance_branch), advance_branch.offsets - push(branch) - else: - push(branch) - push(advance_branch) + for offsets in possible_offsets: + yield score(candidate, offsets), offsets @rich.repr.auto @@ -229,3 +217,8 @@ class Matcher: if not candidate[offset].isspace(): content = content.stylize(self._match_style, offset, offset + 1) return content + + +if __name__ == "__main__": + fuzzy_search = FuzzySearch() + fuzzy_search.match("foo.bar", "foo/egg.bar") diff --git a/contrib/python/textual/textual/getters.py b/contrib/python/textual/textual/getters.py index c8bcfddfabb..6bd59a130fe 100644 --- a/contrib/python/textual/textual/getters.py +++ b/contrib/python/textual/textual/getters.py @@ -5,12 +5,59 @@ Descriptors to define properties on your widget, screen, or App. from __future__ import annotations -from typing import Generic, overload +from inspect import isclass +from typing import TYPE_CHECKING, Callable, Generic, TypeVar, overload +from textual._context import NoActiveAppError, active_app from textual.css.query import NoMatches, QueryType, WrongType -from textual.dom import DOMNode from textual.widget import Widget +if TYPE_CHECKING: + from textual.app import App + from textual.dom import DOMNode + from textual.message_pump import MessagePump + + +AppType = TypeVar("AppType", bound="App") + + +class app(Generic[AppType]): + """Create a property to return the active app. + + All widgets have a default `app` property which returns an App instance. + Type checkers will complain if you try to access attributes defined on your App class, which aren't + present in the base class. To keep the type checker happy you can add this property to get your + specific App subclass. + + Example: + ```python + class MyWidget(Widget): + app = getters.app(MyApp) + ``` + + Args: + app_type: The App subclass, or a callable which returns an App subclass. + """ + + def __init__(self, app_type: type[AppType] | Callable[[], type[AppType]]) -> None: + self._app_type = app_type if isclass(app_type) else app_type() + + def __get__(self, obj: MessagePump, obj_type: type[MessagePump]) -> AppType: + try: + app = active_app.get() + except LookupError: + from textual.app import App + + node: MessagePump | None = obj + while not isinstance(node, App): + if node is None: + raise NoActiveAppError() + node = node._parent + app = node + + assert isinstance(app, self._app_type) + return app + class query_one(Generic[QueryType]): """Create a query one property. @@ -45,7 +92,7 @@ class query_one(Generic[QueryType]): """ selector: str - expect_type: type[Widget] + expect_type: type["Widget"] @overload def __init__(self, selector: str) -> None: @@ -72,6 +119,8 @@ class query_one(Generic[QueryType]): expect_type: type[QueryType] | None = None, ) -> None: if expect_type is None: + from textual.widget import Widget + self.expect_type = Widget else: self.expect_type = expect_type diff --git a/contrib/python/textual/textual/highlight.py b/contrib/python/textual/textual/highlight.py index edfbd2cb207..2b899f9237e 100644 --- a/contrib/python/textual/textual/highlight.py +++ b/contrib/python/textual/textual/highlight.py @@ -19,6 +19,8 @@ class HighlightTheme: STYLES: dict[TokenType, str] = { Token.Comment: "$text 60%", Token.Error: "$text-error on $error-muted", + Token.Generic.Strong: "bold", + Token.Generic.Emph: "italic", Token.Generic.Error: "$text-error on $error-muted", Token.Generic.Heading: "$text-primary underline", Token.Generic.Subheading: "$text-primary", @@ -27,6 +29,7 @@ class HighlightTheme: Token.Keyword.Namespace: "$text-error", Token.Keyword.Type: "bold", Token.Literal.Number: "$text-warning", + Token.Literal.String.Backtick: "$text 60%", Token.Literal.String: "$text-success 90%", Token.Literal.String.Doc: "$text-success 80% italic", Token.Literal.String.Double: "$text-success 90%", @@ -49,8 +52,9 @@ class HighlightTheme: } -def guess_language(code: str, path: str) -> str: +def guess_language(code: str, path: str | None) -> str: """Guess the language based on the code and path. + The result may be used in the [highlight][textual.highlight.highlight] function. Args: code: The code to guess from. @@ -60,19 +64,28 @@ def guess_language(code: str, path: str) -> str: The language, suitable for use with Pygments. """ - if path is not None and os.path.splitext(path)[-1] == ".tcss": + if path and os.path.splitext(path)[-1] == ".tcss": # A special case for TCSS files which aren't known outside of Textual return "scss" lexer: Lexer | None = None lexer_name = "default" if code: - try: - lexer = guess_lexer_for_filename(path, code) - except ClassNotFound: - pass + if path: + try: + lexer = guess_lexer_for_filename(path, code) + except ClassNotFound: + pass + + if lexer is None: + from pygments.lexers import guess_lexer + + try: + lexer = guess_lexer(code) + except Exception: + pass - if not lexer: + if not lexer and path: try: _, ext = os.path.splitext(path) if ext: @@ -104,14 +117,12 @@ def highlight( code: A string to highlight. language: The language to highlight. theme: A HighlightTheme class (type not instance). - tab_size: Number of spaces in a tab. Defaults to 8. + tab_size: Number of spaces in a tab. Returns: A Content instance which may be used in a widget. """ - if language is None: - if path is None: - raise RuntimeError("One of 'language' or 'path' must be supplied.") + if not language: language = guess_language(code, path) assert language is not None diff --git a/contrib/python/textual/textual/layout.py b/contrib/python/textual/textual/layout.py index fe7ca4335b1..61655cb73e3 100644 --- a/contrib/python/textual/textual/layout.py +++ b/contrib/python/textual/textual/layout.py @@ -240,7 +240,7 @@ class Layout(ABC): if not widget._nodes: width = 0 else: - arrangement = widget._arrange( + arrangement = widget.arrange( Size(0 if widget.shrink else container.width, 0), optimal=True, ) @@ -266,9 +266,9 @@ 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)) + arrangement = widget.arrange(Size(width, container.height)) else: - arrangement = widget._arrange(Size(width, 0)) + arrangement = widget.arrange(Size(width, 0)) height = arrangement.total_region.height else: height = 0 diff --git a/contrib/python/textual/textual/layouts/grid.py b/contrib/python/textual/textual/layouts/grid.py index 5aa419b0579..9f91df41a1c 100644 --- a/contrib/python/textual/textual/layouts/grid.py +++ b/contrib/python/textual/textual/layouts/grid.py @@ -20,15 +20,30 @@ class GridLayout(Layout): def __init__(self) -> None: self.min_column_width: int | None = None + """Maintain a minimum column width, or `None` for no minimum.""" + self.max_column_width: int | None = None + """Maintain a maximum column width, or `None` for no maximum.""" self.stretch_height: bool = False """Stretch the height of cells to be equal in each row.""" self.regular: bool = False + """Grid should be regular (no remainder in last row).""" self.expand: bool = False """Expand the grid to fit the container if it is smaller.""" self.shrink: bool = False """Shrink the grid to fit the container if it is larger.""" self.auto_minimum: bool = False """If self.shrink is `True`, auto-detect and limit the width.""" + self._grid_size: tuple[int, int] | None = None + """Grid size after last arrange call.""" + + @property + def grid_size(self) -> tuple[int, int] | None: + """The grid size after the last arrange call. + + Returns: + A tuple of (WIDTH, HEIGHT) or `None` prior to the first `arrange`. + """ + return self._grid_size def arrange( self, parent: Widget, children: list[Widget], size: Size, greedy: bool = True @@ -46,21 +61,31 @@ class GridLayout(Layout): table_size_columns = max(1, styles.grid_size_columns) min_column_width = self.min_column_width + max_column_width = self.max_column_width + + container_width = size.width + if max_column_width is not None: + container_width = ( + max(1, min(len(children), (container_width // max_column_width))) + * max_column_width + ) + size = Size(container_width, size.height) if min_column_width is not None: - container_width = size.width table_size_columns = max( 1, (container_width + gutter_horizontal) // (min_column_width + gutter_horizontal), ) + table_size_columns = min(table_size_columns, len(children)) if self.regular: while len(children) % table_size_columns and table_size_columns > 1: table_size_columns -= 1 table_size_rows = styles.grid_size_rows - viewport = parent.screen.size + + viewport = parent.app.viewport_size keyline_style, _keyline_color = styles.keyline offset = (0, 0) gutter_spacing: Spacing | None @@ -127,8 +152,7 @@ class GridLayout(Layout): cell_map: dict[tuple[int, int], tuple[Widget, bool]] = {} cell_size_map: dict[Widget, tuple[int, int, int, int]] = {} - column_count = table_size_columns - next_coord = iter(cell_coords(column_count)).__next__ + next_coord = iter(cell_coords(table_size_columns)).__next__ cell_coord = (0, 0) column = row = 0 @@ -157,9 +181,9 @@ class GridLayout(Layout): cell_coord = next_coord() column_scalars = repeat_scalars(column_scalars, table_size_columns) - row_scalars = repeat_scalars( - row_scalars, table_size_rows if table_size_rows else row + 1 - ) + table_size_rows = table_size_rows if table_size_rows else row + 1 + row_scalars = repeat_scalars(row_scalars, table_size_rows) + self._grid_size = (table_size_columns, table_size_rows) def apply_width_limits(widget: Widget, width: int) -> int: """Apply min and max widths to dimension. diff --git a/contrib/python/textual/textual/layouts/horizontal.py b/contrib/python/textual/textual/layouts/horizontal.py index c1720173d60..083a7210e1b 100644 --- a/contrib/python/textual/textual/layouts/horizontal.py +++ b/contrib/python/textual/textual/layouts/horizontal.py @@ -25,7 +25,7 @@ class HorizontalLayout(Layout): parent.pre_layout(self) placements: list[WidgetPlacement] = [] add_placement = placements.append - viewport = parent.app.size + viewport = parent.app.viewport_size child_styles = [child.styles for child in children] box_margins: list[Spacing] = [ @@ -83,7 +83,6 @@ class HorizontalLayout(Layout): children, box_models, margins ): styles = widget.styles - overlay = styles.overlay == "screen" offset = ( styles.offset.resolve( diff --git a/contrib/python/textual/textual/layouts/stream.py b/contrib/python/textual/textual/layouts/stream.py index 9d164c6b086..782beb3e3f0 100644 --- a/contrib/python/textual/textual/layouts/stream.py +++ b/contrib/python/textual/textual/layouts/stream.py @@ -1,12 +1,12 @@ from __future__ import annotations +from itertools import zip_longest from typing import TYPE_CHECKING from textual.geometry import NULL_OFFSET, Region, Size from textual.layout import ArrangeResult, Layout, WidgetPlacement if TYPE_CHECKING: - from textual.widget import Widget @@ -21,6 +21,7 @@ class StreamLayout(Layout): - No absolute positioning. - No overlay: screen. - Layers are ignored. + - Non TCSS styles are ignored. The primary use of `layout: stream` is for a long list of widgets in a scrolling container, such as what you might expect from a LLM chat-bot. The speed improvement will only be significant with a lot of @@ -30,13 +31,24 @@ class StreamLayout(Layout): name = "stream" + def __init__(self) -> None: + self._cached_placements: list[WidgetPlacement] | None = None + self._cached_width = 0 + super().__init__() + def arrange( self, parent: Widget, children: list[Widget], size: Size, greedy: bool = True ) -> ArrangeResult: parent.pre_layout(self) if not children: return [] - viewport = parent.app.size + viewport = parent.app.viewport_size + + if size.width != self._cached_width: + self._cached_placements = None + previous_results = self._cached_placements or [] + + layout_widgets = parent.screen._layout_widgets.get(parent, []) _Region = Region _WidgetPlacement = WidgetPlacement @@ -48,19 +60,42 @@ class StreamLayout(Layout): previous_margin = first_child_styles.margin.top null_offset = NULL_OFFSET - for widget in children: - styles = widget.styles.base + pre_populate = bool(previous_results and layout_widgets) + for widget, placement in zip_longest(children, previous_results): + if pre_populate and placement is not None and widget is placement.widget: + if widget in layout_widgets: + pre_populate = False + else: + placements.append(placement) + y = placement.region.bottom + styles = widget.styles._base_styles + previous_margin = styles.margin.bottom + continue + if widget is None: + break + + styles = widget.styles._base_styles margin = styles.margin gutter_width, gutter_height = styles.gutter.totals top, right, bottom, left = margin - y += max(top, previous_margin) + y += top if top > previous_margin else previous_margin previous_margin = bottom height = ( widget.get_content_height(size, viewport, width - gutter_width) + gutter_height ) if (max_height := styles.max_height) is not None and max_height.is_cells: - height = min(height, int(max_height.value)) + height = ( + height + if height < (max_height_value := int(max_height.value)) + else max_height_value + ) + if (min_height := styles.min_height) is not None and min_height.is_cells: + height = ( + height + if height > (min_height_value := int(min_height.value)) + else min_height_value + ) placements.append( _WidgetPlacement( _Region(left, y, width - (left + right), height), @@ -75,4 +110,40 @@ class StreamLayout(Layout): ) y += height + self._cached_width = size.width + self._cached_placements = placements return placements + + def get_content_width(self, widget: Widget, container: Size, viewport: Size) -> int: + """Get the optimal content width by arranging children. + + Args: + widget: The container widget. + container: The container size. + viewport: The viewport size. + + Returns: + Width of the content. + """ + return widget.scrollable_content_region.width + + def get_content_height( + self, widget: Widget, container: Size, viewport: Size, width: int + ) -> int: + """Get the content height. + + Args: + widget: The container widget. + container: The container size. + viewport: The viewport. + width: The content width. + + Returns: + Content height (in lines). + """ + if widget._nodes: + arrangement = widget.arrange(Size(width, 0)) + height = arrangement.total_region.height + else: + height = 0 + return height diff --git a/contrib/python/textual/textual/layouts/vertical.py b/contrib/python/textual/textual/layouts/vertical.py index b229fa66224..da3462267d5 100644 --- a/contrib/python/textual/textual/layouts/vertical.py +++ b/contrib/python/textual/textual/layouts/vertical.py @@ -23,7 +23,7 @@ class VerticalLayout(Layout): parent.pre_layout(self) placements: list[WidgetPlacement] = [] add_placement = placements.append - viewport = parent.app.size + viewport = parent.app.viewport_size child_styles = [child.styles for child in children] box_margins: list[Spacing] = [ @@ -105,7 +105,6 @@ class VerticalLayout(Layout): content_width.__floor__(), next_y.__floor__() - y.__floor__(), ) - absolute = styles.has_rule("position") and styles.position == "absolute" add_placement( _WidgetPlacement( diff --git a/contrib/python/textual/textual/message_pump.py b/contrib/python/textual/textual/message_pump.py index dd2ec422709..a4ed5e7c25b 100644 --- a/contrib/python/textual/textual/message_pump.py +++ b/contrib/python/textual/textual/message_pump.py @@ -36,6 +36,7 @@ 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 from textual._on import OnNoWidget +from textual._queue import Queue from textual._time import time from textual.constants import SLOW_THRESHOLD from textual.css.match import match @@ -143,8 +144,8 @@ class MessagePump(metaclass=_MessagePumpMeta): """ @cached_property - def _message_queue(self) -> asyncio.Queue[Message | None]: - return asyncio.Queue() + def _message_queue(self) -> Queue[Message | None]: + return Queue() @cached_property def _mounted_event(self) -> asyncio.Event: @@ -226,29 +227,35 @@ class MessagePump(metaclass=_MessagePumpMeta): """Is this a root node (i.e. the App)?""" return False - @property - def app(self) -> "App[object]": - """ - Get the current app. + if TYPE_CHECKING: + from textual import getters - Returns: - The current app. + app = getters.app(App) + else: - Raises: - NoActiveAppError: if no active app could be found for the current asyncio context - """ - try: - return active_app.get() - except LookupError: - from textual.app import App + @property + def app(self) -> "App[object]": + """ + Get the current app. + + Returns: + The current app. - node: MessagePump | None = self - while not isinstance(node, App): - if node is None: - raise NoActiveAppError() - node = node._parent + Raises: + NoActiveAppError: if no active app could be found for the current asyncio context + """ + try: + return active_app.get() + except LookupError: + from textual.app import App - return node + node: MessagePump | None = self + while not isinstance(node, App): + if node is None: + raise NoActiveAppError() + node = node._parent + + return node @property def is_attached(self) -> bool: @@ -450,19 +457,26 @@ class MessagePump(metaclass=_MessagePumpMeta): message = messages.InvokeLater(partial(callback, *args, **kwargs)) return self.post_message(message) - async def wait_for_refresh(self) -> None: + async def wait_for_refresh(self) -> bool: """Wait for the next refresh. This method should only be called from a task other than the one running this widget. If called from the same task, it will return immediately to avoid blocking the event loop. - """ + Returns: + `True` if waiting for refresh was successful, or `False` if the call was a null-op + due to calling it within the node's own task. - if self._task is None or asyncio.current_task() is not self._task: - return + """ + assert ( + self._task is not None + ), "Node must be running before calling wait_for_refresh" + if asyncio.current_task() is self._task: + return False refreshed_event = asyncio.Event() self.call_after_refresh(refreshed_event.set) await refreshed_event.wait() + return True def call_later(self, callback: Callback, *args: Any, **kwargs: Any) -> bool: """Schedule a callback to run after all messages are processed in this object. @@ -579,7 +593,6 @@ class MessagePump(metaclass=_MessagePumpMeta): await self._dispatch_message(events.Mount()) else: await self._dispatch_message(events.Mount()) - self.check_idle() self._post_mount() except Exception as error: self.app._handle_exception(error) @@ -613,7 +626,7 @@ class MessagePump(metaclass=_MessagePumpMeta): """Process messages until the queue is closed.""" _rich_traceback_guard = True self._thread_id = threading.get_ident() - + await asyncio.sleep(0) while not self._closed: try: message = await self._get_message() diff --git a/contrib/python/textual/textual/messages.py b/contrib/python/textual/textual/messages.py index dc0f6544a86..90d0c9fd828 100644 --- a/contrib/python/textual/textual/messages.py +++ b/contrib/python/textual/textual/messages.py @@ -52,6 +52,10 @@ class Update(Message, verbose=True): class Layout(Message, verbose=True): """Sent by Textual when a layout is required.""" + def __init__(self, widget: Widget) -> None: + super().__init__() + self.widget = widget + def can_replace(self, message: Message) -> bool: return isinstance(message, Layout) diff --git a/contrib/python/textual/textual/pilot.py b/contrib/python/textual/textual/pilot.py index a9ff1d2067c..8b649e81c51 100644 --- a/contrib/python/textual/textual/pilot.py +++ b/contrib/python/textual/textual/pilot.py @@ -227,8 +227,8 @@ class Pilot(Generic[ReturnType]): OutOfBounds: If the position to be clicked is outside of the (visible) screen. Returns: - True if no selector was specified or if the click landed on the selected - widget, False otherwise. + `True` if no selector was specified or if the selected widget was under the mouse + when the click was initiated. `False` is the selected widget was not under the pointer. """ try: return await self._post_mouse_events( @@ -284,10 +284,10 @@ class Pilot(Generic[ReturnType]): OutOfBounds: If the position to be clicked is outside of the (visible) screen. Returns: - True if no selector was specified or if the clicks landed on the selected - widget, False otherwise. + `True` if no selector was specified or if the selected widget was under the mouse + when the click was initiated. `False` is the selected widget was not under the pointer. """ - await self.click(widget, offset, shift, meta, control, times=2) + return await self.click(widget, offset, shift, meta, control, times=2) async def triple_click( self, @@ -329,10 +329,10 @@ class Pilot(Generic[ReturnType]): OutOfBounds: If the position to be clicked is outside of the (visible) screen. Returns: - True if no selector was specified or if the clicks landed on the selected - widget, False otherwise. + `True` if no selector was specified or if the selected widget was under the mouse + when the click was initiated. `False` is the selected widget was not under the pointer. """ - await self.click(widget, offset, shift, meta, control, times=3) + return await self.click(widget, offset, shift, meta, control, times=3) async def hover( self, @@ -414,7 +414,7 @@ class Pilot(Generic[ReturnType]): elif isinstance(widget, Widget): target_widget = widget else: - target_widget = app.screen.query_one(widget) + target_widget = screen.query_one(widget) message_arguments = _get_mouse_message_arguments( target_widget, @@ -434,6 +434,7 @@ class Pilot(Generic[ReturnType]): widget_at = None for chain in range(1, times + 1): for mouse_event_cls in events: + await self.pause() # Get the widget under the mouse before the event because the app might # react to the event and move things around. We override on each iteration # because we assume the final event in `events` is the actual event we care @@ -444,7 +445,8 @@ class Pilot(Generic[ReturnType]): if mouse_event_cls is Click: kwargs = {**kwargs, "chain": chain} - widget_at, _ = app.get_widget_at(*offset) + if widget_at is None: + widget_at, _ = app.get_widget_at(*offset) event = mouse_event_cls(**kwargs) # Bypass event processing in App.on_event. Because App.on_event # is responsible for updating App.mouse_position, and because @@ -452,8 +454,8 @@ class Pilot(Generic[ReturnType]): # we patch the offset in there as well. app.mouse_position = offset screen._forward_event(event) - await self.pause() + await self.pause() return widget is None or widget_at is target_widget async def _wait_for_screen(self, timeout: float = 30.0) -> bool: diff --git a/contrib/python/textual/textual/reactive.py b/contrib/python/textual/textual/reactive.py index 46d33033601..57c0bf3ea16 100644 --- a/contrib/python/textual/textual/reactive.py +++ b/contrib/python/textual/textual/reactive.py @@ -222,6 +222,8 @@ class Reactive(Generic[ReactiveType]): else default_or_callable ) setattr(obj, internal_name, default) + if (toggle_class := self._toggle_class) is not None: + obj.set_class(bool(default), *toggle_class.split()) if self._init: self._check_watchers(obj, name, default) diff --git a/contrib/python/textual/textual/renderables/bar.py b/contrib/python/textual/textual/renderables/bar.py index 2fa31550fc3..558ea96e61e 100644 --- a/contrib/python/textual/textual/renderables/bar.py +++ b/contrib/python/textual/textual/renderables/bar.py @@ -18,6 +18,10 @@ class Bar: gradient: Optional gradient object. """ + HALF_BAR_LEFT: str = "╺" + BAR: str = "━" + HALF_BAR_RIGHT: str = "╸" + def __init__( self, highlight_range: tuple[float, float] = (0, 0), @@ -40,10 +44,6 @@ class Bar: highlight_style = console.get_style(self.highlight_style) background_style = console.get_style(self.background_style) - half_bar_right = "╸" - half_bar_left = "╺" - bar = "━" - width = self.width or options.max_width start, end = self.highlight_range @@ -53,7 +53,7 @@ class Bar: output_bar = Text("", end="") if start == end == 0 or end < 0 or start > end: - output_bar.append(Text(bar * width, style=background_style, end="")) + output_bar.append(Text(self.BAR * width, style=background_style, end="")) yield output_bar return @@ -67,10 +67,10 @@ class Bar: # Initial non-highlighted portion of bar output_bar.append( - Text(bar * (int(start - 0.5)), style=background_style, end="") + Text(self.BAR * (int(start - 0.5)), style=background_style, end="") ) if not half_start and start > 0: - output_bar.append(Text(half_bar_right, style=background_style, end="")) + output_bar.append(Text(self.HALF_BAR_RIGHT, style=background_style, end="")) highlight_bar = Text("", end="") # The highlighted portion @@ -78,13 +78,19 @@ class Bar: if half_start: highlight_bar.append( Text( - half_bar_left + bar * (bar_width - 1), style=highlight_style, end="" + self.HALF_BAR_LEFT + self.BAR * (bar_width - 1), + style=highlight_style, + end="", ) ) else: - highlight_bar.append(Text(bar * bar_width, style=highlight_style, end="")) + highlight_bar.append( + Text(self.BAR * bar_width, style=highlight_style, end="") + ) if half_end: - highlight_bar.append(Text(half_bar_right, style=highlight_style, end="")) + highlight_bar.append( + Text(self.HALF_BAR_RIGHT, style=highlight_style, end="") + ) if self.gradient is not None: _apply_gradient(highlight_bar, self.gradient, width) @@ -92,9 +98,9 @@ class Bar: # The non-highlighted tail if not half_end and end - width != 0: - output_bar.append(Text(half_bar_left, style=background_style, end="")) + output_bar.append(Text(self.HALF_BAR_LEFT, style=background_style, end="")) output_bar.append( - Text(bar * (int(width) - int(end) - 1), style=background_style, end="") + Text(self.BAR * (int(width) - int(end) - 1), style=background_style, end="") ) # Fire actions when certain ranges are clicked (e.g. for tabs) diff --git a/contrib/python/textual/textual/renderables/text_opacity.py b/contrib/python/textual/textual/renderables/text_opacity.py index a2255ab7fc6..7ba41e36d13 100644 --- a/contrib/python/textual/textual/renderables/text_opacity.py +++ b/contrib/python/textual/textual/renderables/text_opacity.py @@ -79,6 +79,8 @@ class TextOpacity: ): invisible_style = _from_color(bgcolor=style.bgcolor) yield _Segment(cell_len(text) * " ", invisible_style) + elif opacity == 1: + yield from segments else: filter = ANSIToTruecolor(ansi_theme) for segment in filter.apply(list(segments), TRANSPARENT): diff --git a/contrib/python/textual/textual/screen.py b/contrib/python/textual/textual/screen.py index 1ece40259a9..a92776d6595 100644 --- a/contrib/python/textual/textual/screen.py +++ b/contrib/python/textual/textual/screen.py @@ -20,6 +20,7 @@ from typing import ( Generic, Iterable, Iterator, + NamedTuple, Optional, TypeVar, Union, @@ -83,6 +84,23 @@ ScreenResultCallbackType = Union[ """Type of a screen result callback function.""" +class HoverWidgets(NamedTuple): + """Result of [get_hover_widget_at][textual.screen.Screen.get_hover_widget_at]""" + + mouse_over: tuple[Widget, Region] + """Widget and region directly under the mouse.""" + hover_over: tuple[Widget, Region] | None + """Widget with a hover style under the mouse, or `None` for no hover style widget.""" + + @property + def widgets(self) -> tuple[Widget, Widget | None]: + """Just the widgets.""" + return ( + self.mouse_over[0], + None if self.hover_over is None else self.hover_over[0], + ) + + @rich.repr.auto class ResultCallback(Generic[ScreenResultType]): """Holds the details of a callback.""" @@ -303,6 +321,9 @@ class Screen(Generic[ScreenResultType], Widget): self._css_update_count = -1 """Track updates to CSS.""" + self._layout_widgets: dict[DOMNode, set[Widget]] = {} + """Widgets whose layout may have changed.""" + @property def is_modal(self) -> bool: """Is the screen modal?""" @@ -341,6 +362,11 @@ class Screen(Generic[ScreenResultType], Widget): extras.append("_tooltips") return (*super().layers, *extras) + @property + def size(self) -> Size: + """The size of the screen.""" + return self.app.size - self.styles.gutter.totals + def _watch_focused(self): self.refresh_bindings() @@ -463,11 +489,12 @@ class Screen(Generic[ScreenResultType], Widget): return bindings_map - def _arrange(self, size: Size) -> DockArrangeResult: + def arrange(self, size: Size, _optimal: bool = False) -> DockArrangeResult: """Arrange children. Args: size: Size of container. + optimal: Ignored on screen. Returns: Widget locations. @@ -512,7 +539,7 @@ class Screen(Generic[ScreenResultType], Widget): else self._nodes ), size, - self.screen.size, + self.size, False, ) @@ -531,6 +558,17 @@ class Screen(Generic[ScreenResultType], Widget): """Check if this widget permits text selection.""" return self.ALLOW_SELECT + def get_loading_widget(self) -> Widget: + """Get a widget to display a loading indicator. + + The default implementation will defer to App.get_loading_widget. + + Returns: + A widget in place of this widget to indicate a loading. + """ + loading_widget = self.app.get_loading_widget() + return loading_widget + def render(self) -> RenderableType: """Render method inherited from widget, used to render the screen's background. @@ -579,6 +617,33 @@ class Screen(Generic[ScreenResultType], Widget): """ return self._compositor.get_widget_at(x, y) + def get_hover_widgets_at(self, x: int, y: int) -> HoverWidgets: + """Get the widget, and its region directly under the mouse, and the first + widget, region pair with a hover style. + + Args: + x: X Coordinate. + y: Y Coordinate. + + Returns: + A pair of (WIDGET, REGION) tuples for the top most and first hover style respectively. + + Raises: + NoWidget: If there is no widget under the screen coordinate. + + """ + widgets_under_coordinate = iter(self._compositor.get_widgets_at(x, y)) + try: + top_widget, top_region = next(widgets_under_coordinate) + except StopIteration: + raise errors.NoWidget(f"No hover widget under screen coordinate ({x}, {y})") + if not top_widget._has_hover_style: + for widget, region in widgets_under_coordinate: + if widget._has_hover_style: + return HoverWidgets((top_widget, top_region), (widget, region)) + return HoverWidgets((top_widget, top_region), None) + return HoverWidgets((top_widget, top_region), (top_widget, top_region)) + def get_widgets_at(self, x: int, y: int) -> Iterable[tuple[Widget, Region]]: """Get all widgets under a given coordinate. @@ -665,7 +730,7 @@ class Screen(Generic[ScreenResultType], Widget): self._select_end = None def _select_all_in_widget(self, widget: Widget) -> None: - """Select a widget and all it's children. + """Select a widget and all its children. Args: widget: Widget to select. @@ -689,9 +754,18 @@ class Screen(Generic[ScreenResultType], Widget): # Additionally, we manually keep track of the visibility of the DOM # instead of relying on the property `.visible` to save on DOM traversals. # node_stack: list[tuple[iterator over node children, node visibility]] + + root_node = self.screen + + if (focused := self.focused) is not None: + for node in focused.ancestors_with_self: + if node._trap_focus: + root_node = node + break + node_stack: list[tuple[Iterator[Widget], bool]] = [ ( - iter(sorted(self.displayed_children, key=focus_sorter)), + iter(sorted(root_node.displayed_children, key=focus_sorter)), self.visible, ) ] @@ -874,7 +948,7 @@ class Screen(Generic[ScreenResultType], Widget): if selected_text_in_widget is not None: widget_text.extend(selected_text_in_widget) - selected_text = "".join(widget_text) + selected_text = "".join(widget_text).rstrip("\n") return selected_text def action_copy_text(self) -> None: @@ -1220,7 +1294,7 @@ class Screen(Generic[ScreenResultType], Widget): ResizeEvent = events.Resize try: - if scroll: + if scroll and not self._layout_widgets: exposed_widgets = self._compositor.reflow_visible(self, size) if exposed_widgets: layers = self._compositor.layers @@ -1245,6 +1319,7 @@ class Screen(Generic[ScreenResultType], Widget): else: hidden, shown, resized = self._compositor.reflow(self, size) + self._layout_widgets.clear() Hide = events.Hide Show = events.Show @@ -1298,8 +1373,24 @@ class Screen(Generic[ScreenResultType], Widget): async def _on_layout(self, message: messages.Layout) -> None: message.stop() message.prevent_default() - self._layout_required = True - self.check_idle() + + layout_required = False + widget: DOMNode = message.widget + for ancestor in message.widget.ancestors: + if not isinstance(ancestor, Widget): + break + if ancestor not in self._layout_widgets: + self._layout_widgets[ancestor] = set() + if widget not in self._layout_widgets: + self._layout_widgets[ancestor].add(widget) + layout_required = True + if not ancestor.styles.auto_dimensions: + break + widget = ancestor + + if layout_required and not self._layout_required: + self._layout_required = True + self.check_idle() async def _on_update_scroll(self, message: messages.UpdateScroll) -> None: message.stop() @@ -1346,7 +1437,21 @@ class Screen(Generic[ScreenResultType], Widget): self.app._refresh_notifications() size = self.app.size - # Only auto-focus when the app has focus (textual-web only) + self._update_auto_focus() + + if self.is_attached: + self._compositor_refresh() + if self.stack_updates == 1: + self.app.stylesheet.update(self) + self._refresh_layout(size) + self.refresh() + + async def _compose(self) -> None: + await super()._compose() + self._update_auto_focus() + + def _update_auto_focus(self) -> None: + """Update auto focus.""" if self.app.app_focus: auto_focus = ( self.app.AUTO_FOCUS if self.AUTO_FOCUS is None else self.AUTO_FOCUS @@ -1354,20 +1459,15 @@ class Screen(Generic[ScreenResultType], Widget): if auto_focus and self.focused is None: for widget in self.query(auto_focus): if widget.focusable: + widget.has_focus = True self.set_focus(widget) break - 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.""" if self.app.SUSPENDED_SCREEN_CLASS: self.add_class(self.app.SUSPENDED_SCREEN_CLASS) - self.app._set_mouse_over(None) + self.app._set_mouse_over(None, None) self._clear_tooltip() self.stack_updates += 1 @@ -1479,14 +1579,17 @@ class Screen(Generic[ScreenResultType], Widget): tooltip.update(tooltip_content) def _handle_mouse_move(self, event: events.MouseMove) -> None: + hover_widget: Widget | None = None try: if self.app.mouse_captured: widget = self.app.mouse_captured region = self.find_widget(widget).region else: - widget, region = self.get_widget_at(event.x, event.y) + (widget, region), hover = self.get_hover_widgets_at(event.x, event.y) + if hover is not None: + hover_widget = hover[0] except errors.NoWidget: - self.app._set_mouse_over(None) + self.app._set_mouse_over(None, None) if self._tooltip_timer is not None: self._tooltip_timer.stop() if not self.app._disable_tooltips: @@ -1494,9 +1597,8 @@ class Screen(Generic[ScreenResultType], Widget): self.get_child_by_type(Tooltip).display = False except NoMatches: pass - else: - self.app._set_mouse_over(widget) + self.app._set_mouse_over(widget, hover_widget) widget.hover_style = event.style if widget is self: self.post_message(event) @@ -1571,14 +1673,22 @@ class Screen(Generic[ScreenResultType], Widget): ): end_widget = self._select_end[0] select_offset = end_widget.content_region.bottom_right_inclusive - self._select_end = (end_widget, event.offset, select_offset) + self._select_end = ( + end_widget, + event.screen_offset, + select_offset, + ) elif ( select_widget is not None and select_widget.allow_select and select_offset is not None ): - self._select_end = (select_widget, event.offset, select_offset) + self._select_end = ( + select_widget, + event.screen_offset, + select_offset, + ) elif isinstance(event, events.MouseEvent): if isinstance(event, events.MouseUp): @@ -1589,6 +1699,7 @@ class Screen(Generic[ScreenResultType], Widget): self.clear_selection() self._mouse_down_offset = None self._selecting = False + self.post_message(events.TextSelected()) elif isinstance(event, events.MouseDown) and not self.app.mouse_captured: self._box_select = event.shift @@ -1624,7 +1735,10 @@ class Screen(Generic[ScreenResultType], Widget): else: if isinstance(event, events.MouseDown): focusable_widget = self.get_focusable_widget_at(event.x, event.y) - if focusable_widget: + if ( + focusable_widget is not None + and focusable_widget.focus_on_click() + ): self.set_focus(focusable_widget, scroll_visible=False) event.style = self.get_style_at(event.screen_x, event.screen_y) if widget.loading: @@ -1649,7 +1763,6 @@ class Screen(Generic[ScreenResultType], Widget): Args: select_end: The end selection. """ - if select_end is None or self._select_start is None: # Nothing to select return diff --git a/contrib/python/textual/textual/scroll_view.py b/contrib/python/textual/textual/scroll_view.py index 03a05013b18..7323f08d547 100644 --- a/contrib/python/textual/textual/scroll_view.py +++ b/contrib/python/textual/textual/scroll_view.py @@ -16,6 +16,11 @@ class ScrollView(ScrollableContainer): """ A base class for a Widget that handles its own scrolling (i.e. doesn't rely on the compositor to render children). + + !!! note + + This is the typically wrong class for making something scrollable. If you want to make something scroll, set its + `overflow` style to auto or scroll. Or use one of the pre-defined scrolling containers such as [VerticalScroll][textual.containers.VerticalScroll]. """ ALLOW_MAXIMIZE = True @@ -32,17 +37,23 @@ class ScrollView(ScrollableContainer): """Always scrollable.""" return True + @property + def is_container(self) -> bool: + """Since a ScrollView should be a line-api widget, it won't have children, + and therefore isn't a container.""" + return False + def watch_scroll_x(self, old_value: float, new_value: float) -> None: if self.show_horizontal_scrollbar: self.horizontal_scrollbar.position = new_value if round(old_value) != round(new_value): - self.refresh() + self.refresh(self.size.region) def watch_scroll_y(self, old_value: float, new_value: float) -> None: if self.show_vertical_scrollbar: self.vertical_scrollbar.position = new_value if round(old_value) != round(new_value): - self.refresh() + self.refresh(self.size.region) def on_mount(self): self._refresh_scrollbars() diff --git a/contrib/python/textual/textual/scrollbar.py b/contrib/python/textual/textual/scrollbar.py index be832509289..ef039148c5a 100644 --- a/contrib/python/textual/textual/scrollbar.py +++ b/contrib/python/textual/textual/scrollbar.py @@ -287,7 +287,7 @@ class ScrollBar(Widget): background = styles.scrollbar_background color = styles.scrollbar_color if background.a < 1: - base_background, _ = self.parent._opacity_background_colors + base_background, _ = self.parent.background_colors background = base_background + background color = background + color scrollbar_style = Style.from_color(color.rich_color, background.rich_color) diff --git a/contrib/python/textual/textual/selection.py b/contrib/python/textual/textual/selection.py index 0b12fc27ba7..0466fbec5ab 100644 --- a/contrib/python/textual/textual/selection.py +++ b/contrib/python/textual/textual/selection.py @@ -46,8 +46,8 @@ class Selection(NamedTuple): start_line, start_offset = self.start.transpose if self.end is None: - end_line = len(lines) - 1 - end_offset = len(lines[end_line]) + end_line = len(lines) + end_offset = len(lines[-1]) else: end_line, end_offset = self.end.transpose end_line = min(len(lines), end_line) @@ -56,12 +56,12 @@ class Selection(NamedTuple): return lines[start_line][start_offset:end_offset] selection: list[str] = [] - selected_lines = lines[start_line:end_line] + selected_lines = lines[start_line : end_line + 1] if len(selected_lines) >= 2: first_line, *mid_lines, last_line = selected_lines selection.append(first_line[start_offset:]) selection.extend(mid_lines) - selection.append(last_line[: end_offset + 1]) + selection.append(last_line[:end_offset]) else: return lines[start_line][start_offset:end_offset] return "\n".join(selection) diff --git a/contrib/python/textual/textual/strip.py b/contrib/python/textual/textual/strip.py index dc69561a5b0..d97b5820eca 100644 --- a/contrib/python/textual/textual/strip.py +++ b/contrib/python/textual/textual/strip.py @@ -7,11 +7,12 @@ See [Line API](/guide/widgets#line-api) for how to use Strips. from __future__ import annotations -from itertools import chain +from functools import lru_cache from typing import Any, Iterable, Iterator, Sequence import rich.repr from rich.cells import cell_len, set_cell_size +from rich.color import ColorSystem from rich.console import Console, ConsoleOptions, RenderResult from rich.measure import Measurement from rich.segment import Segment @@ -20,10 +21,11 @@ from rich.style import Style, StyleType from textual._segment_tools import index_to_cell_position, line_pad from textual.cache import FIFOCache from textual.color import Color -from textual.constants import DEBUG from textual.css.types import AlignHorizontal, AlignVertical from textual.filter import LineFilter +SGR_STYLES = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "21", "51", "52", "53"] + def get_line_length(segments: Iterable[Segment]) -> int: """Get the line length (total length of all segments). @@ -84,7 +86,9 @@ class Strip: "_render_cache", "_line_length_cache", "_crop_extend_cache", + "_offsets_cache", "_link_ids", + "_cell_count", ] def __init__( @@ -104,12 +108,10 @@ class Strip: tuple[int, int, Style | None], Strip, ] = FIFOCache(4) + self._offsets_cache: FIFOCache[tuple[int, int], Strip] = FIFOCache(4) self._render_cache: str | None = None self._link_ids: set[str] | None = None - - if DEBUG and cell_length is not None: - # If `cell_length` is incorrect, render will be fubar - assert get_line_length(self._segments) == cell_length + self._cell_count: int | None = None def __rich_repr__(self) -> rich.repr.Result: try: @@ -288,16 +290,24 @@ class Strip: Returns: A new combined strip. """ + join_strips = [ + strip for strip in strips if strip is not None and strip.cell_count + ] + segments = [segment for strip in join_strips for segment in strip._segments] + cell_length: int | None = None + if any([strip._cell_length is None for strip in join_strips]): + cell_length = None + else: + cell_length = sum([strip._cell_length or 0 for strip in join_strips]) + joined_strip = cls(segments, cell_length) + if all(strip._render_cache is not None for strip in join_strips): + joined_strip._render_cache = "".join( + [strip._render_cache for strip in join_strips] + ) + return joined_strip - segments: list[list[Segment]] = [] - add_segments = segments.append - total_cell_length = 0 - for strip in strips: - if strip is not None: - total_cell_length += strip.cell_length - add_segments(strip._segments) - strip = cls(chain.from_iterable(segments), total_cell_length) - return strip + def __add__(self, other: Strip) -> Strip: + return Strip.join([self, other]) def __bool__(self) -> bool: return not not self._segments # faster than bool(...) @@ -312,10 +322,22 @@ class Strip: return len(self._segments) def __eq__(self, strip: object) -> bool: - return isinstance(strip, Strip) and ( - self._segments == strip._segments and self.cell_length == strip.cell_length + return isinstance(strip, Strip) and (self._segments == strip._segments) + + def __getitem__(self, index: int | slice) -> Strip: + if isinstance(index, int): + index = slice(index, index + 1) + return self.crop( + index.start, self.cell_count if index.stop is None else index.stop ) + @property + def cell_count(self) -> int: + """Number of cells in the strip""" + if self._cell_count is None: + self._cell_count = sum(len(segment.text) for segment in self._segments) + return self._cell_count + def extend_cell_length(self, cell_length: int, style: Style | None = None) -> Strip: """Extend the cell length if it is less than the given value. @@ -389,7 +411,7 @@ class Strip: return strip def simplify(self) -> Strip: - """Simplify the segments (join segments with same style) + """Simplify the segments (join segments with same style). Returns: New strip. @@ -564,8 +586,7 @@ class Strip: cell_length = self.cell_length cuts = [cut for cut in cuts if cut <= cell_length] cache_key = tuple(cuts) - cached = self._divide_cache.get(cache_key) - if cached is not None: + if (cached := self._divide_cache.get(cache_key)) is not None: return cached strips: list[Strip] @@ -633,6 +654,56 @@ class Strip: ] return Strip(segments, self._cell_length) + @classmethod + @lru_cache(maxsize=16384) + def render_ansi(cls, style: Style, color_system: ColorSystem) -> str: + """Render ANSI codes for a give style. + + Args: + style: A Rich style. + color_system: Color system enumeration. + + Returns: + A string of ANSI escape sequences to render the style. + """ + sgr: list[str] + if attributes := style._attributes & style._set_attributes: + _style_map = SGR_STYLES + sgr = [ + _style_map[bit_offset] + for bit_offset in range(attributes.bit_length()) + if attributes & (1 << bit_offset) + ] + else: + sgr = [] + if (color := style._color) is not None: + sgr.extend(color.downgrade(color_system).get_ansi_codes()) + if (bgcolor := style._bgcolor) is not None: + sgr.extend(bgcolor.downgrade(color_system).get_ansi_codes(False)) + ansi = style._ansi = ";".join(sgr) + return ansi + + @classmethod + def render_style(cls, style: Style, text: str, color_system: ColorSystem) -> str: + """Render a Rich style and text. + + Args: + style: Style to render. + text: Content string. + color_system: Color system enumeration. + + Returns: + Text with ANSI escape sequences. + """ + if (ansi := style._ansi) is None: + ansi = cls.render_ansi(style, color_system) + output = f"\x1b[{ansi}m{text}\x1b[0m" if ansi else text + if style._link: + output = ( + f"\x1b]8;id={style._link_id};{style._link}\x1b\\{output}\x1b]8;;\x1b\\" + ) + return output + def render(self, console: Console) -> str: """Render the strip into terminal sequences. @@ -643,15 +714,19 @@ class Strip: Rendered sequences. """ if self._render_cache is None: - color_system = console._color_system - render = Style.render + color_system = console._color_system or ColorSystem.TRUECOLOR + render = self.render_style self._render_cache = "".join( [ - render(style, text, color_system=color_system) + ( + text + if style is None + else render(style, text, color_system=color_system) + ) for text, style, _ in self._segments - if style is not None ] ) + return self._render_cache def crop_pad(self, cell_length: int, left: int, right: int, style: Style) -> Strip: @@ -723,6 +798,9 @@ class Strip: Returns: New strip. """ + cache_key = (x, y) + if (cached_strip := self._offsets_cache.get(cache_key)) is not None: + return cached_strip segments = self._segments strip_segments: list[Segment] = [] for segment in segments: @@ -732,4 +810,7 @@ class Strip: Segment(text, style + offset_style if style else offset_style) ) x += len(segment.text) - return Strip(strip_segments, self._cell_length) + strip = Strip(strip_segments, self._cell_length) + strip._render_cache = self._render_cache + self._offsets_cache[cache_key] = strip + return strip diff --git a/contrib/python/textual/textual/style.py b/contrib/python/textual/textual/style.py index 43c4be09207..30de58cd4c1 100644 --- a/contrib/python/textual/textual/style.py +++ b/contrib/python/textual/textual/style.py @@ -9,8 +9,8 @@ from __future__ import annotations from dataclasses import dataclass from functools import cached_property, lru_cache -from marshal import dumps, loads from operator import attrgetter +from pickle import dumps, loads from typing import TYPE_CHECKING, Any, Iterable, Mapping import rich.repr @@ -41,7 +41,53 @@ _get_hash_attributes = attrgetter( ) [email protected](angular=True) +_get_simple_attributes = attrgetter( + "background", + "foreground", + "bold", + "dim", + "italic", + "underline", + "underline2", + "reverse", + "strike", + "blink", + "link", + "_meta", +) + +_get_simple_attributes_sans_color = attrgetter( + "bold", + "dim", + "italic", + "underline", + "underline2", + "reverse", + "strike", + "blink", + "link", + "_meta", +) + + +_get_attributes = attrgetter( + "background", + "foreground", + "bold", + "dim", + "italic", + "underline", + "underline2", + "reverse", + "strike", + "blink", + "link", + "meta", + "_meta", +) + + @dataclass(frozen=True) class Style: """Represents a style in the Visual interface (color and other attributes). @@ -82,19 +128,19 @@ class Style: @cached_property def _is_null(self) -> bool: - return ( - self.foreground is None - and self.background is None - and self.bold is None - and self.dim is None - and self.italic is None - and self.underline is None - and self.underline2 is None - and self.reverse is None - and self.strike is None - and self.blink is None - and self.link is None - and self._meta is None + return _get_simple_attributes(self) == ( + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, ) @cached_property @@ -193,30 +239,66 @@ class Style: @lru_cache(maxsize=1024 * 4) def __add__(self, other: object | None) -> Style: if isinstance(other, Style): + if self._is_null: + return other + if other._is_null: + return self + ( + background, + foreground, + bold, + dim, + italic, + underline, + underline2, + reverse, + strike, + blink, + link, + meta, + _meta, + ) = _get_attributes(self) + + ( + other_background, + other_foreground, + other_bold, + other_dim, + other_italic, + other_underline, + other_underline2, + other_reverse, + other_strike, + other_blink, + other_link, + other_meta, + other__meta, + ) = _get_attributes(other) + new_style = Style( ( - other.background - if (self.background is None or self.background.a == 0) - else self.background + other.background + other_background + if (background is None or background.a == 0) + else background + other_background ), ( - self.foreground - if (other.foreground is None or other.foreground.a == 0) - else other.foreground + foreground + if (other_foreground is None or other_foreground.a == 0) + else other_foreground ), - self.bold if other.bold is None else other.bold, - self.dim if other.dim is None else other.dim, - self.italic if other.italic is None else other.italic, - self.underline if other.underline is None else other.underline, - self.underline2 if other.underline2 is None else other.underline2, - self.reverse if other.reverse is None else other.reverse, - self.strike if other.strike is None else other.strike, - self.blink if other.blink is None else other.blink, - self.link if other.link is None else other.link, + bold if other_bold is None else other_bold, + dim if other_dim is None else other_dim, + italic if other_italic is None else other_italic, + underline if other_underline is None else other_underline, + underline2 if other_underline2 is None else other_underline2, + reverse if other_reverse is None else other_reverse, + strike if other_strike is None else other_strike, + blink if other_blink is None else other_blink, + link if other_link is None else other_link, ( - dumps({**self.meta, **other.meta}) - if self._meta is not None and other._meta is not None - else (self._meta if other._meta is None else other._meta) + dumps({**meta, **other_meta}) + if _meta is not None and other__meta is not None + else (_meta if other__meta is None else other__meta) ), ) return new_style @@ -327,6 +409,7 @@ class Style: underline2=text_style.underline2, reverse=text_style.reverse, strike=text_style.strike, + blink=text_style.blink, auto_color=styles.auto_color, ) @@ -349,26 +432,43 @@ class Style: Returns: A Rich style object. """ - color = None if self.foreground is None else self.background + self.foreground + + ( + background, + foreground, + bold, + dim, + italic, + underline, + underline2, + reverse, + strike, + blink, + link, + _meta, + ) = _get_simple_attributes(self) + + color = None if foreground is None else background + foreground + return RichStyle( color=None if color is None else color.rich_color, - bgcolor=None if self.background is None else self.background.rich_color, - bold=self.bold, - dim=self.dim, - italic=self.italic, - underline=self.underline, - underline2=self.underline2, - reverse=self.reverse, - strike=self.strike, - blink=self.blink, - link=self.link, - meta=None if self._meta is None else self.meta, + bgcolor=None if background is None else background.rich_color, + bold=bold, + dim=dim, + italic=italic, + underline=underline, + underline2=underline2, + reverse=reverse, + strike=strike, + blink=blink, + link=link, + meta=None if _meta is None else self.meta, ) def rich_style_with_offset(self, x: int, y: int) -> RichStyle: """Get a Rich style with the given offset included in meta. - This is used in text seleciton. + This is used in text selection. Args: x: X coordinate. @@ -377,37 +477,40 @@ class Style: Returns: A Rich Style object. """ - color = None if self.foreground is None else self.background + self.foreground + ( + background, + foreground, + bold, + dim, + italic, + underline, + underline2, + reverse, + strike, + blink, + link, + _meta, + ) = _get_simple_attributes(self) + color = None if foreground is None else background + foreground return RichStyle( color=None if color is None else color.rich_color, - bgcolor=None if self.background is None else self.background.rich_color, - bold=self.bold, - dim=self.dim, - italic=self.italic, - underline=self.underline, - underline2=self.underline2, - reverse=self.reverse, - strike=self.strike, - blink=self.blink, - link=self.link, + bgcolor=None if background is None else background.rich_color, + bold=bold, + dim=dim, + italic=italic, + underline=underline, + underline2=underline2, + reverse=reverse, + strike=strike, + blink=blink, + link=link, meta={**self.meta, "offset": (x, y)}, ) @cached_property def without_color(self) -> Style: """The style without any colors.""" - return Style( - bold=self.bold, - dim=self.dim, - italic=self.italic, - underline=self.underline, - underline2=self.underline2, - reverse=self.reverse, - strike=self.strike, - blink=self.blink, - link=self.link, - _meta=self._meta, - ) + return Style(None, None, *_get_simple_attributes_sans_color(self)) @cached_property def background_style(self) -> Style: diff --git a/contrib/python/textual/textual/theme.py b/contrib/python/textual/textual/theme.py index af36369b8fc..d80aaacd719 100644 --- a/contrib/python/textual/textual/theme.py +++ b/contrib/python/textual/textual/theme.py @@ -283,6 +283,99 @@ BUILTIN_THEMES: dict[str, Theme] = { "footer-description-foreground": "#fdf6e3", }, ), + "solarized-dark": Theme( + name="solarized-dark", + primary="#268bd2", + secondary="#2aa198", + warning="#cb4b16", + error="#dc322f", + success="#859900", + accent="#6c71c4", + background="#002b36", + surface="#073642", + panel="#073642", + foreground="#839496", + dark=True, + variables={ + "button-color-foreground": "#fdf6e3", + "footer-background": "#268bd2", + "footer-key-foreground": "#fdf6e3", + "footer-description-foreground": "#fdf6e3", + "input-selection-background": "#073642", # Base02 + }, + ), + "rose-pine": Theme( + name="rose-pine", + primary="#c4a7e7", + secondary="#31748f", + warning="#f6c177", + error="#eb6f92", + success="#9ccfd8", + accent="#ebbcba", + foreground="#e0def4", + background="#191724", + surface="#1f1d2e", + panel="#26233a", + dark=True, + variables={ + "input-cursor-background": "#f4ede8", + "input-selection-background": "#403d52", + "border": "#524f67", + "border-blurred": "#6e6a86", + "footer-background": "#26233a", + "block-cursor-foreground": "#191724", + "block-cursor-text-style": "none", + "block-cursor-background": "#c4a7e7", + }, + ), + "rose-pine-moon": Theme( + name="rose-pine-moon", + primary="#c4a7e7", + secondary="#3e8fb0", + warning="#f6c177", + error="#eb6f92", + success="#9ccfd8", + accent="#ea9a97", + foreground="#e0def4", + background="#232136", + surface="#2a273f", + panel="#393552", + dark=True, + variables={ + "input-cursor-background": "#f4ede8", + "input-selection-background": "#44415a", + "border": "#56526e", + "border-blurred": "#6e6a86", + "footer-background": "#393552", + "block-cursor-foreground": "#232136", + "block-cursor-text-style": "none", + "block-cursor-background": "#c4a7e7", + }, + ), + "rose-pine-dawn": Theme( + name="rose-pine-dawn", + primary="#907aa9", + secondary="#286983", + warning="#ea9d34", + error="#b4637a", + success="#56949f", + accent="#d7827e", + foreground="#575279", + background="#faf4ed", + surface="#fffaf3", + panel="#f2e9e1", + dark=False, + variables={ + "input-cursor-background": "#575279", + "input-selection-background": "#dfdad9", + "border": "#cecacd", + "border-blurred": "#9893a5", + "footer-background": "#f2e9e1", + "block-cursor-foreground": "#faf4ed", + "block-cursor-text-style": "none", + "block-cursor-background": "#575279", + }, + ), } diff --git a/contrib/python/textual/textual/timer.py b/contrib/python/textual/textual/timer.py index af657b3b698..46235f00911 100644 --- a/contrib/python/textual/textual/timer.py +++ b/contrib/python/textual/textual/timer.py @@ -36,7 +36,7 @@ class Timer: event_target: The object which will receive the timer events. interval: The time between timer events, in seconds. name: A name to assign the event (for debugging). - callback: A optional callback to invoke when the event is handled. + callback: An optional callback to invoke when the event is handled. repeat: The number of times to repeat the timer, or None to repeat forever. skip: Enable skipping of scheduled events that couldn't be sent in time. pause: Start the timer paused. diff --git a/contrib/python/textual/textual/visual.py b/contrib/python/textual/textual/visual.py index e1882270da7..0cb4d2de72e 100644 --- a/contrib/python/textual/textual/visual.py +++ b/contrib/python/textual/textual/visual.py @@ -57,7 +57,7 @@ class SupportsVisual(Protocol): Args: widget: The widget that generated the render. - obj: The result of the the render. + obj: The result of the render. Returns: A Visual instance, or `None` if it wasn't possible. @@ -255,7 +255,6 @@ class Visual(ABC): align_vertical, ) ) - return strips diff --git a/contrib/python/textual/textual/widget.py b/contrib/python/textual/textual/widget.py index 0380ae4e87a..38cfb6ae5fe 100644 --- a/contrib/python/textual/textual/widget.py +++ b/contrib/python/textual/textual/widget.py @@ -19,6 +19,7 @@ from typing import ( Collection, Generator, Iterable, + Mapping, NamedTuple, Sequence, TypeVar, @@ -59,7 +60,7 @@ from textual._types import AnimationLevel from textual.actions import SkipAction from textual.await_remove import AwaitRemove from textual.box_model import BoxModel -from textual.cache import FIFOCache +from textual.cache import FIFOCache, LRUCache from textual.color import Color from textual.compose import compose from textual.content import Content, ContentType @@ -78,7 +79,7 @@ from textual.geometry import ( Spacing, clamp, ) -from textual.layout import Layout +from textual.layout import Layout, WidgetPlacement from textual.layouts.vertical import VerticalLayout from textual.message import Message from textual.messages import CallbackType, Prune @@ -94,6 +95,7 @@ from textual.visual import Visual, VisualType, visualize if TYPE_CHECKING: from textual.app import App, ComposeResult from textual.css.query import QueryType + from textual.filter import LineFilter from textual.message_pump import MessagePump from textual.scrollbar import ( ScrollBar, @@ -322,7 +324,10 @@ class Widget(DOMNode): """ ALLOW_SELECT: ClassVar[bool] = True - """Does this widget support automatic text selection? May be further refined with [Widget.allow_select][textual.widget.Widget.allow_select]""" + """Does this widget support automatic text selection? May be further refined with [Widget.allow_select][textual.widget.Widget.allow_select].""" + + FOCUS_ON_CLICK: ClassVar[bool] = True + """Should focusable widgets be automatically focused on click? Default return value of [Widget.focus_on_click][textual.widget.Widget.focus_on_click].""" can_focus: bool = False """Widget may receive focus.""" @@ -425,6 +430,7 @@ class Widget(DOMNode): self._size = _null_size self._container_size = _null_size self._layout_required = False + self._layout_updates = 0 self._repaint_required = False self._scroll_required = False self._recompose_required = False @@ -445,18 +451,22 @@ class Widget(DOMNode): self._layout_cache: dict[str, object] = {} """A dict that is refreshed when the widget is resized / refreshed.""" + self._visual_style: VisualStyle | None = None + self._render_cache = _RenderCache(_null_size, []) # Regions which need to be updated (in Widget) self._dirty_regions: set[Region] = set() # Regions which need to be transferred from cache to screen self._repaint_regions: set[Region] = set() + self._box_model_cache: LRUCache[object, BoxModel] = LRUCache(16) + # Cache the auto content dimensions self._content_width_cache: tuple[object, int] = (None, 0) self._content_height_cache: tuple[object, int] = (None, 0) self._arrangement_cache: FIFOCache[ - tuple[Size, int, Widget], DockArrangeResult + tuple[Size, int, bool], DockArrangeResult ] = FIFOCache(4) self._styles_cache = StylesCache() @@ -601,7 +611,7 @@ class Widget(DOMNode): Returns: Relative offset. """ - return self.styles.offset.resolve(self.size, self.app.size) + return self.styles.offset.resolve(self.size, self.screen.size) @offset.setter def offset(self, offset: tuple[int, int]) -> None: @@ -672,6 +682,25 @@ class Widget(DOMNode): """Text selection information, or `None` if no text is selected in this widget.""" return self.screen.selections.get(self, None) + def focus_on_click(self) -> bool: + """Automatically focus the widget on click? + + Implement this if you want to change the default click to focus behavior. + The default will return the classvar `FOCUS_ON_CLICK`. + + Returns: + `True` if Textual should set focus automatically on a click, or `False` if it shouldn't. + """ + return self.FOCUS_ON_CLICK + + def get_line_filters(self) -> Sequence[LineFilter]: + """Get the line filters enabled for this widget. + + Returns: + A sequence of [LineFilter][textual.filters.LineFilter] instances. + """ + return self.app.get_line_filters() + def preflight_checks(self) -> None: """Called in debug mode to do preflight checks. @@ -688,6 +717,14 @@ class Widget(DOMNode): f"'{self.__class__.__name__}.CSS' will be ignored (use 'DEFAULT_CSS' class variable for widgets)" ) + def pre_render(self) -> None: + """Called prior to rendering. + + If you implement this in a subclass, be sure to call the base class method via super. + + """ + self._visual_style = None + def _cover(self, widget: Widget) -> None: """Set a widget used to replace the visuals of this widget (used for loading indicator). @@ -702,6 +739,22 @@ class Widget(DOMNode): self.app.stylesheet.apply(widget) self.refresh(layout=True) + def process_layout( + self, placements: list[WidgetPlacement] + ) -> list[WidgetPlacement]: + """A hook to allow for the manipulation of widget placements before rendering. + + You could use this as a way to modify the positions / margins of widgets if your requirement is + not supported in TCSS. In practice, this method is rarely needed! + + Args: + placements: A list of [`WidgetPlacement`][textual.layout.WidgetPlacement] objects. + + Returns: + A new list of placements. + """ + return placements + def _uncover(self) -> None: """Remove any widget, previously set via [`_cover`][textual.widget.Widget._cover].""" if self._cover_widget is not None: @@ -910,7 +963,7 @@ class Widget(DOMNode): if parent._nodes._updates == self._odd[0]: return self._odd[1] try: - is_odd = parent._nodes.index(self) % 2 == 0 + is_odd = parent._nodes.displayed_and_visible.index(self) % 2 == 0 self._odd = (parent._nodes._updates, is_odd) return is_odd except ValueError: @@ -961,7 +1014,7 @@ class Widget(DOMNode): Returns: A widget in place of this widget to indicate a loading. """ - loading_widget = self.app.get_loading_widget() + loading_widget = self.screen.get_loading_widget() return loading_widget def set_loading(self, loading: bool) -> None: @@ -982,7 +1035,10 @@ class Widget(DOMNode): def _watch_loading(self, loading: bool) -> None: """Called when the 'loading' reactive is changed.""" - self.set_loading(loading) + if not self.is_mounted: + self.call_later(self.set_loading, loading) + else: + self.set_loading(loading) ExpectType = TypeVar("ExpectType", bound="Widget") @@ -1228,11 +1284,14 @@ class Widget(DOMNode): return text_content return Content.from_markup(text_content) - def _arrange(self, size: Size, optimal: bool = False) -> DockArrangeResult: - """Arrange children. + def arrange(self, size: Size, optimal: bool = False) -> DockArrangeResult: + """Arrange child widgets. + + This method is best left alone, unless you have a deep understanding of what it does. Args: size: Size of container. + optimal: Whether fr units should expand the widget (`False`) or avoid expanding the widget (`True`). Returns: Widget locations. @@ -1386,13 +1445,13 @@ class Widget(DOMNode): # 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() + child.update_node_styles() else: for child in children: if child._has_order_style: - child._update_styles() + child.update_node_styles() - self.call_later(update_styles, list(self.children)) + self.call_later(update_styles, self.displayed_children) await_mount = AwaitMount(self, mounted) self.call_next(await_mount) @@ -1428,14 +1487,58 @@ class Widget(DOMNode): MountError: If there is a problem with the mount request. Note: - Only one of ``before`` or ``after`` can be provided. If both are - provided a ``MountError`` will be raised. + Only one of `before` or `after` can be provided. If both are + provided a `MountError` will be raised. """ if self.app._exit: return AwaitMount(self, []) await_mount = self.mount(*widgets, before=before, after=after) return await_mount + def mount_compose( + self, + compose_result: ComposeResult, + *, + before: int | str | Widget | None = None, + after: int | str | Widget | None = None, + ) -> AwaitMount: + """Mount widgets from the result of a compose method. + + Example: + ```python + def on_key(self, event:events.Key) -> None: + + def add_key(key:str) -> ComposeResult: + '''Compose key information widgets''' + with containers.HorizontalGroup(): + yield Label("You pressed:") + yield Label(key) + + self.mount_compose(add_key(event.key)) + + ``` + + Args: + compose_result: The result of a compose method. + before: Optional location to mount before. An `int` is the index + of the child to mount before, a `str` is a `query_one` query to + find the widget to mount before. + after: Optional location to mount after. An `int` is the index + of the child to mount after, a `str` is a `query_one` query to + find the widget to mount after. + + Returns: + An awaitable object that waits for widgets to be mounted. + + Raises: + MountError: If there is a problem with the mount request. + + Note: + Only one of `before` or `after` can be provided. If both are + provided a `MountError` will be raised. + """ + return self.mount_all(compose(self, compose_result), before=before, after=after) + if TYPE_CHECKING: @overload @@ -1603,6 +1706,19 @@ class Widget(DOMNode): Returns: The size and margin for this widget. """ + cache_key = ( + container, + viewport, + width_fraction, + height_fraction, + constrain_width, + greedy, + self._layout_updates, + self.styles._cache_key, + ) + if cached_box_model := self._box_model_cache.get(cache_key): + return cached_box_model + styles = self.styles is_border_box = styles.box_sizing == "border-box" gutter = styles.gutter # Padding plus border @@ -1711,6 +1827,7 @@ class Widget(DOMNode): model = BoxModel( content_width + gutter.width, content_height + gutter.height, margin ) + self._box_model_cache[cache_key] = model return model def get_content_width(self, container: Size, viewport: Size) -> int: @@ -1782,7 +1899,7 @@ class Widget(DOMNode): ) -> None: # TODO: This will cause the widget to refresh, even when there are no links # Can we avoid this? - if self.auto_links: + if self.auto_links and not self.app.mouse_captured: self.highlight_link_id = hover_style.link_id def watch_scroll_x(self, old_value: float, new_value: float) -> None: @@ -2527,6 +2644,7 @@ class Widget(DOMNode): self._dirty_regions.clear() self._repaint_regions.clear() self._styles_cache.clear() + self._styles_cache.set_dirty(self.size.region) outer_size = self.outer_size self._dirty_regions.add(outer_size.region) @@ -3390,7 +3508,11 @@ class Widget(DOMNode): return False while isinstance(widget.parent, Widget) and widget is not self: + if not region: + break + container = widget.parent + if widget.styles.dock != "none": scroll_offset = Offset(0, 0) else: @@ -3414,13 +3536,11 @@ class Widget(DOMNode): # Adjust the region by the amount we just scrolled it, and convert to # its parent's virtual coordinate system. - region = ( ( region.translate(-scroll_offset) .translate(container.styles.margin.top_left) .translate(container.styles.border.spacing.top_left) - .translate(-widget.scroll_offset) .translate(container.virtual_region_with_margin.offset) ) .grow(container.styles.margin) @@ -3568,7 +3688,7 @@ class Widget(DOMNode): """ parent = self.parent if isinstance(parent, Widget): - if self.region: + if self._size: self.screen.scroll_to_widget( self, animate=animate, @@ -3904,14 +4024,9 @@ class Widget(DOMNode): return renderable - def watch_mouse_hover(self, value: bool) -> None: - """Update from CSS if mouse over state changes.""" - if self._has_hover_style: - self._update_styles() - - def watch_has_focus(self, value: bool) -> None: + def watch_has_focus(self, _has_focus: bool) -> None: """Update from CSS if has focus state changes.""" - self._update_styles() + self.update_node_styles() def watch_disabled(self, disabled: bool) -> None: """Update the styles of the widget and its children when disabled is toggled.""" @@ -3931,7 +4046,7 @@ class Widget(DOMNode): except (ScreenStackError, NoActiveAppError, NoScreen): pass - self._update_styles() + self.update_node_styles() def _size_updated( self, size: Size, virtual_size: Size, container_size: Size, layout: bool = True @@ -3993,41 +4108,43 @@ class Widget(DOMNode): @property def visual_style(self) -> VisualStyle: - background = Color(0, 0, 0, 0) - color = Color(255, 255, 255, 0) + if self._visual_style is None: + background = Color(0, 0, 0, 0) + color = Color(255, 255, 255, 0) - style = Style() - opacity = 1.0 + style = Style() + opacity = 1.0 - for node in reversed(self.ancestors_with_self): - styles = node.styles - has_rule = styles.has_rule - opacity *= styles.opacity - if has_rule("background"): - text_background = background + styles.background.tint( - styles.background_tint - ) - background += ( - styles.background.tint(styles.background_tint) - ).multiply_alpha(opacity) - else: - text_background = background - if has_rule("color"): - color = styles.color - style += styles.text_style - if has_rule("auto_color") and styles.auto_color: - color = text_background.get_contrast_text(color.a) + for node in reversed(self.ancestors_with_self): + styles = node.styles + has_rule = styles.has_rule + opacity *= styles.opacity + if has_rule("background"): + text_background = background + styles.background.tint( + styles.background_tint + ) + background += ( + styles.background.tint(styles.background_tint) + ).multiply_alpha(opacity) + else: + text_background = background + if has_rule("color"): + color = styles.color + style += styles.text_style + if has_rule("auto_color") and styles.auto_color: + color = text_background.get_contrast_text(color.a) - return VisualStyle( - background, - color, - bold=style.bold, - dim=style.dim, - italic=style.italic, - reverse=style.reverse, - underline=style.underline, - strike=style.strike, - ) + self._visual_style = VisualStyle( + background, + color, + bold=style.bold, + dim=style.dim, + italic=style.italic, + reverse=style.reverse, + underline=style.underline, + strike=style.strike, + ) + return self._visual_style def get_selection(self, selection: Selection) -> tuple[str, str] | None: """Get the text under the selection. @@ -4075,7 +4192,7 @@ class Widget(DOMNode): try: line = self._render_cache.lines[y] except IndexError: - line = Strip.blank(self.size.width, self.rich_style) + line = Strip.blank(self.size.width, self.visual_style.rich_style) return line @@ -4156,12 +4273,10 @@ class Widget(DOMNode): Returns: The `Widget` instance. """ - if layout: + + if layout and not self._layout_required: self._layout_required = True - for ancestor in self.ancestors: - if not isinstance(ancestor, Widget): - break - ancestor._clear_arrangement_cache() + self._layout_updates += 1 if recompose: self._recompose_required = True @@ -4216,9 +4331,7 @@ class Widget(DOMNode): ] else: children_to_remove = selector - await_remove = self.app._prune( - *children_to_remove, parent=cast(DOMNode, self._parent) - ) + await_remove = self.app._prune(*children_to_remove, parent=self) return await_remove @asynccontextmanager @@ -4282,13 +4395,16 @@ class Widget(DOMNode): self._layout_cache[cache_key] = visual return visual - async def run_action(self, action: str) -> None: + async def run_action( + self, action: str, namespaces: Mapping[str, DOMNode] | None = None + ) -> None: """Perform a given action, with this widget as the default namespace. Args: action: Action encoded as a string. + namespaces: Mapping of namespaces. """ - await self.app.run_action(action, self) + await self.app.run_action(action, self, namespaces) def post_message(self, message: Message) -> bool: """Post a message to this widget. @@ -4359,20 +4475,29 @@ class Widget(DOMNode): else: if self._refresh_styles_required: self._refresh_styles_required = False - self.call_later(self._update_styles) + self.call_later(self.update_node_styles) 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 not self._layout_required: + 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 - screen.post_message(messages.Update(self)) + if self.display: + screen.post_message(messages.Update(self)) if self._layout_required: self._layout_required = False - screen.post_message(messages.Layout()) + for ancestor in self.ancestors: + if not isinstance(ancestor, Widget): + break + ancestor._clear_arrangement_cache() + ancestor._layout_updates += 1 + if not ancestor.styles.auto_dimensions: + break + screen.post_message(messages.Layout(self)) def focus(self, scroll_visible: bool = True) -> Self: """Give focus to this widget. @@ -4391,6 +4516,7 @@ class Widget(DOMNode): except NoScreen: pass + self.refresh() self.app.call_later(set_focus, self) return self @@ -4480,6 +4606,7 @@ class Widget(DOMNode): def notify_style_update(self) -> None: self._rich_style_cache.clear() self._visual_style_cache.clear() + self._visual_style = None super().notify_style_update() async def _on_mouse_down(self, event: events.MouseDown) -> None: diff --git a/contrib/python/textual/textual/widgets/__init__.py b/contrib/python/textual/textual/widgets/__init__.py index 2dfc7d3f73b..ffc861dad15 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, CollapsibleTitle + from textual.widgets._collapsible import Collapsible from textual.widgets._content_switcher import ContentSwitcher from textual.widgets._data_table import DataTable from textual.widgets._digits import Digits @@ -54,7 +54,6 @@ __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 19e50cb424d..907ae843b89 100644 --- a/contrib/python/textual/textual/widgets/__init__.pyi +++ b/contrib/python/textual/textual/widgets/__init__.pyi @@ -2,7 +2,6 @@ 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 df73e7f0d80..0dbb1f26670 100644 --- a/contrib/python/textual/textual/widgets/_button.py +++ b/contrib/python/textual/textual/widgets/_button.py @@ -50,109 +50,177 @@ class Button(Widget, can_focus=True): Button { width: auto; min-width: 16; - height: auto; - color: $button-foreground; - background: $surface; - border: none; - border-top: tall $surface-lighten-1; - border-bottom: tall $surface-darken-1; + height:auto; + line-pad: 1; text-align: center; content-align: center middle; - text-style: bold; - line-pad: 1; + - &.-textual-compact { - border: none !important; - } + &.-style-flat { + text-style: bold; + color: auto 90%; + background: $surface; + border: block $surface; + &:hover { + background: $primary; + border: block $primary; + } + &:focus { + text-style: $button-focus-text-style; + } + &.-active { + background: $surface; + border: block $surface; + tint: $background 30%; + } + &:disabled { + color: auto 50%; + } - &:disabled { - text-opacity: 0.6; - } - - &:focus { - text-style: $button-focus-text-style; - background-tint: $foreground 5%; - } - &:hover { - border-top: tall $surface; - background: $surface-darken-1; + &.-primary { + background: $primary-muted; + border: block $primary-muted; + color: $text-primary; + &:hover { + color: $text; + background: $primary; + border: block $primary; + } + } + &.-success { + background: $success-muted; + border: block $success-muted; + color: $text-success; + &:hover { + color: $text; + background: $success; + border: block $success; + } + } + &.-warning { + background: $warning-muted; + border: block $warning-muted; + color: $text-warning; + &:hover { + color: $text; + background: $warning; + border: block $warning; + } + } + &.-error { + background: $error-muted; + border: block $error-muted; + color: $text-error; + &:hover { + color: $text; + background: $error; + border: block $error; + } + } } - &.-active { + &.-style-default { + text-style: bold; + color: $button-foreground; background: $surface; - border-bottom: tall $surface-lighten-1; - border-top: tall $surface-darken-1; - tint: $background 30%; - } + border: none; + border-top: tall $surface-lighten-1; + border-bottom: tall $surface-darken-1; + - &.-primary { - color: $button-color-foreground; - background: $primary; - border-top: tall $primary-lighten-3; - border-bottom: tall $primary-darken-3; + &.-textual-compact { + border: none !important; + } - &:hover { - background: $primary-darken-2; - border-top: tall $primary; + &:disabled { + text-opacity: 0.6; } + &:focus { + text-style: $button-focus-text-style; + background-tint: $foreground 5%; + } + &:hover { + border-top: tall $surface; + background: $surface-darken-1; + } + &.-active { - background: $primary; - border-bottom: tall $primary-lighten-3; - border-top: tall $primary-darken-3; + background: $surface; + border-bottom: tall $surface-lighten-1; + border-top: tall $surface-darken-1; + tint: $background 30%; } - } - &.-success { - color: $button-color-foreground; - background: $success; - border-top: tall $success-lighten-2; - border-bottom: tall $success-darken-3; + &.-primary { + color: $button-color-foreground; + background: $primary; + border-top: tall $primary-lighten-3; + border-bottom: tall $primary-darken-3; + + &:hover { + background: $primary-darken-2; + border-top: tall $primary; + } - &:hover { - background: $success-darken-2; - border-top: tall $success; + &.-active { + background: $primary; + border-bottom: tall $primary-lighten-3; + border-top: tall $primary-darken-3; + } } - &.-active { + &.-success { + color: $button-color-foreground; background: $success; - border-bottom: tall $success-lighten-2; - border-top: tall $success-darken-2; - } - } + border-top: tall $success-lighten-2; + border-bottom: tall $success-darken-3; - &.-warning{ - color: $button-color-foreground; - background: $warning; - border-top: tall $warning-lighten-2; - border-bottom: tall $warning-darken-3; + &:hover { + background: $success-darken-2; + border-top: tall $success; + } - &:hover { - background: $warning-darken-2; - border-top: tall $warning; + &.-active { + background: $success; + border-bottom: tall $success-lighten-2; + border-top: tall $success-darken-2; + } } - &.-active { + &.-warning{ + color: $button-color-foreground; background: $warning; - border-bottom: tall $warning-lighten-2; - border-top: tall $warning-darken-2; - } - } + border-top: tall $warning-lighten-2; + border-bottom: tall $warning-darken-3; - &.-error { - color: $button-color-foreground; - background: $error; - border-top: tall $error-lighten-2; - border-bottom: tall $error-darken-3; + &:hover { + background: $warning-darken-2; + border-top: tall $warning; + } - &:hover { - background: $error-darken-1; - border-top: tall $error; + &.-active { + background: $warning; + border-bottom: tall $warning-lighten-2; + border-top: tall $warning-darken-2; + } } - &.-active { + &.-error { + color: $button-color-foreground; background: $error; - border-bottom: tall $error-lighten-2; - border-top: tall $error-darken-2; + border-top: tall $error-lighten-2; + border-bottom: tall $error-darken-3; + + &:hover { + background: $error-darken-1; + border-top: tall $error; + } + + &.-active { + background: $error; + border-bottom: tall $error-lighten-2; + border-top: tall $error-darken-2; + } } } } @@ -160,7 +228,7 @@ class Button(Widget, can_focus=True): BINDINGS = [Binding("enter", "press", "Press button", show=False)] - label: reactive[ContentText] = reactive[ContentText](Content.empty) + label: reactive[ContentText] = reactive[ContentText](Content.empty()) """The text label that appears within the button.""" variant = reactive("default", init=False) @@ -169,6 +237,9 @@ class Button(Widget, can_focus=True): compact = reactive(False, toggle_class="-textual-compact") """Make the button compact (without borders).""" + flat = reactive(False) + """Enable alternative flat button style.""" + class Pressed(Message): """Event sent when a `Button` is pressed and there is no Button action. @@ -201,6 +272,7 @@ class Button(Widget, can_focus=True): tooltip: RenderableType | None = None, action: str | None = None, compact: bool = False, + flat: bool = False, ): """Create a Button widget. @@ -214,16 +286,19 @@ class Button(Widget, can_focus=True): tooltip: Optional tooltip. action: Optional action to run when clicked. compact: Enable compact button style. + flat: Enable alternative flat look buttons. """ super().__init__(name=name, id=id, classes=classes, disabled=disabled) if label is None: label = self.css_identifier_styled - self.label = Content.from_text(label) self.variant = variant - self.action = action + self.flat = flat self.compact = compact + self.set_reactive(Button.label, Content.from_text(label)) + + self.action = action self.active_effect_duration = 0.2 """Amount of time in seconds the button 'press' animation lasts.""" @@ -253,6 +328,10 @@ class Button(Widget, can_focus=True): self.remove_class(f"-{old_variant}") self.add_class(f"-{variant}") + def watch_flat(self, flat: bool) -> None: + self.set_class(flat, "-style-flat") + self.set_class(not flat, "-style-default") + def validate_label(self, label: ContentText) -> Content: """Parse markup for self.label""" return Content.from_text(label) @@ -314,16 +393,17 @@ class Button(Widget, can_focus=True): id: str | None = None, classes: str | None = None, disabled: bool = False, + flat: bool = False, ) -> Button: """Utility constructor for creating a success Button variant. Args: label: The text that appears within the button. - disabled: Whether the button is disabled or not. name: The name of the button. id: The ID of the button in the DOM. classes: The CSS classes of the button. disabled: Whether the button is disabled or not. + flat: Enable alternative flat look buttons. Returns: A [`Button`][textual.widgets.Button] widget of the 'success' @@ -336,6 +416,7 @@ class Button(Widget, can_focus=True): id=id, classes=classes, disabled=disabled, + flat=flat, ) @classmethod @@ -347,16 +428,17 @@ class Button(Widget, can_focus=True): id: str | None = None, classes: str | None = None, disabled: bool = False, + flat: bool = False, ) -> Button: """Utility constructor for creating a warning Button variant. Args: label: The text that appears within the button. - disabled: Whether the button is disabled or not. name: The name of the button. id: The ID of the button in the DOM. classes: The CSS classes of the button. disabled: Whether the button is disabled or not. + flat: Enable alternative flat look buttons. Returns: A [`Button`][textual.widgets.Button] widget of the 'warning' @@ -369,6 +451,7 @@ class Button(Widget, can_focus=True): id=id, classes=classes, disabled=disabled, + flat=flat, ) @classmethod @@ -380,16 +463,17 @@ class Button(Widget, can_focus=True): id: str | None = None, classes: str | None = None, disabled: bool = False, + flat: bool = False, ) -> Button: """Utility constructor for creating an error Button variant. Args: label: The text that appears within the button. - disabled: Whether the button is disabled or not. name: The name of the button. id: The ID of the button in the DOM. classes: The CSS classes of the button. disabled: Whether the button is disabled or not. + flat: Enable alternative flat look buttons. Returns: A [`Button`][textual.widgets.Button] widget of the 'error' @@ -402,4 +486,5 @@ class Button(Widget, can_focus=True): id=id, classes=classes, disabled=disabled, + flat=flat, ) diff --git a/contrib/python/textual/textual/widgets/_data_table.py b/contrib/python/textual/textual/widgets/_data_table.py index b78ce528a1f..04e9b0599ef 100644 --- a/contrib/python/textual/textual/widgets/_data_table.py +++ b/contrib/python/textual/textual/widgets/_data_table.py @@ -4,7 +4,16 @@ import functools from dataclasses import dataclass from itertools import chain, zip_longest from operator import itemgetter -from typing import Any, Callable, ClassVar, Generic, Iterable, NamedTuple, TypeVar +from typing import ( + Any, + Callable, + ClassVar, + Generic, + Iterable, + NamedTuple, + TypeVar, + Union, +) import rich.repr from rich.console import RenderableType @@ -1160,6 +1169,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def watch_zebra_stripes(self) -> None: self._clear_caches() + def watch_header_height(self) -> None: + self._clear_caches() + def validate_cell_padding(self, cell_padding: int) -> int: return max(cell_padding, 0) @@ -1722,20 +1734,41 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.check_idle() return row_key - def add_columns(self, *labels: TextType) -> list[ColumnKey]: - """Add a number of columns. + def add_columns( + self, *columns: Union[TextType, tuple[TextType, str]] + ) -> list[ColumnKey]: + """Add multiple columns to the DataTable. Args: - *labels: Column headers. + *columns: Column specifications. Each can be either: + - A string or Text object (label only, auto-generated key) + - A tuple of (label, key) for manual key control Returns: A list of the keys for the columns that were added. See the `add_column` method docstring for more information on how these keys are used. + + Examples: + ```python + # Add columns with auto-generated keys + keys = table.add_columns("Name", "Age", "City") + + # Add columns with manual keys + keys = table.add_columns( + ("Name", "name_col"), + ("Age", "age_col"), + "City" # Mixed with auto-generated key + ) + ``` """ column_keys = [] - for label in labels: - column_key = self.add_column(label, width=None) + for column in columns: + if isinstance(column, tuple): + label, key = column + column_key = self.add_column(label, width=None, key=key) + else: + column_key = self.add_column(column, width=None) column_keys.append(column_key) return column_keys diff --git a/contrib/python/textual/textual/widgets/_directory_tree.py b/contrib/python/textual/textual/widgets/_directory_tree.py index efc56d8613f..24a2426e86d 100644 --- a/contrib/python/textual/textual/widgets/_directory_tree.py +++ b/contrib/python/textual/textual/widgets/_directory_tree.py @@ -66,7 +66,7 @@ class DirectoryTree(Tree[DirEntry]): } & > .directory-tree--hidden { - color: $text 50%; + text-style: dim; } &:ansi { @@ -431,7 +431,7 @@ class DirectoryTree(Tree[DirEntry]): if node_label.plain.startswith("."): node_label.stylize_before( - self.get_component_rich_style("directory-tree--hidden") + self.get_component_rich_style("directory-tree--hidden", partial=True) ) text = Text.assemble(prefix, node_label) @@ -525,7 +525,7 @@ class DirectoryTree(Tree[DirEntry]): key=lambda path: (not self._safe_is_dir(path), path.name.lower()), ) - @work(exclusive=True) + @work() async def _loader(self) -> None: """Background loading queue processor.""" worker = get_current_worker() diff --git a/contrib/python/textual/textual/widgets/_footer.py b/contrib/python/textual/textual/widgets/_footer.py index b643ea8fda7..14aa56cb81f 100644 --- a/contrib/python/textual/textual/widgets/_footer.py +++ b/contrib/python/textual/textual/widgets/_footer.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections import defaultdict +from itertools import groupby from typing import TYPE_CHECKING import rich.repr @@ -9,15 +10,25 @@ from rich.text import Text from textual import events from textual.app import ComposeResult from textual.binding import Binding -from textual.containers import ScrollableContainer +from textual.containers import HorizontalGroup, ScrollableContainer from textual.reactive import reactive from textual.widget import Widget +from textual.widgets import Label if TYPE_CHECKING: from textual.screen import Screen @rich.repr.auto +class KeyGroup(HorizontalGroup): + DEFAULT_CSS = """ + KeyGroup { + width: auto; + } + """ + + class FooterKey(Widget): ALLOW_SELECT = False COMPONENT_CLASSES = { @@ -29,6 +40,7 @@ class FooterKey(Widget): FooterKey { width: auto; height: 1; + text-wrap: nowrap; background: $footer-item-background; .footer-key--key { color: $footer-key-foreground; @@ -64,6 +76,7 @@ class FooterKey(Widget): """ compact = reactive(True) + """Display compact style.""" def __init__( self, @@ -83,6 +96,7 @@ class FooterKey(Widget): if disabled: classes += " -disabled" super().__init__(classes=classes) + self.shrink = False if tooltip: self.tooltip = tooltip @@ -94,23 +108,28 @@ class FooterKey(Widget): description_padding = self.get_component_styles( "footer-key--description" ).padding + description = self.description - label_text = Text.assemble( - ( - " " * key_padding.left + key_display + " " * key_padding.right, - key_style, - ), - ( - " " * description_padding.left - + description - + " " * description_padding.right, - description_style, - ), - ) + if description: + label_text = Text.assemble( + ( + " " * key_padding.left + key_display + " " * key_padding.right, + key_style, + ), + ( + " " * description_padding.left + + description + + " " * description_padding.right, + description_style, + ), + ) + else: + label_text = Text.assemble((key_display, key_style)) + label_text.stylize_before(self.rich_style) return label_text - async def on_mouse_down(self) -> None: + def on_mouse_down(self) -> None: if self._disabled: self.app.bell() else: @@ -120,26 +139,61 @@ class FooterKey(Widget): self.set_class(compact, "-compact") +class FooterLabel(Label): + """Text displayed in the footer (used by binding groups).""" + + @rich.repr.auto class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): ALLOW_SELECT = False DEFAULT_CSS = """ Footer { - layout: grid; - grid-columns: auto; + layout: horizontal; color: $footer-foreground; background: $footer-background; dock: bottom; height: 1; scrollbar-size: 0 0; &.-compact { - grid-gutter: 1; + FooterLabel { + margin: 0; + } + FooterKey { + margin-right: 1; + } + FooterKey.-grouped { + margin: 0 1; + } + FooterKey.-command-palette { + padding-right: 0; + } } FooterKey.-command-palette { dock: right; padding-right: 1; border-left: vkey $foreground 20%; } + HorizontalGroup.binding-group { + width: auto; + height: 1; + layout: horizontal; + } + KeyGroup.-compact { + FooterKey.-grouped { + margin: 0; + } + margin: 0 1 0 0; + padding-left: 1; + } + + FooterKey.-grouped { + margin: 0 1; + } + FooterLabel { + margin: 0 1 0 0; + color: $footer-description-foreground; + background: $footer-description-background; + } &:ansi { background: ansi_default; @@ -164,15 +218,18 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): border-left: vkey ansi_black; } } + } """ - compact = reactive(False) + compact = reactive(False, toggle_class="-compact") """Display in compact style.""" _bindings_ready = reactive(False, repaint=False) """True if the bindings are ready to be displayed.""" show_command_palette = reactive(True) """Show the key to invoke the command palette.""" + combine_groups = reactive(True) + """Combine bindings in the same group?""" def __init__( self, @@ -182,6 +239,7 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): classes: str | None = None, disabled: bool = False, show_command_palette: bool = True, + compact: bool = False, ) -> None: """A footer to show key bindings. @@ -192,6 +250,7 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): classes: The CSS classes for the widget. disabled: Whether the widget is disabled or not. show_command_palette: Show key binding to invoke the command palette, on the right of the footer. + compact: Display a compact style (less whitespace) footer. """ super().__init__( *children, @@ -201,6 +260,7 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): disabled=disabled, ) self.set_reactive(Footer.show_command_palette, show_command_palette) + self.compact = compact def compose(self) -> ComposeResult: if not self._bindings_ready: @@ -217,16 +277,37 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): action_to_bindings[binding.action].append((binding, enabled, tooltip)) self.styles.grid_size_columns = len(action_to_bindings) - for multi_bindings in action_to_bindings.values(): - binding, enabled, tooltip = multi_bindings[0] - yield FooterKey( - binding.key, - self.app.get_key_display(binding), - binding.description, - binding.action, - disabled=not enabled, - tooltip=tooltip, - ).data_bind(Footer.compact) + + for group, multi_bindings_iterable in groupby( + action_to_bindings.values(), + lambda multi_bindings_: multi_bindings_[0][0].group, + ): + multi_bindings = list(multi_bindings_iterable) + if group is not None and len(multi_bindings) > 1: + with KeyGroup(classes="-compact" if group.compact else ""): + for multi_bindings in multi_bindings: + binding, enabled, tooltip = multi_bindings[0] + yield FooterKey( + binding.key, + self.app.get_key_display(binding), + "", + binding.action, + disabled=not enabled, + tooltip=tooltip or binding.description, + classes="-grouped", + ).data_bind(compact=Footer.compact) + yield FooterLabel(group.description) + else: + for multi_bindings in multi_bindings: + binding, enabled, tooltip = multi_bindings[0] + yield FooterKey( + binding.key, + self.app.get_key_display(binding), + binding.description, + binding.action, + disabled=not enabled, + tooltip=tooltip, + ).data_bind(compact=Footer.compact) if self.show_command_palette and self.app.ENABLE_COMMAND_PALETTE: try: _node, binding, enabled, tooltip = active_bindings[ @@ -245,12 +326,12 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): tooltip=binding.tooltip or binding.description, ) - async def bindings_changed(self, screen: Screen) -> None: + def bindings_changed(self, screen: Screen) -> None: self._bindings_ready = True if not screen.app.app_focus: return if self.is_attached and screen is self.screen: - await self.recompose() + self.call_after_refresh(self.recompose) def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None: if self.allow_horizontal_scroll: @@ -267,16 +348,7 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): event.prevent_default() def on_mount(self) -> None: - self.call_next(self.bindings_changed, self.screen) - - def bindings_changed(screen: Screen) -> None: - """Update bindings after a short delay to avoid flicker.""" - self.call_after_refresh(self.bindings_changed, screen) - - self.screen.bindings_updated_signal.subscribe(self, bindings_changed) + self.screen.bindings_updated_signal.subscribe(self, self.bindings_changed) def on_unmount(self) -> None: self.screen.bindings_updated_signal.unsubscribe(self) - - def watch_compact(self, compact: bool) -> None: - self.set_class(compact, "-compact") diff --git a/contrib/python/textual/textual/widgets/_header.py b/contrib/python/textual/textual/widgets/_header.py index a760aa81d18..1656a5730c4 100644 --- a/contrib/python/textual/textual/widgets/_header.py +++ b/contrib/python/textual/textual/widgets/_header.py @@ -6,11 +6,13 @@ from datetime import datetime from rich.text import Text -from textual.app import RenderResult +from textual.app import ComposeResult, RenderResult +from textual.content import Content from textual.dom import NoScreen from textual.events import Click, Mount from textual.reactive import Reactive from textual.widget import Widget +from textual.widgets import Static class HeaderIcon(Widget): @@ -98,34 +100,18 @@ class HeaderClock(HeaderClockSpace): return Text(datetime.now().time().strftime(self.time_format)) -class HeaderTitle(Widget): +class HeaderTitle(Static): """Display the title / subtitle in the header.""" DEFAULT_CSS = """ HeaderTitle { + text-wrap: nowrap; + text-overflow: ellipsis; content-align: center middle; width: 100%; } """ - text: Reactive[str] = Reactive("") - """The main title text.""" - - sub_text = Reactive("") - """The sub-title text.""" - - def render(self) -> RenderResult: - """Render the title and sub-title. - - Returns: - The value to render. - """ - text = Text(self.text, no_wrap=True, overflow="ellipsis") - if self.sub_text: - text.append(" — ") - text.append(self.sub_text, "dim") - return text - class Header(Widget): """A header widget with icon and clock.""" @@ -181,7 +167,7 @@ class Header(Widget): if time_format is not None: self.time_format = time_format - def compose(self): + def compose(self) -> ComposeResult: yield HeaderIcon().data_bind(Header.icon) yield HeaderTitle() yield ( @@ -196,6 +182,17 @@ class Header(Widget): def _on_click(self): self.toggle_class("-tall") + def format_title(self) -> Content: + """Format the title and subtitle. + + Defers to [App.format_title][textual.app.App.format_title] by default. + Override this method if you want to customize how the title is displayed in the header. + + Returns: + Content for title display. + """ + return self.app.format_title(self.screen_title, self.screen_sub_title) + @property def screen_title(self) -> str: """The title that this header will display. @@ -221,17 +218,11 @@ class Header(Widget): def _on_mount(self, _: Mount) -> None: async def set_title() -> None: try: - self.query_one(HeaderTitle).text = self.screen_title - except NoScreen: - pass - - async def set_sub_title() -> None: - try: - self.query_one(HeaderTitle).sub_text = self.screen_sub_title + self.query_one(HeaderTitle).update(self.format_title()) except NoScreen: pass self.watch(self.app, "title", set_title) - self.watch(self.app, "sub_title", set_sub_title) + self.watch(self.app, "sub_title", set_title) self.watch(self.screen, "title", set_title) - self.watch(self.screen, "sub_title", set_sub_title) + self.watch(self.screen, "sub_title", set_title) diff --git a/contrib/python/textual/textual/widgets/_help_panel.py b/contrib/python/textual/textual/widgets/_help_panel.py index 7b8da2e7c23..36717d24451 100644 --- a/contrib/python/textual/textual/widgets/_help_panel.py +++ b/contrib/python/textual/textual/widgets/_help_panel.py @@ -79,7 +79,10 @@ class HelpPanel(Widget): DEFAULT_CLASSES = "-textual-system" def on_mount(self): - self.watch(self.screen, "focused", self.update_help) + def update_help(focused_widget: Widget | None): + self.update_help(focused_widget) + + self.watch(self.screen, "focused", update_help) def update_help(self, focused_widget: Widget | None) -> None: """Update the help for the focused widget. diff --git a/contrib/python/textual/textual/widgets/_input.py b/contrib/python/textual/textual/widgets/_input.py index 7933c97ebb3..e8470ddfbbf 100644 --- a/contrib/python/textual/textual/widgets/_input.py +++ b/contrib/python/textual/textual/widgets/_input.py @@ -11,6 +11,7 @@ from rich.text import Text from typing_extensions import Literal from textual import events +from textual.actions import SkipAction from textual.expand_tabs import expand_tabs_inline from textual.screen import Screen from textual.scroll_view import ScrollView @@ -217,7 +218,8 @@ class Input(ScrollView): background: ansi_default; color: ansi_default; &>.input--cursor { - text-style: reverse; + background: ansi_white; + color: ansi_black; } &>.input--placeholder, &>.input--suggestion { text-style: dim; @@ -491,7 +493,7 @@ class Input(ScrollView): character: A character associated with the key, or `None` if there isn't one. Returns: - `True` if the widget may capture the key in it's `Key` message, or `False` if it won't. + `True` if the widget may capture the key in its `Key` message, or `False` if it won't. """ return character is not None and character.isprintable() @@ -601,7 +603,7 @@ class Input(ScrollView): def render_line(self, y: int) -> Strip: if y != 0: - return Strip.blank(self.size.width) + return Strip.blank(self.size.width, self.rich_style) console = self.app.console console_options = self.app.console_options @@ -1106,7 +1108,11 @@ class Input(ScrollView): def action_copy(self) -> None: """Copy the current selection to the clipboard.""" - self.app.copy_to_clipboard(self.selected_text) + selected_text = self.selected_text + if selected_text: + self.app.copy_to_clipboard(selected_text) + else: + raise SkipAction() def action_paste(self) -> None: """Paste from the local clipboard.""" diff --git a/contrib/python/textual/textual/widgets/_key_panel.py b/contrib/python/textual/textual/widgets/_key_panel.py index 39bc8322bdc..4794c6cb925 100644 --- a/contrib/python/textual/textual/widgets/_key_panel.py +++ b/contrib/python/textual/textual/widgets/_key_panel.py @@ -1,7 +1,6 @@ from __future__ import annotations from collections import defaultdict -from functools import partial from itertools import groupby from operator import itemgetter from typing import TYPE_CHECKING @@ -164,16 +163,17 @@ class KeyPanel(VerticalScroll, can_focus=False): yield BindingsTable(shrink=True, expand=False) async def on_mount(self) -> None: + mount_screen = self.screen + async def bindings_changed(screen: Screen) -> None: """Update bindings.""" if not screen.app.app_focus: return - if self.is_attached and screen is self.screen: - self.refresh(recompose=True) + if self.is_attached and screen is mount_screen: + await self.recompose() def _bindings_changed(screen: Screen) -> None: - """Update bindings after a short delay.""" - screen.set_timer(1 / 20, partial(bindings_changed, screen)) + self.call_after_refresh(bindings_changed, screen) self.set_class(self.app.ansi_color, "-ansi-scrollbar") self.screen.bindings_updated_signal.subscribe(self, _bindings_changed) diff --git a/contrib/python/textual/textual/widgets/_label.py b/contrib/python/textual/textual/widgets/_label.py index 89a1dc1db18..5a14ad2e124 100644 --- a/contrib/python/textual/textual/widgets/_label.py +++ b/contrib/python/textual/textual/widgets/_label.py @@ -48,8 +48,7 @@ class Label(Static): def __init__( self, - # TODO: Should probably be renamed to `content`. - renderable: VisualType = "", + content: VisualType = "", *, variant: LabelVariant | None = None, expand: bool = False, @@ -61,7 +60,7 @@ class Label(Static): disabled: bool = False, ) -> None: super().__init__( - renderable, + content, expand=expand, shrink=shrink, markup=markup, diff --git a/contrib/python/textual/textual/widgets/_markdown.py b/contrib/python/textual/textual/widgets/_markdown.py index c6e05fa4443..c5bea8d4d03 100644 --- a/contrib/python/textual/textual/widgets/_markdown.py +++ b/contrib/python/textual/textual/widgets/_markdown.py @@ -38,7 +38,7 @@ The triples encode the level, the label, and the optional block id of each headi class MarkdownStream: - """An object to manager streaming markdown. + """An object to manage streaming markdown. This will accumulate markdown fragments if they can't be rendered fast enough. @@ -649,7 +649,7 @@ class MarkdownTableContent(Widget): def pre_layout(self, layout: Layout) -> None: assert isinstance(layout, GridLayout) layout.auto_minimum = True - layout.expand = True + layout.expand = not self.query_ancestor(MarkdownTable).styles.is_auto_width layout.shrink = True layout.stretch_height = True @@ -870,12 +870,13 @@ class MarkdownFence(MarkdownBlock): self.lexer = token.info self._highlighted_code = self.highlight(self.code, self.lexer) + @property def allow_horizontal_scroll(self) -> bool: return True @classmethod def highlight(cls, code: str, language: str) -> Content: - return highlight(code, language=language) + return highlight(code, language=language or None) def _copy_context(self, block: MarkdownBlock) -> None: if isinstance(block, MarkdownFence): diff --git a/contrib/python/textual/textual/widgets/_masked_input.py b/contrib/python/textual/textual/widgets/_masked_input.py index 8f4c4931ddb..a48ef9b60be 100644 --- a/contrib/python/textual/textual/widgets/_masked_input.py +++ b/contrib/python/textual/textual/widgets/_masked_input.py @@ -553,7 +553,7 @@ class MaskedInput(Input, can_focus=True): def render_line(self, y: int) -> Strip: if y != 0: - return Strip.blank(self.size.width) + return Strip.blank(self.size.width, self.rich_style) result = self._value width = self.content_size.width diff --git a/contrib/python/textual/textual/widgets/_option_list.py b/contrib/python/textual/textual/widgets/_option_list.py index a63dca9726d..6a57821a003 100644 --- a/contrib/python/textual/textual/widgets/_option_list.py +++ b/contrib/python/textual/textual/widgets/_option_list.py @@ -283,7 +283,7 @@ class OptionList(ScrollView, can_focus=True): self._id_to_option: dict[str, Option] = {} """Maps an Options's ID on to the option itself.""" self._option_to_index: dict[Option, int] = {} - """Maps an Option to it's index in self._options.""" + """Maps an Option to its index in self._options.""" self._option_render_cache: LRUCache[tuple[Option, Style, Spacing], list[Strip]] self._option_render_cache = LRUCache(maxsize=1024 * 2) @@ -311,6 +311,18 @@ class OptionList(ScrollView, can_focus=True): """The number of options.""" return len(self._options) + @property + def highlighted_option(self) -> Option | None: + """The currently highlighted option, or `None` if no option is highlighted. + + Returns: + An Option, or `None`. + """ + if self.highlighted is not None: + return self.options[self.highlighted] + else: + return None + def clear_options(self) -> Self: """Clear the content of the option list. @@ -324,15 +336,37 @@ class OptionList(ScrollView, can_focus=True): self._option_to_index.clear() self.highlighted = None self.refresh() - self.scroll_to(0, 0, animate=False) + self.scroll_y = 0 self._update_lines() return self + def set_options(self, options: Iterable[OptionListContent]) -> Self: + """Set options, potentially clearing existing options. + + Args: + options: Options to set. + + Returns: + The `OptionList` instance. + """ + self._options.clear() + self._line_cache.clear() + self._option_render_cache.clear() + self._id_to_option.clear() + self._option_to_index.clear() + self.highlighted = None + self.scroll_y = 0 + self.add_options(options) + return self + def add_options(self, new_options: Iterable[OptionListContent]) -> Self: """Add new options. Args: new_options: Content of new options. + + Returns: + The `OptionList` instance. """ new_options = list(new_options) @@ -853,7 +887,10 @@ class OptionList(ScrollView, can_focus=True): option_index, line_offset = self._lines[line_number] option = self.options[option_index] except IndexError: - return Strip.blank(self.scrollable_content_region.width) + return Strip.blank( + self.scrollable_content_region.width, + self.get_visual_style("option-list--option").rich_style, + ) mouse_over = self._mouse_hovering_over == option_index component_class = "" @@ -873,7 +910,10 @@ class OptionList(ScrollView, can_focus=True): try: strip = strips[line_offset] except IndexError: - return Strip.blank(self.scrollable_content_region.width) + return Strip.blank( + self.scrollable_content_region.width, + self.get_visual_style("option-list--option").rich_style, + ) return strip def validate_highlighted(self, highlighted: int | None) -> int | None: diff --git a/contrib/python/textual/textual/widgets/_placeholder.py b/contrib/python/textual/textual/widgets/_placeholder.py index bc1257d78dd..95b4061818d 100644 --- a/contrib/python/textual/textual/widgets/_placeholder.py +++ b/contrib/python/textual/textual/widgets/_placeholder.py @@ -3,7 +3,7 @@ from __future__ import annotations from itertools import cycle -from typing import TYPE_CHECKING, Iterator +from typing import TYPE_CHECKING from weakref import WeakKeyDictionary from typing_extensions import Literal, Self @@ -13,6 +13,7 @@ from textual import events if TYPE_CHECKING: from textual.app import RenderResult +from textual._context import NoActiveAppError from textual.css._error_tools import friendly_list from textual.reactive import Reactive, reactive from textual.widget import Widget @@ -84,7 +85,7 @@ class Placeholder(Widget): """ # Consecutive placeholders get assigned consecutive colors. - _COLORS: WeakKeyDictionary[App, Iterator[str]] = WeakKeyDictionary() + _COLORS: WeakKeyDictionary[App, int] = WeakKeyDictionary() _SIZE_RENDER_TEMPLATE = "[b]{} x {}[/b]" variant: Reactive[PlaceholderVariant] = reactive[PlaceholderVariant]("default") @@ -125,6 +126,12 @@ class Placeholder(Widget): self.variant = self.validate_variant(variant) """The current variant of the placeholder.""" + try: + self._COLORS[self.app] = self._COLORS.setdefault(self.app, -1) + 1 + self._color_offset = self._COLORS[self.app] + except NoActiveAppError: + self._color_offset = 0 + # Set a cycle through the variants with the correct starting point. self._variants_cycle = cycle(_VALID_PLACEHOLDER_VARIANTS_ORDERED) while next(self._variants_cycle) != self.variant: @@ -132,10 +139,9 @@ class Placeholder(Widget): async def _on_compose(self, event: events.Compose) -> None: """Set the color for this placeholder.""" - colors = Placeholder._COLORS.setdefault( - self.app, cycle(_PLACEHOLDER_BACKGROUND_COLORS) - ) - self.styles.background = f"{next(colors)} 50%" + color_count = len(_PLACEHOLDER_BACKGROUND_COLORS) + color = _PLACEHOLDER_BACKGROUND_COLORS[self._color_offset % color_count] + self.styles.background = f"{color} 50%" def render(self) -> RenderResult: """Render the placeholder. diff --git a/contrib/python/textual/textual/widgets/_pretty.py b/contrib/python/textual/textual/widgets/_pretty.py index 64dce7598e5..5314a572c3a 100644 --- a/contrib/python/textual/textual/widgets/_pretty.py +++ b/contrib/python/textual/textual/widgets/_pretty.py @@ -6,6 +6,7 @@ from typing import Any from rich.pretty import Pretty as PrettyRenderable +from textual.app import RenderResult from textual.widget import Widget @@ -37,27 +38,19 @@ class Pretty(Widget): id: The ID of the pretty in the DOM. classes: The CSS classes of the pretty. """ - super().__init__( - name=name, - id=id, - classes=classes, - ) - self._renderable = PrettyRenderable(object) + super().__init__(name=name, id=id, classes=classes) + self.shrink = False + self._pretty_renderable = PrettyRenderable(object) - def render(self) -> PrettyRenderable: - """Render the pretty-printed object. + def render(self) -> RenderResult: + return self._pretty_renderable - Returns: - The rendered pretty-print. - """ - return self._renderable - - def update(self, object: Any) -> None: + def update(self, object: object) -> None: """Update the content of the pretty widget. Args: object: The object to pretty-print. """ - self._renderable = PrettyRenderable(object) + self._pretty_renderable = PrettyRenderable(object) self.clear_cached_dimensions() self.refresh(layout=True) diff --git a/contrib/python/textual/textual/widgets/_progress_bar.py b/contrib/python/textual/textual/widgets/_progress_bar.py index a1c0260b8ed..934594d4d67 100644 --- a/contrib/python/textual/textual/widgets/_progress_bar.py +++ b/contrib/python/textual/textual/widgets/_progress_bar.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Optional +from typing import Optional, Type from rich.style import Style @@ -72,11 +72,13 @@ class Bar(Widget, can_focus=False): disabled: bool = False, clock: Clock | None = None, gradient: Gradient | None = None, + bar_renderable: Type[BarRenderable] = BarRenderable, ): """Create a bar for a [`ProgressBar`][textual.widgets.ProgressBar].""" self._clock = (clock or Clock()).clone() super().__init__(name=name, id=id, classes=classes, disabled=disabled) self.set_reactive(Bar.gradient, gradient) + self.bar_renderable = bar_renderable def _validate_percentage(self, percentage: float | None) -> float | None: """Avoid updating the bar, if the percentage increase is too small to render.""" @@ -104,7 +106,7 @@ class Bar(Widget, can_focus=False): if self.percentage < 1 else self.get_component_rich_style("bar--complete") ) - return BarRenderable( + return self.bar_renderable( highlight_range=(0, self.size.width * self.percentage), highlight_style=Style.from_color(bar_style.color), background_style=Style.from_color(bar_style.bgcolor), @@ -125,7 +127,11 @@ class Bar(Widget, can_focus=False): else: speed = 30 # Cells per second. # Compute the position of the bar. - start = (speed * self._clock.time) % (2 * total_imaginary_width) + start = ( + (speed * self._clock.time) % (2 * total_imaginary_width) + if total_imaginary_width + else 0 + ) if start > total_imaginary_width: # If the bar is to the right of its width, wrap it back from right to left. start = 2 * total_imaginary_width - start # = (tiw - (start - tiw)) @@ -133,7 +139,7 @@ class Bar(Widget, can_focus=False): end = start + highlighted_bar_width bar_style = self.get_component_rich_style("bar--indeterminate") - return BarRenderable( + return self.bar_renderable( highlight_range=(max(0, start), min(end, width)), highlight_style=Style.from_color(bar_style.color), background_style=Style.from_color(bar_style.bgcolor), @@ -226,6 +232,9 @@ class ProgressBar(Widget, can_focus=False): gradient: reactive[Gradient | None] = reactive(None) """Optional gradient object (will replace CSS styling in bar).""" + BAR_RENDERABLE: Type[BarRenderable] = BarRenderable + """BarRenderable to use for rendering the bar-part of the ProgressBar""" + def __init__( self, total: float | None = None, @@ -283,7 +292,7 @@ class ProgressBar(Widget, can_focus=False): def compose(self) -> ComposeResult: if self.show_bar: yield ( - Bar(id="bar", clock=self._clock) + Bar(id="bar", clock=self._clock, bar_renderable=self.BAR_RENDERABLE) .data_bind(ProgressBar.percentage) .data_bind(ProgressBar.gradient) ) diff --git a/contrib/python/textual/textual/widgets/_radio_set.py b/contrib/python/textual/textual/widgets/_radio_set.py index 7fedbac00ec..ddb2adf335a 100644 --- a/contrib/python/textual/textual/widgets/_radio_set.py +++ b/contrib/python/textual/textual/widgets/_radio_set.py @@ -50,17 +50,21 @@ class RadioSet(VerticalScroll, can_focus=True, can_focus_children=False): & > .toggle--button { color: $panel-darken-2; background: $panel; - } - - &.-selected { - background: $block-cursor-blurred-background; - } + } } & > RadioButton.-on .toggle--button { color: $text-success; } + &:blur { + & > RadioButton.-selected { + & > .toggle--label { + background: $block-cursor-blurred-background; + } + } + } + &:focus { /* The following rules/styles mimic similar ToggleButton:focus rules in * ToggleButton. If those styles ever get updated, these should be too. @@ -68,9 +72,12 @@ class RadioSet(VerticalScroll, can_focus=True, can_focus_children=False): border: tall $border; background-tint: $foreground 5%; & > RadioButton.-selected { - color: $block-cursor-foreground; - text-style: $block-cursor-text-style; - background: $block-cursor-background; + + & > .toggle--label { + background: $block-cursor-background; + color: $block-cursor-foreground; + text-style: $block-cursor-text-style; + } } } diff --git a/contrib/python/textual/textual/widgets/_select.py b/contrib/python/textual/textual/widgets/_select.py index ac7dac6ee5f..1926e5a530c 100644 --- a/contrib/python/textual/textual/widgets/_select.py +++ b/contrib/python/textual/textual/widgets/_select.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Generic, Iterable, TypeVar, Union +from typing import TYPE_CHECKING, Generic, Hashable, Iterable, TypeVar, Union import rich.repr from rich.console import RenderableType @@ -261,7 +261,7 @@ class SelectCurrent(Horizontal): self.post_message(self.Toggle()) -SelectType = TypeVar("SelectType") +SelectType = TypeVar("SelectType", bound=Hashable) """The type used for data in the Select.""" SelectOption: TypeAlias = "tuple[str, SelectType]" """The type used for options in the Select.""" diff --git a/contrib/python/textual/textual/widgets/_selection_list.py b/contrib/python/textual/textual/widgets/_selection_list.py index 50777c3accf..a5f53740da7 100644 --- a/contrib/python/textual/textual/widgets/_selection_list.py +++ b/contrib/python/textual/textual/widgets/_selection_list.py @@ -193,7 +193,7 @@ class SelectionList(Generic[SelectionType], OptionList): """Message sent when the collection of selected values changes. This is sent regardless of whether the change occurred via user interaction - or programmatically the the `SelectionList` API. + or programmatically via the `SelectionList` API. When a bulk change occurs, such as through `select_all` or `deselect_all`, only a single `SelectedChanged` message will be sent (rather than one per diff --git a/contrib/python/textual/textual/widgets/_static.py b/contrib/python/textual/textual/widgets/_static.py index 4ed79268e3b..cd2f443ab28 100644 --- a/contrib/python/textual/textual/widgets/_static.py +++ b/contrib/python/textual/textual/widgets/_static.py @@ -29,8 +29,6 @@ class Static(Widget, inherit_bindings=False): } """ - _renderable: VisualType - def __init__( self, content: VisualType = "", @@ -48,25 +46,33 @@ class Static(Widget, inherit_bindings=False): ) self.expand = expand self.shrink = shrink - self._content = content - self._visual: Visual | None = None + self.__content = content + self.__visual: Visual | None = None @property def visual(self) -> Visual: - if self._visual is None: - self._visual = visualize(self, self._content, markup=self._render_markup) - return self._visual + """The visual to be displayed. + + Note that the visual is what is ultimately rendered in the widget, but may not be the + same object set with the `update` method or `content` property. For instance, if you + update with a string, then the visual will be a [Content][textual.content.Content] instance. + + """ + if self.__visual is None: + self.__visual = visualize(self, self.__content, markup=self._render_markup) + return self.__visual @property - def renderable(self) -> VisualType: - return self._content or "" + def content(self) -> VisualType: + """The original content set in the constructor.""" + return self.__content - # TODO: Should probably be renamed to `content`. - @renderable.setter - def renderable(self, renderable: VisualType) -> None: - self._renderable = renderable - self._visual = None + @content.setter + def content(self, content: VisualType) -> None: + self.__content = content + self.__visual = visualize(self, content, markup=self._render_markup) self.clear_cached_dimensions() + self.refresh(layout=True) def render(self) -> RenderResult: """Get a rich renderable for the widget's content. @@ -77,13 +83,13 @@ class Static(Widget, inherit_bindings=False): return self.visual def update(self, content: VisualType = "", *, layout: bool = True) -> None: - """Update the widget's content area with new text or Rich renderable. + """Update the widget's content area with a string, a Visual (such as [Content][textual.content.Content]), or a [Rich renderable](https://rich.readthedocs.io/en/latest/protocol.html). Args: content: New content. - layout: Also perform a layout operation (set to `False` if you are certain the size won't change.) + layout: Also perform a layout operation (set to `False` if you are certain the size won't change). """ - self._content = content - self._visual = visualize(self, content, markup=self._render_markup) + self.__content = content + self.__visual = visualize(self, content, markup=self._render_markup) self.refresh(layout=layout) diff --git a/contrib/python/textual/textual/widgets/_text_area.py b/contrib/python/textual/textual/widgets/_text_area.py index f3ebd2bfdc5..47ba4522e90 100644 --- a/contrib/python/textual/textual/widgets/_text_area.py +++ b/contrib/python/textual/textual/widgets/_text_area.py @@ -16,8 +16,10 @@ from typing_extensions import Literal from textual._text_area_theme import TextAreaTheme from textual._tree_sitter import TREE_SITTER, get_language +from textual.actions import SkipAction from textual.cache import LRUCache from textual.color import Color +from textual.content import Content from textual.document._document import ( Document, DocumentBase, @@ -36,6 +38,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 +from textual.style import Style as ContentStyle if TYPE_CHECKING: from tree_sitter import Language, Query @@ -143,6 +146,14 @@ TextArea { background: $foreground 30%; } + & .text-area--suggestion { + color: $text-muted; + } + + & .text-area--placeholder { + color: $text 40%; + } + &:focus { border: tall $border; } @@ -183,6 +194,8 @@ TextArea { "text-area--cursor-line", "text-area--selection", "text-area--matching-bracket", + "text-area--suggestion", + "text-area--placeholder", } """ `TextArea` offers some component classes which can be used to style aspects of the widget. @@ -197,6 +210,8 @@ TextArea { | `text-area--cursor-line` | Target the line the cursor is on. | | `text-area--selection` | Target the current selection. | | `text-area--matching-bracket` | Target matching brackets. | + | `text-area--suggestion` | Target the text set in the `suggestion` reactive. | + | `text-area--placeholder` | Target the placeholder text. | """ BINDINGS = [ @@ -392,6 +407,15 @@ TextArea { """Indicates where the cursor is in the blink cycle. If it's currently not visible due to blinking, this is False.""" + suggestion: Reactive[str] = reactive("") + """A suggestion for auto-complete (pressing right will insert it).""" + + hide_suggestion_on_blur: Reactive[bool] = reactive(True) + """Hide suggestion when the TextArea does not have focus.""" + + placeholder: Reactive[str | Content] = reactive("") + """Text to show when the text area has no content.""" + @dataclass class Changed(Message): """Posted when the content inside the TextArea changes. @@ -443,6 +467,7 @@ TextArea { tooltip: RenderableType | None = None, compact: bool = False, highlight_cursor_line: bool = True, + placeholder: str | Content = "", ) -> None: """Construct a new `TextArea`. @@ -464,6 +489,7 @@ TextArea { tooltip: Optional tooltip. compact: Enable compact style (without borders). highlight_cursor_line: Highlight the line under the cursor. + placeholder: Text to display when there is not content. """ super().__init__(name=name, id=id, classes=classes, disabled=disabled) @@ -524,6 +550,7 @@ TextArea { 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.set_reactive(TextArea.placeholder, placeholder) self._line_cache: LRUCache[tuple, Strip] = LRUCache(1024) @@ -565,6 +592,7 @@ TextArea { tooltip: RenderableType | None = None, compact: bool = False, highlight_cursor_line: bool = True, + placeholder: str | Content = "", ) -> TextArea: """Construct a new `TextArea` with sensible defaults for editing code. @@ -607,6 +635,7 @@ TextArea { tooltip=tooltip, compact=compact, highlight_cursor_line=highlight_cursor_line, + placeholder=placeholder, ) @staticmethod @@ -632,6 +661,10 @@ TextArea { def notify_style_update(self) -> None: self._line_cache.clear() + super().notify_style_update() + + def update_suggestion(self) -> None: + """A hook to update the [`suggestion`][textual.widgets.TextArea.suggestion] attribute.""" def check_consume_key(self, key: str, character: str | None = None) -> bool: """Check if the widget may consume the given key. @@ -643,7 +676,7 @@ TextArea { character: A character associated with the key, or `None` if there isn't one. Returns: - `True` if the widget may capture the key in it's `Key` message, or `False` if it won't. + `True` if the widget may capture the key in its `Key` message, or `False` if it won't. """ if self.read_only: # In read only mode we don't consume any key events @@ -1002,7 +1035,7 @@ TextArea { "Try `pip install 'textual[syntax]'` or '`poetry add textual[syntax]' to get started quickly.\n\n" "Alternatively, install tree-sitter manually (`pip install tree-sitter`) and then\n" "install the required language (e.g. `pip install tree-sitter-ruby`), then register it.\n" - "and it's highlight query using TextArea.register_language().\n\n" + "and its highlight query using TextArea.register_language().\n\n" "Falling back to plain text for now." ) document = Document(text) @@ -1045,6 +1078,7 @@ TextArea { self.history.clear() self._set_document(text, self.language) self.post_message(self.Changed(self).set_sender(self)) + self.update_suggestion() def _on_resize(self) -> None: self._rewrap_and_refresh_virtual_size() @@ -1124,6 +1158,7 @@ TextArea { # +1 width to make space for the cursor resting at the end of the line width, height = self.document.get_size(self.indent_width) self.virtual_size = Size(width + self.gutter_width + 1, height) + self._refresh_scrollbars() @property def _draw_cursor(self) -> bool: @@ -1173,9 +1208,29 @@ TextArea { Returns: A rendered line. """ + + if not self.text and self.placeholder: + placeholder_lines = Content.from_text(self.placeholder).wrap( + self.content_size.width + ) + if y < len(placeholder_lines): + style = self.get_visual_style("text-area--placeholder") + content = placeholder_lines[y].stylize(style) + if self._draw_cursor and y == 0: + theme = self._theme + cursor_style = theme.cursor_style if theme else None + if cursor_style: + content = content.stylize( + ContentStyle.from_rich_style(cursor_style), 0, 1 + ) + return Strip( + content.render_segments(self.visual_style), content.cell_length + ) + scroll_x, scroll_y = self.scroll_offset absolute_y = scroll_y + y selection = self.selection + _, cursor_y = self._cursor_offset cache_key = ( self.size, scroll_x, @@ -1190,7 +1245,7 @@ TextArea { if ( self._cursor_visible and self.cursor_blink - and absolute_y == selection.end[0] + and absolute_y == cursor_y ) else None ), @@ -1201,6 +1256,7 @@ TextArea { self.show_line_numbers, self.read_only, self.show_cursor, + self.suggestion, ) if (cached_line := self._line_cache.get(cache_key)) is not None: return cached_line @@ -1218,6 +1274,11 @@ TextArea { A rendered line. """ theme = self._theme + base_style = ( + theme.base_style + if theme and theme.base_style is not None + else self.rich_style + ) wrapped_document = self.wrapped_document scroll_x, scroll_y = self.scroll_offset @@ -1229,7 +1290,7 @@ TextArea { out_of_bounds = y_offset >= wrapped_document.height if out_of_bounds: - return Strip.blank(self.size.width) + return Strip.blank(self.size.width, base_style) # Get the line corresponding to this offset try: @@ -1238,7 +1299,7 @@ TextArea { line_info = None if line_info is None: - return Strip.blank(self.size.width) + return Strip.blank(self.size.width, base_style) line_index, section_offset = line_info @@ -1332,6 +1393,16 @@ TextArea { cursor_column + 1, ) + if self.suggestion and (self.has_focus or not self.hide_suggestion_on_blur): + suggestion_style = self.get_component_rich_style( + "text-area--suggestion" + ) + line = Text.assemble( + line[:cursor_column], + (self.suggestion, suggestion_style), + line[cursor_column:], + ) + if draw_cursor: cursor_style = theme.cursor_style if theme else None if cursor_style: @@ -1416,13 +1487,12 @@ TextArea { line_style = theme.base_style if theme else None text_strip = text_strip.extend_cell_length(target_width, line_style) - strip = Strip.join([Strip(gutter, cell_length=gutter_width), text_strip]) + if gutter: + strip = Strip.join([Strip(gutter, cell_length=gutter_width), text_strip]) + else: + strip = text_strip - return strip.apply_style( - theme.base_style - if theme and theme.base_style is not None - else self.rich_style - ) + return strip.apply_style(base_style) @property def text(self) -> str: @@ -1474,6 +1544,10 @@ TextArea { Data relating to the edit that may be useful. The data returned may be different depending on the edit performed. """ + if self.suggestion.startswith(edit.text): + self.suggestion = self.suggestion[len(edit.text) :] + else: + self.suggestion = "" old_gutter_width = self.gutter_width result = edit.do(self) self.history.record(edit) @@ -1488,10 +1562,11 @@ TextArea { result.end_location, ) - self._refresh_size() edit.after(self) self._build_highlight_map() self.post_message(self.Changed(self)) + self.update_suggestion() + self._refresh_size() return result def undo(self) -> None: @@ -1555,6 +1630,7 @@ TextArea { edit.after(self) self._build_highlight_map() self.post_message(self.Changed(self)) + self.update_suggestion() def _redo_batch(self, edits: Sequence[Edit]) -> None: """Redo a batch of Edits in order. @@ -1603,6 +1679,7 @@ TextArea { edit.after(self) self._build_highlight_map() self.post_message(self.Changed(self)) + self.update_suggestion() async def _on_key(self, event: events.Key) -> None: """Handle key presses which correspond to document inserts.""" @@ -1725,12 +1802,14 @@ TextArea { """Reset the cursor blink timer.""" if self.cursor_blink: self._cursor_visible = True - self.blink_timer.reset() + if self.is_mounted: + self.blink_timer.reset() def _pause_blink(self, visible: bool = True) -> None: """Pause the cursor blinking but ensure it stays visible.""" self._cursor_visible = visible - self.blink_timer.pause() + if self.is_mounted: + self.blink_timer.pause() async def _on_mouse_down(self, event: events.MouseDown) -> None: """Update the cursor position, and begin a selection using the mouse.""" @@ -2030,6 +2109,9 @@ TextArea { if not self._has_cursor: self.scroll_right() return + if self.suggestion: + self.insert(self.suggestion) + return target = ( self.get_cursor_right_location() if select or self.selection.is_empty @@ -2266,6 +2348,8 @@ TextArea { Returns: An `EditResult` containing information about the edit. """ + if len(text) > 1: + self._restart_blink() if location is None: location = self.cursor_location return self.edit(Edit(text, location, location, maintain_selection_offset)) @@ -2437,6 +2521,8 @@ TextArea { selected_text = self.selected_text if selected_text: self.app.copy_to_clipboard(selected_text) + else: + raise SkipAction() def action_paste(self) -> None: """Paste from local clipboard.""" diff --git a/contrib/python/textual/textual/widgets/_toast.py b/contrib/python/textual/textual/widgets/_toast.py index c43ee09fcdf..4d10114c8ea 100644 --- a/contrib/python/textual/textual/widgets/_toast.py +++ b/contrib/python/textual/textual/widgets/_toast.py @@ -144,6 +144,7 @@ class ToastRack(Container, inherit_css=False): DEFAULT_CSS = """ ToastRack { + display: none; layer: _toastrack; width: 1fr; height: auto; @@ -175,6 +176,7 @@ class ToastRack(Container, inherit_css=False): Args: notifications: The notifications to show. """ + self.display = bool(notifications) # Look for any stale toasts and remove them. for toast in self.query(Toast): if toast._notification not in notifications: diff --git a/contrib/python/textual/textual/widgets/_toggle_button.py b/contrib/python/textual/textual/widgets/_toggle_button.py index 720e7c77d7b..dc0a8f6c88a 100644 --- a/contrib/python/textual/textual/widgets/_toggle_button.py +++ b/contrib/python/textual/textual/widgets/_toggle_button.py @@ -64,6 +64,15 @@ class ToggleButton(Static, can_focus=True): &.-textual-compact { border: none !important; padding: 0; + &:focus { + border: tall $border; + background-tint: $foreground 5%; + & > .toggle--label { + color: $block-cursor-foreground; + background: $block-cursor-background; + text-style: $block-cursor-text-style; + } + } } & > .toggle--button { @@ -76,13 +85,14 @@ class ToggleButton(Static, can_focus=True): background: $panel; } - &:focus { - border: tall $border; - background-tint: $foreground 5%; - & > .toggle--label { + &:focus { + border: tall $border; + background-tint: $foreground 5%; + + & > .toggle--label { color: $block-cursor-foreground; - background: $block-cursor-background; - text-style: $block-cursor-text-style; + background: $block-cursor-background; + text-style: $block-cursor-text-style; } } &:blur:hover { @@ -153,7 +163,7 @@ class ToggleButton(Static, can_focus=True): Returns: A `Content` rendering of the label for use in the button. """ - label = Content.from_text(label).first_line + label = Content.from_text(label).first_line.rstrip() return label @property @@ -195,19 +205,18 @@ class ToggleButton(Static, can_focus=True): """ button = self._button label_style = self.get_visual_style("toggle--label") - label = self._label.stylize_before(label_style) - spacer = " " if label else "" + label = self._label.pad(1, 1).stylize_before(label_style) if self._button_first: - content = Content.assemble(button, spacer, label) + content = Content.assemble(button, label) else: - content = Content.assemble(label, spacer, button) + content = Content.assemble(label, button) return content def get_content_width(self, container: Size, viewport: Size) -> int: return ( self._button.get_optimal_width(self.styles, 0) - + (1 if self._label else 0) + + (2 if self._label else 0) + self._label.get_optimal_width(self.styles, 0) ) diff --git a/contrib/python/textual/textual/widgets/select.py b/contrib/python/textual/textual/widgets/select.py index 9c52c35b89d..91b5b3657ce 100644 --- a/contrib/python/textual/textual/widgets/select.py +++ b/contrib/python/textual/textual/widgets/select.py @@ -1,3 +1,8 @@ -from textual.widgets._select import EmptySelectError, InvalidSelectValueError +from textual.widgets._select import ( + BLANK, + EmptySelectError, + InvalidSelectValueError, + NoSelection, +) -__all__ = ["EmptySelectError", "InvalidSelectValueError"] +__all__ = ["EmptySelectError", "InvalidSelectValueError", "NoSelection", "BLANK"] diff --git a/contrib/python/textual/textual/worker_manager.py b/contrib/python/textual/textual/worker_manager.py index 884239e9531..c9083a2968c 100644 --- a/contrib/python/textual/textual/worker_manager.py +++ b/contrib/python/textual/textual/worker_manager.py @@ -22,7 +22,7 @@ if TYPE_CHECKING: @rich.repr.auto(angular=True) class WorkerManager: - """An object to manager a number of workers. + """An object to manage a number of workers. You will not have to construct this class manually, as widgets, screens, and apps have a worker manager accessibly via a `workers` attribute. diff --git a/contrib/python/textual/ya.make b/contrib/python/textual/ya.make index 3c3acc2b4f5..964e11cd029 100644 --- a/contrib/python/textual/ya.make +++ b/contrib/python/textual/ya.make @@ -2,13 +2,14 @@ PY3_LIBRARY() -VERSION(5.3.0) +VERSION(6.12.0) LICENSE(MIT) PEERDIR( contrib/python/Pygments contrib/python/markdown-it-py + contrib/python/mdit-py-plugins contrib/python/platformdirs contrib/python/rich contrib/python/typing-extensions @@ -66,6 +67,7 @@ PY_SRCS( textual/_partition.py textual/_path.py textual/_profile.py + textual/_queue.py textual/_resolve.py textual/_segment_tools.py textual/_sleep.py @@ -121,6 +123,9 @@ PY_SRCS( textual/css/transition.py textual/css/types.py textual/demo/__main__.py + textual/demo/_project_data.py + textual/demo/_project_stargazer_updater.py + textual/demo/_project_stars.py textual/demo/data.py textual/demo/demo_app.py textual/demo/game.py |
