From 6bfc26c1ec37262b9cd4bbab35d15907dc6742bf Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 25 May 2022 15:36:58 +0100 Subject: [PATCH 01/14] more docs --- docs/examples/introduction/clock.py | 22 ++++ docs/examples/introduction/clock01.py | 22 ++++ docs/examples/introduction/intro01.py | 9 ++ docs/examples/introduction/intro02.py | 29 ++++++ docs/examples/styles/width.py | 17 +++ docs/index.md | 2 +- docs/introduction.md | 145 ++++++++++++++++++++++++++ docs/reference/widget.md | 1 + docs/styles/width.md | 37 +++++++ e2e_tests/test_apps/basic.py | 12 +-- examples/borders.py | 4 +- examples/calculator.py | 2 +- mkdocs.yml | 4 +- poetry.lock | 108 ++++++++++++++----- pyproject.toml | 3 +- reference/README.md | 1 + {docs => reference}/color_system.md | 0 sandbox/align.py | 2 +- sandbox/basic.py | 12 +-- sandbox/borders.py | 4 +- sandbox/dev_sandbox.py | 2 +- sandbox/scroll_to_widget.py | 2 +- sandbox/tabs.py | 4 +- src/textual/_compositor.py | 5 +- src/textual/_doc.py | 4 +- src/textual/app.py | 9 +- src/textual/blank.py | 32 ------ src/textual/css/_style_properties.py | 22 +++- src/textual/css/styles.py | 6 +- src/textual/renderables/blank.py | 19 +++- src/textual/screen.py | 12 +-- src/textual/scrollbar.py | 2 +- src/textual/widget.py | 8 +- src/textual/widgets/_button.py | 2 +- src/textual/widgets/_footer.py | 2 +- src/textual/widgets/_header.py | 2 +- src/textual/widgets/_placeholder.py | 2 +- src/textual/widgets/_static.py | 4 +- src/textual/widgets/_tree_control.py | 2 +- src/textual/widgets/tabs.py | 2 +- src/textual/widgets/text_input.py | 4 +- tests/test_integration_layout.py | 2 +- tests/test_widget.py | 2 +- 43 files changed, 463 insertions(+), 124 deletions(-) create mode 100644 docs/examples/introduction/clock.py create mode 100644 docs/examples/introduction/clock01.py create mode 100644 docs/examples/introduction/intro01.py create mode 100644 docs/examples/introduction/intro02.py create mode 100644 docs/examples/styles/width.py create mode 100644 docs/introduction.md create mode 100644 docs/reference/widget.md create mode 100644 docs/styles/width.md create mode 100644 reference/README.md rename {docs => reference}/color_system.md (100%) delete mode 100644 src/textual/blank.py diff --git a/docs/examples/introduction/clock.py b/docs/examples/introduction/clock.py new file mode 100644 index 000000000..8731d9d8c --- /dev/null +++ b/docs/examples/introduction/clock.py @@ -0,0 +1,22 @@ +from datetime import datetime + +from textual.app import App +from textual.widget import Widget + + +class Clock(Widget): + def on_mount(self): + self.styles.content_align = ("center", "middle") + self.set_interval(1, self.refresh) + + def render(self): + return datetime.now().strftime("%c") + + +class ClockApp(App): + def compose(self): + yield Clock() + + +app = ClockApp() +app.run() diff --git a/docs/examples/introduction/clock01.py b/docs/examples/introduction/clock01.py new file mode 100644 index 000000000..7a525c1ff --- /dev/null +++ b/docs/examples/introduction/clock01.py @@ -0,0 +1,22 @@ +from datetime import datetime + +from textual.app import App +from textual.widget import Widget + + +class Clock(Widget): + def on_mount(self): + self.styles.content_align = ("center", "middle") + self.set_interval(1, self.refresh) + + def render(self): + return datetime.now().strftime("%c") + + +class ClockApp(App): + def on_mount(self): + self.mount(Clock()) + + +app = ClockApp() +app.run() diff --git a/docs/examples/introduction/intro01.py b/docs/examples/introduction/intro01.py new file mode 100644 index 000000000..6a2e2d4a1 --- /dev/null +++ b/docs/examples/introduction/intro01.py @@ -0,0 +1,9 @@ +from textual.app import App + + +class ExampleApp(App): + pass + + +app = ExampleApp() +app.run() diff --git a/docs/examples/introduction/intro02.py b/docs/examples/introduction/intro02.py new file mode 100644 index 000000000..2f5d3ed64 --- /dev/null +++ b/docs/examples/introduction/intro02.py @@ -0,0 +1,29 @@ +from textual.app import App + + +class ExampleApp(App): + + COLORS = [ + "white", + "maroon", + "red", + "purple", + "fuchsia", + "olive", + "yellow", + "navy", + "teal", + "aqua", + ] + + def on_mount(self): + self.styles.background = "darkblue" + + def on_key(self, event): + if event.key.isdigit(): + self.styles.background = self.COLORS[int(event.key)] + self.bell() + + +app = ExampleApp() +app.run() diff --git a/docs/examples/styles/width.py b/docs/examples/styles/width.py new file mode 100644 index 000000000..c7fafef9d --- /dev/null +++ b/docs/examples/styles/width.py @@ -0,0 +1,17 @@ +from textual.app import App +from textual.widget import Widget + + +class WidthApp(App): + CSS = """ + Widget { + background: blue; + width: 50%; + } + """ + + def compose(self): + yield Widget() + + +app = WidthApp() diff --git a/docs/index.md b/docs/index.md index 81fbfcbb0..acb93f011 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,4 +1,4 @@ -# Welcome to Textual documentation +# Welcome Textual is framework for rapidly creating _text user interfaces_ (TUIs from here on) with Python. diff --git a/docs/introduction.md b/docs/introduction.md new file mode 100644 index 000000000..3145b56ce --- /dev/null +++ b/docs/introduction.md @@ -0,0 +1,145 @@ +# Introduction + +Welcome to the Textual Introduction! + +This is a very gentle introduction to creating Textual applications. + +## Pre-requisites + +- Python 3.7 or later. If you have a choice, pick the most recent version. +- Installed `textual` from Pypi. +- Basic Python skills. + +## A Simple App + +Lets looks at the simplest possible Textual app. It doesn't do much, but will demonstrate the basic steps you will need to create any application. + +If you would like to follow along and run the examples, navigate to the `docs/examples/introduction` directory from the command prompt. We will be looking at `intro01.py`, which you can see here: + +=== "intro01.py" + + ```python + --8<-- "docs/examples/introduction/intro01.py" + ``` + +=== "Output" + + ```{.textual path="docs/examples/introduction/intro01.py"} + ``` + +Enter the following command to run the application: + +```shell +python intro01.py +``` + +The command prompt should disappear and you will see a blank screen. Hit ++ctrl+c++ to exit and return to the command prompt. + +Let's analyze this simple app. + +The first step in all Textual applications is to import the `App` class from `textual.app` and extend it: + +```python +from textual.app import App + +class ExampleApp(App): + pass +``` + +There will be a single App object in any Textual application. The App class is responsible for loading data, setting up the screen, managing events etc. + +The following two lines create an instance of the application and calls `run()`: + +```python +app = ExampleApp() +app.run() +``` + +The `run` method will put your terminal in to "application mode" which disables the prompt and allows Textual to take over input and output. The `run()` method will return when the application exits. + +## Handling Events + +In the previously example our app did next to nothing. Most applications will contain event handler methods, which are called in response to user actions (such as key presses, mouse action) and other changes your app needs to know about such as terminal resize, scrolling, timers, etc. + +!!! note + + Although `intro01.py` did not explicitly define any event handlers, Textual still had to respond to the Key event to catch ++ctrl+c++, otherwise you wouldn't be able to exit the app. + +In our next example, we are going to handle two such events; `Mount` and `Key`. The `Mount` event is sent when the app is first run, and a `Key` event is sent when the user presses a key on the keyboard. Try running `intro02.py` in the `docs/examples/introduction`: + +=== "intro02.py" + + ```python + --8<-- "docs/examples/introduction/intro02.py" + ``` + +=== "Output" + + ```{.textual path="docs/examples/introduction/intro02.py"} + ``` + +When you run this app you should see a blue screen. If you hit any of the number keys ++0++-++9++, the background will change to another color. You may also hear a beep or some other noise when a key is pressed, depending on how your terminal is configured. As before, pressing ++ctrl+c++ will exit the app and return you to your prompt. + +There are two event handlers in this app class. Event handlers start with the text `on_` followed by the name of the event in lower case. Hence `on_mount` is called for the `Mount` event, and `on_key` is called for the `Key` event. + +Here's the `on_mount` method again: + +```python +def on_mount(self): + self.styles.background = "darkblue" +``` + +This method sets the `background` attribute on `self.styles` to `"darkblue"` which makes the application background blue when the app loads. The `styles` object contains a variety of properties which define how your app looks. We will explore what you can do with this object later. + +The second event handler will receive `Key` events whenever you press a key on the keyboard: + +```python +def on_key(self, event): + if event.key.isdigit(): + self.styles.background = self.COLORS[int(event.key)] + self.bell() +``` + +This method has an `event` positional argument which contains information regarding the key that was pressed. The body of the method sets the background to a corresponding color when you press one of the digit keys. It also calls `bell()` which is a method on App that plays your terminal's bell. + +!!! note + + Every event has a corresponding `Event` object, but Textual knows to only call the event handler with the event object if you have it in the argument list. It does this by inspecting the handler method prior to calling it. + +## Widgets + +Most Textual applications will also make use of one or more `Widget` classes. A Widget is a self contained component which is responsible for defining how a given part of the screen should look. Widgets respond to events in much the same way as the App does. More sophisticated user interfaces can be built by combining various widgets. + +Let's look at an app which defines a very simple Widget to show the current time and date. Here is the code for `"clock01.py"` which is n the same directory as the previous examples: + +=== "clock01.py" + + ```python + --8<-- "docs/examples/introduction/clock01.py" + ``` + +=== "Output" + + ```{.textual path="docs/examples/introduction/clock01.py"} + ``` + +This script imports App as before, but also the `Widget` class from `textual.widget`, which is the base class for all Widgets. The `Clock` widget extends `Widget` and adds an `on_mount` handler which is called when the widget is first added to the application. + +Lets have a look at the Clock's Mount event handler: + +```python + def on_mount(self): + self.styles.content_align = ("center", "middle") + self.set_interval(1, self.refresh) +``` + +The first line in that method sets the `content_align` attribute on the styles object, which defines how text is positioned within the Widget. We're setting it to a tuple of `("center", "middle")` which tells Textual to horizontally center the text, and place it in the middle vertically. If you resize the terminal you should notice that the text is automatically centered. + +The second line calls `self.set_interval` to request that Textual calls `self.refresh` to update the screen once a second. When the screen is refreshed, Textual will call the widget's `render()` method, which we can see here: + +```python + def render(self): + return datetime.now().strftime("%c") +``` + +This method uses the datetime module to format the current date and time. It returns a string, but can also return a _Rich renderable_. Don't worry if you aren't familiar with [Rich](https://github.com/Textualize/rich), we will cover that later. diff --git a/docs/reference/widget.md b/docs/reference/widget.md new file mode 100644 index 000000000..aa67df889 --- /dev/null +++ b/docs/reference/widget.md @@ -0,0 +1 @@ +::: textual.widget.Widget diff --git a/docs/styles/width.md b/docs/styles/width.md new file mode 100644 index 000000000..18cc7b57e --- /dev/null +++ b/docs/styles/width.md @@ -0,0 +1,37 @@ +# Width + +The `width` property sets a widget's width. By default, it sets the width of the content area, but if `box-sizing` is set to `border-box` it sets the width of the border area. + +## Example + +=== "width.py" + + ```python + --8<-- "docs/examples/styles/width.py" + ``` + +=== "Output" + + ```{.textual path="docs/examples/styles/width.py"} + ``` + +## CSS + +```sass +/* Explicit cell width */ +width: 10; + +/* Percentage width */ +width: 50%; + +/* Automatic width */ +width: auto +``` + +## Python + +```python +self.styles.width = 10 +self.styles.width = "50% +self.styles.width = "auto" +``` diff --git a/e2e_tests/test_apps/basic.py b/e2e_tests/test_apps/basic.py index 70b1b6fa0..e29bbe17d 100644 --- a/e2e_tests/test_apps/basic.py +++ b/e2e_tests/test_apps/basic.py @@ -53,12 +53,12 @@ lorem = Text.from_markup( class TweetHeader(Widget): - def render(self, style: Style) -> RenderableType: + def render(self) -> RenderableType: return Text("Lorem Impsum", justify="center") class TweetBody(Widget): - def render(self, style: Style) -> Text: + def render(self) -> Text: return lorem @@ -67,22 +67,22 @@ class Tweet(Widget): class OptionItem(Widget): - def render(self, style: Style) -> Text: + def render(self) -> Text: return Text("Option") class Error(Widget): - def render(self, style: Style) -> Text: + def render(self) -> Text: return Text("This is an error message", justify="center") class Warning(Widget): - def render(self, style: Style) -> Text: + def render(self) -> Text: return Text("This is a warning message", justify="center") class Success(Widget): - def render(self, style: Style) -> Text: + def render(self) -> Text: return Text("This is a success message", justify="center") diff --git a/examples/borders.py b/examples/borders.py index 62e4ec7e5..62e4d296e 100644 --- a/examples/borders.py +++ b/examples/borders.py @@ -13,12 +13,12 @@ lorem = Text.from_markup( class Lorem(Widget): - def render(self, style: Style) -> Text: + def render(self) -> Text: return Padding(lorem, 1) class Background(Widget): - def render(self, style: Style): + def render(self): return VerticalGradient("#212121", "#212121") diff --git a/examples/calculator.py b/examples/calculator.py index 8cb5dd3f8..7f4bbe3cc 100644 --- a/examples/calculator.py +++ b/examples/calculator.py @@ -56,7 +56,7 @@ class Numbers(Widget): value = Reactive("0") - def render(self, style: Style) -> RenderableType: + def render(self) -> RenderableType: """Build a Rich renderable to render the calculator display.""" return Padding( Align.right(FigletText(self.value), vertical="middle"), diff --git a/mkdocs.yml b/mkdocs.yml index af3107038..4938ef1ad 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -45,9 +45,9 @@ plugins: handlers: python: rendering: - show_source: false + show_source: true selection: - filters: + filters: - "!^_" - "^__init__$" - "!^can_replace$" diff --git a/poetry.lock b/poetry.lock index f69a66ef7..6aa1ed18d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -35,7 +35,7 @@ frozenlist = ">=1.1.0" name = "astunparse" version = "1.6.3" description = "An AST unparser for Python" -category = "dev" +category = "main" optional = false python-versions = "*" @@ -110,7 +110,7 @@ uvloop = ["uvloop (>=0.15.2)"] name = "cached-property" version = "1.5.2" description = "A decorator for caching properties in classes." -category = "dev" +category = "main" optional = false python-versions = "*" @@ -207,7 +207,7 @@ python-versions = ">=3.7" name = "ghp-import" version = "2.1.0" description = "Copy your docs directly to the gh-pages branch." -category = "dev" +category = "main" optional = false python-versions = "*" @@ -217,6 +217,20 @@ python-dateutil = ">=2.8.1" [package.extras] dev = ["twine", "markdown", "flake8", "wheel"] +[[package]] +name = "griffe" +version = "0.19.2" +description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +cached_property = {version = "*", markers = "python_version < \"3.8\""} + +[package.extras] +async = ["aiofiles (>=0.7,<1.0)"] + [[package]] name = "identify" version = "2.5.1" @@ -265,7 +279,7 @@ python-versions = "*" name = "jinja2" version = "3.0.3" description = "A very fast and expressive template engine." -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -279,7 +293,7 @@ i18n = ["Babel (>=2.7)"] name = "markdown" version = "3.3.7" description = "Python implementation of Markdown." -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -293,7 +307,7 @@ testing = ["coverage", "pyyaml"] name = "markupsafe" version = "2.1.1" description = "Safely add untrusted strings to HTML/XML markup." -category = "dev" +category = "main" optional = false python-versions = ">=3.7" @@ -301,7 +315,7 @@ python-versions = ">=3.7" name = "mergedeep" version = "1.3.4" description = "A deep merge function for 🐍." -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -309,7 +323,7 @@ python-versions = ">=3.6" name = "mkdocs" version = "1.3.0" description = "Project documentation with Markdown." -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -332,7 +346,7 @@ i18n = ["babel (>=2.9.0)"] name = "mkdocs-autorefs" version = "0.4.1" description = "Automatically link across pages in MkDocs." -category = "dev" +category = "main" optional = false python-versions = ">=3.7" @@ -366,20 +380,50 @@ python-versions = ">=3.6" [[package]] name = "mkdocstrings" -version = "0.17.0" +version = "0.18.1" description = "Automatic documentation from sources, for MkDocs." -category = "dev" +category = "main" optional = false -python-versions = ">=3.6.2" +python-versions = ">=3.7" [package.dependencies] Jinja2 = ">=2.11.1" Markdown = ">=3.3" MarkupSafe = ">=1.1" mkdocs = ">=1.2" -mkdocs-autorefs = ">=0.1" +mkdocs-autorefs = ">=0.3.1" +mkdocstrings-python = {version = ">=0.5.2", optional = true, markers = "extra == \"python\""} +mkdocstrings-python-legacy = ">=0.2" pymdown-extensions = ">=6.3" -pytkdocs = ">=0.14.0" + +[package.extras] +crystal = ["mkdocstrings-crystal (>=0.3.4)"] +python = ["mkdocstrings-python (>=0.5.2)"] +python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] + +[[package]] +name = "mkdocstrings-python" +version = "0.6.6" +description = "A Python handler for mkdocstrings." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +griffe = ">=0.11.1" +mkdocstrings = ">=0.18" + +[[package]] +name = "mkdocstrings-python-legacy" +version = "0.2.2" +description = "A legacy Python handler for mkdocstrings." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +mkdocstrings = ">=0.18" +pytkdocs = ">=0.14" [[package]] name = "msgpack" @@ -436,7 +480,7 @@ python-versions = "*" name = "packaging" version = "21.3" description = "Core utilities for Python packages" -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -515,7 +559,7 @@ python-versions = ">=3.6" name = "pymdown-extensions" version = "9.4" description = "Extension pack for Python Markdown." -category = "dev" +category = "main" optional = false python-versions = ">=3.7" @@ -526,7 +570,7 @@ markdown = ">=3.2" name = "pyparsing" version = "3.0.9" description = "pyparsing module - Classes and methods to define and execute parsing grammars" -category = "dev" +category = "main" optional = false python-versions = ">=3.6.8" @@ -606,7 +650,7 @@ testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtuale name = "python-dateutil" version = "2.8.2" description = "Extensions to the standard Python datetime module" -category = "dev" +category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" @@ -617,7 +661,7 @@ six = ">=1.5" name = "pytkdocs" version = "0.16.1" description = "Load Python objects documentation." -category = "dev" +category = "main" optional = false python-versions = ">=3.7" @@ -633,7 +677,7 @@ numpy-style = ["docstring_parser (>=0.7)"] name = "pyyaml" version = "6.0" description = "YAML parser and emitter for Python" -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -641,7 +685,7 @@ python-versions = ">=3.6" name = "pyyaml-env-tag" version = "0.1" description = "A custom YAML tag for referencing environment variables in YAML files. " -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -668,7 +712,7 @@ jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" @@ -738,7 +782,7 @@ testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", name = "watchdog" version = "2.1.8" description = "Filesystem events monitoring" -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -773,7 +817,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "eba121f02e102fd9c551a654bcfab3028ec4fc05fe9b4cf7d5f64002e3586ba0" +content-hash = "f32bca17ffa867133de5437f87390b1122fb84ee9c0b6eafab03f8c518fe6c15" [metadata.files] aiohttp = [ @@ -1039,6 +1083,10 @@ ghp-import = [ {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, ] +griffe = [ + {file = "griffe-0.19.2-py3-none-any.whl", hash = "sha256:5855368feffaabca51be721ca4110220320e3eb361b6c068f2273a6bf7d0bd05"}, + {file = "griffe-0.19.2.tar.gz", hash = "sha256:edf925b6ff3101930a97ce48661e34d547b9347fd11e87d49e8cee4c59d30f90"}, +] identify = [ {file = "identify-2.5.1-py2.py3-none-any.whl", hash = "sha256:0dca2ea3e4381c435ef9c33ba100a78a9b40c0bab11189c7cf121f75815efeaa"}, {file = "identify-2.5.1.tar.gz", hash = "sha256:3d11b16f3fe19f52039fb7e39c9c884b21cb1b586988114fbe42671f03de3e82"}, @@ -1126,8 +1174,16 @@ mkdocs-material-extensions = [ {file = "mkdocs_material_extensions-1.0.3-py3-none-any.whl", hash = "sha256:a82b70e533ce060b2a5d9eb2bc2e1be201cf61f901f93704b4acf6e3d5983a44"}, ] mkdocstrings = [ - {file = "mkdocstrings-0.17.0-py3-none-any.whl", hash = "sha256:103fc1dd58cb23b7e0a6da5292435f01b29dc6fa0ba829132537f3f556f985de"}, - {file = "mkdocstrings-0.17.0.tar.gz", hash = "sha256:75b5cfa2039aeaf3a5f5cf0aa438507b0330ce76c8478da149d692daa7213a98"}, + {file = "mkdocstrings-0.18.1-py3-none-any.whl", hash = "sha256:4053929356df8cd69ed32eef71d8f676a472ef72980c9ffd4f933ead1debcdad"}, + {file = "mkdocstrings-0.18.1.tar.gz", hash = "sha256:fb7c91ce7e3ab70488d3fa6c073a4f827cdc319042f682ef8ea95459790d64fc"}, +] +mkdocstrings-python = [ + {file = "mkdocstrings-python-0.6.6.tar.gz", hash = "sha256:37281696b9f199624ae420e0625b6659b7fdfbea736618bce7fd978682dea3b1"}, + {file = "mkdocstrings_python-0.6.6-py3-none-any.whl", hash = "sha256:c118438d3cb4b14c492a51d109f4e5b27ab06ba19b099d624430dfd904926152"}, +] +mkdocstrings-python-legacy = [ + {file = "mkdocstrings-python-legacy-0.2.2.tar.gz", hash = "sha256:f0e7ec6a19750581b752acb38f6b32fcd1efe006f14f6703125d2c2c9a5c6f02"}, + {file = "mkdocstrings_python_legacy-0.2.2-py3-none-any.whl", hash = "sha256:379107a3a5b8db9b462efc4493c122efe21e825e3702425dbd404621302a563a"}, ] msgpack = [ {file = "msgpack-1.0.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:96acc674bb9c9be63fa8b6dabc3248fdc575c4adc005c440ad02f87ca7edd079"}, diff --git a/pyproject.toml b/pyproject.toml index 896c16292..cf50856ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ click = "8.1.2" importlib-metadata = "^4.11.3" typing-extensions = { version = "^4.0.0", python = "<3.8" } msgpack = "^1.0.3" +mkdocstrings = {extras = ["python"], version = "^0.18.1"} [tool.poetry.dev-dependencies] pytest = "^6.2.3" @@ -35,7 +36,7 @@ black = "^22.3.0" mypy = "^0.950" pytest-cov = "^2.12.1" mkdocs = "^1.3.0" -mkdocstrings = "^0.17.0" +mkdocstrings = "^0.18.1" mkdocs-material = "^7.3.6" pre-commit = "^2.13.0" pytest-aiohttp = "^1.0.4" diff --git a/reference/README.md b/reference/README.md new file mode 100644 index 000000000..62f72ad39 --- /dev/null +++ b/reference/README.md @@ -0,0 +1 @@ +Contains private docs, mainly for the developers reference diff --git a/docs/color_system.md b/reference/color_system.md similarity index 100% rename from docs/color_system.md rename to reference/color_system.md diff --git a/sandbox/align.py b/sandbox/align.py index 76c3b588c..c9a73a733 100644 --- a/sandbox/align.py +++ b/sandbox/align.py @@ -6,7 +6,7 @@ from textual.widgets import Static class Thing(Widget): - def render(self, style: Style): + def render(self): return "Hello, 3434 World.\n[b]Lorem impsum." diff --git a/sandbox/basic.py b/sandbox/basic.py index a52c05aa5..0355b2cb1 100644 --- a/sandbox/basic.py +++ b/sandbox/basic.py @@ -56,14 +56,14 @@ lorem_long_text = Text.from_markup(lorem * 2) class TweetHeader(Widget): - def render(self, style: Style) -> RenderableType: + def render(self) -> RenderableType: return Text("Lorem Impsum", justify="center") class TweetBody(Widget): short_lorem = Reactive[bool](False) - def render(self, style: Style) -> Text: + def render(self) -> Text: return lorem_short_text if self.short_lorem else lorem_long_text @@ -72,22 +72,22 @@ class Tweet(Widget): class OptionItem(Widget): - def render(self, style: Style) -> Text: + def render(self) -> Text: return Text("Option") class Error(Widget): - def render(self, style: Style) -> Text: + def render(self) -> Text: return Text("This is an error message", justify="center") class Warning(Widget): - def render(self, style: Style) -> Text: + def render(self) -> Text: return Text("This is a warning message", justify="center") class Success(Widget): - def render(self, style: Style) -> Text: + def render(self) -> Text: return Text("This is a success message", justify="center") diff --git a/sandbox/borders.py b/sandbox/borders.py index 9a2b7e2d6..04161dd62 100644 --- a/sandbox/borders.py +++ b/sandbox/borders.py @@ -33,7 +33,7 @@ class Introduction(Widget): } """ - def render(self, styles) -> RenderableType: + def render(self) -> RenderableType: return Text("Here are the color edge types we support.", justify="center") @@ -41,7 +41,7 @@ class BorderDemo(Widget): def __init__(self, name: str): super().__init__(name=name) - def render(self, style) -> RenderableType: + def render(self) -> RenderableType: return Text(self.name, style="black on yellow", justify="center") diff --git a/sandbox/dev_sandbox.py b/sandbox/dev_sandbox.py index 8d3794dba..33c24d002 100644 --- a/sandbox/dev_sandbox.py +++ b/sandbox/dev_sandbox.py @@ -7,7 +7,7 @@ from textual.widget import Widget class PanelWidget(Widget): - def render(self, style: Style) -> RenderableType: + def render(self) -> RenderableType: return Panel("hello world!", title="Title") diff --git a/sandbox/scroll_to_widget.py b/sandbox/scroll_to_widget.py index 5e75847cd..81b0bf83c 100644 --- a/sandbox/scroll_to_widget.py +++ b/sandbox/scroll_to_widget.py @@ -35,7 +35,7 @@ class Introduction(Widget): } """ - def render(self, styles) -> RenderableType: + def render(self) -> RenderableType: return Text( "Press keys 0 to 9 to scroll to the Placeholder with that ID.", justify="center", diff --git a/sandbox/tabs.py b/sandbox/tabs.py index efcfcd7c8..e881c2ab2 100644 --- a/sandbox/tabs.py +++ b/sandbox/tabs.py @@ -12,7 +12,7 @@ from textual.widgets.tabs import Tabs, Tab class Hr(Widget): - def render(self, style: Style) -> RenderableType: + def render(self) -> RenderableType: return Rule() @@ -23,7 +23,7 @@ class Info(Widget): super().__init__() self.text = text - def render(self, style: Style) -> RenderableType: + def render(self) -> RenderableType: return Padding(f"{self.text}", pad=(0, 1)) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index f04d42888..ba2f30f29 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -222,6 +222,9 @@ class Compositor: old_map = self.map.copy() old_widgets = old_map.keys() map, widgets = self._arrange_root(parent) + + # parent.log(map) + new_widgets = map.keys() # Newly visible widgets @@ -552,7 +555,7 @@ class Compositor: ] return segment_lines - def render(self, full: bool = True) -> RenderableType: + def render(self, full: bool = False) -> RenderableType: """Render a layout. Returns: diff --git a/src/textual/_doc.py b/src/textual/_doc.py index 24a2bafc4..1f74542c6 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -1,5 +1,6 @@ import os +from textual.cli.cli import import_app # This module defines our "Custom Fences", powered by SuperFences # @link https://facelessuser.github.io/pymdown-extensions/extensions/superfences/#custom-fences @@ -7,7 +8,7 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs): """A superfences formatter to insert a SVG screenshot.""" os.environ["TEXTUAL"] = "headless" - os.environ["TEXTUAL_SCREENSHOT"] = "0.1" + os.environ["TEXTUAL_SCREENSHOT"] = "0.2" os.environ["COLUMNS"] = attrs.get("columns", "80") os.environ["LINES"] = attrs.get("lines", "24") path = attrs.get("path") @@ -21,6 +22,7 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs): source = python_code.read() app_vars = {} exec(source, app_vars) + app = app_vars["app"] app.run() svg = app._screenshot diff --git a/src/textual/app.py b/src/textual/app.py index 83e260ccb..136d440d3 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -33,7 +33,6 @@ from rich.console import Console, RenderableType from rich.measure import Measurement from rich.protocol import is_renderable from rich.segment import Segments -from rich.style import Style from rich.traceback import Traceback from . import actions @@ -58,6 +57,7 @@ from .geometry import Offset, Region, Size from .layouts.dock import Dock from .message_pump import MessagePump from .reactive import Reactive +from .renderables.blank import Blank from .screen import Screen from .widget import Widget @@ -106,7 +106,6 @@ class App(Generic[ReturnType], DOMNode): """The base class for Textual Applications""" CSS = """ - """ CSS_PATH: str | None = None @@ -210,7 +209,6 @@ class App(Generic[ReturnType], DOMNode): title: Reactive[str] = Reactive("Textual") sub_title: Reactive[str] = Reactive("") - background: Reactive[str] = Reactive("black") dark = Reactive(False) @property @@ -525,8 +523,8 @@ class App(Generic[ReturnType], DOMNode): self.stylesheet.update(self) self.screen.refresh(layout=True) - def render(self, styles: Style) -> RenderableType: - return "" + def render(self) -> RenderableType: + return Blank() def query(self, selector: str | None = None) -> DOMQuery: """Get a DOM query in the current screen. @@ -742,6 +740,7 @@ class App(Generic[ReturnType], DOMNode): await self.dispatch_message(mount_event) self.title = self._title + self.stylesheet.update(self) self.refresh() await self.animator.start() diff --git a/src/textual/blank.py b/src/textual/blank.py deleted file mode 100644 index db7fb49b2..000000000 --- a/src/textual/blank.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -from rich.console import Console, ConsoleOptions -from rich.segment import Segment -from rich.style import StyleType - - -class Blank: - """ - Render an empty rectangle. - - Args: - style (StyleType): Style to apply to the box. - width (int, optional): Width of the box in number of cells. Will expand to fit parent if ``None``. - height (int, optional): Height of the box in number of cells. Will expand to fit parent if ``None``. - """ - - def __init__( - self, style: StyleType, width: int | None = None, height: int | None = None - ): - self.style = style - self.width = width - self.height = height - - def __rich_console__(self, console: Console, console_options: ConsoleOptions): - render_width = self.width or console_options.max_width - render_height = ( - self.height or console_options.height or console_options.max_height - ) - style = console.get_style(self.style) - for _ in range(render_height): - yield Segment(" " * render_width + "\n", style) diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 8a181ebff..1b891355c 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -47,7 +47,7 @@ if TYPE_CHECKING: from .._layout import Layout from .styles import DockGroup, Styles, StylesBase -from .types import EdgeType +from .types import EdgeType, AlignHorizontal, AlignVertical BorderDefinition = ( "Sequence[tuple[EdgeType, str | Color] | None] | tuple[EdgeType, str | Color]" @@ -935,3 +935,23 @@ class FractionalProperty: ) if obj.set_rule(name, clamp(float_value, 0, 1)): obj.refresh() + + +class AlignProperty: + def __set_name__(self, owner: StylesBase, name: str) -> None: + self.horizontal = f"{name}_horizontal" + self.vertical = f"{name}_vertical" + + def __get__( + self, obj: StylesBase, type: type[StylesBase] + ) -> tuple[AlignHorizontal, AlignVertical]: + horizontal = getattr(obj, self.horizontal) + vertical = getattr(obj, self.vertical) + return (horizontal, vertical) + + def __set__( + self, obj: StylesBase, value: tuple[AlignHorizontal, AlignVertical] + ) -> None: + horizontal, vertical = value + setattr(obj, self.horizontal, horizontal) + setattr(obj, self.vertical, vertical) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 44cdae7b5..ae3e7f5a1 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -14,6 +14,7 @@ from .._animator import Animation, EasingFunction from ..color import Color from ..geometry import Spacing from ._style_properties import ( + AlignProperty, BorderProperty, BoxProperty, ColorProperty, @@ -230,9 +231,11 @@ class StylesBase(ABC): align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left") align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top") + align = AlignProperty() content_align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left") content_align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top") + content_align = AlignProperty() def __eq__(self, styles: object) -> bool: """Check that Styles contains the same rules.""" @@ -531,7 +534,8 @@ class Styles(StylesBase): ) -> Animation | None: from ..widget import Widget - assert isinstance(self.node, Widget) + # node = self.node + # assert isinstance(self.node, Widget) if isinstance(value, ScalarOffset): return ScalarAnimation( self.node, diff --git a/src/textual/renderables/blank.py b/src/textual/renderables/blank.py index 50c949283..a0df77ed1 100644 --- a/src/textual/renderables/blank.py +++ b/src/textual/renderables/blank.py @@ -1,16 +1,22 @@ from __future__ import annotations from rich.console import ConsoleOptions, Console, RenderResult -from rich.color import Color from rich.segment import Segment from rich.style import Style +from ..color import Color + class Blank: """Draw solid background color.""" - def __init__(self, color: str) -> None: - self._style = Style.from_color(None, Color.parse(color)) + def __init__(self, color: Color | str = "transparent") -> None: + background = ( + color.rich_color + if isinstance(color, Color) + else Color.parse(color).rich_color + ) + self._style = Style.from_color(None, background) def __rich_console__( self, console: Console, options: ConsoleOptions @@ -18,8 +24,11 @@ class Blank: width = options.max_width height = options.height or options.max_height - segment = Segment(f"{' ' * width}\n", self._style) - yield from [segment] * height + segment = Segment(f"{' ' * width}", style=self._style) + line = Segment.line() + for _ in range(height): + yield segment + yield line if __name__ == "__main__": diff --git a/src/textual/screen.py b/src/textual/screen.py index 6f9725647..6fa8947de 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -29,13 +29,6 @@ class Screen(Widget): CSS = """ - Screen { - layout: vertical; - overflow-y: auto; - background: $surface; - color: $text-surface; - } - """ dark = Reactive(False) @@ -48,8 +41,8 @@ class Screen(Widget): def watch_dark(self, dark: bool) -> None: pass - def render(self, style: Style) -> RenderableType: - return self.app.render(style) + def render(self) -> RenderableType: + return self.app.render() def get_offset(self, widget: Widget) -> Offset: """Get the absolute offset of a given Widget. @@ -174,7 +167,6 @@ class Screen(Widget): async def on_resize(self, event: events.Resize) -> None: self.size_updated(event.size, event.virtual_size, event.container_size) - self._refresh_layout() event.stop() async def _on_mouse_move(self, event: events.MouseMove) -> None: diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index bcf3989aa..ac810b4ec 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -205,7 +205,7 @@ class ScrollBar(Widget): yield "window_size", self.window_size yield "position", self.position - def render(self, style: Style) -> RenderableType: + def render(self) -> RenderableType: styles = self.parent.styles scrollbar_style = Style( bgcolor=( diff --git a/src/textual/widget.py b/src/textual/widget.py index d3bdb491c..f0bb88627 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -32,6 +32,7 @@ from .message import Message from . import messages from ._layout import Layout from .reactive import Reactive, watch +from .renderables.blank import Blank from .renderables.opacity import Opacity from .renderables.tint import Tint @@ -232,7 +233,7 @@ class Widget(DOMNode): if self._content_height_cache[0] == cache_key: return self._content_height_cache[1] - renderable = self.render(self.styles.rich_style) + renderable = self.render() options = self.console.options.update_width(width).update(highlight=False) segments = self.console.render(renderable, options) # Cheaper than counting the lines returned from render_lines! @@ -638,11 +639,12 @@ class Widget(DOMNode): Returns: RenderableType: A new renderable. """ - renderable = self.render(self.text_style) (base_background, base_color), (background, color) = self.colors styles = self.styles + renderable = self.render() + content_align = (styles.content_align_horizontal, styles.content_align_vertical) if content_align != ("left", "top"): horizontal, vertical = content_align @@ -852,7 +854,7 @@ class Widget(DOMNode): self._repaint_required = True self.check_idle() - def render(self, style: Style) -> RenderableType: + def render(self) -> RenderableType: """Get renderable for widget. Args: diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 5565d9695..e67c5fade 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -74,7 +74,7 @@ class Button(Widget, can_focus=True): return Text.from_markup(label) return label - def render(self, style: Style) -> RenderableType: + def render(self) -> RenderableType: label = self.label.copy() label.stylize(style) return label diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index e111f0eb3..fba2efe1f 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -59,7 +59,7 @@ class Footer(Widget): text.append_text(key_text) return text - def render(self, style: Style) -> RenderableType: + def render(self) -> RenderableType: if self._key_text is None: self._key_text = self.make_key_text() return self._key_text diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py index 965184bdf..adf5e5e90 100644 --- a/src/textual/widgets/_header.py +++ b/src/textual/widgets/_header.py @@ -49,7 +49,7 @@ class Header(Widget): def get_clock(self) -> str: return datetime.now().time().strftime("%X") - def render(self, style: Style) -> RenderableType: + def render(self) -> RenderableType: header_table = Table.grid(padding=(0, 1), expand=True) header_table.style = self.style header_table.add_column(justify="left", ratio=0, width=8) diff --git a/src/textual/widgets/_placeholder.py b/src/textual/widgets/_placeholder.py index 5979ee884..7ce714a24 100644 --- a/src/textual/widgets/_placeholder.py +++ b/src/textual/widgets/_placeholder.py @@ -37,7 +37,7 @@ class Placeholder(Widget, can_focus=True): yield "has_focus", self.has_focus, False yield "mouse_over", self.mouse_over, False - def render(self, style: Style) -> RenderableType: + def render(self) -> RenderableType: # Apply colours only inside render_styled # Pass the full RICH style object into `render` - not the `Styles` return Panel( diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index 72863f602..0e4c9b76a 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -9,7 +9,7 @@ from ..widget import Widget class Static(Widget): def __init__( self, - renderable: RenderableType, + renderable: RenderableType = "", *, name: str | None = None, id: str | None = None, @@ -18,7 +18,7 @@ class Static(Widget): super().__init__(name=name, id=id, classes=classes) self.renderable = renderable - def render(self, style: Style) -> RenderableType: + def render(self) -> RenderableType: return self.renderable def update(self, renderable: RenderableType) -> None: diff --git a/src/textual/widgets/_tree_control.py b/src/textual/widgets/_tree_control.py index 43dd9fc12..ca21022c2 100644 --- a/src/textual/widgets/_tree_control.py +++ b/src/textual/widgets/_tree_control.py @@ -248,7 +248,7 @@ class TreeControl(Generic[NodeDataType], Widget): push(iter(node.children)) return None - def render(self, style: Style) -> RenderableType: + def render(self) -> RenderableType: return self._tree def render_node(self, node: TreeNode[NodeDataType]) -> RenderableType: diff --git a/src/textual/widgets/tabs.py b/src/textual/widgets/tabs.py index 8d351c510..4346816f6 100644 --- a/src/textual/widgets/tabs.py +++ b/src/textual/widgets/tabs.py @@ -330,7 +330,7 @@ class Tabs(Widget): """ return next((i for i, tab in enumerate(self.tabs) if tab.name == tab_name), 0) - def render(self, style: Style) -> RenderableType: + def render(self) -> RenderableType: return TabsRenderable( self.tabs, tab_padding=self.tab_padding, diff --git a/src/textual/widgets/text_input.py b/src/textual/widgets/text_input.py index d40c63e84..0f8c6fe91 100644 --- a/src/textual/widgets/text_input.py +++ b/src/textual/widgets/text_input.py @@ -150,7 +150,7 @@ class TextInput(TextWidgetBase, can_focus=True): self.visible_range = (new_visible_range_start, new_visible_range_end) self.refresh() - def render(self, style: Style) -> RenderableType: + def render(self) -> RenderableType: # First render: Cursor at start of text, visible range goes from cursor to content region width if not self.visible_range: self.visible_range = (self._editor.cursor_index, self.content_region.width) @@ -255,7 +255,7 @@ class TextAreaChild(TextWidgetBase, can_focus=True): CSS = "TextAreaChild { height: auto; background: $primary-darken-1; }" STOP_PROPAGATE = {"tab", "shift+tab"} - def render(self, style: Style) -> RenderableType: + def render(self) -> RenderableType: # We only show the cursor if the widget has focus show_cursor = self.has_focus display_text = Text(self._editor.content, no_wrap=True) diff --git a/tests/test_integration_layout.py b/tests/test_integration_layout.py index 7fde168fa..e31566943 100644 --- a/tests/test_integration_layout.py +++ b/tests/test_integration_layout.py @@ -202,7 +202,7 @@ async def test_border_edge_types_impact_on_widget_size( expects_visible_char_at_top_left_edge: bool, ): class BorderTarget(Widget): - def render(self, style) -> RenderableType: + def render(self) -> RenderableType: return Text("border target", style="black on yellow", justify="center") border_target = BorderTarget() diff --git a/tests/test_widget.py b/tests/test_widget.py index b1c8e9f96..5ccedd39d 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -39,7 +39,7 @@ def test_widget_content_width(): self.text = text super().__init__(id=id) - def render(self, style: Style) -> str: + def render(self) -> str: return self.text widget1 = TextWidget("foo", id="widget1") From 43e9f8add94b0fedc2cf2b1b2a36d7a7e5c7ef87 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 25 May 2022 16:20:04 +0100 Subject: [PATCH 02/14] docsstrings --- poetry.lock | 64 ++++++++++++++-------------- pyproject.toml | 5 +-- src/textual/app.py | 8 ++-- src/textual/css/_style_properties.py | 2 + src/textual/screen.py | 7 ++- 5 files changed, 47 insertions(+), 39 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6aa1ed18d..0285e2e4d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -35,7 +35,7 @@ frozenlist = ">=1.1.0" name = "astunparse" version = "1.6.3" description = "An AST unparser for Python" -category = "main" +category = "dev" optional = false python-versions = "*" @@ -110,7 +110,7 @@ uvloop = ["uvloop (>=0.15.2)"] name = "cached-property" version = "1.5.2" description = "A decorator for caching properties in classes." -category = "main" +category = "dev" optional = false python-versions = "*" @@ -207,7 +207,7 @@ python-versions = ">=3.7" name = "ghp-import" version = "2.1.0" description = "Copy your docs directly to the gh-pages branch." -category = "main" +category = "dev" optional = false python-versions = "*" @@ -221,7 +221,7 @@ dev = ["twine", "markdown", "flake8", "wheel"] name = "griffe" version = "0.19.2" description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." -category = "main" +category = "dev" optional = false python-versions = ">=3.7" @@ -279,7 +279,7 @@ python-versions = "*" name = "jinja2" version = "3.0.3" description = "A very fast and expressive template engine." -category = "main" +category = "dev" optional = false python-versions = ">=3.6" @@ -293,7 +293,7 @@ i18n = ["Babel (>=2.7)"] name = "markdown" version = "3.3.7" description = "Python implementation of Markdown." -category = "main" +category = "dev" optional = false python-versions = ">=3.6" @@ -307,7 +307,7 @@ testing = ["coverage", "pyyaml"] name = "markupsafe" version = "2.1.1" description = "Safely add untrusted strings to HTML/XML markup." -category = "main" +category = "dev" optional = false python-versions = ">=3.7" @@ -315,7 +315,7 @@ python-versions = ">=3.7" name = "mergedeep" version = "1.3.4" description = "A deep merge function for 🐍." -category = "main" +category = "dev" optional = false python-versions = ">=3.6" @@ -323,7 +323,7 @@ python-versions = ">=3.6" name = "mkdocs" version = "1.3.0" description = "Project documentation with Markdown." -category = "main" +category = "dev" optional = false python-versions = ">=3.6" @@ -346,7 +346,7 @@ i18n = ["babel (>=2.9.0)"] name = "mkdocs-autorefs" version = "0.4.1" description = "Automatically link across pages in MkDocs." -category = "main" +category = "dev" optional = false python-versions = ">=3.7" @@ -356,19 +356,19 @@ mkdocs = ">=1.1" [[package]] name = "mkdocs-material" -version = "7.3.6" -description = "A Material Design theme for MkDocs" +version = "8.2.15" +description = "Documentation that simply works" category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.7" [package.dependencies] jinja2 = ">=2.11.1" markdown = ">=3.2" -mkdocs = ">=1.2.3" -mkdocs-material-extensions = ">=1.0" -pygments = ">=2.10" -pymdown-extensions = ">=9.0" +mkdocs = ">=1.3.0" +mkdocs-material-extensions = ">=1.0.3" +pygments = ">=2.12" +pymdown-extensions = ">=9.4" [[package]] name = "mkdocs-material-extensions" @@ -382,7 +382,7 @@ python-versions = ">=3.6" name = "mkdocstrings" version = "0.18.1" description = "Automatic documentation from sources, for MkDocs." -category = "main" +category = "dev" optional = false python-versions = ">=3.7" @@ -405,7 +405,7 @@ python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] name = "mkdocstrings-python" version = "0.6.6" description = "A Python handler for mkdocstrings." -category = "main" +category = "dev" optional = false python-versions = ">=3.7" @@ -417,7 +417,7 @@ mkdocstrings = ">=0.18" name = "mkdocstrings-python-legacy" version = "0.2.2" description = "A legacy Python handler for mkdocstrings." -category = "main" +category = "dev" optional = false python-versions = ">=3.7" @@ -480,7 +480,7 @@ python-versions = "*" name = "packaging" version = "21.3" description = "Core utilities for Python packages" -category = "main" +category = "dev" optional = false python-versions = ">=3.6" @@ -559,7 +559,7 @@ python-versions = ">=3.6" name = "pymdown-extensions" version = "9.4" description = "Extension pack for Python Markdown." -category = "main" +category = "dev" optional = false python-versions = ">=3.7" @@ -570,7 +570,7 @@ markdown = ">=3.2" name = "pyparsing" version = "3.0.9" description = "pyparsing module - Classes and methods to define and execute parsing grammars" -category = "main" +category = "dev" optional = false python-versions = ">=3.6.8" @@ -650,7 +650,7 @@ testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtuale name = "python-dateutil" version = "2.8.2" description = "Extensions to the standard Python datetime module" -category = "main" +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" @@ -661,7 +661,7 @@ six = ">=1.5" name = "pytkdocs" version = "0.16.1" description = "Load Python objects documentation." -category = "main" +category = "dev" optional = false python-versions = ">=3.7" @@ -677,7 +677,7 @@ numpy-style = ["docstring_parser (>=0.7)"] name = "pyyaml" version = "6.0" description = "YAML parser and emitter for Python" -category = "main" +category = "dev" optional = false python-versions = ">=3.6" @@ -685,7 +685,7 @@ python-versions = ">=3.6" name = "pyyaml-env-tag" version = "0.1" description = "A custom YAML tag for referencing environment variables in YAML files. " -category = "main" +category = "dev" optional = false python-versions = ">=3.6" @@ -712,7 +712,7 @@ jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "main" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" @@ -782,7 +782,7 @@ testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", name = "watchdog" version = "2.1.8" description = "Filesystem events monitoring" -category = "main" +category = "dev" optional = false python-versions = ">=3.6" @@ -817,7 +817,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "f32bca17ffa867133de5437f87390b1122fb84ee9c0b6eafab03f8c518fe6c15" +content-hash = "bfa71851a0d29adf2bf6de97054967e2122355bdefe859439bca5ec4377c9992" [metadata.files] aiohttp = [ @@ -1166,8 +1166,8 @@ mkdocs-autorefs = [ {file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"}, ] mkdocs-material = [ - {file = "mkdocs-material-7.3.6.tar.gz", hash = "sha256:1b1dbd8ef2508b358d93af55a5c5db3f141c95667fad802301ec621c40c7c217"}, - {file = "mkdocs_material-7.3.6-py2.py3-none-any.whl", hash = "sha256:1b6b3e9e09f922c2d7f1160fe15c8f43d4adc0d6fb81aa6ff0cbc7ef5b78ec75"}, + {file = "mkdocs-material-8.2.15.tar.gz", hash = "sha256:93b57e53733051431cc83216446e774bdf08bf516a6251ff2f24974f45f98149"}, + {file = "mkdocs_material-8.2.15-py2.py3-none-any.whl", hash = "sha256:9d6c4ca1ceecc00b2e38c214665ed7605d275321dcaa22f38b9d1175edc58955"}, ] mkdocs-material-extensions = [ {file = "mkdocs-material-extensions-1.0.3.tar.gz", hash = "sha256:bfd24dfdef7b41c312ede42648f9eb83476ea168ec163b613f9abd12bbfddba2"}, diff --git a/pyproject.toml b/pyproject.toml index cf50856ac..b4222f52e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,6 @@ click = "8.1.2" importlib-metadata = "^4.11.3" typing-extensions = { version = "^4.0.0", python = "<3.8" } msgpack = "^1.0.3" -mkdocstrings = {extras = ["python"], version = "^0.18.1"} [tool.poetry.dev-dependencies] pytest = "^6.2.3" @@ -36,8 +35,8 @@ black = "^22.3.0" mypy = "^0.950" pytest-cov = "^2.12.1" mkdocs = "^1.3.0" -mkdocstrings = "^0.18.1" -mkdocs-material = "^7.3.6" +mkdocstrings = {extras = ["python"], version = "^0.18.1"} +mkdocs-material = "^8.2.15" pre-commit = "^2.13.0" pytest-aiohttp = "^1.0.4" time-machine = "^2.6.0" diff --git a/src/textual/app.py b/src/textual/app.py index 136d440d3..366d733f0 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -32,7 +32,7 @@ import rich.repr from rich.console import Console, RenderableType from rich.measure import Measurement from rich.protocol import is_renderable -from rich.segment import Segments +from rich.segment import Segments, SegmentLines from rich.traceback import Traceback from . import actions @@ -441,7 +441,9 @@ class App(Generic[ReturnType], DOMNode): color_system="truecolor", record=True, ) - console.print(self.screen._compositor.render(full=True)) + lines = self.screen._compositor.render(full=True).lines + + console.print(SegmentLines(lines, new_lines=True)) return console.export_svg(title=self.title) def save_screenshot(self, path: str | None = None) -> str: @@ -524,7 +526,7 @@ class App(Generic[ReturnType], DOMNode): self.screen.refresh(layout=True) def render(self) -> RenderableType: - return Blank() + return "" def query(self, selector: str | None = None) -> DOMQuery: """Get a DOM query in the current screen. diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 1b891355c..061a4bd75 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -938,6 +938,8 @@ class FractionalProperty: class AlignProperty: + """Combines the horizontal and vertical alignment properties in to a single property.""" + def __set_name__(self, owner: StylesBase, name: str) -> None: self.horizontal = f"{name}_horizontal" self.vertical = f"{name}_vertical" diff --git a/src/textual/screen.py b/src/textual/screen.py index 6fa8947de..12b8565e8 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -28,7 +28,12 @@ class Screen(Widget): """A widget for the root of the app.""" CSS = """ - + Screen { + layout: vertical; + overflow-y: auto; + background: $surface; + color: $text-surface; + } """ dark = Reactive(False) From 153567d3b1521edea2980a2e7abbbd57ab79f483 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 25 May 2022 17:16:31 +0100 Subject: [PATCH 03/14] debugging app refreshes --- docs/examples/introduction/intro02.py | 7 +++++++ src/textual/_compositor.py | 2 +- src/textual/app.py | 5 +++-- src/textual/css/styles.py | 1 + src/textual/renderables/blank.py | 2 +- src/textual/screen.py | 2 ++ 6 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/examples/introduction/intro02.py b/docs/examples/introduction/intro02.py index 2f5d3ed64..cde514be6 100644 --- a/docs/examples/introduction/intro02.py +++ b/docs/examples/introduction/intro02.py @@ -3,6 +3,10 @@ from textual.app import App class ExampleApp(App): + CSS = """ + + """ + COLORS = [ "white", "maroon", @@ -18,12 +22,15 @@ class ExampleApp(App): def on_mount(self): self.styles.background = "darkblue" + self.bind("t", "tree") def on_key(self, event): if event.key.isdigit(): self.styles.background = self.COLORS[int(event.key)] self.bell() + def action_tree(self): + self.log(self.tree) app = ExampleApp() app.run() diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index ba2f30f29..6b6848c5f 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -498,7 +498,7 @@ class Compositor: """Get rendered widgets (lists of segments) in the composition. Returns: - Iterable[tuple[Region, Region, Lines]]: An interable of , , and + Iterable[tuple[Region, Region, Lines]]: An iterable of , , and """ # If a renderable throws an error while rendering, the user likely doesn't care about the traceback # up to this point. diff --git a/src/textual/app.py b/src/textual/app.py index 366d733f0..88db0ec06 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -526,7 +526,7 @@ class App(Generic[ReturnType], DOMNode): self.screen.refresh(layout=True) def render(self) -> RenderableType: - return "" + return Blank("red") def query(self, selector: str | None = None) -> DOMQuery: """Get a DOM query in the current screen. @@ -999,7 +999,6 @@ class App(Generic[ReturnType], DOMNode): action_target = default_namespace or self action_name = target - log("", action) await self.dispatch_action(action_target, action_name, params) async def dispatch_action( @@ -1014,6 +1013,8 @@ class App(Generic[ReturnType], DOMNode): _rich_traceback_guard = True method_name = f"action_{action_name}" method = getattr(namespace, method_name, None) + if method is None: + log(f" {action_name!r} has no target") if callable(method): await invoke(method, *params) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index ae3e7f5a1..7a079fb3b 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -476,6 +476,7 @@ class Styles(StylesBase): return self._rules.get(rule, default) def refresh(self, *, layout: bool = False) -> None: + print(self, self.node, "REFRESH", layout) if self.node is not None: self.node.refresh(layout=layout) diff --git a/src/textual/renderables/blank.py b/src/textual/renderables/blank.py index a0df77ed1..9dd07b7d9 100644 --- a/src/textual/renderables/blank.py +++ b/src/textual/renderables/blank.py @@ -24,7 +24,7 @@ class Blank: width = options.max_width height = options.height or options.max_height - segment = Segment(f"{' ' * width}", style=self._style) + segment = Segment(" " * width, self._style) line = Segment.line() for _ in range(height): yield segment diff --git a/src/textual/screen.py b/src/textual/screen.py index 12b8565e8..b24a6405e 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -31,8 +31,10 @@ class Screen(Widget): Screen { layout: vertical; overflow-y: auto; + /* background: $surface; color: $text-surface; + */ } """ From 5c10f944ede9f78385f6780469234e111c2a2c13 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 26 May 2022 11:15:44 +0100 Subject: [PATCH 04/14] fix for simple case --- docs/examples/introduction/intro02.py | 7 ------ docs/examples/styles/width.py | 2 +- sandbox/nest.py | 2 +- src/textual/_compositor.py | 15 +++++------ src/textual/app.py | 36 ++++++++++++++++----------- src/textual/css/styles.py | 1 - src/textual/renderables/blank.py | 10 ++++---- src/textual/screen.py | 32 +++++++++++++++--------- src/textual/widget.py | 1 - 9 files changed, 56 insertions(+), 50 deletions(-) diff --git a/docs/examples/introduction/intro02.py b/docs/examples/introduction/intro02.py index cde514be6..2f5d3ed64 100644 --- a/docs/examples/introduction/intro02.py +++ b/docs/examples/introduction/intro02.py @@ -3,10 +3,6 @@ from textual.app import App class ExampleApp(App): - CSS = """ - - """ - COLORS = [ "white", "maroon", @@ -22,15 +18,12 @@ class ExampleApp(App): def on_mount(self): self.styles.background = "darkblue" - self.bind("t", "tree") def on_key(self, event): if event.key.isdigit(): self.styles.background = self.COLORS[int(event.key)] self.bell() - def action_tree(self): - self.log(self.tree) app = ExampleApp() app.run() diff --git a/docs/examples/styles/width.py b/docs/examples/styles/width.py index c7fafef9d..78ee6b699 100644 --- a/docs/examples/styles/width.py +++ b/docs/examples/styles/width.py @@ -5,7 +5,7 @@ from textual.widget import Widget class WidthApp(App): CSS = """ Widget { - background: blue; + background: blue 50%; width: 50%; } """ diff --git a/sandbox/nest.py b/sandbox/nest.py index a02c7fa93..ab643ff7a 100644 --- a/sandbox/nest.py +++ b/sandbox/nest.py @@ -12,7 +12,7 @@ TEXT = Text.from_markup(lorem) class TextWidget(Widget): - def render(self, style): + def render(self): return TEXT diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 6b6848c5f..bd9fedda5 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -221,7 +221,7 @@ class Compositor: # Keep a copy of the old map because we're going to compare it with the update old_map = self.map.copy() old_widgets = old_map.keys() - map, widgets = self._arrange_root(parent) + map, widgets = self._arrange_root(parent, size) # parent.log(map) @@ -246,17 +246,16 @@ class Compositor: resized_widgets = { widget for widget, (region, *_) in map.items() - if widget in old_widgets and widget.size != region.size + if widget in old_widgets and old_map[widget].region.size != region.size } # Gets pairs of tuples of (Widget, MapGeometry) which have changed # i.e. if something is moved / deleted / added screen = size.region + if screen not in self._dirty_regions: crop_screen = screen.intersection - changes: set[tuple[Widget, MapGeometry]] = ( - self.map.items() ^ old_map.items() - ) + changes = map.items() ^ old_map.items() self._dirty_regions.update( [ crop_screen(map_geometry.visible_region) @@ -270,7 +269,9 @@ class Compositor: resized=resized_widgets, ) - def _arrange_root(self, root: Widget) -> tuple[CompositorMap, set[Widget]]: + def _arrange_root( + self, root: Widget, size: Size + ) -> tuple[CompositorMap, set[Widget]]: """Arrange a widgets children based on its layout attribute. Args: @@ -282,7 +283,7 @@ class Compositor: """ ORIGIN = Offset(0, 0) - size = root.size + map: CompositorMap = {} widgets: set[Widget] = set() get_order = attrgetter("order") diff --git a/src/textual/app.py b/src/textual/app.py index 88db0ec06..f5b55fc0d 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -526,7 +526,7 @@ class App(Generic[ReturnType], DOMNode): self.screen.refresh(layout=True) def render(self) -> RenderableType: - return Blank("red") + return Blank() def query(self, selector: str | None = None) -> DOMQuery: """Get a DOM query in the current screen. @@ -737,22 +737,21 @@ class App(Generic[ReturnType], DOMNode): driver = self._driver = self.driver_class(self.console, self) driver.start_application_mode() - try: - mount_event = events.Mount(sender=self) - await self.dispatch_message(mount_event) + with redirect_stdout(StdoutRedirector(self.devtools, self._log_file)): # type: ignore + try: + mount_event = events.Mount(sender=self) + await self.dispatch_message(mount_event) - self.title = self._title - self.stylesheet.update(self) - self.refresh() - await self.animator.start() - - with redirect_stdout(StdoutRedirector(self.devtools, self._log_file)): # type: ignore + self.title = self._title + self.stylesheet.update(self) + self.refresh() + await self.animator.start() await self._ready() await super().process_messages() await self.animator.stop() await self.close_all() - finally: - driver.stop_application_mode() + finally: + driver.stop_application_mode() except Exception as error: self.on_exception(error) finally: @@ -872,7 +871,11 @@ class App(Generic[ReturnType], DOMNode): await self.close_messages() def refresh(self, *, repaint: bool = True, layout: bool = False) -> None: - self._display(self.screen._compositor) + self.screen.refresh(repaint=repaint, layout=layout) + # self._display(self.screen._compositor.render()) + + def _paint(self): + self._display(self.screen._compositor.render()) def refresh_css(self, animate: bool = True) -> None: """Refresh CSS. @@ -1052,11 +1055,11 @@ class App(Generic[ReturnType], DOMNode): async def handle_update(self, message: messages.Update) -> None: message.stop() - self.app.refresh() + self._paint() async def handle_layout(self, message: messages.Layout) -> None: message.stop() - self.app.refresh() + self._paint() async def on_key(self, event: events.Key) -> None: if event.key == "tab": @@ -1071,6 +1074,9 @@ class App(Generic[ReturnType], DOMNode): await self.close_messages() async def on_resize(self, event: events.Resize) -> None: + event.stop() + self.screen._screen_resized(event.size) + await self.screen.post_message(event) async def action_press(self, key: str) -> None: diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 7a079fb3b..ae3e7f5a1 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -476,7 +476,6 @@ class Styles(StylesBase): return self._rules.get(rule, default) def refresh(self, *, layout: bool = False) -> None: - print(self, self.node, "REFRESH", layout) if self.node is not None: self.node.refresh(layout=layout) diff --git a/src/textual/renderables/blank.py b/src/textual/renderables/blank.py index 9dd07b7d9..c0ed42b2f 100644 --- a/src/textual/renderables/blank.py +++ b/src/textual/renderables/blank.py @@ -11,12 +11,12 @@ class Blank: """Draw solid background color.""" def __init__(self, color: Color | str = "transparent") -> None: - background = ( - color.rich_color - if isinstance(color, Color) - else Color.parse(color).rich_color + background = color if isinstance(color, Color) else Color.parse(color) + self._style = ( + Style() + if background.is_transparent + else Style.from_color(None, background.rich_color) ) - self._style = Style.from_color(None, background) def __rich_console__( self, console: Console, options: ConsoleOptions diff --git a/src/textual/screen.py b/src/textual/screen.py index b24a6405e..08d02cbeb 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -9,9 +9,11 @@ from rich.style import Style from . import events, messages, errors -from .geometry import Offset, Region +from .color import Color +from .geometry import Offset, Region, Size from ._compositor import Compositor, MapGeometry from .reactive import Reactive +from .renderables.blank import Blank from .widget import Widget if sys.version_info >= (3, 8): @@ -31,10 +33,6 @@ class Screen(Widget): Screen { layout: vertical; overflow-y: auto; - /* - background: $surface; - color: $text-surface; - */ } """ @@ -45,11 +43,15 @@ class Screen(Widget): self._compositor = Compositor() self._dirty_widgets: set[Widget] = set() + @property + def is_transparent(self) -> bool: + return False + def watch_dark(self, dark: bool) -> None: pass def render(self) -> RenderableType: - return self.app.render() + return Blank() def get_offset(self, widget: Widget) -> Offset: """Get the absolute offset of a given Widget. @@ -114,15 +116,16 @@ class Screen(Widget): self._dirty_widgets.clear() self._update_timer.pause() - def _refresh_layout(self) -> None: + def _refresh_layout(self, size: Size | None = None, full: bool = False) -> None: """Refresh the layout (can change size and positions of widgets).""" - if not self.size: + size = self.size if size is None else size + if not size: return - # This paint the entire screen, so replaces the batched dirty widgets + self._compositor.update_widgets(self._dirty_widgets) self._update_timer.pause() try: - hidden, shown, resized = self._compositor.reflow(self, self.size) + hidden, shown, resized = self._compositor.reflow(self, size) Hide = events.Hide Show = events.Show @@ -131,6 +134,7 @@ class Screen(Widget): for widget in shown: widget.post_message_no_wait(Show(self)) + # We want to send a resize event to widgets that were just added or change since last layout send_resize = shown | resized for ( @@ -151,7 +155,7 @@ class Screen(Widget): self.app.on_exception(error) return - display_update = self._compositor.render() + display_update = self._compositor.render(full=full) if display_update is not None: self.app._display(display_update) @@ -172,9 +176,13 @@ class Screen(Widget): UPDATE_PERIOD, self._on_update, name="screen_update", pause=True ) + def _screen_resized(self, size: Size): + self._refresh_layout(size, full=True) + async def on_resize(self, event: events.Resize) -> None: - self.size_updated(event.size, event.virtual_size, event.container_size) event.stop() + # self._size = event.size + # self._refresh_layout(event.size, full=True) async def _on_mouse_move(self, event: events.MouseMove) -> None: try: diff --git a/src/textual/widget.py b/src/textual/widget.py index f0bb88627..2db2283ab 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -769,7 +769,6 @@ class Widget(DOMNode): self._size = size self._virtual_size = virtual_size self._container_size = container_size - if self.is_container: self._refresh_scrollbars() width, height = self.container_size From 51f1957f1cac1c93de7ffc60e30019f705e95f5f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 26 May 2022 11:16:03 +0100 Subject: [PATCH 05/14] comments --- src/textual/screen.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index 08d02cbeb..941e70a1c 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -181,8 +181,6 @@ class Screen(Widget): async def on_resize(self, event: events.Resize) -> None: event.stop() - # self._size = event.size - # self._refresh_layout(event.size, full=True) async def _on_mouse_move(self, event: events.MouseMove) -> None: try: From 3030557060495f202242e1527d5bbe6924f1e902 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 26 May 2022 17:02:30 +0100 Subject: [PATCH 06/14] refactor of docs --- custom_theme/main.html | 5 + docs/actions.md | 1 + docs/events/mount.md | 15 ++ docs/events/resize.md | 23 +++ docs/guide/guide.md | 1 + docs/index.md | 31 +--- docs/introduction.md | 166 +++++++++--------- mkdocs.yml | 25 +++ .../{color_system.md => _color_system.md} | 0 src/textual/events.py | 8 +- 10 files changed, 165 insertions(+), 110 deletions(-) create mode 100644 custom_theme/main.html create mode 100644 docs/actions.md create mode 100644 docs/events/mount.md create mode 100644 docs/events/resize.md create mode 100644 docs/guide/guide.md rename reference/{color_system.md => _color_system.md} (100%) diff --git a/custom_theme/main.html b/custom_theme/main.html new file mode 100644 index 000000000..810469c9f --- /dev/null +++ b/custom_theme/main.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} + +{% block extrahead %} + +{% endblock %} diff --git a/docs/actions.md b/docs/actions.md new file mode 100644 index 000000000..1060a658b --- /dev/null +++ b/docs/actions.md @@ -0,0 +1 @@ +# Actions diff --git a/docs/events/mount.md b/docs/events/mount.md new file mode 100644 index 000000000..ff393acb6 --- /dev/null +++ b/docs/events/mount.md @@ -0,0 +1,15 @@ +# Mount + +The `Mount` event is sent to a widget and Application when it is first mounted. + +- [ ] Bubbles + +## Parameters + +`sender` + +: The sender of the widget + +## Code + +::: textual.events.Mount diff --git a/docs/events/resize.md b/docs/events/resize.md new file mode 100644 index 000000000..d1a685876 --- /dev/null +++ b/docs/events/resize.md @@ -0,0 +1,23 @@ +# Resize + +The `Resize` event is sent to a widget when its size changes and when it is first made visible. + +- [x] Bubbles + +## Parameters + +`event.size` + +: The new size of the Widget. + +`event.virtual_size` + +: The virtual size (scrollable area) of the Widget. + +`event.container_size` + +: The size of the widget's container. + +## Code + +::: textual.events.Resize diff --git a/docs/guide/guide.md b/docs/guide/guide.md new file mode 100644 index 000000000..8c0d02fad --- /dev/null +++ b/docs/guide/guide.md @@ -0,0 +1 @@ +# Guide diff --git a/docs/index.md b/docs/index.md index acb93f011..a751fcf0e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,30 +1,11 @@ # Welcome -Textual is framework for rapidly creating _text user interfaces_ (TUIs from here on) with Python. +Welcome to the [Textual](https://github.com/Textualize/textual) framework documentation, built with ❤️ by [Textualize.io](https://www.textualize.io) -A TUI is an application that lives within a terminal, which can have mouse and keyboard support and user interface elements like windows and panels, but is rendered purely with text. They have a number of advantages over GUI applications: they can be launched from the command line, and return to the command line, and they work over ssh. +## Getting started -## Foo +Textual is a Python framework which you can install via Pypi. -Creating a TUI can be challenging. It may be easier to create a GUI or web application than it is to build a TUI with traditional techniques. Often projects that could use one or the other never manage to ship either. - -Textual seeks to lower the difficulty level of building a TUI by borrowing developments from the web world and to a lesser extent desktop applications. The goal is for it to be as easy to develop a TUI for your project as it would be to add a command line interface.XX - -=== "simple.py" - - ```python - --8<-- "docs/examples/simple.py" - ``` - -=== "simple.css" - - ```scss - --8<-- "docs/examples/simple.css" - ``` - -=== "Result" - - ```{.textual path="docs/examples/simple.py" columns="80" lines="24"} - ``` - -Textual also offers a number of enhancements over traditional TUI applications by taking advantage of improvements to terminal software and the hardware it runs on. Terminals are a far cry from their roots in ancient hardware and dial-up modems, yet much of the software that runs on them hasn't kept pace. +```bash +pip install textual +``` diff --git a/docs/introduction.md b/docs/introduction.md index 3145b56ce..97a96afa2 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -2,7 +2,7 @@ Welcome to the Textual Introduction! -This is a very gentle introduction to creating Textual applications. +This is a very gentle introduction to creating Textual applications. By the end of this document you should have an understanding of the basic concepts involved in using the Textual framework. ## Pre-requisites @@ -16,91 +16,87 @@ Lets looks at the simplest possible Textual app. It doesn't do much, but will de If you would like to follow along and run the examples, navigate to the `docs/examples/introduction` directory from the command prompt. We will be looking at `intro01.py`, which you can see here: -=== "intro01.py" - - ```python - --8<-- "docs/examples/introduction/intro01.py" - ``` - -=== "Output" - - ```{.textual path="docs/examples/introduction/intro01.py"} - ``` +```python title="intro01.py" +--8<-- "docs/examples/introduction/intro01.py" +``` Enter the following command to run the application: -```shell +```bash python intro01.py ``` -The command prompt should disappear and you will see a blank screen. Hit ++ctrl+c++ to exit and return to the command prompt. +The command prompt should disappear and you will see a blank screen. It will look something like the following: -Let's analyze this simple app. +```{.textual path="docs/examples/introduction/intro01.py"} + +``` + +Hit ++ctrl+c++ to exit and return to the command prompt. The first step in all Textual applications is to import the `App` class from `textual.app` and extend it: -```python -from textual.app import App - -class ExampleApp(App): - pass +```python hl_lines="1 2 3 4 5" title="intro01.py" +--8<-- "docs/examples/introduction/intro01.py" ``` -There will be a single App object in any Textual application. The App class is responsible for loading data, setting up the screen, managing events etc. +There will be a single App class in any Textual application. The App class is responsible for loading data, setting up the screen, managing events etc. In a real app most of the core logic of your application will be contained within methods on the this class. -The following two lines create an instance of the application and calls `run()`: +The last two lines create an instance of the application and calls `run()`: -```python -app = ExampleApp() -app.run() +```python hl_lines="8 9" title="intro01.py" +--8<-- "docs/examples/introduction/intro01.py" ``` -The `run` method will put your terminal in to "application mode" which disables the prompt and allows Textual to take over input and output. The `run()` method will return when the application exits. +The `run` method will put your terminal in to "application mode" which disables the prompt and allows Textual to take over input and output. When you press ++ctrl+c++ the application will exit application mode and re-enable the command prompt. ## Handling Events -In the previously example our app did next to nothing. Most applications will contain event handler methods, which are called in response to user actions (such as key presses, mouse action) and other changes your app needs to know about such as terminal resize, scrolling, timers, etc. +In the previously example our app did next to nothing. Most applications will contain event handler methods, which are called in response to user actions such as key presses and mouse movements in addition to other changes your app needs to know about such as terminal resize, scrolling, timers, etc. !!! note - Although `intro01.py` did not explicitly define any event handlers, Textual still had to respond to the Key event to catch ++ctrl+c++, otherwise you wouldn't be able to exit the app. + Although `intro01.py` did not explicitly define any event handlers, Textual still had to respond to events to catch ++ctrl+c++, otherwise you wouldn't be able to exit the app. -In our next example, we are going to handle two such events; `Mount` and `Key`. The `Mount` event is sent when the app is first run, and a `Key` event is sent when the user presses a key on the keyboard. Try running `intro02.py` in the `docs/examples/introduction`: +In our next example, we are going to handle two events; `Mount` and `Key`. The `Mount` event is sent when the app is first run, and a `Key` event is sent when the user presses a key on the keyboard. Try running `intro02.py` in the `docs/examples/introduction`: -=== "intro02.py" - - ```python - --8<-- "docs/examples/introduction/intro02.py" - ``` - -=== "Output" - - ```{.textual path="docs/examples/introduction/intro02.py"} - ``` - -When you run this app you should see a blue screen. If you hit any of the number keys ++0++-++9++, the background will change to another color. You may also hear a beep or some other noise when a key is pressed, depending on how your terminal is configured. As before, pressing ++ctrl+c++ will exit the app and return you to your prompt. - -There are two event handlers in this app class. Event handlers start with the text `on_` followed by the name of the event in lower case. Hence `on_mount` is called for the `Mount` event, and `on_key` is called for the `Key` event. - -Here's the `on_mount` method again: - -```python -def on_mount(self): - self.styles.background = "darkblue" +```python title="intro02.py" +--8<-- "docs/examples/introduction/intro02.py" ``` -This method sets the `background` attribute on `self.styles` to `"darkblue"` which makes the application background blue when the app loads. The `styles` object contains a variety of properties which define how your app looks. We will explore what you can do with this object later. +When you run this app you should see a blue screen in your terminal, like the following: + +```{.textual path="docs/examples/introduction/intro02.py"} + +``` + +If you hit any of the number keys ++0++-++9++, the background will change color and you should hear a beep. As before, pressing ++ctrl+c++ will exit the app and return you to your prompt. + +!!! note + + The "beep" is your terminal's *bell*. Some terminals may be configured to play different noises or a visual indication of a bell rather than a noise. + +There are two event handlers in this app. Event handlers start with the text `on_` followed by the name of the event in lower case. Hence `on_mount` is called for the `Mount` event, and `on_key` is called for the `Key` event. + +The first event handler to run is `on_mount`: + +```python hl_lines="19 20" title="intro02.py" +--8<-- "docs/examples/introduction/intro02.py" +``` + +This `on_mount` method sets the `background` attribute of `self.styles` to `"darkblue"` which updates the background color. There are a lot of other properties on the Styles object, which define how your app looks. We will explore what you can do with this object later. + +!!! note + + You may have noticed there was no function call to repaint the screen in this examples. Textual will detect when a refresh is required, and do it automatically. The second event handler will receive `Key` events whenever you press a key on the keyboard: -```python -def on_key(self, event): - if event.key.isdigit(): - self.styles.background = self.COLORS[int(event.key)] - self.bell() +```python hl_lines="22 23 24 25" title="intro02.py" +--8<-- "docs/examples/introduction/intro02.py" ``` -This method has an `event` positional argument which contains information regarding the key that was pressed. The body of the method sets the background to a corresponding color when you press one of the digit keys. It also calls `bell()` which is a method on App that plays your terminal's bell. +This method has an `event` positional argument which contains information regarding the key that was pressed. The body of the method sets the background to a corresponding color int the `COLORS` list when you press one of the digit keys. It also calls `bell()` which is a method on App that plays your terminal's bell. !!! note @@ -108,38 +104,44 @@ This method has an `event` positional argument which contains information regard ## Widgets -Most Textual applications will also make use of one or more `Widget` classes. A Widget is a self contained component which is responsible for defining how a given part of the screen should look. Widgets respond to events in much the same way as the App does. More sophisticated user interfaces can be built by combining various widgets. +Most Textual applications will also make use of one or more `Widget` classes. A Widget is a self contained component responsible for defining how a given part of the screen should look. Widgets respond to events in much the same way as the App does. -Let's look at an app which defines a very simple Widget to show the current time and date. Here is the code for `"clock01.py"` which is n the same directory as the previous examples: +Let's look at an app with a simple Widget to show the current time and date. Here is the code for `"clock01.py"` which is in the same directory as the previous examples: -=== "clock01.py" - - ```python - --8<-- "docs/examples/introduction/clock01.py" - ``` - -=== "Output" - - ```{.textual path="docs/examples/introduction/clock01.py"} - ``` - -This script imports App as before, but also the `Widget` class from `textual.widget`, which is the base class for all Widgets. The `Clock` widget extends `Widget` and adds an `on_mount` handler which is called when the widget is first added to the application. - -Lets have a look at the Clock's Mount event handler: - -```python - def on_mount(self): - self.styles.content_align = ("center", "middle") - self.set_interval(1, self.refresh) +```python title="clock01.py" +--8<-- "docs/examples/introduction/clock01.py" ``` -The first line in that method sets the `content_align` attribute on the styles object, which defines how text is positioned within the Widget. We're setting it to a tuple of `("center", "middle")` which tells Textual to horizontally center the text, and place it in the middle vertically. If you resize the terminal you should notice that the text is automatically centered. +Here's what you will see if you run this code: -The second line calls `self.set_interval` to request that Textual calls `self.refresh` to update the screen once a second. When the screen is refreshed, Textual will call the widget's `render()` method, which we can see here: +```{.textual path="docs/examples/introduction/clock01.py"} -```python - def render(self): - return datetime.now().strftime("%c") ``` -This method uses the datetime module to format the current date and time. It returns a string, but can also return a _Rich renderable_. Don't worry if you aren't familiar with [Rich](https://github.com/Textualize/rich), we will cover that later. +This script imports App as before, but also the `Widget` class from `textual.widget`, which is the base class for all Widgets. To create a Clock widget we extend from the Widget base class: + +```python title="clock01.py" hl_lines="7 8 9 10 11 12 13" +--8<-- "docs/examples/introduction/clock01.py" +``` + +Widgets support many of the same events as the Application itself, and can be thought of as mini-applications in their own right. The Clock widget also responds to a Mount event which is the first event received when a widget is _mounted_ (added to the App). The code in `Clock.on_mount` sets `styles.content_align` to tuple of `("center", "middle")` which tells Textual to display the Widget's content aligned to the horizontal center, and in the middle vertically. If you resize the terminal, you should find the time remains in the center. + +The second line in `on_mount` calls `self.set_interval` which tells Textual to invoke the `self.refresh` function once a second to refresh the Clock widget. + +When Textual refreshes a widget it calls it's `render` method: + +```python title="clock01.py" hl_lines="12 13" +--8<-- "docs/examples/introduction/clock01.py" +``` + +The Clocks `render` method uses the datetime module to format the current date and time. It returns a string, but can also return a _Rich renderable_. Don't worry if you aren't familiar with [Rich](https://github.com/Textualize/rich), we will cover that later. + +Before a Widget can be displayed, it must first be mounted on the app. This is typically done within the applications Mount handler, so that an application's widgets are added when the application first starts: + +```python title="clock01.py" hl_lines="17 18" +--8<-- "docs/examples/introduction/clock01.py" +``` + +In the case of the clock application, we call `mount` with an instance of the `Clock` widget. + +That's all there is to this Clock example. It will display the current time until you hit ++ctrl+c++ diff --git a/mkdocs.yml b/mkdocs.yml index 4938ef1ad..aa149ce98 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,12 +1,33 @@ site_name: Textual site_url: https://www.textualize.io/ +nav: + - "index.md" + - "introduction.md" + - Guide: + - "guide/guide.md" + - "actions.md" + - Events: + - "events/mount.md" + - "events/resize.md" + - Styles: + - "styles/width.md" + - Widgets: "/widgets/" + - Reference: + - "reference/app.md" + - "reference/events.md" + - "reference/widget.md" + markdown_extensions: - admonition + - def_list - meta - toc: permalink: true + baselevel: 1 - pymdownx.keys + - pymdownx.tasklist: + custom_checkbox: true - pymdownx.highlight: anchor_linenums: true - pymdownx.superfences: @@ -24,6 +45,9 @@ markdown_extensions: theme: name: material + custom_dir: custom_theme + # features: + # - navigation.tabs palette: - media: "(prefers-color-scheme: light)" scheme: default @@ -39,6 +63,7 @@ theme: name: Switch to light mode plugins: + - search: - mkdocstrings: default_handler: python diff --git a/reference/color_system.md b/reference/_color_system.md similarity index 100% rename from reference/color_system.md rename to reference/_color_system.md diff --git a/src/textual/events.py b/src/textual/events.py index caef3719b..d47c1a9a8 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -98,10 +98,12 @@ class Resize(Event, verbosity=2, bubble=False): container_size: Size | None = None, ) -> None: """ + Args: - sender (MessageTarget): Event sender. - width (int): New width in terminal cells. - height (int): New height in terminal cells. + sender (MessageTarget): The sender of the event (the Screen). + size (Size): The new size of the Widget. + virtual_size (Size): The virtual size (scrollable size) of the Widget. + container_size (Size | None, optional): The size of the Widget's container widget. Defaults to None. """ self.size = size self.virtual_size = virtual_size From 303254f9ac7707e9cd2f9ad2f536ba3a140bee0f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 May 2022 10:24:32 +0100 Subject: [PATCH 07/14] test fixes --- src/textual/widget.py | 2 +- tests/css/test_styles.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 2db2283ab..448a395c5 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -195,7 +195,7 @@ class Widget(DOMNode): return self._content_width_cache[1] console = self.app.console - renderable = self.render(self.styles.rich_style) + renderable = self.render() measurement = Measurement.get( console, console.options.update_width(container.width), diff --git a/tests/css/test_styles.py b/tests/css/test_styles.py index 5d406bcf3..a0286aba9 100644 --- a/tests/css/test_styles.py +++ b/tests/css/test_styles.py @@ -243,7 +243,7 @@ async def test_scrollbar_gutter( from textual.geometry import Size class TextWidget(Widget): - def render(self, styles) -> Text: + def render(self) -> Text: text_multiplier = 10 if text_length == "long_text" else 2 return Text( "Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit liber a a a." From c09dd46b86e7b505416f86a2342ec4faeb577406 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 May 2022 10:28:07 +0100 Subject: [PATCH 08/14] removed import --- src/textual/_doc.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/_doc.py b/src/textual/_doc.py index 1f74542c6..f7a73ce3a 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -1,6 +1,5 @@ import os -from textual.cli.cli import import_app # This module defines our "Custom Fences", powered by SuperFences # @link https://facelessuser.github.io/pymdown-extensions/extensions/superfences/#custom-fences From 9411ebcc9703d7c351187df363c75cf7f9933e6d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 May 2022 10:29:35 +0100 Subject: [PATCH 09/14] simplify --- src/textual/app.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index f5b55fc0d..682af5b5f 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -441,9 +441,8 @@ class App(Generic[ReturnType], DOMNode): color_system="truecolor", record=True, ) - lines = self.screen._compositor.render(full=True).lines - - console.print(SegmentLines(lines, new_lines=True)) + screen_render = self.screen._compositor.render(full=True) + console.print(screen_render) return console.export_svg(title=self.title) def save_screenshot(self, path: str | None = None) -> str: From 9d2d0d8f2b6f261bf7696cc8b1b4896b599a887f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 May 2022 10:32:59 +0100 Subject: [PATCH 10/14] comments --- src/textual/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 682af5b5f..7c64068be 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -871,9 +871,9 @@ class App(Generic[ReturnType], DOMNode): def refresh(self, *, repaint: bool = True, layout: bool = False) -> None: self.screen.refresh(repaint=repaint, layout=layout) - # self._display(self.screen._compositor.render()) def _paint(self): + """Perform a "paint" (draw the screen).""" self._display(self.screen._compositor.render()) def refresh_css(self, animate: bool = True) -> None: From 9697ca11dc60d5dd187742500fcb3deaebd83a53 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 May 2022 10:40:05 +0100 Subject: [PATCH 11/14] comments --- src/textual/_compositor.py | 2 -- src/textual/screen.py | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index bd9fedda5..392a463ce 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -223,8 +223,6 @@ class Compositor: old_widgets = old_map.keys() map, widgets = self._arrange_root(parent, size) - # parent.log(map) - new_widgets = map.keys() # Newly visible widgets diff --git a/src/textual/screen.py b/src/textual/screen.py index 941e70a1c..7b7887c06 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -177,6 +177,7 @@ class Screen(Widget): ) def _screen_resized(self, size: Size): + """Called by App when the screen is resized.""" self._refresh_layout(size, full=True) async def on_resize(self, event: events.Resize) -> None: From d3c800e66faad55d9a6f62d52e67137b5d29d374 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 May 2022 10:47:36 +0100 Subject: [PATCH 12/14] reduce screenshot time --- src/textual/_doc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_doc.py b/src/textual/_doc.py index f7a73ce3a..e02492a2e 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -7,7 +7,7 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs): """A superfences formatter to insert a SVG screenshot.""" os.environ["TEXTUAL"] = "headless" - os.environ["TEXTUAL_SCREENSHOT"] = "0.2" + os.environ["TEXTUAL_SCREENSHOT"] = "0.1" os.environ["COLUMNS"] = attrs.get("columns", "80") os.environ["LINES"] = attrs.get("lines", "24") path = attrs.get("path") From 04e76e1d2d1ddb888b3acf75668e3963b98e8656 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 May 2022 10:48:51 +0100 Subject: [PATCH 13/14] whitespace --- mkdocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index aa149ce98..46fa871b1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -72,7 +72,7 @@ plugins: rendering: show_source: true selection: - filters: + filters: - "!^_" - "^__init__$" - "!^can_replace$" From 0483805f17d3e9474298ab67584cbdde621dff2f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 May 2022 11:50:38 +0100 Subject: [PATCH 14/14] Update docs/introduction.md Co-authored-by: Olivier Philippon --- docs/introduction.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/introduction.md b/docs/introduction.md index 97a96afa2..26daa29dd 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -12,7 +12,7 @@ This is a very gentle introduction to creating Textual applications. By the end ## A Simple App -Lets looks at the simplest possible Textual app. It doesn't do much, but will demonstrate the basic steps you will need to create any application. +Let's looks at the simplest possible Textual app. It doesn't do much, but will demonstrate the basic steps you will need to create any application. If you would like to follow along and run the examples, navigate to the `docs/examples/introduction` directory from the command prompt. We will be looking at `intro01.py`, which you can see here: