├── src └── textual │ ├── __init__.py │ ├── _input.py │ ├── layouts │ ├── __init__.py │ └── dock.py │ ├── widgets │ ├── __init__.py │ ├── _static.py │ ├── _footer.py │ ├── _placeholder.py │ ├── _header.py │ └── _scroll_view.py │ ├── _context.py │ ├── _profile.py │ ├── case.py │ ├── _types.py │ ├── driver.py │ ├── actions.py │ ├── state.py │ ├── screen_update.py │ ├── messages.py │ ├── _loop.py │ ├── message.py │ ├── reactive.py │ ├── _line_cache.py │ ├── _timer.py │ ├── page.py │ ├── _xterm_parser.py │ ├── _parser.py │ ├── _animator.py │ ├── keys.py │ ├── events.py │ ├── scrollbar.py │ ├── widget.py │ ├── view.py │ ├── _linux_driver.py │ ├── message_pump.py │ ├── geometry.py │ ├── layout.py │ ├── _ansi_sequences.py │ ├── app.py │ └── richreadme.md ├── imgs └── rich-tui.png ├── rich-tui.code-workspace ├── .github └── ISSUE_TEMPLATE │ └── bug_report.md ├── pyproject.toml ├── LICENSE ├── examples ├── simple.py └── richreadme.md ├── .gitignore ├── README.md └── poetry.lock /src/textual/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/textual/_input.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/textual/layouts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /imgs/rich-tui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ramiro/textual/main/imgs/rich-tui.png -------------------------------------------------------------------------------- /src/textual/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | from ._footer import Footer 2 | from ._header import Header 3 | from ._placeholder import Placeholder 4 | from ._scroll_view import ScrollView 5 | from ._static import Static 6 | -------------------------------------------------------------------------------- /src/textual/_context.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from contextvars import ContextVar 4 | 5 | if TYPE_CHECKING: 6 | from .app import App 7 | 8 | active_app: ContextVar["App"] = ContextVar("active_app") 9 | -------------------------------------------------------------------------------- /rich-tui.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | }, 6 | { 7 | "path": "../rich" 8 | } 9 | ], 10 | "settings": { 11 | "python.pythonPath": "/Users/willmcgugan/Venvs/textual/bin/python", 12 | "cSpell.words": [ 13 | "tcgetattr" 14 | ] 15 | } 16 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Textual is currently in active development. I will try to keep the sample functional, but I make no guarantee anything will run right now. 11 | 12 | Unless this is a blatant bug, you might want to open a discussion for now. 13 | -------------------------------------------------------------------------------- /src/textual/widgets/_static.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from rich.console import RenderableType 4 | from ..widget import Widget 5 | 6 | 7 | class Static(Widget): 8 | def __init__(self, renderable: RenderableType, name: str | None = None) -> None: 9 | super().__init__(name) 10 | self.renderable = renderable 11 | 12 | def render(self) -> RenderableType: 13 | return self.renderable 14 | -------------------------------------------------------------------------------- /src/textual/_profile.py: -------------------------------------------------------------------------------- 1 | """ 2 | Timer context manager, only used in debug. 3 | 4 | """ 5 | 6 | from time import time 7 | 8 | import contextlib 9 | from typing import Generator 10 | 11 | 12 | @contextlib.contextmanager 13 | def timer(subject: str = "time") -> Generator[None, None, None]: 14 | """print the elapsed time. (only used in debugging)""" 15 | start = time() 16 | yield 17 | elapsed = time() - start 18 | elapsed_ms = elapsed * 1000 19 | print(f"{subject} elapsed {elapsed_ms:.2f}ms") 20 | -------------------------------------------------------------------------------- /src/textual/case.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def camel_to_snake(name: str, _re_snake=re.compile("[a-z][A-Z]")) -> str: 5 | """Convert name from CamelCase to snake_case. 6 | 7 | Args: 8 | name (str): A symbol name, such as a class name. 9 | 10 | Returns: 11 | str: Name in camel case. 12 | """ 13 | 14 | def repl(match) -> str: 15 | lower, upper = match.group() 16 | return f"{lower}_{upper.lower()}" 17 | 18 | return _re_snake.sub(repl, name).lower() 19 | 20 | 21 | if __name__ == "__main__": 22 | print(camel_to_snake("HelloWorldEvent")) -------------------------------------------------------------------------------- /src/textual/_types.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import Awaitable, Callable, Optional, TYPE_CHECKING 3 | from rich.segment import Segment 4 | 5 | if sys.version_info >= (3, 8): 6 | from typing import Protocol 7 | else: 8 | from typing_extensions import Protocol 9 | 10 | 11 | if TYPE_CHECKING: 12 | from .events import Event 13 | from .message import Message 14 | 15 | Callback = Callable[[], None] 16 | # IntervalID = int 17 | 18 | 19 | class MessageTarget(Protocol): 20 | async def post_message(self, message: "Message") -> bool: 21 | ... 22 | 23 | 24 | class EventTarget(Protocol): 25 | async def post_message(self, message: "Message") -> bool: 26 | ... 27 | 28 | 29 | MessageHandler = Callable[["Message"], Awaitable] 30 | 31 | Lines = list[list[Segment]] -------------------------------------------------------------------------------- /src/textual/driver.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import platform 5 | from abc import ABC, abstractmethod 6 | from typing import TYPE_CHECKING 7 | 8 | from ._types import MessageTarget 9 | 10 | if TYPE_CHECKING: 11 | from rich.console import Console 12 | 13 | 14 | log = logging.getLogger("rich") 15 | 16 | WINDOWS = platform.system() == "Windows" 17 | 18 | 19 | class Driver(ABC): 20 | def __init__(self, console: "Console", target: "MessageTarget") -> None: 21 | self.console = console 22 | self._target = target 23 | 24 | @abstractmethod 25 | def start_application_mode(self) -> None: 26 | ... 27 | 28 | @abstractmethod 29 | def disable_input(self) -> None: 30 | ... 31 | 32 | @abstractmethod 33 | def stop_application_mode(self) -> None: 34 | ... 35 | -------------------------------------------------------------------------------- /src/textual/actions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ast 4 | from typing import Any, Tuple 5 | import re 6 | 7 | 8 | class ActionError(Exception): 9 | pass 10 | 11 | 12 | re_action_params = re.compile(r"([\w\.]+)(\(.*?\))") 13 | 14 | 15 | def parse(action: str) -> tuple[str, tuple[Any, ...]]: 16 | params_match = re_action_params.match(action) 17 | if params_match is not None: 18 | action_name, action_params_str = params_match.groups() 19 | try: 20 | action_params = ast.literal_eval(action_params_str) 21 | except Exception as error: 22 | raise ActionError(str(error)) 23 | else: 24 | action_name = action 25 | action_params = () 26 | 27 | return ( 28 | action_name, 29 | action_params if isinstance(action_params, tuple) else (action_params,), 30 | ) 31 | 32 | 33 | if __name__ == "__main__": 34 | 35 | print(parse("view.toggle('side')")) 36 | 37 | print(parse("view.toggle")) -------------------------------------------------------------------------------- /src/textual/widgets/_footer.py: -------------------------------------------------------------------------------- 1 | from rich.console import Console, ConsoleOptions, RenderableType 2 | from rich.repr import rich_repr, RichReprResult 3 | from rich.text import Text 4 | 5 | from .. import events 6 | from ..widget import Widget 7 | 8 | 9 | class Footer(Widget): 10 | def __init__(self) -> None: 11 | self.keys: list[tuple[str, str]] = [] 12 | super().__init__() 13 | self.layout_size = 1 14 | 15 | def __rich_repr__(self) -> RichReprResult: 16 | yield "footer" 17 | 18 | def add_key(self, key: str, label: str) -> None: 19 | self.keys.append((key, label)) 20 | 21 | def render(self) -> RenderableType: 22 | 23 | text = Text( 24 | style="white on dark_green", 25 | no_wrap=True, 26 | overflow="ellipsis", 27 | justify="left", 28 | end="", 29 | ) 30 | for key, label in self.keys: 31 | text.append(f" {key.upper()} ", style="default on default") 32 | text.append(f" {label} ") 33 | return text 34 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "textual" 3 | version = "0.1.5" 4 | description = "Text User Interface using Rich" 5 | authors = ["Will McGugan "] 6 | license = "MIT" 7 | classifiers = [ 8 | "Development Status :: 1 - Planning", 9 | "Environment :: Console", 10 | "Intended Audience :: Developers", 11 | "Operating System :: Microsoft :: Windows", 12 | "Operating System :: MacOS", 13 | "Operating System :: POSIX :: Linux", 14 | "Programming Language :: Python :: 3.7", 15 | "Programming Language :: Python :: 3.8", 16 | "Programming Language :: Python :: 3.9", 17 | "Programming Language :: Python :: 3.10", 18 | ] 19 | 20 | 21 | [tool.poetry.dependencies] 22 | python = "^3.7" 23 | rich = "^10.5.0" 24 | #rich = {git = "git@github.com:willmcgugan/rich", rev = "auto-repr-fix"} 25 | typing-extensions = { version = "^3.10.0", python = "<3.8" } 26 | 27 | [tool.poetry.dev-dependencies] 28 | 29 | mypy = "^0.910" 30 | 31 | [build-system] 32 | requires = ["poetry-core>=1.0.0"] 33 | build-backend = "poetry.core.masonry.api" 34 | -------------------------------------------------------------------------------- /src/textual/state.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, Type, TypeVar 2 | 3 | 4 | ParentType = TypeVar("ParentType") 5 | ValueType = TypeVar("ValueType") 6 | 7 | 8 | class Reactive(Generic[ValueType]): 9 | def __init__(self, default: ValueType) -> None: 10 | self._default = default 11 | 12 | def __set_name__(self, owner: object, name: str) -> None: 13 | self.internal_name = f"__{name}" 14 | setattr(owner, self.internal_name, self._default) 15 | 16 | def __get__(self, obj: object, obj_type: Type[object]) -> ValueType: 17 | print("__get__", obj, obj_type) 18 | 19 | return getattr(obj, self.internal_name) 20 | 21 | def __set__(self, obj: object, value: ValueType) -> None: 22 | print("__set__", obj, value) 23 | setattr(obj, self.internal_name, value) 24 | 25 | 26 | class Example: 27 | def __init__(self, foo: int = 3) -> None: 28 | self.foo = foo 29 | 30 | color: Reactive[str] = Reactive("blue") 31 | 32 | 33 | example = Example() 34 | 35 | print(example.color) 36 | example.color = "red" 37 | print(example.color) 38 | print(example.foo) 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Will McGugan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/textual/screen_update.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Iterable 4 | 5 | from rich.console import Console, RenderableType 6 | from rich.control import Control 7 | from rich.segment import Segment, Segments 8 | 9 | from .geometry import Point 10 | from ._loop import loop_last 11 | 12 | 13 | class ScreenUpdate: 14 | def __init__( 15 | self, console: Console, renderable: RenderableType, width: int, height: int 16 | ) -> None: 17 | 18 | self.lines = console.render_lines( 19 | renderable, console.options.update_dimensions(width, height) 20 | ) 21 | self.offset = Point(0, 0) 22 | 23 | def render(self, x: int, y: int) -> Iterable[Segment]: 24 | move_to = Control.move_to 25 | new_line = Segment.line() 26 | for last, (offset_y, line) in loop_last(enumerate(self.lines, y)): 27 | yield move_to(x, offset_y).segment 28 | yield from line 29 | if not last: 30 | yield new_line 31 | 32 | def __rich__(self) -> RenderableType: 33 | x, y = self.offset 34 | update = self.render(x, y) 35 | return Segments(update) -------------------------------------------------------------------------------- /examples/simple.py: -------------------------------------------------------------------------------- 1 | from rich.markdown import Markdown 2 | 3 | from textual import events 4 | from textual.app import App 5 | from textual.view import DockView 6 | from textual.widgets import Header, Footer, Placeholder, ScrollView 7 | 8 | 9 | class MyApp(App): 10 | """An example of a very simple Textual App""" 11 | 12 | async def on_load(self, event: events.Load) -> None: 13 | await self.bind("q,ctrl+c", "quit") 14 | await self.bind("b", "view.toggle('sidebar')") 15 | 16 | async def on_startup(self, event: events.Startup) -> None: 17 | view = await self.push_view(DockView()) 18 | header = Header(self.title) 19 | footer = Footer() 20 | sidebar = Placeholder(name="sidebar") 21 | 22 | with open("richreadme.md", "rt") as fh: 23 | readme = Markdown(fh.read(), hyperlinks=True) 24 | 25 | body = ScrollView(readme) 26 | 27 | footer.add_key("b", "Toggle sidebar") 28 | footer.add_key("q", "Quit") 29 | 30 | await view.dock(header, edge="top") 31 | await view.dock(footer, edge="bottom") 32 | await view.dock(sidebar, edge="left", size=30) 33 | await view.dock(body, edge="right") 34 | self.require_layout() 35 | 36 | 37 | app = MyApp(title="Simple App") 38 | app.run() 39 | -------------------------------------------------------------------------------- /src/textual/messages.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import TYPE_CHECKING 3 | 4 | import rich.repr 5 | 6 | from .message import Message 7 | 8 | 9 | if TYPE_CHECKING: 10 | from .message_pump import MessagePump 11 | from .widget import Widget 12 | 13 | 14 | @rich.repr.auto 15 | class UpdateMessage(Message): 16 | def __init__( 17 | self, 18 | sender: MessagePump, 19 | widget: Widget, 20 | offset_x: int = 0, 21 | offset_y: int = 0, 22 | reflow: bool = False, 23 | ): 24 | super().__init__(sender) 25 | self.widget = widget 26 | self.offset_x = offset_x 27 | self.offset_y = offset_y 28 | self.reflow = reflow 29 | 30 | def __rich_repr__(self) -> rich.repr.RichReprResult: 31 | yield self.sender 32 | yield "widget" 33 | yield "offset_x", self.offset_x, 0 34 | yield "offset_y", self.offset_y, 0 35 | yield "reflow", self.reflow, False 36 | 37 | def can_batch(self, message: Message) -> bool: 38 | return isinstance(message, UpdateMessage) and message.sender == self.sender 39 | 40 | 41 | @rich.repr.auto 42 | class LayoutMessage(Message): 43 | def can_batch(self, message: Message) -> bool: 44 | return isinstance(message, LayoutMessage) and message.sender == self.sender 45 | -------------------------------------------------------------------------------- /src/textual/_loop.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Tuple, TypeVar 2 | 3 | T = TypeVar("T") 4 | 5 | 6 | def loop_first(values: Iterable[T]) -> Iterable[Tuple[bool, T]]: 7 | """Iterate and generate a tuple with a flag for first value.""" 8 | iter_values = iter(values) 9 | try: 10 | value = next(iter_values) 11 | except StopIteration: 12 | return 13 | yield True, value 14 | for value in iter_values: 15 | yield False, value 16 | 17 | 18 | def loop_last(values: Iterable[T]) -> Iterable[Tuple[bool, T]]: 19 | """Iterate and generate a tuple with a flag for last value.""" 20 | iter_values = iter(values) 21 | try: 22 | previous_value = next(iter_values) 23 | except StopIteration: 24 | return 25 | for value in iter_values: 26 | yield False, previous_value 27 | previous_value = value 28 | yield True, previous_value 29 | 30 | 31 | def loop_first_last(values: Iterable[T]) -> Iterable[Tuple[bool, bool, T]]: 32 | """Iterate and generate a tuple with a flag for first and last value.""" 33 | iter_values = iter(values) 34 | try: 35 | previous_value = next(iter_values) 36 | except StopIteration: 37 | return 38 | first = True 39 | for value in iter_values: 40 | yield first, False, previous_value 41 | first = False 42 | previous_value = value 43 | yield first, True, previous_value 44 | -------------------------------------------------------------------------------- /src/textual/widgets/_placeholder.py: -------------------------------------------------------------------------------- 1 | from rich import box 2 | from rich.align import Align 3 | from rich.console import RenderableType 4 | from rich.panel import Panel 5 | from rich.pretty import Pretty 6 | import rich.repr 7 | 8 | from logging import getLogger 9 | 10 | from .. import events 11 | from ..widget import Reactive, Widget 12 | 13 | log = getLogger("rich") 14 | 15 | 16 | @rich.repr.auto(angular=False) 17 | class Placeholder(Widget, can_focus=True): 18 | 19 | has_focus: Reactive[bool] = Reactive(False) 20 | mouse_over: Reactive[bool] = Reactive(False) 21 | style: Reactive[str] = Reactive("") 22 | 23 | def __rich_repr__(self) -> rich.repr.RichReprResult: 24 | yield "name", self.name 25 | yield "has_focus", self.has_focus, False 26 | yield "mouse_over", self.mouse_over, False 27 | 28 | def render(self) -> RenderableType: 29 | return Panel( 30 | Align.center(Pretty(self), vertical="middle"), 31 | title=self.__class__.__name__, 32 | border_style="green" if self.mouse_over else "blue", 33 | box=box.HEAVY if self.has_focus else box.ROUNDED, 34 | style=self.style, 35 | ) 36 | 37 | async def on_focus(self, event: events.Focus) -> None: 38 | self.has_focus = True 39 | 40 | async def on_blur(self, event: events.Blur) -> None: 41 | self.has_focus = False 42 | 43 | async def on_enter(self, event: events.Enter) -> None: 44 | self.mouse_over = True 45 | 46 | async def on_leave(self, event: events.Leave) -> None: 47 | self.mouse_over = False 48 | -------------------------------------------------------------------------------- /src/textual/message.py: -------------------------------------------------------------------------------- 1 | from time import monotonic 2 | from typing import ClassVar 3 | 4 | import rich.repr 5 | 6 | from .case import camel_to_snake 7 | from ._types import MessageTarget 8 | 9 | 10 | @rich.repr.auto 11 | class Message: 12 | """Base class for a message.""" 13 | 14 | __slots__ = [ 15 | "sender", 16 | "name", 17 | "time", 18 | "_no_default_action", 19 | "_stop_propagation", 20 | ] 21 | 22 | sender: MessageTarget 23 | bubble: ClassVar[bool] = False 24 | 25 | def __init__(self, sender: MessageTarget) -> None: 26 | self.sender = sender 27 | self.name = camel_to_snake(self.__class__.__name__.replace("Message", "")) 28 | self.time = monotonic() 29 | self._no_default_action = False 30 | self._stop_propagaton = False 31 | super().__init__() 32 | 33 | def __rich_repr__(self) -> rich.repr.RichReprResult: 34 | yield self.sender 35 | 36 | def __init_subclass__(cls, bubble: bool = False) -> None: 37 | super().__init_subclass__() 38 | cls.bubble = bubble 39 | 40 | def can_batch(self, message: "Message") -> bool: 41 | """Check if another message may supersede this one. 42 | 43 | Args: 44 | message (Message): [description] 45 | 46 | Returns: 47 | bool: [description] 48 | """ 49 | return False 50 | 51 | def prevent_default(self, prevent: bool = True) -> None: 52 | """Suppress the default action. 53 | 54 | Args: 55 | prevent (bool, optional): True if the default action should be suppressed, 56 | or False if the default actions should be performed. Defaults to True. 57 | """ 58 | self._no_default_action = prevent 59 | 60 | def stop_propagation(self, stop: bool = True) -> None: 61 | self._stop_propagaton = stop 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.ipynb 2 | .pytype 3 | .DS_Store 4 | .vscode 5 | mypy_report 6 | docs/build 7 | docs/source/_build 8 | tools/*.txt 9 | playground/ 10 | 11 | # Byte-compiled / optimized / DLL files 12 | __pycache__/ 13 | *.py[cod] 14 | *$py.class 15 | 16 | # C extensions 17 | *.so 18 | 19 | # Distribution / packaging 20 | .Python 21 | build/ 22 | develop-eggs/ 23 | dist/ 24 | downloads/ 25 | eggs/ 26 | .eggs/ 27 | lib/ 28 | lib64/ 29 | parts/ 30 | sdist/ 31 | var/ 32 | wheels/ 33 | *.egg-info/ 34 | .installed.cfg 35 | *.egg 36 | MANIFEST 37 | 38 | # PyInstaller 39 | # Usually these files are written by a python script from a template 40 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 41 | *.manifest 42 | *.spec 43 | 44 | # Installer logs 45 | pip-log.txt 46 | pip-delete-this-directory.txt 47 | 48 | # Unit test / coverage reports 49 | htmlcov/ 50 | .tox/ 51 | .coverage 52 | .coverage.* 53 | .cache 54 | nosetests.xml 55 | coverage.xml 56 | *.cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # celery beat schedule file 89 | celerybeat-schedule 90 | 91 | # SageMath parsed files 92 | *.sage.py 93 | 94 | # Environments 95 | .env 96 | .venv 97 | env/ 98 | venv/ 99 | ENV/ 100 | env.bak/ 101 | venv.bak/ 102 | 103 | # Spyder project settings 104 | .spyderproject 105 | .spyproject 106 | 107 | # Rope project settings 108 | .ropeproject 109 | 110 | # mkdocs documentation 111 | /site 112 | 113 | # mypy 114 | .mypy_cache/ 115 | -------------------------------------------------------------------------------- /src/textual/widgets/_header.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from logging import getLogger 3 | 4 | from rich.console import Console, ConsoleOptions, RenderableType 5 | from rich.panel import Panel 6 | from rich.repr import rich_repr, RichReprResult 7 | from rich.style import StyleType 8 | from rich.table import Table 9 | from rich.text import TextType 10 | 11 | from .. import events 12 | from ..widget import Widget 13 | 14 | log = getLogger("rich") 15 | 16 | 17 | class Header(Widget): 18 | def __init__( 19 | self, 20 | title: TextType, 21 | *, 22 | panel: bool = True, 23 | style: StyleType = "white on blue", 24 | clock: bool = True 25 | ) -> None: 26 | self.title = title 27 | self.panel = panel 28 | self.style = style 29 | self.clock = clock 30 | 31 | super().__init__() 32 | self.layout_size = 3 33 | 34 | def __rich_repr__(self) -> RichReprResult: 35 | yield self.title 36 | 37 | def get_clock(self) -> str: 38 | return datetime.now().time().strftime("%X") 39 | 40 | def render(self) -> RenderableType: 41 | 42 | header_table = Table.grid(padding=(0, 1), expand=True) 43 | header_table.style = self.style 44 | header_table.add_column(justify="left", ratio=0) 45 | header_table.add_column("title", justify="center", ratio=1) 46 | if self.clock: 47 | header_table.add_column("clock", justify="right") 48 | header_table.add_row("🐞", self.title, self.get_clock()) 49 | else: 50 | header_table.add_row("🐞", self.title) 51 | header: RenderableType 52 | if self.panel: 53 | header = Panel(header_table, style=self.style) 54 | else: 55 | header = header_table 56 | return header 57 | 58 | async def on_mount(self, event: events.Mount) -> None: 59 | self.set_interval(1.0, callback=self.refresh) 60 | -------------------------------------------------------------------------------- /src/textual/reactive.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from typing import Callable, Generic, TypeVar 5 | 6 | from .message_pump import MessagePump 7 | 8 | if sys.version_info >= (3, 8): 9 | from typing import Protocol 10 | else: 11 | from typing_extensions import Protocol 12 | 13 | 14 | class Reactable(Protocol): 15 | def require_layout(self): 16 | ... 17 | 18 | def require_repaint(self): 19 | ... 20 | 21 | 22 | ReactiveType = TypeVar("ReactiveType") 23 | 24 | 25 | class Reactive(Generic[ReactiveType]): 26 | """Reactive descriptor.""" 27 | 28 | def __init__( 29 | self, 30 | default: ReactiveType, 31 | *, 32 | layout: bool = False, 33 | repaint: bool = True, 34 | ) -> None: 35 | self._default = default 36 | self.layout = layout 37 | self.repaint = repaint 38 | 39 | def __set_name__(self, owner: Reactable, name: str) -> None: 40 | self.name = name 41 | self.internal_name = f"__{name}" 42 | setattr(owner, self.internal_name, self._default) 43 | 44 | def __get__(self, obj: Reactable, obj_type: type[object]) -> ReactiveType: 45 | return getattr(obj, self.internal_name) 46 | 47 | def __set__(self, obj: Reactable, value: ReactiveType) -> None: 48 | if getattr(obj, self.internal_name) != value: 49 | 50 | current_value = getattr(obj, self.internal_name, None) 51 | validate_function = getattr(obj, f"validate_{self.name}", None) 52 | if callable(validate_function): 53 | value = validate_function(value) 54 | 55 | if current_value != value: 56 | 57 | watch_function = getattr(obj, f"watch_{self.name}", None) 58 | if callable(watch_function): 59 | watch_function(value) 60 | setattr(obj, self.internal_name, value) 61 | 62 | if self.layout: 63 | obj.require_layout() 64 | elif self.repaint: 65 | obj.require_repaint() 66 | -------------------------------------------------------------------------------- /src/textual/_line_cache.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | import logging 5 | 6 | from typing import Iterable 7 | 8 | from rich.cells import cell_len 9 | from rich.console import Console, ConsoleOptions, RenderableType, RenderResult 10 | from rich.control import Control 11 | from rich.segment import Segment 12 | from rich.style import Style 13 | 14 | from ._loop import loop_last 15 | 16 | log = logging.getLogger("rich") 17 | 18 | 19 | class LineCache: 20 | def __init__(self, lines: list[list[Segment]]) -> None: 21 | self.lines = lines 22 | self._dirty = [True] * len(self.lines) 23 | 24 | @classmethod 25 | def from_renderable( 26 | cls, 27 | console: Console, 28 | renderable: RenderableType, 29 | width: int, 30 | height: int, 31 | ) -> "LineCache": 32 | options = console.options.update_dimensions(width, height) 33 | lines = console.render_lines(renderable, options) 34 | return cls(lines) 35 | 36 | @property 37 | def dirty(self) -> bool: 38 | return any(self._dirty) 39 | 40 | def __rich_console__( 41 | self, console: Console, options: ConsoleOptions 42 | ) -> RenderResult: 43 | 44 | new_line = Segment.line() 45 | for line in self.lines: 46 | yield from line 47 | yield new_line 48 | 49 | def render(self, x: int, y: int, width: int, height: int) -> Iterable[Segment]: 50 | move_to = Control.move_to 51 | lines = self.lines[:height] 52 | new_line = Segment.line() 53 | for last, (offset_y, (line, dirty)) in loop_last( 54 | enumerate(zip(lines, self._dirty), y) 55 | ): 56 | if dirty: 57 | yield move_to(x, offset_y).segment 58 | yield from Segment.adjust_line_length(line, width) 59 | if not last: 60 | yield new_line 61 | self._dirty[:] = [False] * len(self.lines) 62 | 63 | def get_style_at(self, x: int, y: int) -> Style: 64 | try: 65 | line = self.lines[y] 66 | except IndexError: 67 | return Style.null() 68 | end = 0 69 | for segment in line: 70 | end += cell_len(segment.text) 71 | if x < end: 72 | return segment.style or Style.null() 73 | return Style.null() 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Textual 2 | 3 | Textual is a TUI (Text User Interface) framework for Python using [Rich](https://github.com/willmcgugan/rich) as a renderer. 4 | 5 | The end goal is to be able to rapidly create *rich* terminal applications that look as good as possible (within the restrictions imposed by a terminal emulator). 6 | 7 | Rich TUI will integrate tightly with its parent project, Rich. Any of the existing *renderables* can be used in a more dynamic application. 8 | 9 | Textual will be eventually be cross platform, but for now it is MacOS / Linux only. Windows support is in the pipeline. 10 | 11 | This project is currently a work in progress and may not be usable for a while. Follow [@willmcgugan](https://twitter.com/willmcgugan) for progress updates, or post in Discussions if you have any requests / suggestions. 12 | 13 | ![screenshot](./imgs/rich-tui.png) 14 | 15 | 16 | ## Updates 17 | 18 | I'll be documenting progress in video form. 19 | 20 | ### Update 1 - Basic scrolling 21 | 22 | [![Textual update 1](https://yt-embed.herokuapp.com/embed?v=zNW7U36GHlU&img=0)](http://www.youtube.com/watch?v=zNW7U36GHlU) 23 | 24 | ### Update 2 - Keyboard toggle 25 | 26 | [![Textual update 2](https://yt-embed.herokuapp.com/embed?v=bTYeFOVNXDI&img=0)](http://www.youtube.com/watch?v=bTYeFOVNXDI) 27 | 28 | ### Update 3 - New scrollbars, and smooth scrolling 29 | 30 | [![Textual update 3](https://yt-embed.herokuapp.com/embed?v=4LVl3ClrXIs&img=0)](http://www.youtube.com/watch?v=4LVl3ClrXIs) 31 | 32 | ### Update 4 - Animation system with easing function 33 | 34 | Now with a system to animate a value to another value. Here applied to the scroll position. The animation system supports CSS like *easing functions*. You may be able to tell from the video that the page up / down keys cause the window to first speed up and then slow down. 35 | 36 | [![Textual update 4](https://yt-embed.herokuapp.com/embed?v=k2VwOp1YbSk&img=0)](http://www.youtube.com/watch?v=k2VwOp1YbSk) 37 | 38 | ### Update 5 - New Layout system 39 | 40 | A new update system allows for overlapping layers. Animation is now synchronized with the display which makes it very smooth! 41 | 42 | [![Textual update 5](https://yt-embed.herokuapp.com/embed?v=XxRnfx2WYRw&img=0)](http://www.youtube.com/watch?v=XxRnfx2WYRw) 43 | 44 | ### Update 6 - New Layout API 45 | 46 | New version (0.1.4) with API updates and the new layout system. 47 | 48 | [![Textual update 5](https://yt-embed.herokuapp.com/embed?v=jddccDuVd3E&img=0)](http://www.youtube.com/watch?v=jddccDuVd3E) 49 | -------------------------------------------------------------------------------- /src/textual/_timer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import weakref 4 | from asyncio import CancelledError, Event, TimeoutError, wait_for 5 | from time import monotonic 6 | from typing import Awaitable, Callable 7 | 8 | from rich.repr import RichReprResult, rich_repr 9 | 10 | from . import events 11 | from ._types import MessageTarget 12 | 13 | TimerCallback = Callable[[], Awaitable[None]] 14 | 15 | 16 | class EventTargetGone(Exception): 17 | pass 18 | 19 | 20 | @rich_repr 21 | class Timer: 22 | _timer_count: int = 1 23 | 24 | def __init__( 25 | self, 26 | event_target: MessageTarget, 27 | interval: float, 28 | sender: MessageTarget, 29 | *, 30 | name: str | None = None, 31 | callback: TimerCallback | None = None, 32 | repeat: int = None, 33 | skip: bool = False, 34 | ) -> None: 35 | self._target_repr = repr(event_target) 36 | self._target = weakref.ref(event_target) 37 | self._interval = interval 38 | self.sender = sender 39 | self.name = f"Timer#{self._timer_count}" if name is None else name 40 | self._timer_count += 1 41 | self._callback = callback 42 | self._repeat = repeat 43 | self._skip = skip 44 | self._stop_event = Event() 45 | self._active = Event() 46 | self._active.set() 47 | 48 | def __rich_repr__(self) -> RichReprResult: 49 | yield self._interval 50 | yield "name", self.name 51 | yield "repeat", self._repeat, None 52 | 53 | @property 54 | def target(self) -> MessageTarget: 55 | target = self._target() 56 | if target is None: 57 | raise EventTargetGone() 58 | return target 59 | 60 | def stop(self) -> None: 61 | self._active.set() 62 | self._stop_event.set() 63 | 64 | def pause(self) -> None: 65 | self._active.clear() 66 | 67 | def resume(self) -> None: 68 | self._active.set() 69 | 70 | async def run(self) -> None: 71 | count = 0 72 | _repeat = self._repeat 73 | _interval = self._interval 74 | _wait = self._stop_event.wait 75 | _wait_active = self._active.wait 76 | start = monotonic() 77 | try: 78 | while _repeat is None or count <= _repeat: 79 | next_timer = start + (count * _interval) 80 | if self._skip and next_timer < monotonic(): 81 | count += 1 82 | continue 83 | try: 84 | 85 | if await wait_for(_wait(), max(0, next_timer - monotonic())): 86 | break 87 | except TimeoutError: 88 | pass 89 | event = events.Timer( 90 | self.sender, timer=self, count=count, callback=self._callback 91 | ) 92 | try: 93 | await self.target.post_message(event) 94 | except EventTargetGone: 95 | break 96 | count += 1 97 | await _wait_active() 98 | except CancelledError: 99 | pass 100 | -------------------------------------------------------------------------------- /src/textual/page.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from rich.console import Console, ConsoleOptions, RenderableType, RenderResult 4 | from rich.padding import Padding, PaddingDimensions 5 | from rich.segment import Segment 6 | from rich.style import StyleType 7 | 8 | from .geometry import Dimensions, Point 9 | from .widget import Widget, Reactive 10 | 11 | 12 | class PageRender: 13 | def __init__( 14 | self, 15 | renderable: RenderableType, 16 | width: int | None = None, 17 | height: int | None = None, 18 | style: StyleType = "", 19 | padding: PaddingDimensions = 1, 20 | ) -> None: 21 | self.renderable = renderable 22 | self.width = width 23 | self.height = height 24 | self.style = style 25 | self.padding = padding 26 | self.offset = Point(0, 0) 27 | self._render_width: int | None = None 28 | self._render_height: int | None = None 29 | self.size = Dimensions(0, 0) 30 | self._lines: list[list[Segment]] = [] 31 | 32 | def move_to(self, x: int = 0, y: int = 0) -> None: 33 | self.offset = Point(x, y) 34 | 35 | def clear(self) -> None: 36 | self._render_width = None 37 | self._render_height = None 38 | del self._lines[:] 39 | 40 | def update(self, renderable: RenderableType) -> None: 41 | self.renderable = renderable 42 | self.clear() 43 | 44 | def render(self, console: Console, options: ConsoleOptions) -> None: 45 | width = self.width or options.max_width or console.width 46 | height = self.height or options.height or None 47 | options = options.update_dimensions(width, None) 48 | style = console.get_style(self.style) 49 | renderable = self.renderable 50 | if self.padding: 51 | renderable = Padding(renderable, self.padding) 52 | self._lines[:] = console.render_lines(renderable, options, style=style) 53 | self.size = Dimensions(width, len(self._lines)) 54 | 55 | def __rich_console__( 56 | self, console: Console, options: ConsoleOptions 57 | ) -> RenderResult: 58 | if not self._lines: 59 | self.render(console, options) 60 | style = console.get_style(self.style) 61 | width = self._render_width or console.width 62 | height = options.height or console.height 63 | x, y = self.offset 64 | window_lines = self._lines[y : y + height] 65 | 66 | missing_lines = len(window_lines) - height 67 | if missing_lines: 68 | blank_line = [Segment(" " * width, style), Segment.line()] 69 | window_lines.extend(blank_line for _ in range(missing_lines)) 70 | 71 | new_line = Segment.line() 72 | for line in window_lines: 73 | yield from line 74 | yield new_line 75 | 76 | 77 | class Page(Widget): 78 | def __init__( 79 | self, renderable: RenderableType, name: str = None, style: StyleType = "" 80 | ): 81 | self._page = PageRender(renderable, style=style) 82 | super().__init__(name=name) 83 | 84 | x: Reactive[int] = Reactive(0) 85 | y: Reactive[int] = Reactive(0) 86 | 87 | @property 88 | def contents_size(self) -> Dimensions: 89 | return self._page.size 90 | 91 | def validate_y(self, value: int) -> int: 92 | return max(0, value) 93 | 94 | def watch_y(self, new: int) -> None: 95 | x, y = self._page.offset 96 | self._page.offset = Point(x, new) 97 | 98 | def update(self, renderable: RenderableType | None = None) -> None: 99 | if renderable: 100 | self._page.update(renderable) 101 | else: 102 | self._page.clear() 103 | 104 | @property 105 | def virtual_size(self) -> Dimensions: 106 | return self._page.size 107 | 108 | def render(self) -> RenderableType: 109 | return self._page 110 | -------------------------------------------------------------------------------- /src/textual/widgets/_scroll_view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from rich.console import RenderableType 6 | from rich.style import StyleType 7 | 8 | 9 | from .. import events 10 | from ..message import Message 11 | from ..scrollbar import ScrollBar, ScrollDown, ScrollUp 12 | from ..geometry import clamp 13 | from ..page import Page 14 | from ..view import DockView 15 | from ..reactive import Reactive 16 | 17 | 18 | log = logging.getLogger("rich") 19 | 20 | 21 | class ScrollView(DockView): 22 | def __init__( 23 | self, 24 | renderable: RenderableType, 25 | *, 26 | name: str | None = None, 27 | style: StyleType = "", 28 | fluid: bool = True, 29 | ) -> None: 30 | self.fluid = fluid 31 | self._vertical_scrollbar = ScrollBar(vertical=True) 32 | self._page = Page(renderable, style=style) 33 | super().__init__(name="ScrollView") 34 | 35 | x: Reactive[float] = Reactive(0) 36 | y: Reactive[float] = Reactive(0) 37 | 38 | target_y: Reactive[float] = Reactive(0) 39 | 40 | def validate_y(self, value: float) -> float: 41 | return clamp(value, 0, self._page.contents_size.height - self.size.height) 42 | 43 | def validate_target_y(self, value: float) -> float: 44 | return clamp(value, 0, self._page.contents_size.height - self.size.height) 45 | 46 | def watch_y(self, new_value: float) -> None: 47 | self._page.y = round(new_value) 48 | self._vertical_scrollbar.position = round(new_value) 49 | 50 | async def on_mount(self, event: events.Mount) -> None: 51 | await self.dock(self._vertical_scrollbar, edge="right", size=1) 52 | await self.dock(self._page, edge="top") 53 | 54 | async def on_idle(self, event: events.Idle) -> None: 55 | self._vertical_scrollbar.virtual_size = self._page.virtual_size.height 56 | self._vertical_scrollbar.window_size = self.size.height 57 | 58 | await super().on_idle(event) 59 | 60 | async def on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None: 61 | self.scroll_up() 62 | 63 | async def on_mouse_scroll_down(self, event: events.MouseScrollUp) -> None: 64 | self.scroll_down() 65 | 66 | def scroll_up(self) -> None: 67 | self.target_y += 1.5 68 | self.animate("y", self.target_y, easing="out_cubic", speed=80) 69 | 70 | def scroll_down(self) -> None: 71 | self.target_y -= 1.5 72 | self.animate("y", self.target_y, easing="out_cubic", speed=80) 73 | 74 | def page_up(self) -> None: 75 | self.target_y -= self.size.height 76 | self.animate("y", self.target_y, easing="out_cubic") 77 | 78 | def page_down(self) -> None: 79 | self.target_y += self.size.height 80 | self.animate("y", self.target_y, easing="out_cubic") 81 | 82 | async def on_key(self, event: events.Key) -> None: 83 | key = event.key 84 | if key == "down": 85 | self.target_y += 2 86 | self.animate("y", self.target_y, easing="linear", speed=100) 87 | elif key == "up": 88 | self.target_y -= 2 89 | self.animate("y", self.target_y, easing="linear", speed=100) 90 | elif key == "pagedown": 91 | log.debug("%r", self.size) 92 | self.target_y += self.size.height 93 | self.animate("y", self.target_y, easing="out_cubic") 94 | elif key == "pageup": 95 | log.debug("%r", self.size) 96 | self.target_y -= self.size.height 97 | self.animate("y", self.target_y, easing="out_cubic") 98 | 99 | async def on_resize(self, event: events.Resize) -> None: 100 | if self.fluid: 101 | self._page.update() 102 | await super().on_resize(event) 103 | 104 | async def on_message(self, message: Message) -> None: 105 | if isinstance(message, ScrollUp): 106 | self.page_up() 107 | elif isinstance(message, ScrollDown): 108 | self.page_down() 109 | else: 110 | await super().on_message(message) 111 | -------------------------------------------------------------------------------- /src/textual/_xterm_parser.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import re 5 | from typing import Callable, Generator 6 | 7 | from . import events 8 | from ._types import MessageTarget 9 | from ._parser import Awaitable, Parser, TokenCallback 10 | from ._ansi_sequences import ANSI_SEQUENCES 11 | 12 | log = logging.getLogger("rich") 13 | 14 | 15 | _re_mouse_event = re.compile("^" + re.escape("\x1b[") + r"( None: 23 | self.sender = sender 24 | self.more_data = more_data 25 | self.last_x = 0 26 | self.last_y = 0 27 | super().__init__() 28 | 29 | def parse_mouse_code(self, code: str, sender: MessageTarget) -> events.Event | None: 30 | sgr_match = self._re_sgr_mouse.match(code) 31 | if sgr_match: 32 | _buttons, _x, _y, state = sgr_match.groups() 33 | buttons = int(_buttons) 34 | button = (buttons + 1) & 3 35 | x = int(_x) - 1 36 | y = int(_y) - 1 37 | delta_x = x - self.last_x 38 | delta_y = y - self.last_y 39 | self.last_x = x 40 | self.last_y = y 41 | event: events.Event 42 | if buttons & 64: 43 | event = ( 44 | events.MouseScrollDown if button == 1 else events.MouseScrollUp 45 | )(sender, x, y) 46 | else: 47 | event = ( 48 | events.MouseMove 49 | if buttons & 32 50 | else (events.MouseDown if state == "M" else events.MouseUp) 51 | )( 52 | sender, 53 | x, 54 | y, 55 | delta_x, 56 | delta_y, 57 | button, 58 | bool(buttons & 4), 59 | bool(buttons & 8), 60 | bool(buttons & 16), 61 | ) 62 | return event 63 | return None 64 | 65 | def parse(self, on_token: TokenCallback) -> Generator[Awaitable, str, None]: 66 | 67 | ESC = "\x1b" 68 | read1 = self.read1 69 | get_ansi_sequence = ANSI_SEQUENCES.get 70 | more_data = self.more_data 71 | 72 | while not self.is_eof: 73 | character = yield read1() 74 | # log.debug("character=%r", character) 75 | if character == ESC and ((yield self.peek_buffer()) or more_data()): 76 | sequence: str = character 77 | while True: 78 | sequence += yield read1() 79 | # log.debug(f"sequence=%r", sequence) 80 | keys = get_ansi_sequence(sequence, None) 81 | if keys is not None: 82 | for key in keys: 83 | on_token(events.Key(self.sender, key=key)) 84 | break 85 | else: 86 | mouse_match = _re_mouse_event.match(sequence) 87 | if mouse_match is not None: 88 | mouse_code = mouse_match.group(0) 89 | event = self.parse_mouse_code(mouse_code, self.sender) 90 | if event: 91 | on_token(event) 92 | break 93 | else: 94 | 95 | keys = get_ansi_sequence(character, None) 96 | if keys is not None: 97 | for key in keys: 98 | on_token(events.Key(self.sender, key=key)) 99 | else: 100 | on_token(events.Key(self.sender, key=character)) 101 | 102 | 103 | if __name__ == "__main__": 104 | parser = XTermParser() 105 | 106 | import os 107 | import sys 108 | 109 | for token in parser.feed(sys.stdin.read(20)): 110 | print(token) 111 | -------------------------------------------------------------------------------- /src/textual/_parser.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import deque 4 | import io 5 | from typing import ( 6 | Callable, 7 | Deque, 8 | Generator, 9 | TypeVar, 10 | Generic, 11 | Union, 12 | Iterator, 13 | Iterable, 14 | ) 15 | 16 | 17 | class ParseError(Exception): 18 | pass 19 | 20 | 21 | class ParseEOF(ParseError): 22 | """End of Stream.""" 23 | 24 | 25 | class Awaitable: 26 | __slots__: list[str] = [] 27 | 28 | 29 | class _Read(Awaitable): 30 | __slots__ = ["remaining"] 31 | 32 | def __init__(self, count: int) -> None: 33 | self.remaining = count 34 | 35 | def __repr__(self) -> str: 36 | return f"_ReadBytes({self.remaining})" 37 | 38 | 39 | class _Read1(Awaitable): 40 | __slots__: list[str] = [] 41 | 42 | 43 | class _ReadUntil(Awaitable): 44 | __slots__ = ["sep", "max_bytes"] 45 | 46 | def __init__(self, sep, max_bytes=None): 47 | self.sep = sep 48 | self.max_bytes = max_bytes 49 | 50 | 51 | class PeekBuffer(Awaitable): 52 | __slots__: list[str] = [] 53 | 54 | 55 | T = TypeVar("T") 56 | 57 | 58 | TokenCallback = Callable[[T], None] 59 | 60 | 61 | class Parser(Generic[T]): 62 | read = _Read 63 | read1 = _Read1 64 | read_until = _ReadUntil 65 | peek_buffer = PeekBuffer 66 | 67 | def __init__(self) -> None: 68 | self._buffer = io.StringIO() 69 | self._eof = False 70 | self._tokens: Deque[T] = deque() 71 | self._gen = self.parse(self._tokens.append) 72 | self._awaiting: Union[Awaitable, T] = next(self._gen) 73 | 74 | @property 75 | def is_eof(self) -> bool: 76 | return self._eof 77 | 78 | def reset(self) -> None: 79 | self._gen = self.parse(self._tokens.append) 80 | self._awaiting = next(self._gen) 81 | 82 | def feed(self, data: str) -> Iterable[T]: 83 | 84 | if self._eof: 85 | raise ParseError("end of file reached") from None 86 | if not data: 87 | self._eof = True 88 | try: 89 | self._gen.send(self._buffer.getvalue()) 90 | except StopIteration: 91 | raise ParseError("end of file reached") from None 92 | while self._tokens: 93 | yield self._tokens.popleft() 94 | 95 | self._buffer.truncate(0) 96 | return 97 | 98 | _buffer = self._buffer 99 | pos = 0 100 | tokens = self._tokens 101 | popleft = tokens.popleft 102 | data_size = len(data) 103 | 104 | while tokens: 105 | yield popleft() 106 | 107 | while pos < data_size or isinstance(self._awaiting, PeekBuffer): 108 | 109 | _awaiting = self._awaiting 110 | if isinstance(_awaiting, _Read1): 111 | self._awaiting = self._gen.send(data[pos : pos + 1]) 112 | pos += 1 113 | 114 | elif isinstance(_awaiting, PeekBuffer): 115 | self._awaiting = self._gen.send(data[pos:]) 116 | 117 | elif isinstance(_awaiting, _Read): 118 | remaining = _awaiting.remaining 119 | chunk = data[pos : pos + remaining] 120 | chunk_size = len(chunk) 121 | pos += chunk_size 122 | _buffer.write(chunk) 123 | remaining -= chunk_size 124 | if remaining: 125 | _awaiting.remaining = remaining 126 | else: 127 | _awaiting = self._gen.send(_buffer.getvalue()) 128 | _buffer.truncate(0) 129 | 130 | elif isinstance(_awaiting, _ReadUntil): 131 | chunk = data[pos:] 132 | _buffer.write(chunk) 133 | sep = _awaiting.sep 134 | sep_index = _buffer.getvalue().find(sep) 135 | 136 | if sep_index == -1: 137 | pos += len(chunk) 138 | if ( 139 | _awaiting.max_bytes is not None 140 | and _buffer.tell() > _awaiting.max_bytes 141 | ): 142 | self._gen.throw(ParseError(f"expected {sep}")) 143 | else: 144 | sep_index += len(sep) 145 | if ( 146 | _awaiting.max_bytes is not None 147 | and sep_index > _awaiting.max_bytes 148 | ): 149 | self._gen.throw(ParseError(f"expected {sep}")) 150 | data = _buffer.getvalue()[sep_index:] 151 | pos = 0 152 | self._awaiting = self._gen.send(_buffer.getvalue()[:sep_index]) 153 | _buffer.truncate(0) 154 | 155 | while tokens: 156 | yield popleft() 157 | 158 | def parse(self, on_token: Callable[[T], None]) -> Generator[Awaitable, str, None]: 159 | return 160 | yield 161 | 162 | 163 | if __name__ == "__main__": 164 | data = "Where there is a Will there is a way!" 165 | 166 | class TestParser(Parser[str]): 167 | def parse( 168 | self, on_token: Callable[[str], None] 169 | ) -> Generator[Awaitable, str, None]: 170 | data = yield self.read1() 171 | while True: 172 | data = yield self.read1() 173 | if not data: 174 | break 175 | on_token(data) 176 | 177 | test_parser = TestParser() 178 | 179 | import time 180 | 181 | for n in range(0, len(data), 5): 182 | for token in test_parser.feed(data[n : n + 5]): 183 | print(token) 184 | for token in test_parser.feed(""): 185 | print(token) 186 | -------------------------------------------------------------------------------- /src/textual/layouts/dock.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from collections import defaultdict 5 | from dataclasses import dataclass 6 | import logging 7 | from typing import TYPE_CHECKING, Mapping, Sequence 8 | 9 | from rich._ratio import ratio_resolve 10 | 11 | from ..geometry import Region, Point 12 | from ..layout import Layout, MapRegion 13 | from .._types import Lines 14 | 15 | if sys.version_info >= (3, 8): 16 | from typing import Literal 17 | else: 18 | from typing_extensions import Literal 19 | 20 | 21 | if TYPE_CHECKING: 22 | from ..widget import Widget 23 | 24 | log = logging.getLogger("rich") 25 | 26 | 27 | DockEdge = Literal["top", "right", "bottom", "left"] 28 | 29 | 30 | @dataclass 31 | class DockOptions: 32 | size: int | None = None 33 | fraction: int = 1 34 | minimum_size: int = 1 35 | 36 | @property 37 | def ratio(self) -> int: 38 | return self.fraction 39 | 40 | 41 | @dataclass 42 | class Dock: 43 | edge: DockEdge 44 | widgets: Sequence[Widget] 45 | z: int = 0 46 | 47 | 48 | class DockLayout(Layout): 49 | def __init__(self, docks: list[Dock] = None) -> None: 50 | self.docks: list[Dock] = docks or [] 51 | super().__init__() 52 | 53 | def generate_map( 54 | self, width: int, height: int, offset: Point = Point(0, 0) 55 | ) -> dict[Widget, MapRegion]: 56 | from ..view import View 57 | 58 | map: dict[Widget, MapRegion] = {} 59 | 60 | layout_region = Region(0, 0, width, height) 61 | layers: dict[int, Region] = defaultdict(lambda: layout_region) 62 | 63 | def add_widget(widget: Widget, region: Region, order: tuple[int, int]): 64 | region = region + offset + widget.layout_offset 65 | map[widget] = MapRegion(region, order) 66 | if isinstance(widget, View): 67 | sub_map = widget.layout.generate_map( 68 | region.width, region.height, offset=region.origin 69 | ) 70 | map.update(sub_map) 71 | 72 | for index, dock in enumerate(self.docks): 73 | dock_options = [ 74 | DockOptions( 75 | widget.layout_size, 76 | widget.layout_fraction, 77 | widget.layout_minimim_size, 78 | ) 79 | for widget in dock.widgets 80 | ] 81 | region = layers[dock.z] 82 | if not region: 83 | # No space left 84 | continue 85 | 86 | order = (dock.z, index) 87 | x, y, width, height = region 88 | 89 | if dock.edge == "top": 90 | sizes = ratio_resolve(height, dock_options) 91 | render_y = y 92 | remaining = region.height 93 | total = 0 94 | for widget, size in zip(dock.widgets, sizes): 95 | if not widget.visible: 96 | continue 97 | size = min(remaining, size) 98 | if not size: 99 | break 100 | total += size 101 | add_widget(widget, Region(x, render_y, width, size), order) 102 | render_y += size 103 | remaining = max(0, remaining - size) 104 | region = Region(x, y + total, width, height - total) 105 | 106 | elif dock.edge == "bottom": 107 | sizes = ratio_resolve(height, dock_options) 108 | render_y = y + height 109 | remaining = region.height 110 | total = 0 111 | for widget, size in zip(dock.widgets, sizes): 112 | if not widget.visible: 113 | continue 114 | size = min(remaining, size) 115 | if not size: 116 | break 117 | total += size 118 | add_widget(widget, Region(x, render_y - size, width, size), order) 119 | render_y -= size 120 | remaining = max(0, remaining - size) 121 | region = Region(x, y, width, height - total) 122 | 123 | elif dock.edge == "left": 124 | sizes = ratio_resolve(width, dock_options) 125 | render_x = x 126 | remaining = region.width 127 | total = 0 128 | for widget, size in zip(dock.widgets, sizes): 129 | if not widget.visible: 130 | continue 131 | size = min(remaining, size) 132 | if not size: 133 | break 134 | total += size 135 | add_widget(widget, Region(render_x, y, size, height), order) 136 | render_x += size 137 | remaining = max(0, remaining - size) 138 | region = Region(x + total, y, width - total, height) 139 | 140 | elif dock.edge == "right": 141 | sizes = ratio_resolve(width, dock_options) 142 | render_x = x + width 143 | remaining = region.width 144 | total = 0 145 | for widget, size in zip(dock.widgets, sizes): 146 | if not widget.visible: 147 | continue 148 | size = min(remaining, size) 149 | if not size: 150 | break 151 | total += size 152 | add_widget(widget, Region(render_x - size, y, size, height), order) 153 | render_x -= size 154 | remaining = max(0, remaining - size) 155 | region = Region(x, y, width - total, height) 156 | 157 | layers[dock.z] = region 158 | 159 | return map 160 | -------------------------------------------------------------------------------- /src/textual/_animator.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | import asyncio 6 | import sys 7 | from time import time 8 | from tracemalloc import start 9 | from typing import Callable, TypeVar 10 | 11 | from dataclasses import dataclass 12 | 13 | from ._timer import Timer 14 | from ._types import MessageTarget 15 | 16 | if sys.version_info >= (3, 8): 17 | from typing import Protocol 18 | else: 19 | from typing_extensions import Protocol 20 | 21 | 22 | EasingFunction = Callable[[float], float] 23 | 24 | T = TypeVar("T") 25 | 26 | 27 | class Animatable(Protocol): 28 | def blend(self: T, destination: T, factor: float) -> T: 29 | ... 30 | 31 | 32 | # https://easings.net/ 33 | EASING = { 34 | "none": lambda x: 1.0, 35 | "round": lambda x: 0.0 if x < 0.5 else 1.0, 36 | "linear": lambda x: x, 37 | "in_cubic": lambda x: x * x * x, 38 | "in_out_cubic": lambda x: 4 * x * x * x if x < 0.5 else 1 - pow(-2 * x + 2, 3) / 2, 39 | "out_cubic": lambda x: 1 - pow(1 - x, 3), 40 | } 41 | 42 | DEFAULT_EASING = "in_out_cubic" 43 | 44 | 45 | log = logging.getLogger("rich") 46 | 47 | 48 | @dataclass 49 | class Animation: 50 | obj: object 51 | attribute: str 52 | start_time: float 53 | duration: float 54 | start_value: float | Animatable 55 | end_value: float | Animatable 56 | easing_function: EasingFunction 57 | 58 | def __call__(self, time: float) -> bool: 59 | def blend_float(start: float, end: float, factor: float) -> float: 60 | return start + (end - start) * factor 61 | 62 | AnimatableT = TypeVar("AnimatableT", bound=Animatable) 63 | 64 | def blend(start: AnimatableT, end: AnimatableT, factor: float) -> AnimatableT: 65 | return start.blend(end, factor) 66 | 67 | blend_function = ( 68 | blend_float if isinstance(self.start_value, (int, float)) else blend 69 | ) 70 | 71 | if self.duration == 0: 72 | value = self.end_value 73 | else: 74 | factor = min(1.0, (time - self.start_time) / self.duration) 75 | eased_factor = self.easing_function(factor) 76 | # value = blend_function(self.start_value, self.end_value, eased_factor) 77 | 78 | if self.end_value > self.start_value: 79 | eased_factor = self.easing_function(factor) 80 | value = ( 81 | self.start_value 82 | + (self.end_value - self.start_value) * eased_factor 83 | ) 84 | else: 85 | eased_factor = 1 - self.easing_function(factor) 86 | value = ( 87 | self.end_value + (self.start_value - self.end_value) * eased_factor 88 | ) 89 | setattr(self.obj, self.attribute, value) 90 | log.debug("ANIMATE %r %r -> %r", self.obj, self.attribute, value) 91 | return value == self.end_value 92 | 93 | 94 | class BoundAnimator: 95 | def __init__(self, animator: Animator, obj: object) -> None: 96 | self._animator = animator 97 | self._obj = obj 98 | 99 | def __call__( 100 | self, 101 | attribute: str, 102 | value: float, 103 | *, 104 | duration: float | None = None, 105 | speed: float | None = None, 106 | easing: EasingFunction | str = DEFAULT_EASING, 107 | ) -> None: 108 | easing_function = EASING[easing] if isinstance(easing, str) else easing 109 | self._animator.animate( 110 | self._obj, 111 | attribute=attribute, 112 | value=value, 113 | duration=duration, 114 | speed=speed, 115 | easing=easing_function, 116 | ) 117 | 118 | 119 | class Animator: 120 | def __init__(self, target: MessageTarget, frames_per_second: int = 60) -> None: 121 | self._animations: dict[tuple[object, str], Animation] = {} 122 | self._timer = Timer(target, 1 / frames_per_second, target, callback=self) 123 | 124 | async def start(self) -> None: 125 | asyncio.get_event_loop().create_task(self._timer.run()) 126 | 127 | async def stop(self) -> None: 128 | self._timer.stop() 129 | 130 | def bind(self, obj: object) -> BoundAnimator: 131 | return BoundAnimator(self, obj) 132 | 133 | def animate( 134 | self, 135 | obj: object, 136 | attribute: str, 137 | value: float, 138 | *, 139 | duration: float | None = None, 140 | speed: float | None = None, 141 | easing: EasingFunction | str = DEFAULT_EASING, 142 | ) -> None: 143 | 144 | start_time = time() 145 | 146 | animation_key = (obj, attribute) 147 | if animation_key in self._animations: 148 | self._animations[animation_key](start_time) 149 | 150 | start_value = getattr(obj, attribute) 151 | if duration is not None: 152 | animation_duration = duration 153 | else: 154 | animation_duration = abs(value - start_value) / (speed or 50) 155 | easing_function = EASING[easing] if isinstance(easing, str) else easing 156 | animation = Animation( 157 | obj, 158 | attribute=attribute, 159 | start_time=start_time, 160 | duration=animation_duration, 161 | start_value=start_value, 162 | end_value=value, 163 | easing_function=easing_function, 164 | ) 165 | self._animations[animation_key] = animation 166 | self._timer.resume() 167 | 168 | async def __call__(self) -> None: 169 | if not self._animations: 170 | self._timer.pause() 171 | else: 172 | animation_time = time() 173 | animation_keys = list(self._animations.keys()) 174 | for animation_key in animation_keys: 175 | animation = self._animations[animation_key] 176 | if animation(animation_time): 177 | del self._animations[animation_key] 178 | -------------------------------------------------------------------------------- /src/textual/keys.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | 4 | # Adapted from prompt toolkit https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/prompt_toolkit/keys.py 5 | 6 | 7 | class Keys(str, Enum): 8 | """ 9 | List of keys for use in key bindings. 10 | 11 | Note that this is an "StrEnum", all values can be compared against 12 | strings. 13 | """ 14 | 15 | value: str 16 | 17 | Escape = "escape" # Also Control-[ 18 | ShiftEscape = "shift+escape" 19 | 20 | ControlAt = "ctrl+@" # Also Control-Space. 21 | 22 | ControlA = "ctrl+a" 23 | ControlB = "ctrl+b" 24 | ControlC = "ctrl+c" 25 | ControlD = "ctrl+d" 26 | ControlE = "ctrl+e" 27 | ControlF = "ctrl+f" 28 | ControlG = "ctrl+g" 29 | ControlH = "ctrl+h" 30 | ControlI = "ctrl+i" # Tab 31 | ControlJ = "ctrl+j" # Newline 32 | ControlK = "ctrl+k" 33 | ControlL = "ctrl+l" 34 | ControlM = "ctrl+m" # Carriage return 35 | ControlN = "ctrl+n" 36 | ControlO = "ctrl+o" 37 | ControlP = "ctrl+p" 38 | ControlQ = "ctrl+q" 39 | ControlR = "ctrl+r" 40 | ControlS = "ctrl+s" 41 | ControlT = "ctrl+t" 42 | ControlU = "ctrl+u" 43 | ControlV = "ctrl+v" 44 | ControlW = "ctrl+w" 45 | ControlX = "ctrl+x" 46 | ControlY = "ctrl+y" 47 | ControlZ = "ctrl+z" 48 | 49 | Control1 = "ctrl+1" 50 | Control2 = "ctrl+2" 51 | Control3 = "ctrl+3" 52 | Control4 = "ctrl+4" 53 | Control5 = "ctrl+5" 54 | Control6 = "ctrl+6" 55 | Control7 = "ctrl+7" 56 | Control8 = "ctrl+8" 57 | Control9 = "ctrl+9" 58 | Control0 = "ctrl+0" 59 | 60 | ControlShift1 = "ctrl+shift+1" 61 | ControlShift2 = "ctrl+shift+2" 62 | ControlShift3 = "ctrl+shift+3" 63 | ControlShift4 = "ctrl+shift+4" 64 | ControlShift5 = "ctrl+shift+5" 65 | ControlShift6 = "ctrl+shift+6" 66 | ControlShift7 = "ctrl+shift+7" 67 | ControlShift8 = "ctrl+shift+8" 68 | ControlShift9 = "ctrl+shift+9" 69 | ControlShift0 = "ctrl+shift+0" 70 | 71 | ControlBackslash = "ctrl+\\" 72 | ControlSquareClose = "ctrl+]" 73 | ControlCircumflex = "ctrl+^" 74 | ControlUnderscore = "ctrl+_" 75 | 76 | Left = "left" 77 | Right = "right" 78 | Up = "up" 79 | Down = "down" 80 | Home = "home" 81 | End = "end" 82 | Insert = "insert" 83 | Delete = "delete" 84 | PageUp = "pageup" 85 | PageDown = "pagedown" 86 | 87 | ControlLeft = "ctrl+left" 88 | ControlRight = "ctrl+right" 89 | ControlUp = "ctrl+up" 90 | ControlDown = "ctrl+down" 91 | ControlHome = "ctrl+home" 92 | ControlEnd = "ctrl+end" 93 | ControlInsert = "ctrl+insert" 94 | ControlDelete = "ctrl+delete" 95 | ControlPageUp = "ctrl+pageup" 96 | ControlPageDown = "ctrl+pagedown" 97 | 98 | ShiftLeft = "shift+left" 99 | ShiftRight = "shift+right" 100 | ShiftUp = "shift+up" 101 | ShiftDown = "shift+down" 102 | ShiftHome = "shift+home" 103 | ShiftEnd = "shift+end" 104 | ShiftInsert = "shift+insert" 105 | ShiftDelete = "shift+delete" 106 | ShiftPageUp = "shift+pageup" 107 | ShiftPageDown = "shift+pagedown" 108 | 109 | ControlShiftLeft = "ctrl+shift+left" 110 | ControlShiftRight = "ctrl+shift+right" 111 | ControlShiftUp = "ctrl+shift+up" 112 | ControlShiftDown = "ctrl+shift+down" 113 | ControlShiftHome = "ctrl+shift+home" 114 | ControlShiftEnd = "ctrl+shift+end" 115 | ControlShiftInsert = "ctrl+shift+insert" 116 | ControlShiftDelete = "ctrl+shift+delete" 117 | ControlShiftPageUp = "ctrl+shift+pageup" 118 | ControlShiftPageDown = "ctrl+shift+pagedown" 119 | 120 | BackTab = "shift+tab" # shift + tab 121 | 122 | F1 = "f1" 123 | F2 = "f2" 124 | F3 = "f3" 125 | F4 = "f4" 126 | F5 = "f5" 127 | F6 = "f6" 128 | F7 = "f7" 129 | F8 = "f8" 130 | F9 = "f9" 131 | F10 = "f10" 132 | F11 = "f11" 133 | F12 = "f12" 134 | F13 = "f13" 135 | F14 = "f14" 136 | F15 = "f15" 137 | F16 = "f16" 138 | F17 = "f17" 139 | F18 = "f18" 140 | F19 = "f19" 141 | F20 = "f20" 142 | F21 = "f21" 143 | F22 = "f22" 144 | F23 = "f23" 145 | F24 = "f24" 146 | 147 | ControlF1 = "ctrl+f1" 148 | ControlF2 = "ctrl+f2" 149 | ControlF3 = "ctrl+f3" 150 | ControlF4 = "ctrl+f4" 151 | ControlF5 = "ctrl+f5" 152 | ControlF6 = "ctrl+f6" 153 | ControlF7 = "ctrl+f7" 154 | ControlF8 = "ctrl+f8" 155 | ControlF9 = "ctrl+f9" 156 | ControlF10 = "ctrl+f10" 157 | ControlF11 = "ctrl+f11" 158 | ControlF12 = "ctrl+f12" 159 | ControlF13 = "ctrl+f13" 160 | ControlF14 = "ctrl+f14" 161 | ControlF15 = "ctrl+f15" 162 | ControlF16 = "ctrl+f16" 163 | ControlF17 = "ctrl+f17" 164 | ControlF18 = "ctrl+f18" 165 | ControlF19 = "ctrl+f19" 166 | ControlF20 = "ctrl+f20" 167 | ControlF21 = "ctrl+f21" 168 | ControlF22 = "ctrl+f22" 169 | ControlF23 = "ctrl+f23" 170 | ControlF24 = "ctrl+f24" 171 | 172 | # Matches any key. 173 | Any = "" 174 | 175 | # Special. 176 | ScrollUp = "" 177 | ScrollDown = "" 178 | 179 | CPRResponse = "" 180 | Vt100MouseEvent = "" 181 | WindowsMouseEvent = "" 182 | BracketedPaste = "" 183 | 184 | # For internal use: key which is ignored. 185 | # (The key binding for this key should not do anything.) 186 | Ignore = "" 187 | 188 | # Some 'Key' aliases (for backwardshift+compatibility). 189 | ControlSpace = ControlAt 190 | Tab = ControlI 191 | Enter = ControlM 192 | Backspace = ControlH 193 | 194 | # ShiftControl was renamed to ControlShift in 195 | # 888fcb6fa4efea0de8333177e1bbc792f3ff3c24 (20 Feb 2020). 196 | ShiftControlLeft = ControlShiftLeft 197 | ShiftControlRight = ControlShiftRight 198 | ShiftControlHome = ControlShiftHome 199 | ShiftControlEnd = ControlShiftEnd 200 | 201 | 202 | @dataclass 203 | class Binding: 204 | action: str 205 | description: str -------------------------------------------------------------------------------- /src/textual/events.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from rich.repr import rich_repr, RichReprResult 6 | 7 | from .message import Message 8 | from ._types import MessageTarget 9 | from .keys import Keys 10 | 11 | 12 | if TYPE_CHECKING: 13 | from ._timer import Timer as TimerClass 14 | from ._timer import TimerCallback 15 | 16 | 17 | @rich_repr 18 | class Event(Message): 19 | def __rich_repr__(self) -> RichReprResult: 20 | return 21 | yield 22 | 23 | def __init_subclass__(cls, bubble: bool = False) -> None: 24 | super().__init_subclass__(bubble=bubble) 25 | 26 | 27 | class Null(Event): 28 | def can_batch(self, message: Message) -> bool: 29 | return isinstance(message, Null) 30 | 31 | 32 | class ShutdownRequest(Event): 33 | pass 34 | 35 | 36 | class Shutdown(Event): 37 | pass 38 | 39 | 40 | class Load(Event): 41 | pass 42 | 43 | 44 | class Startup(Event): 45 | pass 46 | 47 | 48 | class Created(Event): 49 | pass 50 | 51 | 52 | class Updated(Event): 53 | """Indicates the sender was updated and needs a refresh.""" 54 | 55 | 56 | class Idle(Event): 57 | """Sent when there are no more items in the message queue.""" 58 | 59 | 60 | class Action(Event, bubble=True): 61 | __slots__ = ["action"] 62 | 63 | def __init__(self, sender: MessageTarget, action: str) -> None: 64 | super().__init__(sender) 65 | self.action = action 66 | 67 | def __rich_repr__(self) -> RichReprResult: 68 | yield "action", self.action 69 | 70 | 71 | class Resize(Event): 72 | __slots__ = ["width", "height"] 73 | width: int 74 | height: int 75 | 76 | def __init__(self, sender: MessageTarget, width: int, height: int) -> None: 77 | self.width = width 78 | self.height = height 79 | super().__init__(sender) 80 | 81 | def __rich_repr__(self) -> RichReprResult: 82 | yield self.width 83 | yield self.height 84 | 85 | 86 | class Mount(Event): 87 | pass 88 | 89 | 90 | class Unmount(Event): 91 | pass 92 | 93 | 94 | class Show(Event): 95 | """Widget has become visible.""" 96 | 97 | 98 | class Hide(Event): 99 | """Widget has been hidden.""" 100 | 101 | 102 | class InputEvent(Event, bubble=True): 103 | pass 104 | 105 | 106 | @rich_repr 107 | class Key(InputEvent, bubble=True): 108 | __slots__ = ["key"] 109 | 110 | def __init__(self, sender: MessageTarget, key: Keys | str) -> None: 111 | super().__init__(sender) 112 | self.key = key.value if isinstance(key, Keys) else key 113 | 114 | def __rich_repr__(self) -> RichReprResult: 115 | yield "key", self.key 116 | 117 | 118 | @rich_repr 119 | class MouseEvent(InputEvent): 120 | __slots__ = ["x", "y", "button"] 121 | 122 | def __init__( 123 | self, 124 | sender: MessageTarget, 125 | x: int, 126 | y: int, 127 | delta_x: int, 128 | delta_y: int, 129 | button: int, 130 | shift: bool, 131 | meta: bool, 132 | ctrl: bool, 133 | screen_x: int | None = None, 134 | screen_y: int | None = None, 135 | ) -> None: 136 | super().__init__(sender) 137 | self.x = x 138 | self.y = y 139 | self.delta_x = delta_x 140 | self.delta_y = delta_y 141 | self.button = button 142 | self.shift = shift 143 | self.meta = meta 144 | self.ctrl = ctrl 145 | self.screen_x = x if screen_x is None else screen_x 146 | self.screen_y = y if screen_y is None else screen_y 147 | 148 | def __rich_repr__(self) -> RichReprResult: 149 | yield "x", self.x 150 | yield "y", self.y 151 | yield "delta_x", self.delta_x, 0 152 | yield "delta_y", self.delta_y, 0 153 | if self.screen_x != self.x: 154 | yield "screen_x", self.screen_x 155 | if self.screen_y != self.y: 156 | yield "screen_y", self.screen_y 157 | yield "button", self.button, 0 158 | yield "shift", self.shift, False 159 | yield "meta", self.meta, False 160 | yield "ctrl", self.ctrl, False 161 | 162 | def offset(self, x: int, y: int): 163 | return self.__class__( 164 | self.sender, 165 | x=self.x + x, 166 | y=self.y + y, 167 | delta_x=self.delta_x, 168 | delta_y=self.delta_y, 169 | button=self.button, 170 | shift=self.shift, 171 | meta=self.meta, 172 | ctrl=self.ctrl, 173 | screen_x=self.screen_x, 174 | screen_y=self.screen_y, 175 | ) 176 | 177 | 178 | @rich_repr 179 | class MouseMove(MouseEvent): 180 | pass 181 | 182 | 183 | @rich_repr 184 | class MouseDown(MouseEvent): 185 | pass 186 | 187 | 188 | @rich_repr 189 | class MouseUp(MouseEvent): 190 | pass 191 | 192 | 193 | class MouseScrollDown(InputEvent, bubble=True): 194 | __slots__ = ["x", "y"] 195 | 196 | def __init__(self, sender: MessageTarget, x: int, y: int) -> None: 197 | super().__init__(sender) 198 | self.x = x 199 | self.y = y 200 | 201 | 202 | class MouseScrollUp(MouseScrollDown, bubble=True): 203 | pass 204 | 205 | 206 | class Click(MouseEvent): 207 | pass 208 | 209 | 210 | class DoubleClick(MouseEvent): 211 | pass 212 | 213 | 214 | @rich_repr 215 | class Timer(Event): 216 | __slots__ = ["time", "count", "callback"] 217 | 218 | def __init__( 219 | self, 220 | sender: MessageTarget, 221 | timer: "TimerClass", 222 | count: int = 0, 223 | callback: TimerCallback | None = None, 224 | ) -> None: 225 | super().__init__(sender) 226 | self.timer = timer 227 | self.count = count 228 | self.callback = callback 229 | 230 | def __rich_repr__(self) -> RichReprResult: 231 | yield self.timer.name 232 | 233 | 234 | class Enter(Event): 235 | pass 236 | 237 | 238 | class Leave(Event): 239 | pass 240 | 241 | 242 | class Focus(Event): 243 | pass 244 | 245 | 246 | class Blur(Event): 247 | pass 248 | 249 | 250 | class Update(Event): 251 | def can_batch(self, event: Message) -> bool: 252 | return isinstance(event, Update) and event.sender == self.sender 253 | -------------------------------------------------------------------------------- /src/textual/scrollbar.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | import logging 5 | 6 | from rich.repr import rich_repr, RichReprResult 7 | from rich.color import Color 8 | from rich.style import Style 9 | from rich.console import Console, ConsoleOptions, RenderResult, RenderableType 10 | from rich.segment import Segment, Segments 11 | from rich.style import Style, StyleType 12 | 13 | log = logging.getLogger("rich") 14 | 15 | from . import events 16 | from .message import Message 17 | from .widget import Reactive, Widget 18 | 19 | 20 | class ScrollUp(Message, bubble=True): 21 | pass 22 | 23 | 24 | class ScrollDown(Message, bubble=True): 25 | pass 26 | 27 | 28 | class ScrollBarRender: 29 | def __init__( 30 | self, 31 | virtual_size: int = 100, 32 | window_size: int = 25, 33 | position: float = 0, 34 | thickness: int = 1, 35 | vertical: bool = True, 36 | style: StyleType = "bright_magenta on #555555", 37 | ) -> None: 38 | self.virtual_size = virtual_size 39 | self.window_size = window_size 40 | self.position = position 41 | self.thickness = thickness 42 | self.vertical = vertical 43 | self.style = style 44 | 45 | @classmethod 46 | def render_bar( 47 | cls, 48 | size: int = 25, 49 | virtual_size: float = 50, 50 | window_size: float = 20, 51 | position: float = 0, 52 | ascii_only: bool = False, 53 | thickness: int = 1, 54 | vertical: bool = True, 55 | back_color: Color = Color.parse("#555555"), 56 | bar_color: Color = Color.parse("bright_magenta"), 57 | ) -> Segments: 58 | 59 | if vertical: 60 | if ascii_only: 61 | bars = ["|", "|", "|", "|", "|", "|", "|", "|"] 62 | else: 63 | bars = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] 64 | else: 65 | if ascii_only: 66 | bars = ["-", "-", "-", "-", "-", "-", "-", "-"] 67 | else: 68 | bars = ["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"] 69 | 70 | back = back_color 71 | bar = bar_color 72 | 73 | width_thickness = thickness if vertical else 1 74 | 75 | _Segment = Segment 76 | _Style = Style 77 | blank = " " * width_thickness 78 | 79 | foreground_meta = {"background": False} 80 | 81 | if window_size and size and virtual_size: 82 | step_size = virtual_size / size 83 | 84 | start = int(position / step_size * 8) 85 | end = start + max(8, int(window_size / step_size * 8)) 86 | 87 | start_index, start_bar = divmod(start, 8) 88 | end_index, end_bar = divmod(end, 8) 89 | 90 | upper = {"@click": "scroll_up"} 91 | lower = {"@click": "scroll_down"} 92 | 93 | upper_back_segment = Segment(blank, _Style(bgcolor=back, meta=upper)) 94 | lower_back_segment = Segment(blank, _Style(bgcolor=back, meta=lower)) 95 | 96 | segments = [upper_back_segment] * int(size) 97 | segments[end_index:] = [lower_back_segment] * (size - end_index) 98 | 99 | segments[start_index:end_index] = [ 100 | _Segment(blank, _Style(bgcolor=bar, meta=foreground_meta)) 101 | ] * (end_index - start_index) 102 | 103 | if start_index < len(segments): 104 | segments[start_index] = _Segment( 105 | bars[7 - start_bar] * width_thickness, 106 | _Style(bgcolor=back, color=bar, meta=foreground_meta) 107 | if vertical 108 | else _Style(bgcolor=bar, color=back, meta=foreground_meta), 109 | ) 110 | if end_index < len(segments): 111 | segments[end_index] = _Segment( 112 | bars[7 - end_bar] * width_thickness, 113 | _Style(bgcolor=bar, color=back, meta=foreground_meta) 114 | if vertical 115 | else _Style(bgcolor=back, color=bar, meta=foreground_meta), 116 | ) 117 | else: 118 | segments = [_Segment(blank)] * int(size) 119 | if vertical: 120 | return Segments(segments, new_lines=True) 121 | else: 122 | return Segments((segments + [_Segment.line()]) * thickness, new_lines=False) 123 | 124 | def __rich_console__( 125 | self, console: Console, options: ConsoleOptions 126 | ) -> RenderResult: 127 | size = ( 128 | (options.height or console.height) 129 | if self.vertical 130 | else (options.max_width or console.width) 131 | ) 132 | thickness = ( 133 | (options.max_width or console.width) 134 | if self.vertical 135 | else (options.height or console.height) 136 | ) 137 | 138 | _style = console.get_style(self.style) 139 | 140 | bar = self.render_bar( 141 | size=size, 142 | window_size=self.window_size, 143 | virtual_size=self.virtual_size, 144 | position=self.position, 145 | vertical=self.vertical, 146 | thickness=thickness, 147 | back_color=_style.bgcolor or Color.parse("#555555"), 148 | bar_color=_style.color or Color.parse("bright_magenta"), 149 | ) 150 | yield bar 151 | 152 | 153 | @rich_repr 154 | class ScrollBar(Widget): 155 | def __init__(self, vertical: bool = True, name: str | None = None) -> None: 156 | self.vertical = vertical 157 | super().__init__(name=name) 158 | 159 | virtual_size: Reactive[int] = Reactive(100) 160 | window_size: Reactive[int] = Reactive(20) 161 | position: Reactive[int] = Reactive(0) 162 | mouse_over: Reactive[bool] = Reactive(False) 163 | 164 | def __rich_repr__(self) -> RichReprResult: 165 | yield "virtual_size", self.virtual_size 166 | yield "window_size", self.window_size 167 | yield "position", self.position 168 | 169 | def render(self) -> RenderableType: 170 | return ScrollBarRender( 171 | virtual_size=self.virtual_size, 172 | window_size=self.window_size, 173 | position=self.position, 174 | vertical=self.vertical, 175 | style="bright_magenta on #555555" 176 | if self.mouse_over 177 | else "bright_magenta on #444444", 178 | ) 179 | 180 | async def on_enter(self, event: events.Enter) -> None: 181 | self.mouse_over = True 182 | 183 | async def on_leave(self, event: events.Leave) -> None: 184 | self.mouse_over = False 185 | 186 | async def action_scroll_down(self) -> None: 187 | await self.emit(ScrollDown(self)) 188 | 189 | async def action_scroll_up(self) -> None: 190 | await self.emit(ScrollUp(self)) 191 | 192 | 193 | if __name__ == "__main__": 194 | from rich.console import Console 195 | from rich.segment import Segments 196 | 197 | console = Console() 198 | bar = ScrollBarRender() 199 | 200 | console.print(ScrollBarRender(position=15.3, thickness=5, vertical=False)) 201 | -------------------------------------------------------------------------------- /src/textual/widget.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from logging import getLogger 4 | from typing import ( 5 | Callable, 6 | cast, 7 | ClassVar, 8 | Generic, 9 | Iterable, 10 | NewType, 11 | TypeVar, 12 | TYPE_CHECKING, 13 | ) 14 | 15 | from rich.align import Align 16 | 17 | from rich.console import Console, RenderableType 18 | from rich.pretty import Pretty 19 | from rich.panel import Panel 20 | import rich.repr 21 | from rich.segment import Segment 22 | from rich.style import Style 23 | 24 | from . import events 25 | from ._animator import BoundAnimator 26 | from ._context import active_app 27 | from ._loop import loop_last 28 | from ._line_cache import LineCache 29 | from .message import Message 30 | from .messages import UpdateMessage, LayoutMessage 31 | from .message_pump import MessagePump 32 | from .geometry import Point, Dimensions 33 | from .reactive import Reactive 34 | 35 | 36 | if TYPE_CHECKING: 37 | from .app import App 38 | from .view import View 39 | 40 | 41 | WidgetID = NewType("WidgetID", int) 42 | 43 | log = getLogger("rich") 44 | 45 | 46 | @rich.repr.auto 47 | class Widget(MessagePump): 48 | _id: ClassVar[int] = 0 49 | _counts: ClassVar[dict[str, int]] = {} 50 | can_focus: bool = False 51 | 52 | def __init__(self, name: str | None = None) -> None: 53 | class_name = self.__class__.__name__ 54 | Widget._counts.setdefault(class_name, 0) 55 | Widget._counts[class_name] += 1 56 | _count = self._counts[class_name] 57 | self.id: WidgetID = cast(WidgetID, Widget._id) 58 | Widget._id += 1 59 | 60 | self.name = name or f"{class_name}#{_count}" 61 | 62 | self.size = Dimensions(0, 0) 63 | self.size_changed = False 64 | self._repaint_required = False 65 | self._layout_required = False 66 | self._animate: BoundAnimator | None = None 67 | 68 | super().__init__() 69 | 70 | visible: Reactive[bool] = Reactive(True, layout=True) 71 | layout_size: Reactive[int | None] = Reactive(None) 72 | layout_fraction: Reactive[int] = Reactive(1) 73 | layout_minimim_size: Reactive[int] = Reactive(1) 74 | layout_offset_x: Reactive[float] = Reactive(0, layout=True) 75 | layout_offset_y: Reactive[float] = Reactive(0, layout=True) 76 | 77 | def __init_subclass__(cls, can_focus: bool = True) -> None: 78 | super().__init_subclass__() 79 | cls.can_focus = can_focus 80 | 81 | def __rich_repr__(self) -> rich.repr.RichReprResult: 82 | yield "name", self.name 83 | 84 | def __rich__(self) -> RenderableType: 85 | return self.render() 86 | 87 | @property 88 | def is_visual(self) -> bool: 89 | return True 90 | 91 | @property 92 | def app(self) -> "App": 93 | """Get the current app.""" 94 | return active_app.get() 95 | 96 | @property 97 | def console(self) -> Console: 98 | """Get the current console.""" 99 | return active_app.get().console 100 | 101 | @property 102 | def root_view(self) -> "View": 103 | """Return the top-most view.""" 104 | return active_app.get().view 105 | 106 | @property 107 | def animate(self) -> BoundAnimator: 108 | if self._animate is None: 109 | self._animate = self.app.animator.bind(self) 110 | assert self._animate is not None 111 | return self._animate 112 | 113 | @property 114 | def layout_offset(self) -> tuple[int, int]: 115 | """Get the layout offset as a tuple.""" 116 | return (round(self.layout_offset_x), round(self.layout_offset_y)) 117 | 118 | def require_repaint(self) -> None: 119 | """Mark widget as requiring a repaint. 120 | 121 | Actual repaint is done by parent on idle. 122 | """ 123 | self._repaint_required = True 124 | self.post_message_no_wait(events.Null(self)) 125 | 126 | def require_layout(self) -> None: 127 | self._layout_required = True 128 | self.post_message_no_wait(events.Null(self)) 129 | 130 | def check_repaint(self) -> bool: 131 | return self._repaint_required 132 | 133 | def check_layout(self) -> bool: 134 | return self._layout_required 135 | 136 | def reset_check_repaint(self) -> None: 137 | self._repaint_required = False 138 | 139 | def reset_check_layout(self) -> None: 140 | self._layout_required = False 141 | 142 | def get_style_at(self, x: int, y: int) -> Style: 143 | offset_x, offset_y = self.root_view.get_offset(self) 144 | return self.root_view.get_style_at(x + offset_x, y + offset_y) 145 | 146 | async def forward_event(self, event: events.Event) -> None: 147 | await self.post_message(event) 148 | 149 | async def refresh(self) -> None: 150 | """Re-render the window and repaint it.""" 151 | self.require_repaint() 152 | await self.repaint() 153 | 154 | async def repaint(self) -> None: 155 | """Instructs parent to repaint this widget.""" 156 | await self.emit(UpdateMessage(self, self)) 157 | 158 | async def update_layout(self) -> None: 159 | await self.emit(LayoutMessage(self)) 160 | 161 | def render(self) -> RenderableType: 162 | """Get renderable for widget. 163 | 164 | Returns: 165 | RenderableType: Any renderable 166 | """ 167 | return Panel( 168 | Align.center(Pretty(self), vertical="middle"), title=self.__class__.__name__ 169 | ) 170 | 171 | async def action(self, action: str, *params) -> None: 172 | await self.app.action(action, self) 173 | 174 | async def post_message(self, message: Message) -> bool: 175 | if not self.check_message_enabled(message): 176 | return True 177 | return await super().post_message(message) 178 | 179 | async def on_event(self, event: events.Event) -> None: 180 | if isinstance(event, events.Resize): 181 | new_size = Dimensions(event.width, event.height) 182 | if self.size != new_size: 183 | self.size = new_size 184 | self.require_repaint() 185 | await super().on_event(event) 186 | 187 | async def on_idle(self, event: events.Idle) -> None: 188 | if self.check_layout(): 189 | self.reset_check_repaint() 190 | self.reset_check_layout() 191 | await self.update_layout() 192 | elif self.check_repaint(): 193 | self.reset_check_repaint() 194 | self.reset_check_layout() 195 | await self.repaint() 196 | 197 | async def focus(self) -> None: 198 | await self.app.set_focus(self) 199 | 200 | async def capture_mouse(self, capture: bool = True) -> None: 201 | await self.app.capture_mouse(self if capture else None) 202 | 203 | async def on_mouse_move(self, event: events.MouseMove) -> None: 204 | style_under_cursor = self.get_style_at(event.x, event.y) 205 | log.debug("%r", style_under_cursor) 206 | 207 | async def on_mouse_up(self, event: events.MouseUp) -> None: 208 | style = self.get_style_at(event.x, event.y) 209 | if "@click" in style.meta: 210 | log.debug(style._link_id) 211 | await self.app.action(style.meta["@click"], default_namespace=self) 212 | -------------------------------------------------------------------------------- /src/textual/view.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | from itertools import chain 5 | from time import time 6 | import logging 7 | from typing import cast, Iterable, Optional, Tuple, TYPE_CHECKING 8 | 9 | from rich.console import Console, ConsoleOptions, RenderResult, RenderableType 10 | import rich.repr 11 | from rich.style import Style 12 | 13 | from . import events 14 | from .layout import Layout, NoWidget 15 | from .layouts.dock import DockEdge, DockLayout, Dock 16 | from .geometry import Dimensions, Point, Region 17 | from .messages import UpdateMessage, LayoutMessage 18 | 19 | from .widget import Widget, Widget 20 | 21 | 22 | if TYPE_CHECKING: 23 | from .app import App 24 | 25 | log = logging.getLogger("rich") 26 | 27 | 28 | @rich.repr.auto 29 | class View(Widget): 30 | def __init__(self, layout: Layout = None, name: str | None = None) -> None: 31 | self.layout: Layout = layout or DockLayout() 32 | self.mouse_over: Widget | None = None 33 | self.focused: Widget | None = None 34 | self.size = Dimensions(0, 0) 35 | self.widgets: set[Widget] = set() 36 | self.named_widgets: dict[str, Widget] = {} 37 | super().__init__(name) 38 | 39 | def __rich_console__( 40 | self, console: Console, options: ConsoleOptions 41 | ) -> RenderResult: 42 | return 43 | yield 44 | 45 | def __rich_repr__(self) -> rich.repr.RichReprResult: 46 | yield "name", self.name 47 | 48 | @property 49 | def is_visual(self) -> bool: 50 | return False 51 | 52 | @property 53 | def is_root_view(self) -> bool: 54 | return self.parent is self.app 55 | 56 | def is_mounted(self, widget: Widget) -> bool: 57 | return widget in self.widgets 58 | 59 | def render(self) -> RenderableType: 60 | return self.layout 61 | 62 | def get_offset(self, widget: Widget) -> Point: 63 | return self.layout.get_offset(widget) 64 | 65 | async def message_update(self, message: UpdateMessage) -> None: 66 | widget = message.widget 67 | assert isinstance(widget, Widget) 68 | display_update = self.root_view.layout.update_widget(self.console, widget) 69 | if display_update is not None: 70 | self.app.display(display_update) 71 | 72 | async def message_layout(self, message: LayoutMessage) -> None: 73 | await self.root_view.refresh_layout() 74 | 75 | async def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None: 76 | 77 | name_widgets: Iterable[tuple[str | None, Widget]] 78 | name_widgets = chain( 79 | ((None, widget) for widget in anon_widgets), widgets.items() 80 | ) 81 | for name, widget in name_widgets: 82 | name = name or widget.name 83 | if name: 84 | self.named_widgets[name] = widget 85 | await self.app.register(widget) 86 | widget.set_parent(self) 87 | await widget.post_message(events.Mount(sender=self)) 88 | self.widgets.add(widget) 89 | 90 | self.require_repaint() 91 | 92 | async def refresh_layout(self) -> None: 93 | 94 | if not self.size: 95 | return 96 | 97 | width, height = self.console.size 98 | hidden, shown, resized = self.layout.reflow(width, height) 99 | self.app.refresh() 100 | 101 | send_resize = shown 102 | send_resize.update(resized) 103 | for widget, region in self.layout: 104 | if widget in send_resize: 105 | await widget.post_message( 106 | events.Resize(self, region.width, region.height) 107 | ) 108 | # for widget, region in self.layout: 109 | # if isinstance(widget, Widget): 110 | # await widget.post_message( 111 | # events.Resize(self, region.width, region.height) 112 | # ) 113 | 114 | async def on_resize(self, event: events.Resize) -> None: 115 | self.size = Dimensions(event.width, event.height) 116 | await self.refresh_layout() 117 | 118 | def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: 119 | return self.layout.get_widget_at(x, y) 120 | 121 | def get_style_at(self, x: int, y: int) -> Style: 122 | return self.layout.get_style_at(x, y) 123 | 124 | async def _on_mouse_move(self, event: events.MouseMove) -> None: 125 | try: 126 | widget, region = self.get_widget_at(event.x, event.y) 127 | except NoWidget: 128 | await self.app.set_mouse_over(None) 129 | else: 130 | await self.app.set_mouse_over(widget) 131 | 132 | await widget.forward_event( 133 | events.MouseMove( 134 | self, 135 | event.x - region.x, 136 | event.y - region.y, 137 | event.delta_x, 138 | event.delta_y, 139 | event.button, 140 | event.shift, 141 | event.meta, 142 | event.ctrl, 143 | ) 144 | ) 145 | 146 | async def forward_event(self, event: events.Event) -> None: 147 | 148 | if isinstance(event, (events.Enter, events.Leave)): 149 | await self.post_message(event) 150 | 151 | elif isinstance(event, events.MouseMove): 152 | await self._on_mouse_move(event) 153 | 154 | elif isinstance(event, events.MouseEvent): 155 | 156 | try: 157 | widget, region = self.get_widget_at(event.x, event.y) 158 | except NoWidget: 159 | pass 160 | else: 161 | if isinstance(event, events.MouseDown) and widget.can_focus: 162 | await self.app.set_focus(widget) 163 | await widget.forward_event(event.offset(-region.x, -region.y)) 164 | 165 | elif isinstance(event, (events.MouseScrollDown, events.MouseScrollUp)): 166 | try: 167 | widget, _region = self.get_widget_at(event.x, event.y) 168 | except NoWidget: 169 | return 170 | scroll_widget = widget or self.focused 171 | if scroll_widget is not None: 172 | await scroll_widget.forward_event(event) 173 | else: 174 | if self.focused is not None: 175 | await self.focused.forward_event(event) 176 | 177 | async def action_toggle(self, name: str) -> None: 178 | widget = self.named_widgets[name] 179 | widget.visible = not widget.visible 180 | await self.post_message(LayoutMessage(self)) 181 | # await self.refresh_layout() 182 | 183 | 184 | class DoNotSet: 185 | pass 186 | 187 | 188 | do_not_set = DoNotSet() 189 | 190 | 191 | class DockView(View): 192 | def __init__(self, name: str | None = None) -> None: 193 | super().__init__(layout=DockLayout(), name=name) 194 | 195 | async def dock( 196 | self, 197 | *widgets: Widget, 198 | edge: DockEdge = "top", 199 | z: int = 0, 200 | size: int | None | DoNotSet = do_not_set, 201 | name: str | None = None 202 | ) -> Widget | tuple[Widget, ...]: 203 | 204 | dock = Dock(edge, widgets, z) 205 | assert isinstance(self.layout, DockLayout) 206 | self.layout.docks.append(dock) 207 | for widget in widgets: 208 | if size is not do_not_set: 209 | widget.layout_size = cast(Optional[int], size) 210 | if not self.is_mounted(widget): 211 | await self.mount(widget) 212 | await self.refresh_layout() 213 | 214 | widget, *rest = widgets 215 | if rest: 216 | return widgets 217 | else: 218 | return widget 219 | -------------------------------------------------------------------------------- /src/textual/_linux_driver.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import os 5 | from codecs import getincrementaldecoder 6 | import selectors 7 | import signal 8 | import sys 9 | import logging 10 | import termios 11 | import tty 12 | from typing import Any, TYPE_CHECKING 13 | from threading import Event, Thread 14 | 15 | if TYPE_CHECKING: 16 | from rich.console import Console 17 | 18 | 19 | from . import events 20 | from .driver import Driver 21 | from ._types import MessageTarget 22 | from ._xterm_parser import XTermParser 23 | 24 | 25 | log = logging.getLogger("rich") 26 | 27 | 28 | class LinuxDriver(Driver): 29 | def __init__(self, console: "Console", target: "MessageTarget") -> None: 30 | super().__init__(console, target) 31 | self.fileno = sys.stdin.fileno() 32 | self.attrs_before: list[Any] | None = None 33 | self.exit_event = Event() 34 | self._key_thread: Thread | None = None 35 | 36 | def _get_terminal_size(self) -> tuple[int, int]: 37 | width: int | None = 80 38 | height: int | None = 25 39 | try: 40 | width, height = os.get_terminal_size(sys.stdin.fileno()) 41 | except (AttributeError, ValueError, OSError): 42 | try: 43 | width, height = os.get_terminal_size(sys.stdout.fileno()) 44 | except (AttributeError, ValueError, OSError): 45 | pass 46 | width = width or 80 47 | height = height or 25 48 | return width, height 49 | 50 | def _enable_mouse_support(self) -> None: 51 | write = self.console.file.write 52 | write("\x1b[?1000h") 53 | write("\x1b[?1015h") 54 | write("\x1b[?1006h") 55 | # write("\x1b[?1007h") 56 | self.console.file.flush() 57 | 58 | # Note: E.g. lxterminal understands 1000h, but not the urxvt or sgr 59 | # extensions. 60 | 61 | def _disable_mouse_support(self) -> None: 62 | write = self.console.file.write 63 | write("\x1b[?1000l") 64 | write("\x1b[?1015l") 65 | write("\x1b[?1006l") 66 | self.console.file.flush() 67 | 68 | def start_application_mode(self): 69 | 70 | loop = asyncio.get_event_loop() 71 | 72 | def on_terminal_resize(signum, stack) -> None: 73 | terminal_size = self._get_terminal_size() 74 | width, height = terminal_size 75 | event = events.Resize(self._target, width, height) 76 | self.console.size = terminal_size 77 | asyncio.run_coroutine_threadsafe( 78 | self._target.post_message(event), 79 | loop=loop, 80 | ) 81 | 82 | signal.signal(signal.SIGWINCH, on_terminal_resize) 83 | 84 | self.console.set_alt_screen(True) 85 | self._enable_mouse_support() 86 | try: 87 | self.attrs_before = termios.tcgetattr(self.fileno) 88 | except termios.error: 89 | # Ignore attribute errors. 90 | self.attrs_before = None 91 | 92 | try: 93 | newattr = termios.tcgetattr(self.fileno) 94 | except termios.error: 95 | pass 96 | else: 97 | 98 | newattr[tty.LFLAG] = self._patch_lflag(newattr[tty.LFLAG]) 99 | newattr[tty.IFLAG] = self._patch_iflag(newattr[tty.IFLAG]) 100 | 101 | # VMIN defines the number of characters read at a time in 102 | # non-canonical mode. It seems to default to 1 on Linux, but on 103 | # Solaris and derived operating systems it defaults to 4. (This is 104 | # because the VMIN slot is the same as the VEOF slot, which 105 | # defaults to ASCII EOT = Ctrl-D = 4.) 106 | newattr[tty.CC][termios.VMIN] = 1 107 | 108 | termios.tcsetattr(self.fileno, termios.TCSANOW, newattr) 109 | 110 | self.console.show_cursor(False) 111 | self.console.file.write("\033[?1003h\n") 112 | 113 | self._key_thread = Thread( 114 | target=self.run_input_thread, args=(asyncio.get_event_loop(),) 115 | ) 116 | width, height = self.console.size = self._get_terminal_size() 117 | asyncio.run_coroutine_threadsafe( 118 | self._target.post_message(events.Resize(self._target, width, height)), 119 | loop=loop, 120 | ) 121 | self._key_thread.start() 122 | 123 | @classmethod 124 | def _patch_lflag(cls, attrs: int) -> int: 125 | return attrs & ~(termios.ECHO | termios.ICANON | termios.IEXTEN | termios.ISIG) 126 | 127 | @classmethod 128 | def _patch_iflag(cls, attrs: int) -> int: 129 | return attrs & ~( 130 | # Disable XON/XOFF flow control on output and input. 131 | # (Don't capture Ctrl-S and Ctrl-Q.) 132 | # Like executing: "stty -ixon." 133 | termios.IXON 134 | | termios.IXOFF 135 | | 136 | # Don't translate carriage return into newline on input. 137 | termios.ICRNL 138 | | termios.INLCR 139 | | termios.IGNCR 140 | ) 141 | 142 | def disable_input(self) -> None: 143 | try: 144 | if not self.exit_event.is_set(): 145 | signal.signal(signal.SIGWINCH, signal.SIG_DFL) 146 | self._disable_mouse_support() 147 | self.exit_event.set() 148 | if self._key_thread is not None: 149 | self._key_thread.join() 150 | except Exception: 151 | log.exception("error in disable_input") 152 | 153 | def stop_application_mode(self) -> None: 154 | log.debug("stop_application_mode()") 155 | 156 | self.disable_input() 157 | 158 | if self.attrs_before is not None: 159 | try: 160 | termios.tcsetattr(self.fileno, termios.TCSANOW, self.attrs_before) 161 | except termios.error: 162 | pass 163 | 164 | self.console.set_alt_screen(False) 165 | self.console.show_cursor(True) 166 | 167 | def run_input_thread(self, loop) -> None: 168 | try: 169 | self._run_input_thread(loop) 170 | except Exception: 171 | log.exception("error running input thread") 172 | 173 | def _run_input_thread(self, loop) -> None: 174 | def send_event(event: events.Event) -> None: 175 | asyncio.run_coroutine_threadsafe( 176 | self._target.post_message(event), 177 | loop=loop, 178 | ) 179 | 180 | selector = selectors.DefaultSelector() 181 | selector.register(self.fileno, selectors.EVENT_READ) 182 | 183 | fileno = self.fileno 184 | 185 | def more_data() -> bool: 186 | """Check if there is more data to parse.""" 187 | for key, events in selector.select(0.1): 188 | if events: 189 | return True 190 | return False 191 | 192 | parser = XTermParser(self._target, more_data) 193 | 194 | utf8_decoder = getincrementaldecoder("utf-8")().decode 195 | decode = utf8_decoder 196 | read = os.read 197 | 198 | log.debug("started key thread") 199 | try: 200 | while not self.exit_event.is_set(): 201 | selector_events = selector.select(0.1) 202 | for _selector_key, mask in selector_events: 203 | if mask | selectors.EVENT_READ: 204 | unicode_data = decode(read(fileno, 1024)) 205 | for event in parser.feed(unicode_data): 206 | send_event(event) 207 | except Exception: 208 | log.exception("error running key thread") 209 | finally: 210 | selector.close() 211 | 212 | 213 | if __name__ == "__main__": 214 | from time import sleep 215 | from rich.console import Console 216 | from . import events 217 | 218 | console = Console() 219 | 220 | from .app import App 221 | 222 | class MyApp(App): 223 | async def on_startup(self, event: events.Startup) -> None: 224 | self.set_timer(5, callback=self.close_messages) 225 | 226 | MyApp.run() 227 | -------------------------------------------------------------------------------- /src/textual/message_pump.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Coroutine, Awaitable, NamedTuple 4 | import asyncio 5 | from asyncio import Event, Queue, Task, QueueEmpty 6 | 7 | import logging 8 | 9 | from . import events 10 | from .message import Message 11 | from ._timer import Timer, TimerCallback 12 | from ._types import MessageHandler 13 | 14 | log = logging.getLogger("rich") 15 | 16 | 17 | class NoParent(Exception): 18 | pass 19 | 20 | 21 | class MessagePumpClosed(Exception): 22 | pass 23 | 24 | 25 | class MessagePump: 26 | def __init__(self, queue_size: int = 10, parent: MessagePump | None = None) -> None: 27 | self._message_queue: Queue[Message | None] = Queue() 28 | self._parent = parent 29 | self._closing: bool = False 30 | self._closed: bool = False 31 | self._disabled_messages: set[type[Message]] = set() 32 | self._pending_message: Message | None = None 33 | self._task: Task | None = None 34 | self._child_tasks: set[Task] = set() 35 | 36 | @property 37 | def task(self) -> Task: 38 | assert self._task is not None 39 | return self._task 40 | 41 | @property 42 | def parent(self) -> MessagePump: 43 | if self._parent is None: 44 | raise NoParent(f"{self._parent} has no parent") 45 | return self._parent 46 | 47 | def set_parent(self, parent: MessagePump) -> None: 48 | self._parent = parent 49 | 50 | def check_message_enabled(self, message: Message) -> bool: 51 | return type(message) not in self._disabled_messages 52 | 53 | def disable_messages(self, *messages: type[Message]) -> None: 54 | """Disable message types from being processed.""" 55 | self._disabled_messages.update(messages) 56 | 57 | def enable_messages(self, *messages: type[Message]) -> None: 58 | """Enable processing of messages types.""" 59 | self._disabled_messages.difference_update(messages) 60 | 61 | async def get_message(self) -> Message: 62 | """Get the next event on the queue, or None if queue is closed. 63 | 64 | Returns: 65 | Optional[Event]: Event object or None. 66 | """ 67 | if self._closed: 68 | raise MessagePumpClosed("The message pump is closed") 69 | if self._pending_message is not None: 70 | try: 71 | return self._pending_message 72 | finally: 73 | self._pending_message = None 74 | message = await self._message_queue.get() 75 | if message is None: 76 | self._closed = True 77 | raise MessagePumpClosed("The message pump is now closed") 78 | return message 79 | 80 | def peek_message(self) -> Message | None: 81 | """Peek the message at the head of the queue (does not remove it from the queue), 82 | or return None if the queue is empty. 83 | 84 | Returns: 85 | Optional[Message]: The message or None. 86 | """ 87 | if self._pending_message is None: 88 | try: 89 | self._pending_message = self._message_queue.get_nowait() 90 | except QueueEmpty: 91 | pass 92 | 93 | if self._pending_message is not None: 94 | return self._pending_message 95 | return None 96 | 97 | def set_timer( 98 | self, 99 | delay: float, 100 | *, 101 | name: str | None = None, 102 | callback: TimerCallback = None, 103 | ) -> Timer: 104 | timer = Timer(self, delay, self, name=name, callback=callback, repeat=0) 105 | timer_task = asyncio.get_event_loop().create_task(timer.run()) 106 | self._child_tasks.add(timer_task) 107 | return timer 108 | 109 | def set_interval( 110 | self, 111 | interval: float, 112 | *, 113 | name: str | None = None, 114 | callback: TimerCallback = None, 115 | repeat: int = 0, 116 | ): 117 | timer = Timer( 118 | self, interval, self, name=name, callback=callback, repeat=repeat or None 119 | ) 120 | asyncio.get_event_loop().create_task(timer.run()) 121 | return timer 122 | 123 | async def close_messages(self, wait: bool = True) -> None: 124 | """Close message queue, and optionally wait for queue to finish processing.""" 125 | if self._closed: 126 | return 127 | 128 | self._closing = True 129 | await self._message_queue.put(None) 130 | 131 | for task in self._child_tasks: 132 | task.cancel() 133 | 134 | def start_messages(self) -> None: 135 | self._task = asyncio.create_task(self.process_messages()) 136 | 137 | async def process_messages(self) -> None: 138 | """Process messages until the queue is closed.""" 139 | while not self._closed: 140 | try: 141 | message = await self.get_message() 142 | except MessagePumpClosed: 143 | break 144 | except Exception as error: 145 | log.exception("error in get_message()") 146 | raise error from None 147 | 148 | log.debug("%r -> %r", message, self) 149 | # Combine any pending messages that may supersede this one 150 | while not (self._closed or self._closing): 151 | pending = self.peek_message() 152 | if pending is None or not message.can_batch(pending): 153 | break 154 | try: 155 | message = await self.get_message() 156 | except MessagePumpClosed: 157 | break 158 | 159 | try: 160 | await self.dispatch_message(message) 161 | except Exception as error: 162 | log.exception("error in dispatch_message") 163 | raise 164 | finally: 165 | if isinstance(message, events.Event) and self._message_queue.empty(): 166 | if not self._closed: 167 | idle_handler = getattr(self, "on_idle", None) 168 | if idle_handler is not None and not self._closed: 169 | await idle_handler(events.Idle(self)) 170 | log.debug("CLOSED %r", self) 171 | 172 | async def dispatch_message(self, message: Message) -> bool | None: 173 | if isinstance(message, events.Event): 174 | if not isinstance(message, events.Null): 175 | await self.on_event(message) 176 | else: 177 | return await self.on_message(message) 178 | return False 179 | 180 | async def on_event(self, event: events.Event) -> None: 181 | method_name = f"on_{event.name}" 182 | 183 | dispatch_function: MessageHandler = getattr(self, method_name, None) 184 | if dispatch_function is not None: 185 | await dispatch_function(event) 186 | if event.bubble and self._parent and not event._stop_propagaton: 187 | if event.sender == self._parent: 188 | pass 189 | # log.debug("bubbled event abandoned; %r", event) 190 | elif not self._parent._closed and not self._parent._closing: 191 | await self._parent.post_message(event) 192 | 193 | async def on_message(self, message: Message) -> None: 194 | method_name = f"message_{message.name}" 195 | method = getattr(self, method_name, None) 196 | if method is not None: 197 | await method(message) 198 | 199 | def post_message_no_wait(self, message: Message) -> bool: 200 | if self._closing or self._closed: 201 | return False 202 | if not self.check_message_enabled(message): 203 | return True 204 | self._message_queue.put_nowait(message) 205 | return True 206 | 207 | async def post_message(self, message: Message) -> bool: 208 | if self._closing or self._closed: 209 | return False 210 | if not self.check_message_enabled(message): 211 | return True 212 | await self._message_queue.put(message) 213 | return True 214 | 215 | async def post_message_from_child(self, message: Message) -> bool: 216 | if self._closing or self._closed: 217 | return False 218 | return await self.post_message(message) 219 | 220 | async def emit(self, message: Message) -> bool: 221 | if self._parent: 222 | await self._parent.post_message_from_child(message) 223 | return True 224 | else: 225 | log.warning("NO PARENT %r %r", self, message) 226 | return False 227 | 228 | async def on_timer(self, event: events.Timer) -> None: 229 | if event.callback is not None: 230 | await event.callback() 231 | -------------------------------------------------------------------------------- /src/textual/geometry.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, NamedTuple, TypeVar 4 | 5 | 6 | T = TypeVar("T", int, float) 7 | 8 | 9 | def clamp(value: T, minimum: T, maximum: T) -> T: 10 | """Clamps a value between two other values. 11 | 12 | Args: 13 | value (T): A value 14 | minimum (T): Minimum value 15 | maximum (T): maximum value 16 | 17 | Returns: 18 | T: New value that is not less than the minimum or greater than the maximum. 19 | """ 20 | if value < minimum: 21 | return minimum 22 | elif value > maximum: 23 | return maximum 24 | else: 25 | return value 26 | 27 | 28 | class Point(NamedTuple): 29 | """A point defined by x and y coordinates.""" 30 | 31 | x: int 32 | y: int 33 | 34 | @property 35 | def is_origin(self) -> bool: 36 | """Check if the point is at the origin (0, 0)""" 37 | return self == (0, 0) 38 | 39 | def __add__(self, other: object) -> Point: 40 | if isinstance(other, tuple): 41 | _x, _y = self 42 | x, y = other 43 | return Point(_x + x, _y + y) 44 | return NotImplemented 45 | 46 | def __sub__(self, other: object) -> Point: 47 | if isinstance(other, tuple): 48 | _x, _y = self 49 | x, y = other 50 | return Point(_x - x, _y - y) 51 | return NotImplemented 52 | 53 | def blend(self, destination: Point, factor: float) -> Point: 54 | """Blend (interpolate) to a new point. 55 | 56 | Args: 57 | destination (Point): Point where progress is 1.0 58 | factor (float): A value between 0 and 1.0 59 | 60 | Returns: 61 | Point: A new point on a line between self and destination 62 | """ 63 | x1, y1 = self 64 | x2, y2 = destination 65 | return Point(int(x1 + (x2 - x1) * factor), int((y1 + (y2 - y1) * factor))) 66 | 67 | 68 | class Dimensions(NamedTuple): 69 | """An area defined by its width and height.""" 70 | 71 | width: int 72 | height: int 73 | 74 | def __bool__(self) -> bool: 75 | return self.width * self.height != 0 76 | 77 | @property 78 | def area(self) -> int: 79 | return self.width * self.height 80 | 81 | def contains(self, x: int, y: int) -> bool: 82 | """Check if a point is in the region. 83 | 84 | Args: 85 | x (int): X coordinate (column) 86 | y (int): Y coordinate (row) 87 | 88 | Returns: 89 | bool: True if the point is within the region. 90 | """ 91 | width, height = self 92 | return width > x >= 0 and height > y >= 0 93 | 94 | def contains_point(self, point: tuple[int, int]) -> bool: 95 | """Check if a point is in the region. 96 | 97 | Args: 98 | point (tuple[int, int]): A tuple of x and y coordinates. 99 | 100 | Returns: 101 | bool: True if the point is within the region. 102 | """ 103 | x, y = point 104 | width, height = self 105 | return width > x >= 0 and height > y >= 0 106 | 107 | def __contains__(self, other: Any) -> bool: 108 | try: 109 | x, y = other 110 | except Exception: 111 | raise TypeError( 112 | "Dimensions.__contains__ requires an iterable of two integers" 113 | ) 114 | width, height = self 115 | return width > x >= 0 and height > y >= 0 116 | 117 | 118 | class Region(NamedTuple): 119 | """Defines a rectangular region of the screen.""" 120 | 121 | x: int 122 | y: int 123 | width: int 124 | height: int 125 | 126 | @classmethod 127 | def from_corners(cls, x1: int, y1: int, x2: int, y2: int) -> Region: 128 | """Construct a Region form the top left and bottom right corners. 129 | 130 | Args: 131 | x1 (int): Top left x 132 | y1 (int): Top left y 133 | x2 (int): Bottom right x 134 | y2 (int): Bottom right y 135 | 136 | Returns: 137 | Region: A new region. 138 | """ 139 | return cls(x1, y1, x2 - x1, y2 - y1) 140 | 141 | def __bool__(self) -> bool: 142 | return self.width != 0 and self.height != 0 143 | 144 | @property 145 | def area(self) -> int: 146 | """Get the area within the region.""" 147 | return self.width * self.height 148 | 149 | @property 150 | def origin(self) -> Point: 151 | """Get the start point of the region.""" 152 | return Point(self.x, self.y) 153 | 154 | @property 155 | def limit(self) -> Point: 156 | x, y, width, height = self 157 | return Point(x + width, y + height) 158 | 159 | @property 160 | def limit_inclusive(self) -> Point: 161 | """Get the end point of the region.""" 162 | x, y, width, height = self 163 | return Point(x + width - 1, y + height - 1) 164 | 165 | @property 166 | def size(self) -> Dimensions: 167 | """Get the size of the region.""" 168 | return Dimensions(self.width, self.height) 169 | 170 | @property 171 | def corners(self) -> tuple[int, int, int, int]: 172 | """Get the maxima and minima of region. 173 | 174 | Returns: 175 | tuple[int, int, int, int]: A tuple of (, , , ) 176 | """ 177 | x, y, width, height = self 178 | return x, y, x + width, y + height 179 | 180 | def __add__(self, other: Any) -> Region: 181 | if isinstance(other, tuple): 182 | ox, oy = other 183 | x, y, width, height = self 184 | return Region(x + ox, y + oy, width, height) 185 | return NotImplemented 186 | 187 | def overlaps(self, other: Region) -> bool: 188 | """Check if another region overlaps this region. 189 | 190 | Args: 191 | other (Region): A Region. 192 | 193 | Returns: 194 | bool: True if other region shares any cells with this region. 195 | """ 196 | x, y, x2, y2 = self.corners 197 | ox, oy, ox2, oy2 = other.corners 198 | 199 | return ((x2 > ox >= x) or (x2 > ox2 >= x) or (ox < x and ox2 > x2)) and ( 200 | (y2 > oy >= y) or (y2 > oy2 >= y) or (oy < y and oy2 > x2) 201 | ) 202 | 203 | def contains(self, x: int, y: int) -> bool: 204 | """Check if a point is in the region. 205 | 206 | Args: 207 | x (int): X coordinate (column) 208 | y (int): Y coordinate (row) 209 | 210 | Returns: 211 | bool: True if the point is within the region. 212 | """ 213 | self_x, self_y, width, height = self 214 | return (self_x + width > x >= self_x) and (self_y + height > y >= self_y) 215 | 216 | def contains_point(self, point: tuple[int, int]) -> bool: 217 | """Check if a point is in the region. 218 | 219 | Args: 220 | point (tuple[int, int]): A tuple of x and y coordinates. 221 | 222 | Returns: 223 | bool: True if the point is within the region. 224 | """ 225 | x1, y1, x2, y2 = self.corners 226 | try: 227 | ox, oy = point 228 | except Exception: 229 | raise TypeError(f"a tuple of two integers is required, not {point!r}") 230 | return (x2 > ox >= x1) and (y2 > oy >= y1) 231 | 232 | def contains_region(self, other: Region) -> bool: 233 | """Check if a region is entirely contained within this region. 234 | 235 | Args: 236 | other (Region): A region. 237 | 238 | Returns: 239 | bool: True if the other region fits perfectly within this region. 240 | """ 241 | x1, y1, x2, y2 = self.corners 242 | ox, oy, ox2, oy2 = other.corners 243 | return (x2 >= ox >= x1 and y2 >= oy >= y1) and ( 244 | x2 >= ox2 >= x1 and y2 >= oy2 >= y1 245 | ) 246 | 247 | def translate(self, translate_x: int, translate_y: int) -> Region: 248 | """Move the origin of the Region. 249 | 250 | Args: 251 | translate_x (int): Value to add to x coordinate. 252 | translate_y (int): Value to add to y coordinate. 253 | 254 | Returns: 255 | Region: A new region shifted by x, y 256 | """ 257 | 258 | x, y, width, height = self 259 | return Region(x + translate_x, y + translate_y, width, height) 260 | 261 | def __contains__(self, other: Any) -> bool: 262 | """Check if a point is in this region.""" 263 | if isinstance(other, Region): 264 | return self.contains_region(other) 265 | else: 266 | try: 267 | return self.contains_point(other) 268 | except TypeError: 269 | return False 270 | 271 | def clip(self, width: int, height: int) -> Region: 272 | """Clip this region to fit within width, height. 273 | 274 | Args: 275 | width (int): Width of bounds. 276 | height (int): Height of bounds. 277 | 278 | Returns: 279 | Region: Clipped region. 280 | """ 281 | x1, y1, x2, y2 = self.corners 282 | 283 | new_region = Region.from_corners( 284 | clamp(x1, 0, width), 285 | clamp(y1, 0, height), 286 | clamp(x2, 0, width), 287 | clamp(y2, 0, height), 288 | ) 289 | return new_region 290 | 291 | def clip_region(self, region: Region) -> Region: 292 | """Clip this region to fit within another region. 293 | 294 | Args: 295 | region ([type]): A region that overlaps this region. 296 | 297 | Returns: 298 | Region: A new region that fits within ``region``. 299 | """ 300 | x1, y1, x2, y2 = self.corners 301 | cx1, cy1, cx2, cy2 = region.corners 302 | 303 | new_region = Region.from_corners( 304 | clamp(x1, cx1, cx2), 305 | clamp(y1, cy1, cy2), 306 | clamp(x2, cx2, cx2), 307 | clamp(y2, cy2, cy2), 308 | ) 309 | return new_region 310 | 311 | 312 | if __name__ == "__main__": 313 | from rich import print 314 | 315 | region = Region(-5, -5, 60, 100) 316 | 317 | print(region.clip(80, 25)) 318 | 319 | region = Region(10, 10, 90, 90) 320 | 321 | print(region.corners) 322 | 323 | print((15, 15) in region) 324 | print((5, 15) in region) 325 | print(Region(15, 15, 10, 10) in region) 326 | -------------------------------------------------------------------------------- /src/textual/layout.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod, abstractmethod 4 | from dataclasses import dataclass 5 | from itertools import chain 6 | import logging 7 | from operator import itemgetter 8 | from time import time 9 | from typing import cast, Iterable, Mapping, NamedTuple, TYPE_CHECKING 10 | 11 | import rich.repr 12 | from rich.control import Control 13 | from rich.console import Console, ConsoleOptions, RenderResult, RenderableType 14 | from rich.segment import Segment, Segments, SegmentLines 15 | from rich.style import Style 16 | 17 | from ._loop import loop_last 18 | from ._profile import timer 19 | from ._types import Lines 20 | 21 | from .geometry import clamp, Region, Point 22 | 23 | log = logging.getLogger("rich") 24 | 25 | 26 | if TYPE_CHECKING: 27 | from .widget import Widget, WidgetID 28 | 29 | 30 | class NoWidget(Exception): 31 | pass 32 | 33 | 34 | class MapRegion(NamedTuple): 35 | region: Region 36 | order: tuple[int, int] 37 | 38 | 39 | class ReflowResult(NamedTuple): 40 | hidden: set[Widget] 41 | shown: set[Widget] 42 | resized: set[Widget] 43 | 44 | 45 | class LayoutUpdate: 46 | def __init__(self, lines: Lines, x: int, y: int) -> None: 47 | self.lines = lines 48 | self.x = x 49 | self.y = y 50 | 51 | def __rich_console__( 52 | self, console: Console, options: ConsoleOptions 53 | ) -> RenderResult: 54 | yield Control.home().segment 55 | x = self.x 56 | new_line = Segment.line() 57 | move_to = Control.move_to 58 | for last, (y, line) in loop_last(enumerate(self.lines, self.y)): 59 | yield move_to(x, y).segment 60 | yield from line 61 | if not last: 62 | yield new_line 63 | 64 | 65 | class Layout(ABC): 66 | """Responsible for arranging Widgets in a view.""" 67 | 68 | def __init__(self) -> None: 69 | self._layout_map: dict[Widget, MapRegion] = {} 70 | self.width = 0 71 | self.height = 0 72 | self.renders: dict[Widget, tuple[Region, Lines]] = {} 73 | self._cuts: list[list[int]] | None = None 74 | 75 | def reset(self) -> None: 76 | self.renders.clear() 77 | self._cuts = None 78 | 79 | def reflow(self, width: int, height: int) -> ReflowResult: 80 | self.reset() 81 | 82 | old_map = self._layout_map 83 | map = self.generate_map(width, height) 84 | 85 | old_widgets = set(self._layout_map.keys()) 86 | new_widgets = set(map.keys()) 87 | shown_widgets = new_widgets - old_widgets 88 | hidden_widgets = old_widgets - new_widgets 89 | resized_widgets = set() 90 | 91 | self._layout_map = map 92 | self.width = width 93 | self.height = height 94 | 95 | # TODO: make this more efficient 96 | new_renders: dict[Widget, tuple[Region, Lines]] = {} 97 | for widget, (region, order) in map.items(): 98 | if widget in old_widgets and widget.size != region.size: 99 | resized_widgets.add(widget) 100 | if widget in self.renders and self.renders[widget][0].size == region.size: 101 | new_renders[widget] = (region, self.renders[widget][1]) 102 | self.renders = new_renders 103 | return ReflowResult(hidden_widgets, shown_widgets, resized_widgets) 104 | 105 | @abstractmethod 106 | def generate_map( 107 | self, width: int, height: int, offset: Point = Point(0, 0) 108 | ) -> dict[Widget, MapRegion]: 109 | ... 110 | 111 | @property 112 | def map(self) -> dict[Widget, MapRegion]: 113 | return self._layout_map 114 | 115 | def __iter__(self) -> Iterable[tuple[Widget, Region]]: 116 | layers = sorted( 117 | self._layout_map.items(), key=lambda item: item[1].order, reverse=True 118 | ) 119 | for widget, (region, _) in layers: 120 | yield widget, region 121 | 122 | def get_offset(self, widget: Widget) -> Point: 123 | try: 124 | return self._layout_map[widget].region.origin 125 | except KeyError: 126 | raise NoWidget("Widget is not in layout") 127 | 128 | def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: 129 | """Get the widget under the given point or None.""" 130 | for widget, region in self: 131 | if widget.is_visual and region.contains(x, y): 132 | return widget, region 133 | raise NoWidget(f"No widget under screen coordinate ({x}, {y})") 134 | 135 | def get_style_at(self, x: int, y: int) -> Style: 136 | try: 137 | widget, region = self.get_widget_at(x, y) 138 | except NoWidget: 139 | return Style.null() 140 | _region, lines = self.renders[widget] 141 | x -= region.x 142 | y -= region.y 143 | line = lines[y] 144 | end = 0 145 | for segment in line: 146 | end += segment.cell_length 147 | if x < end: 148 | return segment.style or Style.null() 149 | return Style.null() 150 | 151 | @property 152 | def cuts(self) -> list[list[int]]: 153 | if self._cuts is not None: 154 | return self._cuts 155 | width = self.width 156 | height = self.height 157 | screen = Region(0, 0, width, height) 158 | cuts_sets = [{0, width} for _ in range(height)] 159 | 160 | for region, order in self._layout_map.values(): 161 | region = region.clip(width, height) 162 | if region and region in screen: 163 | for y in range(region.y, region.y + region.height): 164 | cuts_sets[y].update({region.x, region.x + region.width}) 165 | 166 | # Sort the cuts for each line 167 | self._cuts = [sorted(cut_set) for cut_set in cuts_sets] 168 | return self._cuts 169 | 170 | def _get_renders(self, console: Console) -> Iterable[tuple[Region, Lines]]: 171 | width = self.width 172 | height = self.height 173 | screen_region = Region(0, 0, width, height) 174 | layout_map = self._layout_map 175 | 176 | widget_regions = sorted( 177 | ((widget, region, order) for widget, (region, order) in layout_map.items()), 178 | key=itemgetter(2), 179 | reverse=True, 180 | ) 181 | 182 | def render(widget: Widget, width: int, height: int) -> Lines: 183 | lines = console.render_lines( 184 | widget, console.options.update_dimensions(width, height) 185 | ) 186 | return lines 187 | 188 | for widget, region, _order in widget_regions: 189 | 190 | if not widget.is_visual: 191 | continue 192 | region_lines = self.renders.get(widget) 193 | if region_lines is not None: 194 | yield region_lines 195 | continue 196 | 197 | lines = render(widget, region.width, region.height) 198 | if region in screen_region: 199 | self.renders[widget] = (region, lines) 200 | yield region, lines 201 | elif screen_region.overlaps(region): 202 | new_region = region.clip(width, height) 203 | delta_x = new_region.x - region.x 204 | delta_y = new_region.y - region.y 205 | region = new_region 206 | 207 | splits = [delta_x, delta_x + region.width] 208 | divide = Segment.divide 209 | lines = [ 210 | list(divide(line, splits))[1] 211 | for line in lines[delta_y : delta_y + region.height] 212 | ] 213 | self.renders[widget] = (region, lines) 214 | yield region, lines 215 | 216 | @classmethod 217 | def _assemble_chops( 218 | cls, chops: list[dict[int, list[Segment] | None]] 219 | ) -> Iterable[list[Segment]]: 220 | 221 | for bucket in chops: 222 | yield sum( 223 | (segments for _, segments in sorted(bucket.items()) if segments), 224 | start=[], 225 | ) 226 | 227 | def render( 228 | self, 229 | console: Console, 230 | clip: Region = None, 231 | ) -> SegmentLines: 232 | """Render a layout. 233 | 234 | Args: 235 | layout_map (dict[WidgetID, MapRegion]): A layout map. 236 | console (Console): Console instance. 237 | width (int): Width 238 | height (int): Height 239 | 240 | Returns: 241 | SegmentLines: A renderable 242 | """ 243 | width = self.width 244 | height = self.height 245 | screen = Region(0, 0, width, height) 246 | clip = clip or screen 247 | clip_x, clip_y, clip_x2, clip_y2 = clip.corners 248 | 249 | divide = Segment.divide 250 | back = Style.parse("on blue") 251 | 252 | # Maps each cut on to a list of segments 253 | cuts = self.cuts 254 | chops: list[dict[int, list[Segment] | None]] = [ 255 | {cut: None for cut in cut_set} for cut_set in cuts 256 | ] 257 | 258 | # TODO: Provide an option to update the background 259 | background_render = [[Segment(" " * width, back)] for _ in range(height)] 260 | # Go through all the renders in reverse order and fill buckets with no render 261 | renders = self._get_renders(console) 262 | for region, lines in chain(renders, [(screen, background_render)]): 263 | for y, line in enumerate(lines, region.y): 264 | if clip_y > y > clip_y2: 265 | continue 266 | first_cut = clamp(region.x, clip_x, clip_x2) 267 | last_cut = clamp(region.x + region.width, clip_x, clip_x2) 268 | final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)] 269 | if len(final_cuts) > 1: 270 | if final_cuts == [region.x, region.x + region.width]: 271 | cut_segments = [line] 272 | else: 273 | relative_cuts = [cut - region.x for cut in final_cuts] 274 | _, *cut_segments = divide(line, relative_cuts) 275 | for cut, segments in zip(final_cuts, cut_segments): 276 | if chops[y][cut] is None: 277 | chops[y][cut] = segments 278 | 279 | # Assemble the cut renders in to lists of segments 280 | output_lines = list(self._assemble_chops(chops[clip_y:clip_y2])) 281 | return SegmentLines(output_lines, new_lines=True) 282 | 283 | def __rich_console__( 284 | self, console: Console, options: ConsoleOptions 285 | ) -> RenderResult: 286 | yield self.render(console) 287 | 288 | def update_widget(self, console: Console, widget: Widget) -> LayoutUpdate | None: 289 | if widget not in self.renders: 290 | return None 291 | region, lines = self.renders[widget] 292 | new_lines = console.render_lines( 293 | widget, console.options.update_dimensions(region.width, region.height) 294 | ) 295 | self.renders[widget] = (region, new_lines) 296 | 297 | update_lines = self.render(console, region).lines 298 | return LayoutUpdate(update_lines, region.x, region.y) 299 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "colorama" 3 | version = "0.4.4" 4 | description = "Cross-platform colored terminal text." 5 | category = "main" 6 | optional = false 7 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 8 | 9 | [[package]] 10 | name = "commonmark" 11 | version = "0.9.1" 12 | description = "Python parser for the CommonMark Markdown spec" 13 | category = "main" 14 | optional = false 15 | python-versions = "*" 16 | 17 | [package.extras] 18 | test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] 19 | 20 | [[package]] 21 | name = "mypy" 22 | version = "0.910" 23 | description = "Optional static typing for Python" 24 | category = "dev" 25 | optional = false 26 | python-versions = ">=3.5" 27 | 28 | [package.dependencies] 29 | mypy-extensions = ">=0.4.3,<0.5.0" 30 | toml = "*" 31 | typed-ast = {version = ">=1.4.0,<1.5.0", markers = "python_version < \"3.8\""} 32 | typing-extensions = ">=3.7.4" 33 | 34 | [package.extras] 35 | dmypy = ["psutil (>=4.0)"] 36 | python2 = ["typed-ast (>=1.4.0,<1.5.0)"] 37 | 38 | [[package]] 39 | name = "mypy-extensions" 40 | version = "0.4.3" 41 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 42 | category = "dev" 43 | optional = false 44 | python-versions = "*" 45 | 46 | [[package]] 47 | name = "pygments" 48 | version = "2.9.0" 49 | description = "Pygments is a syntax highlighting package written in Python." 50 | category = "main" 51 | optional = false 52 | python-versions = ">=3.5" 53 | 54 | [[package]] 55 | name = "rich" 56 | version = "10.4.1" 57 | description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" 58 | category = "main" 59 | optional = false 60 | python-versions = "^3.6" 61 | develop = false 62 | 63 | [package.dependencies] 64 | colorama = "^0.4.0" 65 | commonmark = "^0.9.0" 66 | pygments = "^2.6.0" 67 | typing-extensions = {version = "^3.7.4", markers = "python_version < \"3.8\""} 68 | 69 | [package.extras] 70 | jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] 71 | 72 | [package.source] 73 | type = "git" 74 | url = "git@github.com:willmcgugan/rich" 75 | reference = "auto-repr-fix" 76 | resolved_reference = "3cf97f151ddf7031cfaa84a4e48f2c3867e1829f" 77 | 78 | [[package]] 79 | name = "toml" 80 | version = "0.10.2" 81 | description = "Python Library for Tom's Obvious, Minimal Language" 82 | category = "dev" 83 | optional = false 84 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 85 | 86 | [[package]] 87 | name = "typed-ast" 88 | version = "1.4.3" 89 | description = "a fork of Python 2 and 3 ast modules with type comment support" 90 | category = "dev" 91 | optional = false 92 | python-versions = "*" 93 | 94 | [[package]] 95 | name = "typing-extensions" 96 | version = "3.10.0.0" 97 | description = "Backported and Experimental Type Hints for Python 3.5+" 98 | category = "main" 99 | optional = false 100 | python-versions = "*" 101 | 102 | [metadata] 103 | lock-version = "1.1" 104 | python-versions = "^3.7" 105 | content-hash = "5066e8a466aa996d7fca7c797d7672a4c4b8a1ad13066875920dc0cdb3343989" 106 | 107 | [metadata.files] 108 | colorama = [ 109 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 110 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 111 | ] 112 | commonmark = [ 113 | {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"}, 114 | {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"}, 115 | ] 116 | mypy = [ 117 | {file = "mypy-0.910-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a155d80ea6cee511a3694b108c4494a39f42de11ee4e61e72bc424c490e46457"}, 118 | {file = "mypy-0.910-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b94e4b785e304a04ea0828759172a15add27088520dc7e49ceade7834275bedb"}, 119 | {file = "mypy-0.910-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:088cd9c7904b4ad80bec811053272986611b84221835e079be5bcad029e79dd9"}, 120 | {file = "mypy-0.910-cp35-cp35m-win_amd64.whl", hash = "sha256:adaeee09bfde366d2c13fe6093a7df5df83c9a2ba98638c7d76b010694db760e"}, 121 | {file = "mypy-0.910-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ecd2c3fe726758037234c93df7e98deb257fd15c24c9180dacf1ef829da5f921"}, 122 | {file = "mypy-0.910-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d9dd839eb0dc1bbe866a288ba3c1afc33a202015d2ad83b31e875b5905a079b6"}, 123 | {file = "mypy-0.910-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:3e382b29f8e0ccf19a2df2b29a167591245df90c0b5a2542249873b5c1d78212"}, 124 | {file = "mypy-0.910-cp36-cp36m-win_amd64.whl", hash = "sha256:53fd2eb27a8ee2892614370896956af2ff61254c275aaee4c230ae771cadd885"}, 125 | {file = "mypy-0.910-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b6fb13123aeef4a3abbcfd7e71773ff3ff1526a7d3dc538f3929a49b42be03f0"}, 126 | {file = "mypy-0.910-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e4dab234478e3bd3ce83bac4193b2ecd9cf94e720ddd95ce69840273bf44f6de"}, 127 | {file = "mypy-0.910-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:7df1ead20c81371ccd6091fa3e2878559b5c4d4caadaf1a484cf88d93ca06703"}, 128 | {file = "mypy-0.910-cp37-cp37m-win_amd64.whl", hash = "sha256:0aadfb2d3935988ec3815952e44058a3100499f5be5b28c34ac9d79f002a4a9a"}, 129 | {file = "mypy-0.910-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ec4e0cd079db280b6bdabdc807047ff3e199f334050db5cbb91ba3e959a67504"}, 130 | {file = "mypy-0.910-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:119bed3832d961f3a880787bf621634ba042cb8dc850a7429f643508eeac97b9"}, 131 | {file = "mypy-0.910-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:866c41f28cee548475f146aa4d39a51cf3b6a84246969f3759cb3e9c742fc072"}, 132 | {file = "mypy-0.910-cp38-cp38-win_amd64.whl", hash = "sha256:ceb6e0a6e27fb364fb3853389607cf7eb3a126ad335790fa1e14ed02fba50811"}, 133 | {file = "mypy-0.910-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a85e280d4d217150ce8cb1a6dddffd14e753a4e0c3cf90baabb32cefa41b59e"}, 134 | {file = "mypy-0.910-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42c266ced41b65ed40a282c575705325fa7991af370036d3f134518336636f5b"}, 135 | {file = "mypy-0.910-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:3c4b8ca36877fc75339253721f69603a9c7fdb5d4d5a95a1a1b899d8b86a4de2"}, 136 | {file = "mypy-0.910-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:c0df2d30ed496a08de5daed2a9ea807d07c21ae0ab23acf541ab88c24b26ab97"}, 137 | {file = "mypy-0.910-cp39-cp39-win_amd64.whl", hash = "sha256:c6c2602dffb74867498f86e6129fd52a2770c48b7cd3ece77ada4fa38f94eba8"}, 138 | {file = "mypy-0.910-py3-none-any.whl", hash = "sha256:ef565033fa5a958e62796867b1df10c40263ea9ded87164d67572834e57a174d"}, 139 | {file = "mypy-0.910.tar.gz", hash = "sha256:704098302473cb31a218f1775a873b376b30b4c18229421e9e9dc8916fd16150"}, 140 | ] 141 | mypy-extensions = [ 142 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 143 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 144 | ] 145 | pygments = [ 146 | {file = "Pygments-2.9.0-py3-none-any.whl", hash = "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e"}, 147 | {file = "Pygments-2.9.0.tar.gz", hash = "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f"}, 148 | ] 149 | rich = [] 150 | toml = [ 151 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 152 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 153 | ] 154 | typed-ast = [ 155 | {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"}, 156 | {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"}, 157 | {file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"}, 158 | {file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"}, 159 | {file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"}, 160 | {file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"}, 161 | {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"}, 162 | {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"}, 163 | {file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"}, 164 | {file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"}, 165 | {file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"}, 166 | {file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"}, 167 | {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"}, 168 | {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"}, 169 | {file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"}, 170 | {file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"}, 171 | {file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"}, 172 | {file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"}, 173 | {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"}, 174 | {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"}, 175 | {file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"}, 176 | {file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"}, 177 | {file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"}, 178 | {file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"}, 179 | {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"}, 180 | {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"}, 181 | {file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"}, 182 | {file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"}, 183 | {file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"}, 184 | {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"}, 185 | ] 186 | typing-extensions = [ 187 | {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, 188 | {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, 189 | {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, 190 | ] 191 | -------------------------------------------------------------------------------- /src/textual/_ansi_sequences.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Tuple, Union 2 | 3 | from .keys import Keys 4 | 5 | # Mapping of vt100 escape codes to Keys. 6 | ANSI_SEQUENCES: Dict[str, Tuple[Keys, ...]] = { 7 | # Control keys. 8 | "\x00": (Keys.ControlAt,), # Control-At (Also for Ctrl-Space) 9 | "\x01": (Keys.ControlA,), # Control-A (home) 10 | "\x02": (Keys.ControlB,), # Control-B (emacs cursor left) 11 | "\x03": (Keys.ControlC,), # Control-C (interrupt) 12 | "\x04": (Keys.ControlD,), # Control-D (exit) 13 | "\x05": (Keys.ControlE,), # Control-E (end) 14 | "\x06": (Keys.ControlF,), # Control-F (cursor forward) 15 | "\x07": (Keys.ControlG,), # Control-G 16 | "\x08": (Keys.ControlH,), # Control-H (8) (Identical to '\b') 17 | "\x09": (Keys.ControlI,), # Control-I (9) (Identical to '\t') 18 | "\x0a": (Keys.ControlJ,), # Control-J (10) (Identical to '\n') 19 | "\x0b": (Keys.ControlK,), # Control-K (delete until end of line; vertical tab) 20 | "\x0c": (Keys.ControlL,), # Control-L (clear; form feed) 21 | "\x0d": (Keys.ControlM,), # Control-M (13) (Identical to '\r') 22 | "\x0e": (Keys.ControlN,), # Control-N (14) (history forward) 23 | "\x0f": (Keys.ControlO,), # Control-O (15) 24 | "\x10": (Keys.ControlP,), # Control-P (16) (history back) 25 | "\x11": (Keys.ControlQ,), # Control-Q 26 | "\x12": (Keys.ControlR,), # Control-R (18) (reverse search) 27 | "\x13": (Keys.ControlS,), # Control-S (19) (forward search) 28 | "\x14": (Keys.ControlT,), # Control-T 29 | "\x15": (Keys.ControlU,), # Control-U 30 | "\x16": (Keys.ControlV,), # Control-V 31 | "\x17": (Keys.ControlW,), # Control-W 32 | "\x18": (Keys.ControlX,), # Control-X 33 | "\x19": (Keys.ControlY,), # Control-Y (25) 34 | "\x1a": (Keys.ControlZ,), # Control-Z 35 | "\x1b": (Keys.Escape,), # Also Control-[ 36 | "\x9b": (Keys.ShiftEscape,), 37 | "\x1c": (Keys.ControlBackslash,), # Both Control-\ (also Ctrl-| ) 38 | "\x1d": (Keys.ControlSquareClose,), # Control-] 39 | "\x1e": (Keys.ControlCircumflex,), # Control-^ 40 | "\x1f": (Keys.ControlUnderscore,), # Control-underscore (Also for Ctrl-hyphen.) 41 | # ASCII Delete (0x7f) 42 | # Vt220 (and Linux terminal) send this when pressing backspace. We map this 43 | # to ControlH, because that will make it easier to create key bindings that 44 | # work everywhere, with the trade-off that it's no longer possible to 45 | # handle backspace and control-h individually for the few terminals that 46 | # support it. (Most terminals send ControlH when backspace is pressed.) 47 | # See: http://www.ibb.net/~anne/keyboard.html 48 | "\x7f": (Keys.ControlH,), 49 | # -- 50 | # Various 51 | "\x1b[1~": (Keys.Home,), # tmux 52 | "\x1b[2~": (Keys.Insert,), 53 | "\x1b[3~": (Keys.Delete,), 54 | "\x1b[4~": (Keys.End,), # tmux 55 | "\x1b[5~": (Keys.PageUp,), 56 | "\x1b[6~": (Keys.PageDown,), 57 | "\x1b[7~": (Keys.Home,), # xrvt 58 | "\x1b[8~": (Keys.End,), # xrvt 59 | "\x1b[Z": (Keys.BackTab,), # shift + tab 60 | "\x1b\x09": (Keys.BackTab,), # Linux console 61 | "\x1b[~": (Keys.BackTab,), # Windows console 62 | # -- 63 | # Function keys. 64 | "\x1bOP": (Keys.F1,), 65 | "\x1bOQ": (Keys.F2,), 66 | "\x1bOR": (Keys.F3,), 67 | "\x1bOS": (Keys.F4,), 68 | "\x1b[[A": (Keys.F1,), # Linux console. 69 | "\x1b[[B": (Keys.F2,), # Linux console. 70 | "\x1b[[C": (Keys.F3,), # Linux console. 71 | "\x1b[[D": (Keys.F4,), # Linux console. 72 | "\x1b[[E": (Keys.F5,), # Linux console. 73 | "\x1b[11~": (Keys.F1,), # rxvt-unicode 74 | "\x1b[12~": (Keys.F2,), # rxvt-unicode 75 | "\x1b[13~": (Keys.F3,), # rxvt-unicode 76 | "\x1b[14~": (Keys.F4,), # rxvt-unicode 77 | "\x1b[15~": (Keys.F5,), 78 | "\x1b[17~": (Keys.F6,), 79 | "\x1b[18~": (Keys.F7,), 80 | "\x1b[19~": (Keys.F8,), 81 | "\x1b[20~": (Keys.F9,), 82 | "\x1b[21~": (Keys.F10,), 83 | "\x1b[23~": (Keys.F11,), 84 | "\x1b[24~": (Keys.F12,), 85 | "\x1b[25~": (Keys.F13,), 86 | "\x1b[26~": (Keys.F14,), 87 | "\x1b[28~": (Keys.F15,), 88 | "\x1b[29~": (Keys.F16,), 89 | "\x1b[31~": (Keys.F17,), 90 | "\x1b[32~": (Keys.F18,), 91 | "\x1b[33~": (Keys.F19,), 92 | "\x1b[34~": (Keys.F20,), 93 | # Xterm 94 | "\x1b[1;2P": (Keys.F13,), 95 | "\x1b[1;2Q": (Keys.F14,), 96 | # '\x1b[1;2R': Keys.F15, # Conflicts with CPR response. 97 | "\x1b[1;2S": (Keys.F16,), 98 | "\x1b[15;2~": (Keys.F17,), 99 | "\x1b[17;2~": (Keys.F18,), 100 | "\x1b[18;2~": (Keys.F19,), 101 | "\x1b[19;2~": (Keys.F20,), 102 | "\x1b[20;2~": (Keys.F21,), 103 | "\x1b[21;2~": (Keys.F22,), 104 | "\x1b[23;2~": (Keys.F23,), 105 | "\x1b[24;2~": (Keys.F24,), 106 | # -- 107 | # Control + function keys. 108 | "\x1b[1;5P": (Keys.ControlF1,), 109 | "\x1b[1;5Q": (Keys.ControlF2,), 110 | # "\x1b[1;5R": Keys.ControlF3, # Conflicts with CPR response. 111 | "\x1b[1;5S": (Keys.ControlF4,), 112 | "\x1b[15;5~": (Keys.ControlF5,), 113 | "\x1b[17;5~": (Keys.ControlF6,), 114 | "\x1b[18;5~": (Keys.ControlF7,), 115 | "\x1b[19;5~": (Keys.ControlF8,), 116 | "\x1b[20;5~": (Keys.ControlF9,), 117 | "\x1b[21;5~": (Keys.ControlF10,), 118 | "\x1b[23;5~": (Keys.ControlF11,), 119 | "\x1b[24;5~": (Keys.ControlF12,), 120 | "\x1b[1;6P": (Keys.ControlF13,), 121 | "\x1b[1;6Q": (Keys.ControlF14,), 122 | # "\x1b[1;6R": Keys.ControlF15, # Conflicts with CPR response. 123 | "\x1b[1;6S": (Keys.ControlF16,), 124 | "\x1b[15;6~": (Keys.ControlF17,), 125 | "\x1b[17;6~": (Keys.ControlF18,), 126 | "\x1b[18;6~": (Keys.ControlF19,), 127 | "\x1b[19;6~": (Keys.ControlF20,), 128 | "\x1b[20;6~": (Keys.ControlF21,), 129 | "\x1b[21;6~": (Keys.ControlF22,), 130 | "\x1b[23;6~": (Keys.ControlF23,), 131 | "\x1b[24;6~": (Keys.ControlF24,), 132 | # -- 133 | # Tmux (Win32 subsystem) sends the following scroll events. 134 | "\x1b[62~": (Keys.ScrollUp,), 135 | "\x1b[63~": (Keys.ScrollDown,), 136 | "\x1b[200~": (Keys.BracketedPaste,), # Start of bracketed paste. 137 | # -- 138 | # Sequences generated by numpad 5. Not sure what it means. (It doesn't 139 | # appear in 'infocmp'. Just ignore. 140 | "\x1b[E": (Keys.Ignore,), # Xterm. 141 | "\x1b[G": (Keys.Ignore,), # Linux console. 142 | # -- 143 | # Meta/control/escape + pageup/pagedown/insert/delete. 144 | "\x1b[3;2~": (Keys.ShiftDelete,), # xterm, gnome-terminal. 145 | "\x1b[5;2~": (Keys.ShiftPageUp,), 146 | "\x1b[6;2~": (Keys.ShiftPageDown,), 147 | "\x1b[2;3~": (Keys.Escape, Keys.Insert), 148 | "\x1b[3;3~": (Keys.Escape, Keys.Delete), 149 | "\x1b[5;3~": (Keys.Escape, Keys.PageUp), 150 | "\x1b[6;3~": (Keys.Escape, Keys.PageDown), 151 | "\x1b[2;4~": (Keys.Escape, Keys.ShiftInsert), 152 | "\x1b[3;4~": (Keys.Escape, Keys.ShiftDelete), 153 | "\x1b[5;4~": (Keys.Escape, Keys.ShiftPageUp), 154 | "\x1b[6;4~": (Keys.Escape, Keys.ShiftPageDown), 155 | "\x1b[3;5~": Keys.ControlDelete, # xterm, gnome-terminal. 156 | "\x1b[5;5~": Keys.ControlPageUp, 157 | "\x1b[6;5~": Keys.ControlPageDown, 158 | "\x1b[3;6~": Keys.ControlShiftDelete, 159 | "\x1b[5;6~": Keys.ControlShiftPageUp, 160 | "\x1b[6;6~": Keys.ControlShiftPageDown, 161 | "\x1b[2;7~": (Keys.Escape, Keys.ControlInsert), 162 | "\x1b[5;7~": (Keys.Escape, Keys.ControlPageDown), 163 | "\x1b[6;7~": (Keys.Escape, Keys.ControlPageDown), 164 | "\x1b[2;8~": (Keys.Escape, Keys.ControlShiftInsert), 165 | "\x1b[5;8~": (Keys.Escape, Keys.ControlShiftPageDown), 166 | "\x1b[6;8~": (Keys.Escape, Keys.ControlShiftPageDown), 167 | # -- 168 | # Arrows. 169 | # (Normal cursor mode). 170 | "\x1b[A": (Keys.Up,), 171 | "\x1b[B": (Keys.Down,), 172 | "\x1b[C": (Keys.Right,), 173 | "\x1b[D": (Keys.Left,), 174 | "\x1b[H": (Keys.Home,), 175 | "\x1b[F": (Keys.End,), 176 | # Tmux sends following keystrokes when control+arrow is pressed, but for 177 | # Emacs ansi-term sends the same sequences for normal arrow keys. Consider 178 | # it a normal arrow press, because that's more important. 179 | # (Application cursor mode). 180 | "\x1bOA": (Keys.Up,), 181 | "\x1bOB": (Keys.Down,), 182 | "\x1bOC": (Keys.Right,), 183 | "\x1bOD": (Keys.Left,), 184 | "\x1bOF": (Keys.End,), 185 | "\x1bOH": (Keys.Home,), 186 | # Shift + arrows. 187 | "\x1b[1;2A": (Keys.ShiftUp,), 188 | "\x1b[1;2B": (Keys.ShiftDown,), 189 | "\x1b[1;2C": (Keys.ShiftRight,), 190 | "\x1b[1;2D": (Keys.ShiftLeft,), 191 | "\x1b[1;2F": (Keys.ShiftEnd,), 192 | "\x1b[1;2H": (Keys.ShiftHome,), 193 | # Meta + arrow keys. Several terminals handle this differently. 194 | # The following sequences are for xterm and gnome-terminal. 195 | # (Iterm sends ESC followed by the normal arrow_up/down/left/right 196 | # sequences, and the OSX Terminal sends ESCb and ESCf for "alt 197 | # arrow_left" and "alt arrow_right." We don't handle these 198 | # explicitly, in here, because would could not distinguish between 199 | # pressing ESC (to go to Vi navigation mode), followed by just the 200 | # 'b' or 'f' key. These combinations are handled in 201 | # the input processor.) 202 | "\x1b[1;3A": (Keys.Escape, Keys.Up), 203 | "\x1b[1;3B": (Keys.Escape, Keys.Down), 204 | "\x1b[1;3C": (Keys.Escape, Keys.Right), 205 | "\x1b[1;3D": (Keys.Escape, Keys.Left), 206 | "\x1b[1;3F": (Keys.Escape, Keys.End), 207 | "\x1b[1;3H": (Keys.Escape, Keys.Home), 208 | # Alt+shift+number. 209 | "\x1b[1;4A": (Keys.Escape, Keys.ShiftDown), 210 | "\x1b[1;4B": (Keys.Escape, Keys.ShiftUp), 211 | "\x1b[1;4C": (Keys.Escape, Keys.ShiftRight), 212 | "\x1b[1;4D": (Keys.Escape, Keys.ShiftLeft), 213 | "\x1b[1;4F": (Keys.Escape, Keys.ShiftEnd), 214 | "\x1b[1;4H": (Keys.Escape, Keys.ShiftHome), 215 | # Control + arrows. 216 | "\x1b[1;5A": (Keys.ControlUp,), # Cursor Mode 217 | "\x1b[1;5B": (Keys.ControlDown,), # Cursor Mode 218 | "\x1b[1;5C": (Keys.ControlRight,), # Cursor Mode 219 | "\x1b[1;5D": (Keys.ControlLeft,), # Cursor Mode 220 | "\x1b[1;5F": (Keys.ControlEnd,), 221 | "\x1b[1;5H": (Keys.ControlHome,), 222 | # Tmux sends following keystrokes when control+arrow is pressed, but for 223 | # Emacs ansi-term sends the same sequences for normal arrow keys. Consider 224 | # it a normal arrow press, because that's more important. 225 | "\x1b[5A": (Keys.ControlUp,), 226 | "\x1b[5B": (Keys.ControlDown,), 227 | "\x1b[5C": (Keys.ControlRight,), 228 | "\x1b[5D": (Keys.ControlLeft,), 229 | "\x1bOc": (Keys.ControlRight,), # rxvt 230 | "\x1bOd": (Keys.ControlLeft,), # rxvt 231 | # Control + shift + arrows. 232 | "\x1b[1;6A": (Keys.ControlShiftDown,), 233 | "\x1b[1;6B": (Keys.ControlShiftUp,), 234 | "\x1b[1;6C": (Keys.ControlShiftRight,), 235 | "\x1b[1;6D": (Keys.ControlShiftLeft,), 236 | "\x1b[1;6F": (Keys.ControlShiftEnd,), 237 | "\x1b[1;6H": (Keys.ControlShiftHome,), 238 | # Control + Meta + arrows. 239 | "\x1b[1;7A": (Keys.Escape, Keys.ControlDown), 240 | "\x1b[1;7B": (Keys.Escape, Keys.ControlUp), 241 | "\x1b[1;7C": (Keys.Escape, Keys.ControlRight), 242 | "\x1b[1;7D": (Keys.Escape, Keys.ControlLeft), 243 | "\x1b[1;7F": (Keys.Escape, Keys.ControlEnd), 244 | "\x1b[1;7H": (Keys.Escape, Keys.ControlHome), 245 | # Meta + Shift + arrows. 246 | "\x1b[1;8A": (Keys.Escape, Keys.ControlShiftDown), 247 | "\x1b[1;8B": (Keys.Escape, Keys.ControlShiftUp), 248 | "\x1b[1;8C": (Keys.Escape, Keys.ControlShiftRight), 249 | "\x1b[1;8D": (Keys.Escape, Keys.ControlShiftLeft), 250 | "\x1b[1;8F": (Keys.Escape, Keys.ControlShiftEnd), 251 | "\x1b[1;8H": (Keys.Escape, Keys.ControlShiftHome), 252 | # Meta + arrow on (some?) Macs when using iTerm defaults (see issue #483). 253 | "\x1b[1;9A": (Keys.Escape, Keys.Up), 254 | "\x1b[1;9B": (Keys.Escape, Keys.Down), 255 | "\x1b[1;9C": (Keys.Escape, Keys.Right), 256 | "\x1b[1;9D": (Keys.Escape, Keys.Left), 257 | # -- 258 | # Control/shift/meta + number in mintty. 259 | # (c-2 will actually send c-@ and c-6 will send c-^.) 260 | "\x1b[1;5p": (Keys.Control0,), 261 | "\x1b[1;5q": (Keys.Control1,), 262 | "\x1b[1;5r": (Keys.Control2,), 263 | "\x1b[1;5s": (Keys.Control3,), 264 | "\x1b[1;5t": (Keys.Control4,), 265 | "\x1b[1;5u": (Keys.Control5,), 266 | "\x1b[1;5v": (Keys.Control6,), 267 | "\x1b[1;5w": (Keys.Control7,), 268 | "\x1b[1;5x": (Keys.Control8,), 269 | "\x1b[1;5y": (Keys.Control9,), 270 | "\x1b[1;6p": (Keys.ControlShift0,), 271 | "\x1b[1;6q": (Keys.ControlShift1,), 272 | "\x1b[1;6r": (Keys.ControlShift2,), 273 | "\x1b[1;6s": (Keys.ControlShift3,), 274 | "\x1b[1;6t": (Keys.ControlShift4,), 275 | "\x1b[1;6u": (Keys.ControlShift5,), 276 | "\x1b[1;6v": (Keys.ControlShift6,), 277 | "\x1b[1;6w": (Keys.ControlShift7,), 278 | "\x1b[1;6x": (Keys.ControlShift8,), 279 | "\x1b[1;6y": (Keys.ControlShift9,), 280 | "\x1b[1;7p": (Keys.Escape, Keys.Control0), 281 | "\x1b[1;7q": (Keys.Escape, Keys.Control1), 282 | "\x1b[1;7r": (Keys.Escape, Keys.Control2), 283 | "\x1b[1;7s": (Keys.Escape, Keys.Control3), 284 | "\x1b[1;7t": (Keys.Escape, Keys.Control4), 285 | "\x1b[1;7u": (Keys.Escape, Keys.Control5), 286 | "\x1b[1;7v": (Keys.Escape, Keys.Control6), 287 | "\x1b[1;7w": (Keys.Escape, Keys.Control7), 288 | "\x1b[1;7x": (Keys.Escape, Keys.Control8), 289 | "\x1b[1;7y": (Keys.Escape, Keys.Control9), 290 | "\x1b[1;8p": (Keys.Escape, Keys.ControlShift0), 291 | "\x1b[1;8q": (Keys.Escape, Keys.ControlShift1), 292 | "\x1b[1;8r": (Keys.Escape, Keys.ControlShift2), 293 | "\x1b[1;8s": (Keys.Escape, Keys.ControlShift3), 294 | "\x1b[1;8t": (Keys.Escape, Keys.ControlShift4), 295 | "\x1b[1;8u": (Keys.Escape, Keys.ControlShift5), 296 | "\x1b[1;8v": (Keys.Escape, Keys.ControlShift6), 297 | "\x1b[1;8w": (Keys.Escape, Keys.ControlShift7), 298 | "\x1b[1;8x": (Keys.Escape, Keys.ControlShift8), 299 | "\x1b[1;8y": (Keys.Escape, Keys.ControlShift9), 300 | } 301 | -------------------------------------------------------------------------------- /src/textual/app.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import os 3 | 4 | import asyncio 5 | 6 | import logging 7 | import signal 8 | from typing import Any, ClassVar, Type, TypeVar 9 | import warnings 10 | 11 | from rich.control import Control 12 | import rich.repr 13 | from rich.screen import Screen 14 | from rich import get_console 15 | from rich.console import Console, RenderableType 16 | from rich.traceback import Traceback 17 | 18 | from . import events 19 | from . import actions 20 | from ._animator import Animator 21 | from .geometry import Region 22 | from ._context import active_app 23 | from .keys import Binding 24 | from .driver import Driver 25 | from .layouts.dock import DockLayout, Dock, DockEdge, DockOptions 26 | from ._linux_driver import LinuxDriver 27 | from .message_pump import MessagePump 28 | from .message import Message 29 | from .view import DockView, View 30 | from .widget import Widget, Widget, Reactive 31 | 32 | log = logging.getLogger("rich") 33 | 34 | 35 | # asyncio will warn against resources not being cleared 36 | warnings.simplefilter("always", ResourceWarning) 37 | # https://github.com/boto/boto3/issues/454 38 | # warnings.filterwarnings( 39 | # "ignore", category=ResourceWarning, message="unclosed.*" 40 | # ) 41 | 42 | 43 | LayoutDefinition = "dict[str, Any]" 44 | 45 | ViewType = TypeVar("ViewType", bound=View) 46 | 47 | # try: 48 | # import uvloop 49 | # except ImportError: 50 | # pass 51 | # else: 52 | # uvloop.install() 53 | 54 | 55 | class ActionError(Exception): 56 | pass 57 | 58 | 59 | class ShutdownError(Exception): 60 | pass 61 | 62 | 63 | @rich.repr.auto 64 | class App(MessagePump): 65 | 66 | KEYS: ClassVar[dict[str, str]] = {} 67 | 68 | def __init__( 69 | self, 70 | console: Console = None, 71 | screen: bool = True, 72 | driver_class: Type[Driver] = None, 73 | title: str = "Megasoma Application", 74 | ): 75 | super().__init__() 76 | self.console = console or get_console() 77 | self._screen = screen 78 | self.driver_class = driver_class or LinuxDriver 79 | self.title = title 80 | self._layout = DockLayout() 81 | self._view_stack: list[View] = [View()] 82 | self.children: set[MessagePump] = set() 83 | 84 | self.focused: Widget | None = None 85 | self.mouse_over: Widget | None = None 86 | self.mouse_captured: Widget | None = None 87 | self._driver: Driver | None = None 88 | 89 | self._bindings: dict[str, Binding] = {} 90 | self._docks: list[Dock] = [] 91 | self._action_targets = {"app", "view"} 92 | self._animator = Animator(self) 93 | self.animate = self._animator.bind(self) 94 | 95 | def __rich_repr__(self) -> rich.repr.RichReprResult: 96 | yield "title", self.title 97 | 98 | def __rich__(self) -> RenderableType: 99 | return self.view 100 | 101 | @property 102 | def animator(self) -> Animator: 103 | return self._animator 104 | 105 | @property 106 | def view(self) -> View: 107 | return self._view_stack[-1] 108 | 109 | @property 110 | def bindings(self) -> dict[str, Binding]: 111 | return self._bindings 112 | 113 | async def bind(self, keys: str, action: str, description: str = "") -> None: 114 | all_keys = [key.strip() for key in keys.split(",")] 115 | for key in all_keys: 116 | self._bindings[key] = Binding(action, description) 117 | 118 | @classmethod 119 | def run( 120 | cls, console: Console = None, screen: bool = True, driver: Type[Driver] = None 121 | ): 122 | """Run the app. 123 | 124 | Args: 125 | console (Console, optional): Console object. Defaults to None. 126 | screen (bool, optional): Enable application mode. Defaults to True. 127 | driver (Type[Driver], optional): Driver class or None for default. Defaults to None. 128 | """ 129 | 130 | async def run_app() -> None: 131 | app = cls(console=console, screen=screen, driver_class=driver) 132 | 133 | await app.process_messages() 134 | 135 | asyncio.run(run_app()) 136 | 137 | async def push_view(self, view: ViewType) -> ViewType: 138 | await self.register(view) 139 | view.set_parent(self) 140 | self._view_stack[0] = view 141 | return view 142 | 143 | def on_keyboard_interupt(self) -> None: 144 | loop = asyncio.get_event_loop() 145 | event = events.ShutdownRequest(sender=self) 146 | asyncio.run_coroutine_threadsafe(self.post_message(event), loop=loop) 147 | 148 | async def set_focus(self, widget: Widget | None) -> None: 149 | log.debug("set_focus %r", widget) 150 | if widget == self.focused: 151 | return 152 | 153 | if widget is None: 154 | if self.focused is not None: 155 | focused = self.focused 156 | self.focused = None 157 | await focused.post_message(events.Blur(self)) 158 | elif widget.can_focus: 159 | if self.focused is not None: 160 | await self.focused.post_message(events.Blur(self)) 161 | if widget is not None and self.focused != widget: 162 | self.focused = widget 163 | await widget.post_message(events.Focus(self)) 164 | 165 | async def set_mouse_over(self, widget: Widget | None) -> None: 166 | if widget is None: 167 | if self.mouse_over is not None: 168 | try: 169 | await self.mouse_over.post_message(events.Leave(self)) 170 | finally: 171 | self.mouse_over = None 172 | else: 173 | if self.mouse_over != widget: 174 | try: 175 | if self.mouse_over is not None: 176 | await self.mouse_over.forward_event(events.Leave(self)) 177 | if widget is not None: 178 | await widget.forward_event(events.Enter(self)) 179 | finally: 180 | self.mouse_over = widget 181 | 182 | async def capture_mouse(self, widget: Widget | None) -> None: 183 | """Send all Mouse events to a given widget.""" 184 | self.mouse_captured = widget 185 | 186 | async def process_messages(self) -> None: 187 | log.debug("driver=%r", self.driver_class) 188 | # loop = asyncio.get_event_loop() 189 | # loop.add_signal_handler(signal.SIGINT, self.on_keyboard_interupt) 190 | driver = self._driver = self.driver_class(self.console, self) 191 | active_app.set(self) 192 | self.view.set_parent(self) 193 | await self.register(self.view) 194 | 195 | if hasattr(self, "on_load"): 196 | await self.on_load(events.Load(sender=self)) 197 | 198 | await self.post_message(events.Startup(sender=self)) 199 | 200 | try: 201 | driver.start_application_mode() 202 | except Exception: 203 | self.console.print_exception() 204 | log.exception("error starting application mode") 205 | else: 206 | traceback: Traceback | None = None 207 | await self.animator.start() 208 | try: 209 | await super().process_messages() 210 | except Exception: 211 | traceback = Traceback(show_locals=True) 212 | 213 | await self.animator.stop() 214 | await self.view.close_messages() 215 | driver.stop_application_mode() 216 | if traceback is not None: 217 | self.console.print(traceback) 218 | 219 | def require_repaint(self) -> None: 220 | self.refresh() 221 | 222 | def require_layout(self) -> None: 223 | self.view.require_layout() 224 | 225 | async def message_update(self, message: Message) -> None: 226 | self.refresh() 227 | 228 | async def register(self, child: MessagePump) -> None: 229 | self.children.add(child) 230 | child.start_messages() 231 | await child.post_message(events.Created(sender=self)) 232 | 233 | async def remove(self, child: MessagePump) -> None: 234 | self.children.remove(child) 235 | 236 | async def shutdown(self): 237 | driver = self._driver 238 | driver.disable_input() 239 | await self.close_messages() 240 | 241 | def refresh(self) -> None: 242 | sync_available = os.environ.get("TERM_PROGRAM", "") != "Apple_Terminal" 243 | if not self._closed: 244 | console = self.console 245 | try: 246 | if sync_available: 247 | console.file.write("\x1bP=1s\x1b\\") 248 | with console: 249 | console.print(Screen(Control.home(), self.view, Control.home())) 250 | if sync_available: 251 | console.file.write("\x1bP=2s\x1b\\") 252 | except Exception: 253 | log.exception("refresh failed") 254 | 255 | def display(self, renderable: RenderableType) -> None: 256 | if not self._closed: 257 | console = self.console 258 | try: 259 | with console: 260 | console.print(renderable) 261 | except Exception: 262 | log.exception("display failed") 263 | 264 | def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: 265 | return self.view.get_widget_at(x, y) 266 | 267 | async def on_event(self, event: events.Event) -> None: 268 | if isinstance(event, events.Key): 269 | binding = self._bindings.get(event.key, None) 270 | if binding is not None: 271 | await self.action(binding.action) 272 | return 273 | 274 | if isinstance(event, events.InputEvent): 275 | if isinstance(event, events.Key) and self.focused is not None: 276 | await self.focused.forward_event(event) 277 | await self.view.forward_event(event) 278 | else: 279 | await super().on_event(event) 280 | 281 | async def action( 282 | self, action: str, default_namespace: object | None = None 283 | ) -> None: 284 | """Perform an action. 285 | 286 | Args: 287 | action (str): Action encoded in a string. 288 | """ 289 | target, params = actions.parse(action) 290 | if "." in target: 291 | destination, action_name = target.split(".", 1) 292 | if destination not in self._action_targets: 293 | raise ActionError("Action namespace {destination} is not known") 294 | action_target = getattr(self, destination) 295 | else: 296 | action_target = default_namespace or self 297 | action_name = action 298 | 299 | log.debug("ACTION %r %r", action_target, action_name) 300 | await self.dispatch_action(action_target, action_name, params) 301 | 302 | async def dispatch_action( 303 | self, namespace: object, action_name: str, params: Any 304 | ) -> None: 305 | method_name = f"action_{action_name}" 306 | method = getattr(namespace, method_name, None) 307 | if method is not None: 308 | await method(*params) 309 | 310 | async def on_load(self, event: events.Load) -> None: 311 | pass 312 | 313 | async def on_shutdown_request(self, event: events.ShutdownRequest) -> None: 314 | log.debug("shutdown request") 315 | await self.close_messages() 316 | 317 | async def on_resize(self, event: events.Resize) -> None: 318 | await self.view.post_message(event) 319 | 320 | async def on_mouse_move(self, event: events.MouseMove) -> None: 321 | await self.view.post_message(event) 322 | 323 | async def on_mouse_down(self, event: events.MouseDown) -> None: 324 | await self.view.post_message(event) 325 | 326 | async def on_mouse_up(self, event: events.MouseUp) -> None: 327 | await self.view.post_message(event) 328 | 329 | async def on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None: 330 | await self.view.post_message(event) 331 | 332 | async def on_mouse_scroll_down(self, event: events.MouseScrollUp) -> None: 333 | await self.view.post_message(event) 334 | 335 | async def action_quit(self) -> None: 336 | await self.shutdown() 337 | 338 | async def action_bang(self) -> None: 339 | 1 / 0 340 | 341 | async def action_bell(self) -> None: 342 | self.console.bell() 343 | 344 | 345 | if __name__ == "__main__": 346 | import asyncio 347 | from logging import FileHandler 348 | 349 | from rich.panel import Panel 350 | 351 | from .widgets import Header 352 | from .widgets import Footer 353 | 354 | from .widgets import Placeholder 355 | from .scrollbar import ScrollBar 356 | 357 | from rich.markdown import Markdown 358 | 359 | # from .widgets.scroll_view import ScrollView 360 | 361 | import os 362 | 363 | # from rich.console import Console 364 | 365 | # console = Console() 366 | # console.print(scroll_bar, height=10) 367 | # console.print(scroll_view, height=20) 368 | 369 | # import sys 370 | 371 | # sys.exit() 372 | 373 | logging.basicConfig( 374 | level="NOTSET", 375 | format="%(message)s", 376 | datefmt="[%X]", 377 | handlers=[FileHandler("richtui.log")], 378 | ) 379 | 380 | class MyApp(App): 381 | """Just a test app.""" 382 | 383 | async def on_load(self, event: events.Load) -> None: 384 | await self.bind("q,ctrl+c", "quit") 385 | await self.bind("x", "bang") 386 | await self.bind("b", "toggle_sidebar") 387 | 388 | show_bar: Reactive[bool] = Reactive(False) 389 | 390 | def watch_show_bar(self, show_bar: bool) -> None: 391 | self.animator.animate(self.bar, "layout_offset_x", -40 if show_bar else 0) 392 | 393 | async def action_toggle_sidebar(self) -> None: 394 | self.show_bar = not self.show_bar 395 | 396 | async def on_startup(self, event: events.Startup) -> None: 397 | 398 | view = await self.push_view(DockView()) 399 | 400 | header = Header(self.title) 401 | footer = Footer() 402 | self.bar = Placeholder(name="left") 403 | footer.add_key("b", "Toggle sidebar") 404 | footer.add_key("q", "Quit") 405 | 406 | await view.dock(header, edge="top") 407 | await view.dock(footer, edge="bottom") 408 | await view.dock(self.bar, edge="left", size=40, z=1) 409 | 410 | # await view.dock(Placeholder(), Placeholder(), edge="top") 411 | 412 | sub_view = DockView() 413 | await sub_view.dock(Placeholder(), Placeholder(), edge="top") 414 | await view.dock(sub_view, edge="left") 415 | 416 | # self.refresh() 417 | 418 | # footer = Footer() 419 | # footer.add_key("b", "Toggle sidebar") 420 | # footer.add_key("q", "Quit") 421 | 422 | # readme_path = os.path.join( 423 | # os.path.dirname(os.path.abspath(__file__)), "richreadme.md" 424 | # ) 425 | # # scroll_view = LayoutView() 426 | # # scroll_bar = ScrollBar() 427 | # with open(readme_path, "rt") as fh: 428 | # readme = Markdown(fh.read(), hyperlinks=True, code_theme="fruity") 429 | # # scroll_view.layout.split_column( 430 | # # Layout(readme, ratio=1), Layout(scroll_bar, ratio=2, size=2) 431 | # # ) 432 | # layout = Layout() 433 | # layout.split_column(Layout(name="l1"), Layout(name="l2")) 434 | # # sub_view = LayoutView(name="Sub view", layout=layout) 435 | 436 | # sub_view = ScrollView(readme) 437 | 438 | # # await sub_view.mount_all(l1=Placeholder(), l2=Placeholder()) 439 | 440 | # await self.view.mount_all( 441 | # header=Header(self.title), 442 | # left=Placeholder(), 443 | # body=sub_view, 444 | # footer=footer, 445 | # ) 446 | 447 | # app = MyApp() 448 | # from rich.console import Console 449 | 450 | # console = Console() 451 | # console.print(app._view_stack[0], height=30) 452 | # console.print(app._view_stack) 453 | MyApp.run() 454 | -------------------------------------------------------------------------------- /examples/richreadme.md: -------------------------------------------------------------------------------- 1 | [![Downloads](https://pepy.tech/badge/rich/month)](https://pepy.tech/project/rich) 2 | [![PyPI version](https://badge.fury.io/py/rich.svg)](https://badge.fury.io/py/rich) 3 | [![codecov](https://codecov.io/gh/willmcgugan/rich/branch/master/graph/badge.svg)](https://codecov.io/gh/willmcgugan/rich) 4 | [![Rich blog](https://img.shields.io/badge/blog-rich%20news-yellowgreen)](https://www.willmcgugan.com/tag/rich/) 5 | [![Twitter Follow](https://img.shields.io/twitter/follow/willmcgugan.svg?style=social)](https://twitter.com/willmcgugan) 6 | 7 | ![Logo](https://github.com/willmcgugan/rich/raw/master/imgs/logo.svg) 8 | 9 | [中文 readme](https://github.com/willmcgugan/rich/blob/master/README.cn.md) • [Lengua española readme](https://github.com/willmcgugan/rich/blob/master/README.es.md) • [Deutsche readme](https://github.com/willmcgugan/rich/blob/master/README.de.md) • [Läs på svenska](https://github.com/willmcgugan/rich/blob/master/README.sv.md) • [日本語 readme](https://github.com/willmcgugan/rich/blob/master/README.ja.md) • [한국어 readme](https://github.com/willmcgugan/rich/blob/master/README.kr.md) 10 | 11 | Rich is a Python library for _rich_ text and beautiful formatting in the terminal. 12 | 13 | The [Rich API](https://rich.readthedocs.io/en/latest/) makes it easy to add color and style to terminal output. Rich can also render pretty tables, progress bars, markdown, syntax highlighted source code, tracebacks, and more — out of the box. 14 | 15 | ![Features](https://github.com/willmcgugan/rich/raw/master/imgs/features.png) 16 | 17 | For a video introduction to Rich see [calmcode.io](https://calmcode.io/rich/introduction.html) by [@fishnets88](https://twitter.com/fishnets88). 18 | 19 | See what [people are saying about Rich](https://www.willmcgugan.com/blog/pages/post/rich-tweets/). 20 | 21 | ## Compatibility 22 | 23 | Rich works with Linux, OSX, and Windows. True color / emoji works with new Windows Terminal, classic terminal is limited to 16 colors. Rich requires Python 3.6.1 or later. 24 | 25 | Rich works with [Jupyter notebooks](https://jupyter.org/) with no additional configuration required. 26 | 27 | ## Installing 28 | 29 | Install with `pip` or your favorite PyPi package manager. 30 | 31 | ``` 32 | pip install rich 33 | ``` 34 | 35 | Run the following to test Rich output on your terminal: 36 | 37 | ``` 38 | python -m rich 39 | ``` 40 | 41 | ## Rich Print 42 | 43 | To effortlessly add rich output to your application, you can import the [rich print](https://rich.readthedocs.io/en/latest/introduction.html#quick-start) method, which has the same signature as the builtin Python function. Try this: 44 | 45 | ```python 46 | from rich import print 47 | 48 | print("Hello, [bold magenta]World[/bold magenta]!", ":vampire:", locals()) 49 | ``` 50 | 51 | ![Hello World](https://github.com/willmcgugan/rich/raw/master/imgs/print.png) 52 | 53 | ## Rich REPL 54 | 55 | Rich can be installed in the Python REPL, so that any data structures will be pretty printed and highlighted. 56 | 57 | ```python 58 | >>> from rich import pretty 59 | >>> pretty.install() 60 | ``` 61 | 62 | ![REPL](https://github.com/willmcgugan/rich/raw/master/imgs/repl.png) 63 | 64 | ## Using the Console 65 | 66 | For more control over rich terminal content, import and construct a [Console](https://rich.readthedocs.io/en/latest/reference/console.html#rich.console.Console) object. 67 | 68 | ```python 69 | from rich.console import Console 70 | 71 | console = Console() 72 | ``` 73 | 74 | The Console object has a `print` method which has an intentionally similar interface to the builtin `print` function. Here's an example of use: 75 | 76 | ```python 77 | console.print("Hello", "World!") 78 | ``` 79 | 80 | As you might expect, this will print `"Hello World!"` to the terminal. Note that unlike the builtin `print` function, Rich will word-wrap your text to fit within the terminal width. 81 | 82 | There are a few ways of adding color and style to your output. You can set a style for the entire output by adding a `style` keyword argument. Here's an example: 83 | 84 | ```python 85 | console.print("Hello", "World!", style="bold red") 86 | ``` 87 | 88 | The output will be something like the following: 89 | 90 | ![Hello World](https://github.com/willmcgugan/rich/raw/master/imgs/hello_world.png) 91 | 92 | That's fine for styling a line of text at a time. For more finely grained styling, Rich renders a special markup which is similar in syntax to [bbcode](https://en.wikipedia.org/wiki/BBCode). Here's an example: 93 | 94 | ```python 95 | console.print("Where there is a [bold cyan]Will[/bold cyan] there [u]is[/u] a [i]way[/i].") 96 | ``` 97 | 98 | ![Console Markup](https://github.com/willmcgugan/rich/raw/master/imgs/where_there_is_a_will.png) 99 | 100 | You can use a Console object to generate sophisticated output with minimal effort. See the [Console API](https://rich.readthedocs.io/en/latest/console.html) docs for details. 101 | 102 | ## Rich Inspect 103 | 104 | Rich has an [inspect](https://rich.readthedocs.io/en/latest/reference/init.html?highlight=inspect#rich.inspect) function which can produce a report on any Python object, such as class, instance, or builtin. 105 | 106 | ```python 107 | >>> my_list = ["foo", "bar"] 108 | >>> from rich import inspect 109 | >>> inspect(my_list, methods=True) 110 | ``` 111 | 112 | ![Log](https://github.com/willmcgugan/rich/raw/master/imgs/inspect.png) 113 | 114 | See the [inspect docs](https://rich.readthedocs.io/en/latest/reference/init.html#rich.inspect) for details. 115 | 116 | # Rich Library 117 | 118 | Rich contains a number of builtin _renderables_ you can use to create elegant output in your CLI and help you debug your code. 119 | 120 | Click the following headings for details: 121 | 122 |
123 | Log 124 | 125 | The Console object has a `log()` method which has a similar interface to `print()`, but also renders a column for the current time and the file and line which made the call. By default Rich will do syntax highlighting for Python structures and for repr strings. If you log a collection (i.e. a dict or a list) Rich will pretty print it so that it fits in the available space. Here's an example of some of these features. 126 | 127 | ```python 128 | from rich.console import Console 129 | console = Console() 130 | 131 | test_data = [ 132 | {"jsonrpc": "2.0", "method": "sum", "params": [None, 1, 2, 4, False, True], "id": "1",}, 133 | {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]}, 134 | {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": "2"}, 135 | ] 136 | 137 | def test_log(): 138 | enabled = False 139 | context = { 140 | "foo": "bar", 141 | } 142 | movies = ["Deadpool", "Rise of the Skywalker"] 143 | console.log("Hello from", console, "!") 144 | console.log(test_data, log_locals=True) 145 | 146 | 147 | test_log() 148 | ``` 149 | 150 | The above produces the following output: 151 | 152 | ![Log](https://github.com/willmcgugan/rich/raw/master/imgs/log.png) 153 | 154 | Note the `log_locals` argument, which outputs a table containing the local variables where the log method was called. 155 | 156 | The log method could be used for logging to the terminal for long running applications such as servers, but is also a very nice debugging aid. 157 | 158 |
159 |
160 | Logging Handler 161 | 162 | You can also use the builtin [Handler class](https://rich.readthedocs.io/en/latest/logging.html) to format and colorize output from Python's logging module. Here's an example of the output: 163 | 164 | ![Logging](https://github.com/willmcgugan/rich/raw/master/imgs/logging.png) 165 | 166 |
167 | 168 |
169 | Emoji 170 | 171 | To insert an emoji in to console output place the name between two colons. Here's an example: 172 | 173 | ```python 174 | >>> console.print(":smiley: :vampire: :pile_of_poo: :thumbs_up: :raccoon:") 175 | 😃 🧛 💩 👍 🦝 176 | ``` 177 | 178 | Please use this feature wisely. 179 | 180 |
181 | 182 |
183 | Tables 184 | 185 | Rich can render flexible [tables](https://rich.readthedocs.io/en/latest/tables.html) with unicode box characters. There is a large variety of formatting options for borders, styles, cell alignment etc. 186 | 187 | ![table movie](https://github.com/willmcgugan/rich/raw/master/imgs/table_movie.gif) 188 | 189 | The animation above was generated with [table_movie.py](https://github.com/willmcgugan/rich/blob/master/examples/table_movie.py) in the examples directory. 190 | 191 | Here's a simpler table example: 192 | 193 | ```python 194 | from rich.console import Console 195 | from rich.table import Table 196 | 197 | console = Console() 198 | 199 | table = Table(show_header=True, header_style="bold magenta") 200 | table.add_column("Date", style="dim", width=12) 201 | table.add_column("Title") 202 | table.add_column("Production Budget", justify="right") 203 | table.add_column("Box Office", justify="right") 204 | table.add_row( 205 | "Dev 20, 2019", "Star Wars: The Rise of Skywalker", "$275,000,000", "$375,126,118" 206 | ) 207 | table.add_row( 208 | "May 25, 2018", 209 | "[red]Solo[/red]: A Star Wars Story", 210 | "$275,000,000", 211 | "$393,151,347", 212 | ) 213 | table.add_row( 214 | "Dec 15, 2017", 215 | "Star Wars Ep. VIII: The Last Jedi", 216 | "$262,000,000", 217 | "[bold]$1,332,539,889[/bold]", 218 | ) 219 | 220 | console.print(table) 221 | ``` 222 | 223 | This produces the following output: 224 | 225 | ![table](https://github.com/willmcgugan/rich/raw/master/imgs/table.png) 226 | 227 | Note that console markup is rendered in the same way as `print()` and `log()`. In fact, anything that is renderable by Rich may be included in the headers / rows (even other tables). 228 | 229 | The `Table` class is smart enough to resize columns to fit the available width of the terminal, wrapping text as required. Here's the same example, with the terminal made smaller than the table above: 230 | 231 | ![table2](https://github.com/willmcgugan/rich/raw/master/imgs/table2.png) 232 | 233 |
234 | 235 |
236 | Progress Bars 237 | 238 | Rich can render multiple flicker-free [progress](https://rich.readthedocs.io/en/latest/progress.html) bars to track long-running tasks. 239 | 240 | For basic usage, wrap any sequence in the `track` function and iterate over the result. Here's an example: 241 | 242 | ```python 243 | from rich.progress import track 244 | 245 | for step in track(range(100)): 246 | do_step(step) 247 | ``` 248 | 249 | It's not much harder to add multiple progress bars. Here's an example taken from the docs: 250 | 251 | ![progress](https://github.com/willmcgugan/rich/raw/master/imgs/progress.gif) 252 | 253 | The columns may be configured to show any details you want. Built-in columns include percentage complete, file size, file speed, and time remaining. Here's another example showing a download in progress: 254 | 255 | ![progress](https://github.com/willmcgugan/rich/raw/master/imgs/downloader.gif) 256 | 257 | To try this out yourself, see [examples/downloader.py](https://github.com/willmcgugan/rich/blob/master/examples/downloader.py) which can download multiple URLs simultaneously while displaying progress. 258 | 259 |
260 | 261 |
262 | Status 263 | 264 | For situations where it is hard to calculate progress, you can use the [status](https://rich.readthedocs.io/en/latest/reference/console.html#rich.console.Console.status) method which will display a 'spinner' animation and message. The animation won't prevent you from using the console as normal. Here's an example: 265 | 266 | ```python 267 | from time import sleep 268 | from rich.console import Console 269 | 270 | console = Console() 271 | tasks = [f"task {n}" for n in range(1, 11)] 272 | 273 | with console.status("[bold green]Working on tasks...") as status: 274 | while tasks: 275 | task = tasks.pop(0) 276 | sleep(1) 277 | console.log(f"{task} complete") 278 | ``` 279 | 280 | This generates the following output in the terminal. 281 | 282 | ![status](https://github.com/willmcgugan/rich/raw/master/imgs/status.gif) 283 | 284 | The spinner animations were borrowed from [cli-spinners](https://www.npmjs.com/package/cli-spinners). You can select a spinner by specifying the `spinner` parameter. Run the following command to see the available values: 285 | 286 | ``` 287 | python -m rich.spinner 288 | ``` 289 | 290 | The above command generate the following output in the terminal: 291 | 292 | ![spinners](https://github.com/willmcgugan/rich/raw/master/imgs/spinners.gif) 293 | 294 |
295 | 296 |
297 | Tree 298 | 299 | Rich can render a [tree](https://rich.readthedocs.io/en/latest/tree.html) with guide lines. A tree is ideal for displaying a file structure, or any other hierarchical data. 300 | 301 | The labels of the tree can be simple text or anything else Rich can render. Run the following for a demonstration: 302 | 303 | ``` 304 | python -m rich.tree 305 | ``` 306 | 307 | This generates the following output: 308 | 309 | ![markdown](https://github.com/willmcgugan/rich/raw/master/imgs/tree.png) 310 | 311 | See the [tree.py](https://github.com/willmcgugan/rich/blob/master/examples/tree.py) example for a script that displays a tree view of any directory, similar to the linux `tree` command. 312 | 313 |
314 | 315 |
316 | Columns 317 | 318 | Rich can render content in neat [columns](https://rich.readthedocs.io/en/latest/columns.html) with equal or optimal width. Here's a very basic clone of the (MacOS / Linux) `ls` command which displays a directory listing in columns: 319 | 320 | ```python 321 | import os 322 | import sys 323 | 324 | from rich import print 325 | from rich.columns import Columns 326 | 327 | directory = os.listdir(sys.argv[1]) 328 | print(Columns(directory)) 329 | ``` 330 | 331 | The following screenshot is the output from the [columns example](https://github.com/willmcgugan/rich/blob/master/examples/columns.py) which displays data pulled from an API in columns: 332 | 333 | ![columns](https://github.com/willmcgugan/rich/raw/master/imgs/columns.png) 334 | 335 |
336 | 337 |
338 | Markdown 339 | 340 | Rich can render [markdown](https://rich.readthedocs.io/en/latest/markdown.html) and does a reasonable job of translating the formatting to the terminal. 341 | 342 | To render markdown import the `Markdown` class and construct it with a string containing markdown code. Then print it to the console. Here's an example: 343 | 344 | ```python 345 | from rich.console import Console 346 | from rich.markdown import Markdown 347 | 348 | console = Console() 349 | with open("README.md") as readme: 350 | markdown = Markdown(readme.read()) 351 | console.print(markdown) 352 | ``` 353 | 354 | This will produce output something like the following: 355 | 356 | ![markdown](https://github.com/willmcgugan/rich/raw/master/imgs/markdown.png) 357 | 358 |
359 | 360 |
361 | Syntax Highlighting 362 | 363 | Rich uses the [pygments](https://pygments.org/) library to implement [syntax highlighting](https://rich.readthedocs.io/en/latest/syntax.html). Usage is similar to rendering markdown; construct a `Syntax` object and print it to the console. Here's an example: 364 | 365 | ```python 366 | from rich.console import Console 367 | from rich.syntax import Syntax 368 | 369 | my_code = ''' 370 | def iter_first_last(values: Iterable[T]) -> Iterable[Tuple[bool, bool, T]]: 371 | """Iterate and generate a tuple with a flag for first and last value.""" 372 | iter_values = iter(values) 373 | try: 374 | previous_value = next(iter_values) 375 | except StopIteration: 376 | return 377 | first = True 378 | for value in iter_values: 379 | yield first, False, previous_value 380 | first = False 381 | previous_value = value 382 | yield first, True, previous_value 383 | ''' 384 | syntax = Syntax(my_code, "python", theme="monokai", line_numbers=True) 385 | console = Console() 386 | console.print(syntax) 387 | ``` 388 | 389 | This will produce the following output: 390 | 391 | ![syntax](https://github.com/willmcgugan/rich/raw/master/imgs/syntax.png) 392 | 393 |
394 | 395 |
396 | Tracebacks 397 | 398 | Rich can render [beautiful tracebacks](https://rich.readthedocs.io/en/latest/traceback.html) which are easier to read and show more code than standard Python tracebacks. You can set Rich as the default traceback handler so all uncaught exceptions will be rendered by Rich. 399 | 400 | Here's what it looks like on OSX (similar on Linux): 401 | 402 | ![traceback](https://github.com/willmcgugan/rich/raw/master/imgs/traceback.png) 403 | 404 |
405 | 406 | All Rich renderables make use of the [Console Protocol](https://rich.readthedocs.io/en/latest/protocol.html), which you can also use to implement your own Rich content. 407 | 408 | # Rich for enterprise 409 | 410 | Available as part of the Tidelift Subscription. 411 | 412 | The maintainers of Rich and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source packages you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact packages you use. [Learn more.](https://tidelift.com/subscription/pkg/pypi-rich?utm_source=pypi-rich&utm_medium=referral&utm_campaign=enterprise&utm_term=repo) 413 | 414 | # Project using Rich 415 | 416 | Here are a few projects using Rich: 417 | 418 | - [BrancoLab/BrainRender](https://github.com/BrancoLab/BrainRender) 419 | a python package for the visualization of three dimensional neuro-anatomical data 420 | - [Ciphey/Ciphey](https://github.com/Ciphey/Ciphey) 421 | Automated decryption tool 422 | - [emeryberger/scalene](https://github.com/emeryberger/scalene) 423 | a high-performance, high-precision CPU and memory profiler for Python 424 | - [hedythedev/StarCli](https://github.com/hedythedev/starcli) 425 | Browse GitHub trending projects from your command line 426 | - [intel/cve-bin-tool](https://github.com/intel/cve-bin-tool) 427 | This tool scans for a number of common, vulnerable components (openssl, libpng, libxml2, expat and a few others) to let you know if your system includes common libraries with known vulnerabilities. 428 | - [nf-core/tools](https://github.com/nf-core/tools) 429 | Python package with helper tools for the nf-core community. 430 | - [cansarigol/pdbr](https://github.com/cansarigol/pdbr) 431 | pdb + Rich library for enhanced debugging 432 | - [plant99/felicette](https://github.com/plant99/felicette) 433 | Satellite imagery for dummies. 434 | - [seleniumbase/SeleniumBase](https://github.com/seleniumbase/SeleniumBase) 435 | Automate & test 10x faster with Selenium & pytest. Batteries included. 436 | - [smacke/ffsubsync](https://github.com/smacke/ffsubsync) 437 | Automagically synchronize subtitles with video. 438 | - [tryolabs/norfair](https://github.com/tryolabs/norfair) 439 | Lightweight Python library for adding real-time 2D object tracking to any detector. 440 | - [ansible/ansible-lint](https://github.com/ansible/ansible-lint) Ansible-lint checks playbooks for practices and behaviour that could potentially be improved 441 | - [ansible-community/molecule](https://github.com/ansible-community/molecule) Ansible Molecule testing framework 442 | - +[Many more](https://github.com/willmcgugan/rich/network/dependents)! 443 | 444 | 445 | -------------------------------------------------------------------------------- /src/textual/richreadme.md: -------------------------------------------------------------------------------- 1 | [![Downloads](https://pepy.tech/badge/rich/month)](https://pepy.tech/project/rich) 2 | [![PyPI version](https://badge.fury.io/py/rich.svg)](https://badge.fury.io/py/rich) 3 | [![codecov](https://codecov.io/gh/willmcgugan/rich/branch/master/graph/badge.svg)](https://codecov.io/gh/willmcgugan/rich) 4 | [![Rich blog](https://img.shields.io/badge/blog-rich%20news-yellowgreen)](https://www.willmcgugan.com/tag/rich/) 5 | [![Twitter Follow](https://img.shields.io/twitter/follow/willmcgugan.svg?style=social)](https://twitter.com/willmcgugan) 6 | 7 | ![Logo](https://github.com/willmcgugan/rich/raw/master/imgs/logo.svg) 8 | 9 | [中文 readme](https://github.com/willmcgugan/rich/blob/master/README.cn.md) • [Lengua española readme](https://github.com/willmcgugan/rich/blob/master/README.es.md) • [Deutsche readme](https://github.com/willmcgugan/rich/blob/master/README.de.md) • [Läs på svenska](https://github.com/willmcgugan/rich/blob/master/README.sv.md) • [日本語 readme](https://github.com/willmcgugan/rich/blob/master/README.ja.md) • [한국어 readme](https://github.com/willmcgugan/rich/blob/master/README.kr.md) 10 | 11 | Rich is a Python library for _rich_ text and beautiful formatting in the terminal. 12 | 13 | The [Rich API](https://rich.readthedocs.io/en/latest/) makes it easy to add color and style to terminal output. Rich can also render pretty tables, progress bars, markdown, syntax highlighted source code, tracebacks, and more — out of the box. 14 | 15 | ![Features](https://github.com/willmcgugan/rich/raw/master/imgs/features.png) 16 | 17 | For a video introduction to Rich see [calmcode.io](https://calmcode.io/rich/introduction.html) by [@fishnets88](https://twitter.com/fishnets88). 18 | 19 | See what [people are saying about Rich](https://www.willmcgugan.com/blog/pages/post/rich-tweets/). 20 | 21 | ## Compatibility 22 | 23 | Rich works with Linux, OSX, and Windows. True color / emoji works with new Windows Terminal, classic terminal is limited to 16 colors. Rich requires Python 3.6.1 or later. 24 | 25 | Rich works with [Jupyter notebooks](https://jupyter.org/) with no additional configuration required. 26 | 27 | ## Installing 28 | 29 | Install with `pip` or your favorite PyPi package manager. 30 | 31 | ``` 32 | pip install rich 33 | ``` 34 | 35 | Run the following to test Rich output on your terminal: 36 | 37 | ``` 38 | python -m rich 39 | ``` 40 | 41 | ## Rich Print 42 | 43 | To effortlessly add rich output to your application, you can import the [rich print](https://rich.readthedocs.io/en/latest/introduction.html#quick-start) method, which has the same signature as the builtin Python function. Try this: 44 | 45 | ```python 46 | from rich import print 47 | 48 | print("Hello, [bold magenta]World[/bold magenta]!", ":vampire:", locals()) 49 | ``` 50 | 51 | ![Hello World](https://github.com/willmcgugan/rich/raw/master/imgs/print.png) 52 | 53 | ## Rich REPL 54 | 55 | Rich can be installed in the Python REPL, so that any data structures will be pretty printed and highlighted. 56 | 57 | ```python 58 | >>> from rich import pretty 59 | >>> pretty.install() 60 | ``` 61 | 62 | ![REPL](https://github.com/willmcgugan/rich/raw/master/imgs/repl.png) 63 | 64 | ## Using the Console 65 | 66 | For more control over rich terminal content, import and construct a [Console](https://rich.readthedocs.io/en/latest/reference/console.html#rich.console.Console) object. 67 | 68 | ```python 69 | from rich.console import Console 70 | 71 | console = Console() 72 | ``` 73 | 74 | The Console object has a `print` method which has an intentionally similar interface to the builtin `print` function. Here's an example of use: 75 | 76 | ```python 77 | console.print("Hello", "World!") 78 | ``` 79 | 80 | As you might expect, this will print `"Hello World!"` to the terminal. Note that unlike the builtin `print` function, Rich will word-wrap your text to fit within the terminal width. 81 | 82 | There are a few ways of adding color and style to your output. You can set a style for the entire output by adding a `style` keyword argument. Here's an example: 83 | 84 | ```python 85 | console.print("Hello", "World!", style="bold red") 86 | ``` 87 | 88 | The output will be something like the following: 89 | 90 | ![Hello World](https://github.com/willmcgugan/rich/raw/master/imgs/hello_world.png) 91 | 92 | That's fine for styling a line of text at a time. For more finely grained styling, Rich renders a special markup which is similar in syntax to [bbcode](https://en.wikipedia.org/wiki/BBCode). Here's an example: 93 | 94 | ```python 95 | console.print("Where there is a [bold cyan]Will[/bold cyan] there [u]is[/u] a [i]way[/i].") 96 | ``` 97 | 98 | ![Console Markup](https://github.com/willmcgugan/rich/raw/master/imgs/where_there_is_a_will.png) 99 | 100 | You can use a Console object to generate sophisticated output with minimal effort. See the [Console API](https://rich.readthedocs.io/en/latest/console.html) docs for details. 101 | 102 | ## Rich Inspect 103 | 104 | Rich has an [inspect](https://rich.readthedocs.io/en/latest/reference/init.html?highlight=inspect#rich.inspect) function which can produce a report on any Python object, such as class, instance, or builtin. 105 | 106 | ```python 107 | >>> my_list = ["foo", "bar"] 108 | >>> from rich import inspect 109 | >>> inspect(my_list, methods=True) 110 | ``` 111 | 112 | ![Log](https://github.com/willmcgugan/rich/raw/master/imgs/inspect.png) 113 | 114 | See the [inspect docs](https://rich.readthedocs.io/en/latest/reference/init.html#rich.inspect) for details. 115 | 116 | # Rich Library 117 | 118 | Rich contains a number of builtin _renderables_ you can use to create elegant output in your CLI and help you debug your code. 119 | 120 | Click the following headings for details: 121 | 122 |
123 | Log 124 | 125 | The Console object has a `log()` method which has a similar interface to `print()`, but also renders a column for the current time and the file and line which made the call. By default Rich will do syntax highlighting for Python structures and for repr strings. If you log a collection (i.e. a dict or a list) Rich will pretty print it so that it fits in the available space. Here's an example of some of these features. 126 | 127 | ```python 128 | from rich.console import Console 129 | console = Console() 130 | 131 | test_data = [ 132 | {"jsonrpc": "2.0", "method": "sum", "params": [None, 1, 2, 4, False, True], "id": "1",}, 133 | {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]}, 134 | {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": "2"}, 135 | ] 136 | 137 | def test_log(): 138 | enabled = False 139 | context = { 140 | "foo": "bar", 141 | } 142 | movies = ["Deadpool", "Rise of the Skywalker"] 143 | console.log("Hello from", console, "!") 144 | console.log(test_data, log_locals=True) 145 | 146 | 147 | test_log() 148 | ``` 149 | 150 | The above produces the following output: 151 | 152 | ![Log](https://github.com/willmcgugan/rich/raw/master/imgs/log.png) 153 | 154 | Note the `log_locals` argument, which outputs a table containing the local variables where the log method was called. 155 | 156 | The log method could be used for logging to the terminal for long running applications such as servers, but is also a very nice debugging aid. 157 | 158 |
159 |
160 | Logging Handler 161 | 162 | You can also use the builtin [Handler class](https://rich.readthedocs.io/en/latest/logging.html) to format and colorize output from Python's logging module. Here's an example of the output: 163 | 164 | ![Logging](https://github.com/willmcgugan/rich/raw/master/imgs/logging.png) 165 | 166 |
167 | 168 |
169 | Emoji 170 | 171 | To insert an emoji in to console output place the name between two colons. Here's an example: 172 | 173 | ```python 174 | >>> console.print(":smiley: :vampire: :pile_of_poo: :thumbs_up: :raccoon:") 175 | 😃 🧛 💩 👍 🦝 176 | ``` 177 | 178 | Please use this feature wisely. 179 | 180 |
181 | 182 |
183 | Tables 184 | 185 | Rich can render flexible [tables](https://rich.readthedocs.io/en/latest/tables.html) with unicode box characters. There is a large variety of formatting options for borders, styles, cell alignment etc. 186 | 187 | ![table movie](https://github.com/willmcgugan/rich/raw/master/imgs/table_movie.gif) 188 | 189 | The animation above was generated with [table_movie.py](https://github.com/willmcgugan/rich/blob/master/examples/table_movie.py) in the examples directory. 190 | 191 | Here's a simpler table example: 192 | 193 | ```python 194 | from rich.console import Console 195 | from rich.table import Table 196 | 197 | console = Console() 198 | 199 | table = Table(show_header=True, header_style="bold magenta") 200 | table.add_column("Date", style="dim", width=12) 201 | table.add_column("Title") 202 | table.add_column("Production Budget", justify="right") 203 | table.add_column("Box Office", justify="right") 204 | table.add_row( 205 | "Dev 20, 2019", "Star Wars: The Rise of Skywalker", "$275,000,000", "$375,126,118" 206 | ) 207 | table.add_row( 208 | "May 25, 2018", 209 | "[red]Solo[/red]: A Star Wars Story", 210 | "$275,000,000", 211 | "$393,151,347", 212 | ) 213 | table.add_row( 214 | "Dec 15, 2017", 215 | "Star Wars Ep. VIII: The Last Jedi", 216 | "$262,000,000", 217 | "[bold]$1,332,539,889[/bold]", 218 | ) 219 | 220 | console.print(table) 221 | ``` 222 | 223 | This produces the following output: 224 | 225 | ![table](https://github.com/willmcgugan/rich/raw/master/imgs/table.png) 226 | 227 | Note that console markup is rendered in the same way as `print()` and `log()`. In fact, anything that is renderable by Rich may be included in the headers / rows (even other tables). 228 | 229 | The `Table` class is smart enough to resize columns to fit the available width of the terminal, wrapping text as required. Here's the same example, with the terminal made smaller than the table above: 230 | 231 | ![table2](https://github.com/willmcgugan/rich/raw/master/imgs/table2.png) 232 | 233 |
234 | 235 |
236 | Progress Bars 237 | 238 | Rich can render multiple flicker-free [progress](https://rich.readthedocs.io/en/latest/progress.html) bars to track long-running tasks. 239 | 240 | For basic usage, wrap any sequence in the `track` function and iterate over the result. Here's an example: 241 | 242 | ```python 243 | from rich.progress import track 244 | 245 | for step in track(range(100)): 246 | do_step(step) 247 | ``` 248 | 249 | It's not much harder to add multiple progress bars. Here's an example taken from the docs: 250 | 251 | ![progress](https://github.com/willmcgugan/rich/raw/master/imgs/progress.gif) 252 | 253 | The columns may be configured to show any details you want. Built-in columns include percentage complete, file size, file speed, and time remaining. Here's another example showing a download in progress: 254 | 255 | ![progress](https://github.com/willmcgugan/rich/raw/master/imgs/downloader.gif) 256 | 257 | To try this out yourself, see [examples/downloader.py](https://github.com/willmcgugan/rich/blob/master/examples/downloader.py) which can download multiple URLs simultaneously while displaying progress. 258 | 259 |
260 | 261 |
262 | Status 263 | 264 | For situations where it is hard to calculate progress, you can use the [status](https://rich.readthedocs.io/en/latest/reference/console.html#rich.console.Console.status) method which will display a 'spinner' animation and message. The animation won't prevent you from using the console as normal. Here's an example: 265 | 266 | ```python 267 | from time import sleep 268 | from rich.console import Console 269 | 270 | console = Console() 271 | tasks = [f"task {n}" for n in range(1, 11)] 272 | 273 | with console.status("[bold green]Working on tasks...") as status: 274 | while tasks: 275 | task = tasks.pop(0) 276 | sleep(1) 277 | console.log(f"{task} complete") 278 | ``` 279 | 280 | This generates the following output in the terminal. 281 | 282 | ![status](https://github.com/willmcgugan/rich/raw/master/imgs/status.gif) 283 | 284 | The spinner animations were borrowed from [cli-spinners](https://www.npmjs.com/package/cli-spinners). You can select a spinner by specifying the `spinner` parameter. Run the following command to see the available values: 285 | 286 | ``` 287 | python -m rich.spinner 288 | ``` 289 | 290 | The above command generate the following output in the terminal: 291 | 292 | ![spinners](https://github.com/willmcgugan/rich/raw/master/imgs/spinners.gif) 293 | 294 |
295 | 296 |
297 | Tree 298 | 299 | Rich can render a [tree](https://rich.readthedocs.io/en/latest/tree.html) with guide lines. A tree is ideal for displaying a file structure, or any other hierarchical data. 300 | 301 | The labels of the tree can be simple text or anything else Rich can render. Run the following for a demonstration: 302 | 303 | ``` 304 | python -m rich.tree 305 | ``` 306 | 307 | This generates the following output: 308 | 309 | ![markdown](https://github.com/willmcgugan/rich/raw/master/imgs/tree.png) 310 | 311 | See the [tree.py](https://github.com/willmcgugan/rich/blob/master/examples/tree.py) example for a script that displays a tree view of any directory, similar to the linux `tree` command. 312 | 313 |
314 | 315 |
316 | Columns 317 | 318 | Rich can render content in neat [columns](https://rich.readthedocs.io/en/latest/columns.html) with equal or optimal width. Here's a very basic clone of the (MacOS / Linux) `ls` command which displays a directory listing in columns: 319 | 320 | ```python 321 | import os 322 | import sys 323 | 324 | from rich import print 325 | from rich.columns import Columns 326 | 327 | directory = os.listdir(sys.argv[1]) 328 | print(Columns(directory)) 329 | ``` 330 | 331 | The following screenshot is the output from the [columns example](https://github.com/willmcgugan/rich/blob/master/examples/columns.py) which displays data pulled from an API in columns: 332 | 333 | ![columns](https://github.com/willmcgugan/rich/raw/master/imgs/columns.png) 334 | 335 |
336 | 337 |
338 | Markdown 339 | 340 | Rich can render [markdown](https://rich.readthedocs.io/en/latest/markdown.html) and does a reasonable job of translating the formatting to the terminal. 341 | 342 | To render markdown import the `Markdown` class and construct it with a string containing markdown code. Then print it to the console. Here's an example: 343 | 344 | ```python 345 | from rich.console import Console 346 | from rich.markdown import Markdown 347 | 348 | console = Console() 349 | with open("README.md") as readme: 350 | markdown = Markdown(readme.read()) 351 | console.print(markdown) 352 | ``` 353 | 354 | This will produce output something like the following: 355 | 356 | ![markdown](https://github.com/willmcgugan/rich/raw/master/imgs/markdown.png) 357 | 358 |
359 | 360 |
361 | Syntax Highlighting 362 | 363 | Rich uses the [pygments](https://pygments.org/) library to implement [syntax highlighting](https://rich.readthedocs.io/en/latest/syntax.html). Usage is similar to rendering markdown; construct a `Syntax` object and print it to the console. Here's an example: 364 | 365 | ```python 366 | from rich.console import Console 367 | from rich.syntax import Syntax 368 | 369 | my_code = ''' 370 | def iter_first_last(values: Iterable[T]) -> Iterable[Tuple[bool, bool, T]]: 371 | """Iterate and generate a tuple with a flag for first and last value.""" 372 | iter_values = iter(values) 373 | try: 374 | previous_value = next(iter_values) 375 | except StopIteration: 376 | return 377 | first = True 378 | for value in iter_values: 379 | yield first, False, previous_value 380 | first = False 381 | previous_value = value 382 | yield first, True, previous_value 383 | ''' 384 | syntax = Syntax(my_code, "python", theme="monokai", line_numbers=True) 385 | console = Console() 386 | console.print(syntax) 387 | ``` 388 | 389 | This will produce the following output: 390 | 391 | ![syntax](https://github.com/willmcgugan/rich/raw/master/imgs/syntax.png) 392 | 393 |
394 | 395 |
396 | Tracebacks 397 | 398 | Rich can render [beautiful tracebacks](https://rich.readthedocs.io/en/latest/traceback.html) which are easier to read and show more code than standard Python tracebacks. You can set Rich as the default traceback handler so all uncaught exceptions will be rendered by Rich. 399 | 400 | Here's what it looks like on OSX (similar on Linux): 401 | 402 | ![traceback](https://github.com/willmcgugan/rich/raw/master/imgs/traceback.png) 403 | 404 |
405 | 406 | All Rich renderables make use of the [Console Protocol](https://rich.readthedocs.io/en/latest/protocol.html), which you can also use to implement your own Rich content. 407 | 408 | # Rich for enterprise 409 | 410 | Available as part of the Tidelift Subscription. 411 | 412 | The maintainers of Rich and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source packages you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact packages you use. [Learn more.](https://tidelift.com/subscription/pkg/pypi-rich?utm_source=pypi-rich&utm_medium=referral&utm_campaign=enterprise&utm_term=repo) 413 | 414 | # Project using Rich 415 | 416 | Here are a few projects using Rich: 417 | 418 | - [BrancoLab/BrainRender](https://github.com/BrancoLab/BrainRender) 419 | a python package for the visualization of three dimensional neuro-anatomical data 420 | - [Ciphey/Ciphey](https://github.com/Ciphey/Ciphey) 421 | Automated decryption tool 422 | - [emeryberger/scalene](https://github.com/emeryberger/scalene) 423 | a high-performance, high-precision CPU and memory profiler for Python 424 | - [hedythedev/StarCli](https://github.com/hedythedev/starcli) 425 | Browse GitHub trending projects from your command line 426 | - [intel/cve-bin-tool](https://github.com/intel/cve-bin-tool) 427 | This tool scans for a number of common, vulnerable components (openssl, libpng, libxml2, expat and a few others) to let you know if your system includes common libraries with known vulnerabilities. 428 | - [nf-core/tools](https://github.com/nf-core/tools) 429 | Python package with helper tools for the nf-core community. 430 | - [cansarigol/pdbr](https://github.com/cansarigol/pdbr) 431 | pdb + Rich library for enhanced debugging 432 | - [plant99/felicette](https://github.com/plant99/felicette) 433 | Satellite imagery for dummies. 434 | - [seleniumbase/SeleniumBase](https://github.com/seleniumbase/SeleniumBase) 435 | Automate & test 10x faster with Selenium & pytest. Batteries included. 436 | - [smacke/ffsubsync](https://github.com/smacke/ffsubsync) 437 | Automagically synchronize subtitles with video. 438 | - [tryolabs/norfair](https://github.com/tryolabs/norfair) 439 | Lightweight Python library for adding real-time 2D object tracking to any detector. 440 | - [ansible/ansible-lint](https://github.com/ansible/ansible-lint) Ansible-lint checks playbooks for practices and behaviour that could potentially be improved 441 | - [ansible-community/molecule](https://github.com/ansible-community/molecule) Ansible Molecule testing framework 442 | - +[Many more](https://github.com/willmcgugan/rich/network/dependents)! 443 | 444 | 445 | --------------------------------------------------------------------------------