From caf62a97daa0d20bf8948311e920fe5e82c8b51c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 14 Jul 2021 20:08:27 +0100 Subject: [PATCH] calculator example --- CHANGELOG.md | 10 +- examples/calculator.py | 170 ++++++++++-- poetry.lock | 386 +++++++++++++++++++++++++++- pyproject.toml | 5 +- src/textual/app.py | 33 ++- src/textual/layout.py | 20 +- src/textual/layouts/grid.py | 40 ++- src/textual/message.py | 6 +- src/textual/message_pump.py | 77 ++++-- src/textual/page.py | 2 - src/textual/scrollbar.py | 2 - src/textual/view.py | 3 + src/textual/widget.py | 5 - src/textual/widgets/_button.py | 16 +- src/textual/widgets/_scroll_view.py | 61 +++-- 15 files changed, 726 insertions(+), 110 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2268a7d2f..7183ba13d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [Unreleased] - yyyy-mm-dd +## [0.1.7] - 2021-07-14 -Here we write upgrading notes for brands. It's a team effort to make them as -straightforward as possible. +### Changed + +- Added functionality to calculator example. +- Scrollview now shows scrollbars automatically +- New handler system for messages that doesn't require inheritance +- Improved traceback handling diff --git a/examples/calculator.py b/examples/calculator.py index 98dd44f88..80ff06d6c 100644 --- a/examples/calculator.py +++ b/examples/calculator.py @@ -1,17 +1,27 @@ +from decimal import Decimal + from rich.align import Align -from rich.console import Console, ConsoleOptions, RenderResult +from rich.console import Console, ConsoleOptions, RenderResult, RenderableType +from rich.padding import Padding from rich.text import Text + from textual.app import App from textual import events +from textual.message import Message +from textual.reactive import Reactive from textual.view import View -from textual.widgets import Button, Static +from textual.widget import Widget +from textual.widgets import Button from textual.layouts.grid import GridLayout try: from pyfiglet import Figlet except ImportError: print("Please install pyfiglet to run this example") + import sys + + sys.exit() class FigletText: @@ -24,10 +34,8 @@ class FigletText: self, console: Console, options: ConsoleOptions ) -> RenderResult: size = min(options.max_width / 2, options.max_height) - - text = self.text if size < 4: - yield Text(text, style="bold") + yield Text(self.text, style="bold") else: if size < 7: font_name = "mini" @@ -37,42 +45,148 @@ class FigletText: font_name = "standard" else: font_name = "big" - font = Figlet(font=font_name) - yield Text(font.renderText(text).rstrip("\n"), style="bold") + font = Figlet(font=font_name, width=options.max_width) + yield Text(font.renderText(self.text).rstrip("\n"), style="bold") + + +class Numbers(Widget): + """The digital display of the calculator.""" + + value: Reactive[str] = Reactive("0") + + def render(self) -> RenderableType: + return Padding( + Align.right(FigletText(self.value), vertical="middle"), + (0, 1), + style="white on rgb(51,51,51)", + ) class CalculatorApp(App): - async def on_startup(self, event: events.Startup) -> None: + """A working calculator app.""" + async def on_load(self, event: events.Load) -> None: + """Sent when the app starts, but before displaying anything.""" + + self.left = Decimal("0") + self.right = Decimal("0") + self.value = "" + self.operator = "+" + self.numbers = Numbers() + + def make_button(text: str, style: str) -> Button: + """Create a button with the given Figlet label.""" + return Button(FigletText(text), style=style, name=text) + + dark = "white on rgb(51,51,51)" + light = "black on rgb(165,165,165)" + yellow = "white on rgb(255,159,7)" + + button_styles = { + "AC": light, + "C": light, + "+/-": light, + "%": light, + "/": yellow, + "X": yellow, + "-": yellow, + "+": yellow, + "=": yellow, + } + + # Make all the buttons + self.buttons = { + name: make_button(name, button_styles.get(name, dark)) + for name in "+/-,%,/,7,8,9,X,4,5,6,-,1,2,3,+,.,=".split(",") + } + + self.zero = make_button("0", dark) + self.ac = make_button("AC", light) + self.c = make_button("C", light) + self.c.visible = False + + async def on_startup(self, event: events.Startup) -> None: + """Sent when the app has gone full screen.""" + + # Create the layout which defines where our widgets will go layout = GridLayout(gap=(2, 1), gutter=1, align=("center", "center")) await self.push_view(View(layout=layout)) + # Create rows / columns / areas layout.add_column("col", max_size=30, repeat=4) - layout.add_row("numbers") + layout.add_row("numbers", max_size=15) layout.add_row("row", max_size=15, repeat=5) layout.add_areas( - numbers="col1-start|col4-end,numbers", zero="col1-start|col2-end,row5" + clear="col1,row1", + numbers="col1-start|col4-end,numbers", + zero="col1-start|col2-end,row5", ) - - def make_button(text: str) -> Button: - return Button(FigletText(text), style="white on rgb(51,51,51)") - - buttons = { - name: make_button(name) - for name in "AC,+/-,%,/,7,8,9,X,4,5,6,-,1,2,3,+,.,=".split(",") - } - for name in ("AC", "+/-", "%"): - buttons[name].style = "black on rgb(165,165,165)" - for name in "/X-+=": - buttons[name].style = "white on rgb(255,159,7)" - - numbers = Align.right(FigletText("0"), vertical="middle") - + # Place out widgets in to the layout + layout.place(clear=self.c) layout.place( - *buttons.values(), - numbers=Static(numbers, padding=(0, 1), style="white on rgb(51,51,51)"), - zero=make_button("0"), + *self.buttons.values(), clear=self.ac, numbers=self.numbers, zero=self.zero ) + async def message_button_pressed(self, message: Message) -> None: + """A message sent by the button widget""" + + assert isinstance(message.sender, Button) + button_name = message.sender.name + + def do_math() -> bool: + operator = self.operator + right = self.right + if operator == "+": + self.left += right + elif operator == "-": + self.left -= right + elif operator == "/": + try: + self.left /= right + except ZeroDivisionError: + self.numbers.value = "Error" + return False + elif operator == "X": + self.left *= right + return True + + if button_name.isdigit(): + self.value = self.value.lstrip("0") + button_name + self.numbers.value = self.value + elif button_name == "+/-": + self.value = str(Decimal(self.value or "0") * -1) + self.numbers.value = self.value + elif button_name == "%": + self.value = str(Decimal(self.value or "0") / Decimal(100)) + self.numbers.value = self.value + elif button_name == ".": + if "." not in self.value: + self.value += "." + self.numbers.value = self.value + elif button_name == "AC": + self.value = "" + self.left = self.right = Decimal(0) + self.operator = "+" + self.numbers.value = "0" + elif button_name == "C": + self.value = "" + self.numbers.value = "0" + elif button_name in ("+", "-", "/", "X"): + self.right = Decimal(self.value or "0") + if do_math(): + self.numbers.value = str(self.left) + self.value = "" + self.operator = button_name + elif button_name == "=": + if self.value: + self.right = Decimal(self.value or "0") + if do_math(): + self.numbers.value = str(self.left) + self.value = "" + + show_ac = self.value in ("", "0") and self.numbers.value == "0" + self.c.visible = not show_ac + self.ac.visible = show_ac + CalculatorApp.run(title="Calculator Test") diff --git a/poetry.lock b/poetry.lock index ba100a237..dbda88cef 100644 --- a/poetry.lock +++ b/poetry.lock @@ -6,6 +6,17 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "astunparse" +version = "1.6.3" +description = "An AST unparser for Python" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +six = ">=1.6.1,<2.0" + [[package]] name = "atomicwrites" version = "1.4.0" @@ -50,6 +61,14 @@ typing-extensions = ">=3.7.4" colorama = ["colorama (>=0.4.3)"] d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] +[[package]] +name = "cached-property" +version = "1.5.2" +description = "A decorator for caching properties in classes." +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "click" version = "8.0.1" @@ -92,6 +111,20 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" [package.extras] toml = ["toml"] +[[package]] +name = "ghp-import" +version = "2.0.1" +description = "Copy your docs directly to the gh-pages branch." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +python-dateutil = ">=2.8.1" + +[package.extras] +dev = ["twine", "markdown", "flake8"] + [[package]] name = "importlib-metadata" version = "4.6.1" @@ -117,6 +150,128 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "jinja2" +version = "3.0.1" +description = "A very fast and expressive template engine." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "markdown" +version = "3.3.4" +description = "Python implementation of Markdown." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} + +[package.extras] +testing = ["coverage", "pyyaml"] + +[[package]] +name = "markupsafe" +version = "2.0.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "mergedeep" +version = "1.3.4" +description = "A deep merge function for 🐍." +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "mkdocs" +version = "1.2.1" +description = "Project documentation with Markdown." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +click = ">=3.3" +ghp-import = ">=1.0" +importlib-metadata = ">=3.10" +Jinja2 = ">=2.10.1" +Markdown = ">=3.2.1" +mergedeep = ">=1.3.4" +packaging = ">=20.5" +PyYAML = ">=3.10" +pyyaml-env-tag = ">=0.1" +watchdog = ">=2.0" + +[package.extras] +i18n = ["babel (>=2.9.0)"] + +[[package]] +name = "mkdocs-autorefs" +version = "0.2.1" +description = "Automatically link across pages in MkDocs." +category = "dev" +optional = false +python-versions = ">=3.6,<4.0" + +[package.dependencies] +Markdown = ">=3.3,<4.0" +mkdocs = ">=1.1,<2.0" + +[[package]] +name = "mkdocs-material" +version = "7.1.10" +description = "A Material Design theme for MkDocs" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +markdown = ">=3.2" +mkdocs = ">=1.1" +mkdocs-material-extensions = ">=1.0" +Pygments = ">=2.4" +pymdown-extensions = ">=7.0" + +[[package]] +name = "mkdocs-material-extensions" +version = "1.0.1" +description = "Extension pack for Python Markdown." +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +mkdocs-material = ">=5.0.0" + +[[package]] +name = "mkdocstrings" +version = "0.15.2" +description = "Automatic documentation from sources, for MkDocs." +category = "dev" +optional = false +python-versions = ">=3.6,<4.0" + +[package.dependencies] +Jinja2 = ">=2.11.1,<4.0" +Markdown = ">=3.3,<4.0" +MarkupSafe = ">=1.1,<3.0" +mkdocs = ">=1.1.1,<2.0.0" +mkdocs-autorefs = ">=0.1,<0.3" +pymdown-extensions = ">=6.3,<9.0" +pytkdocs = ">=0.2.0,<0.12.0" + [[package]] name = "mypy" version = "0.910" @@ -192,6 +347,17 @@ category = "main" optional = false python-versions = ">=3.5" +[[package]] +name = "pymdown-extensions" +version = "8.2" +description = "Extension pack for Python Markdown." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +Markdown = ">=3.2" + [[package]] name = "pyparsing" version = "2.4.7" @@ -238,6 +404,52 @@ toml = "*" [package.extras] testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pytkdocs" +version = "0.11.1" +description = "Load Python objects documentation." +category = "dev" +optional = false +python-versions = ">=3.6.1,<4.0.0" + +[package.dependencies] +astunparse = {version = ">=1.6.3,<2.0.0", markers = "python_version < \"3.9\""} +cached-property = {version = ">=1.5.2,<2.0.0", markers = "python_version < \"3.8\""} +typing-extensions = {version = ">=3.7.4.3,<4.0.0.0", markers = "python_version < \"3.8\""} + +[package.extras] +numpy-style = ["docstring_parser (>=0.7.3,<0.8.0)"] + +[[package]] +name = "pyyaml" +version = "5.4.1" +description = "YAML parser and emitter for Python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" + +[[package]] +name = "pyyaml-env-tag" +version = "0.1" +description = "A custom YAML tag for referencing environment variables in YAML files. " +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyyaml = "*" + [[package]] name = "regex" version = "2021.7.6" @@ -263,6 +475,14 @@ typing-extensions = {version = ">=3.7.4,<4.0.0", markers = "python_version < \"3 [package.extras] jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + [[package]] name = "toml" version = "0.10.2" @@ -287,6 +507,17 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "watchdog" +version = "2.1.3" +description = "Filesystem events monitoring" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +watchmedo = ["PyYAML (>=3.10)", "argh (>=0.24.1)"] + [[package]] name = "zipp" version = "3.5.0" @@ -302,13 +533,17 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "137e746aef38f8e8eac2674219503221c2705c80b9ee4b463129b1c47e459460" +content-hash = "ac64f9d88e11df57c6fabe30fb9f39aee4ea9925809a8c850a61e0b4e92bf64f" [metadata.files] appdirs = [ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, ] +astunparse = [ + {file = "astunparse-1.6.3-py2.py3-none-any.whl", hash = "sha256:c2652417f2c8b5bb325c885ae329bdf3f86424075c4fd1a128674bc6fba4b8e8"}, + {file = "astunparse-1.6.3.tar.gz", hash = "sha256:5ad93a8456f0d084c3456d059fd9a92cce667963232cbf763eac3bc5b7940872"}, +] atomicwrites = [ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, @@ -320,6 +555,10 @@ attrs = [ black = [ {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, ] +cached-property = [ + {file = "cached-property-1.5.2.tar.gz", hash = "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130"}, + {file = "cached_property-1.5.2-py2.py3-none-any.whl", hash = "sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0"}, +] click = [ {file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"}, {file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"}, @@ -386,6 +625,9 @@ coverage = [ {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, ] +ghp-import = [ + {file = "ghp-import-2.0.1.tar.gz", hash = "sha256:753de2eace6e0f7d4edfb3cce5e3c3b98cd52aadb80163303d1d036bda7b4483"}, +] importlib-metadata = [ {file = "importlib_metadata-4.6.1-py3-none-any.whl", hash = "sha256:9f55f560e116f8643ecf2922d9cd3e1c7e8d52e683178fecd9d08f6aa357e11e"}, {file = "importlib_metadata-4.6.1.tar.gz", hash = "sha256:079ada16b7fc30dfbb5d13399a5113110dab1aa7c2bc62f66af75f0b717c8cac"}, @@ -394,6 +636,74 @@ iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] +jinja2 = [ + {file = "Jinja2-3.0.1-py3-none-any.whl", hash = "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4"}, + {file = "Jinja2-3.0.1.tar.gz", hash = "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4"}, +] +markdown = [ + {file = "Markdown-3.3.4-py3-none-any.whl", hash = "sha256:96c3ba1261de2f7547b46a00ea8463832c921d3f9d6aba3f255a6f71386db20c"}, + {file = "Markdown-3.3.4.tar.gz", hash = "sha256:31b5b491868dcc87d6c24b7e3d19a0d730d59d3e46f4eea6430a321bed387a49"}, +] +markupsafe = [ + {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, + {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, +] +mergedeep = [ + {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, + {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, +] +mkdocs = [ + {file = "mkdocs-1.2.1-py3-none-any.whl", hash = "sha256:11141126e5896dd9d279b3e4814eb488e409a0990fb638856255020406a8e2e7"}, + {file = "mkdocs-1.2.1.tar.gz", hash = "sha256:6e0ea175366e3a50d334597b0bc042b8cebd512398cdd3f6f34842d0ef524905"}, +] +mkdocs-autorefs = [ + {file = "mkdocs-autorefs-0.2.1.tar.gz", hash = "sha256:b8156d653ed91356e71675ce1fa1186d2b2c2085050012522895c9aa98fca3e5"}, + {file = "mkdocs_autorefs-0.2.1-py3-none-any.whl", hash = "sha256:f301b983a34259df90b3fcf7edc234b5e6c7065bd578781e66fd90b8cfbe76be"}, +] +mkdocs-material = [ + {file = "mkdocs-material-7.1.10.tar.gz", hash = "sha256:890e9be00bfbe4d22ccccbcde1bf9bad67a3ba495f2a7d2422ea4acb5099f014"}, + {file = "mkdocs_material-7.1.10-py2.py3-none-any.whl", hash = "sha256:92ff8c4a8e78555ef7b7ed0ba3043421d18971b48d066ea2cefb50e889fc66db"}, +] +mkdocs-material-extensions = [ + {file = "mkdocs-material-extensions-1.0.1.tar.gz", hash = "sha256:6947fb7f5e4291e3c61405bad3539d81e0b3cd62ae0d66ced018128af509c68f"}, + {file = "mkdocs_material_extensions-1.0.1-py3-none-any.whl", hash = "sha256:d90c807a88348aa6d1805657ec5c0b2d8d609c110e62b9dce4daf7fa981fa338"}, +] +mkdocstrings = [ + {file = "mkdocstrings-0.15.2-py3-none-any.whl", hash = "sha256:8d6cbe64c07ae66739010979ca01d49dd2f64d1a45009f089d217b9cd2a65e36"}, + {file = "mkdocstrings-0.15.2.tar.gz", hash = "sha256:c2fee9a3a644647c06eb2044fdfede1073adfd1a55bf6752005d3db10705fe73"}, +] mypy = [ {file = "mypy-0.910-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a155d80ea6cee511a3694b108c4494a39f42de11ee4e61e72bc424c490e46457"}, {file = "mypy-0.910-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b94e4b785e304a04ea0828759172a15add27088520dc7e49ceade7834275bedb"}, @@ -443,6 +753,10 @@ pygments = [ {file = "Pygments-2.9.0-py3-none-any.whl", hash = "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e"}, {file = "Pygments-2.9.0.tar.gz", hash = "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f"}, ] +pymdown-extensions = [ + {file = "pymdown-extensions-8.2.tar.gz", hash = "sha256:b6daa94aad9e1310f9c64c8b1f01e4ce82937ab7eb53bfc92876a97aca02a6f4"}, + {file = "pymdown_extensions-8.2-py3-none-any.whl", hash = "sha256:141452d8ed61165518f2c923454bf054866b85cf466feedb0eb68f04acdc2560"}, +] pyparsing = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, @@ -455,6 +769,49 @@ pytest-cov = [ {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, ] +python-dateutil = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] +pytkdocs = [ + {file = "pytkdocs-0.11.1-py3-none-any.whl", hash = "sha256:89ca4926d0acc266235beb24cb0b0591aa6bf7adedfae54bf9421d529d782c8d"}, + {file = "pytkdocs-0.11.1.tar.gz", hash = "sha256:1ec7e028fe8361acc1ce909ada4e6beabec28ef31e629618549109e1d58549f0"}, +] +pyyaml = [ + {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"}, + {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"}, + {file = "PyYAML-5.4.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8"}, + {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"}, + {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"}, + {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"}, + {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"}, + {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"}, + {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"}, + {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"}, + {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"}, + {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"}, + {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"}, + {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"}, + {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"}, + {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"}, + {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"}, + {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"}, + {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"}, + {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"}, + {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"}, + {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"}, + {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"}, + {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"}, + {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"}, + {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"}, + {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"}, + {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, + {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, +] +pyyaml-env-tag = [ + {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, + {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, +] regex = [ {file = "regex-2021.7.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e6a1e5ca97d411a461041d057348e578dc344ecd2add3555aedba3b408c9f874"}, {file = "regex-2021.7.6-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:6afe6a627888c9a6cfbb603d1d017ce204cebd589d66e0703309b8048c3b0854"}, @@ -502,6 +859,10 @@ rich = [ {file = "rich-10.6.0-py3-none-any.whl", hash = "sha256:d3f72827cd5df13b2ef7f1a97f81ec65548d4fdeb92cef653234f227580bbb2a"}, {file = "rich-10.6.0.tar.gz", hash = "sha256:128261b3e2419a4ef9c97066ccc2abbfb49fa7c5e89c3fe4056d00aa5e9c1e65"}, ] +six = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, @@ -543,6 +904,29 @@ typing-extensions = [ {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, ] +watchdog = [ + {file = "watchdog-2.1.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9628f3f85375a17614a2ab5eac7665f7f7be8b6b0a2a228e6f6a2e91dd4bfe26"}, + {file = "watchdog-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:acc4e2d5be6f140f02ee8590e51c002829e2c33ee199036fcd61311d558d89f4"}, + {file = "watchdog-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:85b851237cf3533fabbc034ffcd84d0fa52014b3121454e5f8b86974b531560c"}, + {file = "watchdog-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a12539ecf2478a94e4ba4d13476bb2c7a2e0a2080af2bb37df84d88b1b01358a"}, + {file = "watchdog-2.1.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6fe9c8533e955c6589cfea6f3f0a1a95fb16867a211125236c82e1815932b5d7"}, + {file = "watchdog-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d9456f0433845e7153b102fffeb767bde2406b76042f2216838af3b21707894e"}, + {file = "watchdog-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fd8c595d5a93abd441ee7c5bb3ff0d7170e79031520d113d6f401d0cf49d7c8f"}, + {file = "watchdog-2.1.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0bcfe904c7d404eb6905f7106c54873503b442e8e918cc226e1828f498bdc0ca"}, + {file = "watchdog-2.1.3-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:bf84bd94cbaad8f6b9cbaeef43080920f4cb0e61ad90af7106b3de402f5fe127"}, + {file = "watchdog-2.1.3-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b8ddb2c9f92e0c686ea77341dcb58216fa5ff7d5f992c7278ee8a392a06e86bb"}, + {file = "watchdog-2.1.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8805a5f468862daf1e4f4447b0ccf3acaff626eaa57fbb46d7960d1cf09f2e6d"}, + {file = "watchdog-2.1.3-py3-none-manylinux2014_armv7l.whl", hash = "sha256:3e305ea2757f81d8ebd8559d1a944ed83e3ab1bdf68bcf16ec851b97c08dc035"}, + {file = "watchdog-2.1.3-py3-none-manylinux2014_i686.whl", hash = "sha256:431a3ea70b20962e6dee65f0eeecd768cd3085ea613ccb9b53c8969de9f6ebd2"}, + {file = "watchdog-2.1.3-py3-none-manylinux2014_ppc64.whl", hash = "sha256:e4929ac2aaa2e4f1a30a36751160be391911da463a8799460340901517298b13"}, + {file = "watchdog-2.1.3-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:201cadf0b8c11922f54ec97482f95b2aafca429c4c3a4bb869a14f3c20c32686"}, + {file = "watchdog-2.1.3-py3-none-manylinux2014_s390x.whl", hash = "sha256:3a7d242a7963174684206093846537220ee37ba9986b824a326a8bb4ef329a33"}, + {file = "watchdog-2.1.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:54e057727dd18bd01a3060dbf5104eb5a495ca26316487e0f32a394fd5fe725a"}, + {file = "watchdog-2.1.3-py3-none-win32.whl", hash = "sha256:b5fc5c127bad6983eecf1ad117ab3418949f18af9c8758bd10158be3647298a9"}, + {file = "watchdog-2.1.3-py3-none-win_amd64.whl", hash = "sha256:44acad6f642996a2b50bb9ce4fb3730dde08f23e79e20cd3d8e2a2076b730381"}, + {file = "watchdog-2.1.3-py3-none-win_ia64.whl", hash = "sha256:0bcdf7b99b56a3ae069866c33d247c9994ffde91b620eaf0306b27e099bd1ae0"}, + {file = "watchdog-2.1.3.tar.gz", hash = "sha256:e5236a8e8602ab6db4b873664c2d356c365ab3cac96fbdec4970ad616415dd45"}, +] zipp = [ {file = "zipp-3.5.0-py3-none-any.whl", hash = "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3"}, {file = "zipp-3.5.0.tar.gz", hash = "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4"}, diff --git a/pyproject.toml b/pyproject.toml index 01a0a153c..3d220c86f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.1.6" +version = "0.1.7" homepage = "https://github.com/willmcgugan/textual" description = "Text User Interface using Rich" authors = ["Will McGugan "] @@ -31,6 +31,9 @@ pytest = "^6.2.3" black = "^20.8b1" mypy = "^0.910" pytest-cov = "^2.12.1" +mkdocs = "^1.2.1" +mkdocstrings = "^0.15.2" +mkdocs-material = "^7.1.10" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/src/textual/app.py b/src/textual/app.py index 43fe7bf18..107640293 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -23,7 +23,7 @@ from .binding import Bindings, NoBinding from .geometry import Point, Region from ._context import active_app from ._event_broker import extract_handler_actions, NoHandler -from .keys import Binding +from ._types import MessageTarget from .driver import Driver from .layouts.dock import DockLayout, Dock from ._linux_driver import LinuxDriver @@ -56,6 +56,12 @@ ViewType = TypeVar("ViewType", bound=View) # uvloop.install() +class PanicMessage(Message): + def __init__(self, sender: MessageTarget, traceback: Traceback) -> None: + self.traceback = traceback + super().__init__(sender) + + class ActionError(Exception): pass @@ -71,11 +77,19 @@ class App(MessagePump): def __init__( self, - console: Console = None, + console: Console | None = None, screen: bool = True, - driver_class: Type[Driver] = None, + driver_class: Type[Driver] | None = None, title: str = "Textual Application", ): + """The Textual Application base class + + Args: + console (Console, optional): A Rich Console. Defaults to None. + screen (bool, optional): Enable full-screen application mode. Defaults to True. + driver_class (Type[Driver], optional): Driver class, or None to use default. Defaults to None. + title (str, optional): Title of the application. Defaults to "Textual Application". + """ self.console = console or get_console() self._screen = screen self.driver_class = driver_class or LinuxDriver @@ -88,6 +102,7 @@ class App(MessagePump): self.mouse_over: Widget | None = None self.mouse_captured: Widget | None = None self._driver: Driver | None = None + self._tracebacks: list[Traceback] = [] self._docks: list[Dock] = [] self._action_targets = {"app", "view"} @@ -202,10 +217,12 @@ class App(MessagePump): if widget is not None: await widget.post_message(events.MouseCaptured(self, self.mouse_position)) + def panic(self, traceback: Traceback) -> None: + self._tracebacks.append(traceback) + self.close_messages_no_wait() + async def process_messages(self) -> None: log.debug("driver=%r", self.driver_class) - # loop = asyncio.get_event_loop() - # loop.add_signal_handler(signal.SIGINT, self.on_keyboard_interupt) driver = self._driver = self.driver_class(self.console, self) active_app.set(self) @@ -242,10 +259,10 @@ class App(MessagePump): view = self._view_stack.pop() await view.close_messages() except Exception: - traceback = Traceback(show_locals=True) + self._tracebacks.append(Traceback(show_locals=True)) finally: driver.stop_application_mode() - if traceback is not None: + for traceback in self._tracebacks: self.console.print(traceback) def require_repaint(self) -> None: @@ -289,7 +306,7 @@ class App(MessagePump): if sync_available: console.file.write("\x1bP=2s\x1b\\") except Exception: - log.exception("refresh failed") + self.panic(Traceback(show_locals=True)) def display(self, renderable: RenderableType) -> None: if not self._closed: diff --git a/src/textual/layout.py b/src/textual/layout.py index fa8a306fc..3415d3f2e 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -79,6 +79,16 @@ class Layout(ABC): self.height = 0 self.renders: dict[Widget, tuple[Region, Lines]] = {} self._cuts: list[list[int]] | None = None + self._require_update: bool = True + + def check_update(self) -> bool: + return self._require_update + + def require_update(self) -> None: + self._require_update = True + + def reset_update(self) -> None: + self._require_update = False def reset(self) -> None: self._cuts = None @@ -265,10 +275,12 @@ class Layout(ABC): ) -> Iterable[list[Segment]]: for bucket in chops: - yield sum( - (segments for _, segments in sorted(bucket.items()) if segments), - start=[], - ) + line: list[Segment] = [] + extend = line.extend + for _, segments in sorted(bucket.items()): + if segments: + extend(segments) + yield line def render( self, diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index e205fa97e..8c14f47f5 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -9,7 +9,7 @@ import sys from typing import Iterable, NamedTuple from .._layout_resolve import layout_resolve -from .._loop import loop_last + from ..geometry import Dimensions, Point, Region from ..layout import Layout, OrderedRegion from ..view import View @@ -80,17 +80,39 @@ class GridLayout(Layout): super().__init__() - def hide_row(self, row_name: str) -> None: - self.hidden_rows.add(row_name) + def is_row_visible(self, row_name: str) -> bool: + return row_name not in self.hidden_rows - def show_row(self, row_name: str) -> None: - self.hidden_rows.discard(row_name) + def is_column_visible(self, column_name: str) -> bool: + return column_name not in self.hidden_columns - def hide_column(self, column_name: str) -> None: - self.hidden_rows.add(column_name) + def show_row(self, row_name: str, visible: bool = True) -> bool: + changed = False + if visible: + if not self.is_row_visible(row_name): + self.require_update() + changed = True + self.hidden_rows.discard(row_name) + else: + if self.is_row_visible(row_name): + self.require_update() + changed = True + self.hidden_rows.add(row_name) + return changed - def show_column(self, column_name: str) -> None: - self.hidden_rows.discard(column_name) + def show_column(self, column_name: str, visible: bool = True) -> bool: + changed = False + if visible: + if not self.is_column_visible(column_name): + self.require_update() + changed = True + self.hidden_rows.discard(column_name) + else: + if self.is_column_visible(column_name): + self.require_update() + changed = True + self.hidden_rows.add(column_name) + return changed def add_column( self, diff --git a/src/textual/message.py b/src/textual/message.py index c467ded88..64aff560b 100644 --- a/src/textual/message.py +++ b/src/textual/message.py @@ -27,7 +27,7 @@ class Message: self.name = camel_to_snake(self.__class__.__name__.replace("Message", "")) self.time = monotonic() self._no_default_action = False - self._stop_propagaton = False + self._stop_propagation = False super().__init__() def __rich_repr__(self) -> rich.repr.RichReprResult: @@ -57,5 +57,5 @@ class Message: """ self._no_default_action = prevent - def stop_propagation(self, stop: bool = True) -> None: - self._stop_propagaton = stop + def stop(self, stop: bool = True) -> None: + self._stop_propagation = stop diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 7baef1a0e..bc74b18cc 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -1,19 +1,28 @@ from __future__ import annotations import asyncio +from asyncio import CancelledError +from functools import partial import logging -from asyncio import Event, Queue, QueueEmpty, Task -from typing import Any, Awaitable, Coroutine, NamedTuple +from asyncio import Queue, QueueEmpty, Task +from typing import TYPE_CHECKING, Awaitable, Iterable, Callable from weakref import WeakSet +from rich.traceback import Traceback + from . import events from ._timer import Timer, TimerCallback from ._types import MessageHandler +from ._context import active_app from .message import Message log = logging.getLogger("rich") +if TYPE_CHECKING: + from .app import App + + class NoParent(Exception): pass @@ -44,6 +53,15 @@ class MessagePump: raise NoParent(f"{self._parent} has no parent") return self._parent + @property + def app(self) -> "App": + """Get the current app.""" + return active_app.get() + + @property + def is_parent_active(self): + return self._parent and not self._parent._closed and not self._parent._closing + def set_parent(self, parent: MessagePump) -> None: self._parent = parent @@ -120,6 +138,9 @@ class MessagePump: self._child_tasks.add(asyncio.get_event_loop().create_task(timer.run())) return timer + def close_messages_no_wait(self) -> None: + self._message_queue.put_nowait(None) + async def close_messages(self, wait: bool = True) -> None: """Close message queue, and optionally wait for queue to finish processing.""" if self._closed: @@ -138,12 +159,20 @@ class MessagePump: self._task = asyncio.create_task(self.process_messages()) async def process_messages(self) -> None: + try: + return await self._process_messages() + except CancelledError: + pass + + async def _process_messages(self) -> None: """Process messages until the queue is closed.""" while not self._closed: try: message = await self.get_message() except MessagePumpClosed: break + except CancelledError: + raise except Exception as error: log.exception("error in get_message()") raise error from None @@ -161,9 +190,11 @@ class MessagePump: try: await self.dispatch_message(message) - except Exception as error: - log.exception("error in dispatch_message") + except CancelledError: raise + except Exception as error: + self.app.panic(Traceback(show_locals=True)) + break finally: if isinstance(message, events.Event) and self._message_queue.empty(): if not self._closed: @@ -182,25 +213,39 @@ class MessagePump: return await self.on_message(message) return False - async def on_event(self, event: events.Event) -> None: - method_name = f"on_{event.name}" + def _get_dispatch_methods( + self, method_name: str, message: Message + ) -> Iterable[Callable[[Message], Awaitable]]: + for cls in self.__class__.__mro__: + if message._no_default_action: + break + method = getattr(cls, method_name, None) + if method is not None: + yield method.__get__(self, cls) - dispatch_function: MessageHandler = getattr(self, method_name, None) - if dispatch_function is not None: - await dispatch_function(event) - if event.bubble and self._parent and not event._stop_propagaton: - if event.sender == self._parent: - pass - # log.debug("bubbled event abandoned; %r", event) - elif not self._parent._closed and not self._parent._closing: + async def on_event(self, event: events.Event) -> None: + _rich_traceback_guard = True + + for method in self._get_dispatch_methods(f"on_{event.name}", event): + await method(event) + + if event.bubble and self._parent and not event._stop_propagation: + if event.sender != self._parent and self.is_parent_active: await self._parent.post_message(event) async def on_message(self, message: Message) -> None: + _rich_traceback_guard = True method_name = f"message_{message.name}" - method = getattr(self, method_name, None) - if method is not None: + + for method in self._get_dispatch_methods(method_name, message): await method(message) + if message.bubble and self._parent and not message._stop_propagation: + if message.sender == self._parent: + pass + elif not self._parent._closed and not self._parent._closing: + await self._parent.post_message(message) + def post_message_no_wait(self, message: Message) -> bool: if self._closing or self._closed: return False diff --git a/src/textual/page.py b/src/textual/page.py index c9428c993..decb90b8e 100644 --- a/src/textual/page.py +++ b/src/textual/page.py @@ -7,7 +7,6 @@ from rich.padding import Padding, PaddingDimensions from rich.segment import Segment from rich.style import StyleType -from . import events from .geometry import Dimensions, Point from .message import Message from .widget import Widget, Reactive @@ -56,7 +55,6 @@ class PageRender: def render(self, console: Console, options: ConsoleOptions) -> None: width = self.width or options.max_width or console.width - width *= 2 options = options.update_dimensions(width, None) style = console.get_style(self.style) renderable = self.renderable diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 0a829848d..0efcc635c 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -152,7 +152,6 @@ class ScrollBarRender: def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: - log.debug("SCROLLBAR RENDER") size = ( (options.height or console.height) if self.vertical @@ -231,7 +230,6 @@ class ScrollBar(Widget): async def on_mouse_up(self, event: events.MouseUp) -> None: if self.grabbed: await self.release_mouse() - await super().on_mouse_up(event) async def on_mouse_captured(self, event: events.MouseCaptured) -> None: self.grabbed = event.mouse_position diff --git a/src/textual/view.py b/src/textual/view.py index 53656b18f..7f4c33dd3 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -44,6 +44,9 @@ class View(Widget): def __rich_repr__(self) -> rich.repr.RichReprResult: yield "name", self.name + def __getitem__(self, widget_name: str) -> Widget: + return self.named_widgets[widget_name] + @property def is_visual(self) -> bool: return False diff --git a/src/textual/widget.py b/src/textual/widget.py index a4d3d38f5..53800fcdf 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -87,11 +87,6 @@ class Widget(MessagePump): def is_visual(self) -> bool: return True - @property - def app(self) -> "App": - """Get the current app.""" - return active_app.get() - @property def console(self) -> Console: """Get the current console.""" diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 79c2e6bb7..7b014deef 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -7,10 +7,16 @@ from rich.panel import Panel import rich.repr from rich.style import StyleType +from .. import events +from ..message import Message from ..reactive import Reactive from ..widget import Widget +class ButtonPressed(Message, bubble=True): + pass + + class Expand: def __init__(self, renderable: RenderableType) -> None: self.renderable = renderable @@ -48,11 +54,15 @@ class Button(Widget): name: str | None = None, style: StyleType = "white on dark_blue", ): - self.label = label self.name = name or str(label) self.style = style - super().__init__() + super().__init__(name=name) + self.label = label + + label: Reactive[RenderableType] = Reactive("") def render(self) -> RenderableType: return ButtonRenderable(self.label, style=self.style) - return Align.center(self.label, vertical="middle", style=self.style) + + async def on_click(self, event: events.Click) -> None: + await self.emit(ButtonPressed(self)) diff --git a/src/textual/widgets/_scroll_view.py b/src/textual/widgets/_scroll_view.py index a155165fd..b72f4f725 100644 --- a/src/textual/widgets/_scroll_view.py +++ b/src/textual/widgets/_scroll_view.py @@ -29,18 +29,19 @@ class ScrollView(View): fluid: bool = True, ) -> None: self.fluid = fluid - self._vertical_scrollbar = ScrollBar(vertical=True) - self._horizontal_scrollbar = ScrollBar(vertical=False) - self._page = Page(renderable or "", style=style) + self.vscroll = ScrollBar(vertical=True) + self.hscroll = ScrollBar(vertical=False) + self.page = Page(renderable or "", style=style) layout = GridLayout() layout.add_column("main") - layout.add_column("vertical", size=1) + layout.add_column("vscroll", size=1) layout.add_row("main") - layout.add_row("horizontal", size=1) + layout.add_row("hscroll", size=1) layout.add_areas( - content="main,main", vertical="vertical,main", horizontal="main,horizontal" + content="main,main", vscroll="vscroll,main", hscroll="main,hscroll" ) - # layout.hide_row("horizontal") + layout.show_row("hscroll", False) + layout.show_row("vscroll", False) super().__init__(name=name, layout=layout) x: Reactive[float] = Reactive(0) @@ -50,35 +51,35 @@ class ScrollView(View): target_y: Reactive[float] = Reactive(0) def validate_x(self, value: float) -> float: - return clamp(value, 0, self._page.contents_size.width - self.size.width) + return clamp(value, 0, self.page.contents_size.width - self.size.width) def validate_target_x(self, value: float) -> float: - return clamp(value, 0, self._page.contents_size.width - self.size.width) + return clamp(value, 0, self.page.contents_size.width - self.size.width) def validate_y(self, value: float) -> float: - return clamp(value, 0, self._page.contents_size.height - self.size.height) + return clamp(value, 0, self.page.contents_size.height - self.size.height) def validate_target_y(self, value: float) -> float: - return clamp(value, 0, self._page.contents_size.height - self.size.height) + return clamp(value, 0, self.page.contents_size.height - self.size.height) async def watch_x(self, new_value: float) -> None: - self._page.x = round(new_value) - self._horizontal_scrollbar.position = round(new_value) + self.page.x = round(new_value) + self.hscroll.position = round(new_value) async def watch_y(self, new_value: float) -> None: - self._page.y = round(new_value) - self._vertical_scrollbar.position = round(new_value) + self.page.y = round(new_value) + self.vscroll.position = round(new_value) async def update(self, renderabe: RenderableType) -> None: - self._page.update(renderabe) + self.page.update(renderabe) self.require_repaint() async def on_mount(self, event: events.Mount) -> None: assert isinstance(self.layout, GridLayout) self.layout.place( - content=self._page, - vertical=self._vertical_scrollbar, - horizontal=self._horizontal_scrollbar, + content=self.page, + vscroll=self.vscroll, + hscroll=self.hscroll, ) await self.layout.mount_all(self) @@ -133,7 +134,7 @@ class ScrollView(View): async def key_end(self) -> None: self.target_x = 0 - self.target_y = self._page.contents_size.height - self.size.height + self.target_y = self.page.contents_size.height - self.size.height self.animate("x", self.target_x, duration=1, easing="out_cubic") self.animate("y", self.target_y, duration=1, easing="out_cubic") @@ -146,7 +147,7 @@ class ScrollView(View): async def on_resize(self, event: events.Resize) -> None: await super().on_resize(event) if self.fluid: - self._page.update() + self.page.update() async def message_scroll_up(self, message: Message) -> None: self.page_up() @@ -171,7 +172,17 @@ class ScrollView(View): async def message_page_update(self, message: Message) -> None: self.x = self.validate_x(self.x) self.y = self.validate_y(self.y) - self._horizontal_scrollbar.virtual_size = self._page.virtual_size.width - self._horizontal_scrollbar.window_size = self.size.width - self._vertical_scrollbar.virtual_size = self._page.virtual_size.height - self._vertical_scrollbar.window_size = self.size.height + self.vscroll.virtual_size = self.page.virtual_size.height + self.vscroll.window_size = self.size.height + if self.layout.show_column( + "vscroll", self.page.virtual_size.height > self.size.height + ): + self.page.update() + + self.hscroll.virtual_size = self.page.virtual_size.width + self.hscroll.window_size = self.size.width + + if self.layout.show_row( + "hscroll", self.page.virtual_size.width > self.size.width + ): + self.page.update()