├── .github └── workflows │ └── code-checks.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .pylintrc ├── .python-version ├── ChangeLog.md ├── LICENSE ├── Makefile ├── README.md ├── docs ├── examples │ ├── clear_pixel.py │ ├── clear_pixels.py │ ├── default_background.py │ ├── draw_circle.py │ ├── draw_line.py │ ├── draw_rectangle.py │ ├── mandelbrot.py │ ├── own_background.py │ ├── set_pixel.py │ ├── set_pixel_colour.py │ ├── set_pixels.py │ └── sizing.py └── source │ ├── CNAME │ ├── canvas.md │ ├── changelog.md │ ├── guide.md │ ├── index.md │ └── licence.md ├── img ├── textual-canvas.png └── textual-mandelbrot.png ├── mkdocs.yml ├── pyproject.toml ├── requirements-dev.lock ├── requirements.lock └── src └── textual_canvas ├── __init__.py ├── __main__.py ├── canvas.py └── py.typed /.github/workflows/code-checks.yaml: -------------------------------------------------------------------------------- 1 | name: Code quality checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | 11 | quality-checks: 12 | 13 | name: Quality checks 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 18 | 19 | steps: 20 | 21 | - name: Checkout Code 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up Python 25 | uses: actions/setup-python@v4 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | 29 | - name: Install Rye 30 | uses: eifinger/setup-rye@v4 31 | with: 32 | version: "latest" 33 | 34 | - name: Install Dependencies 35 | run: | 36 | echo ${{ matrix.python-version }} > .python-version 37 | make setup 38 | 39 | - name: Check for typos 40 | run: make spellcheck 41 | 42 | - name: Check the code style 43 | run: make codestyle 44 | 45 | - name: Lint the code 46 | run: make lint 47 | 48 | - name: Type check the code 49 | run: make stricttypecheck 50 | 51 | ### code-checks.yaml ends here 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | .coverage 3 | .coverage_report/ 4 | .DS_Store 5 | *.egg-info/ 6 | build/ 7 | dist/ 8 | __pycache__ 9 | site/ 10 | .screenshot_cache/ 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | # Ruff version. 4 | rev: v0.6.4 5 | hooks: 6 | # Run the linter. 7 | - id: ruff 8 | args: [ --fix, --select, I ] 9 | # Run the formatter. 10 | - id: ruff-format 11 | - repo: https://github.com/codespell-project/codespell 12 | rev: v2.4.1 13 | hooks: 14 | - id: codespell 15 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [BASIC] 2 | good-names=x,x0,x1,y,y0,y1,n,dx,sx,dy,sy,e2,f_M 3 | max-args=10 4 | 5 | [FORMAT] 6 | max-line-length=120 7 | 8 | [MESSAGES CONTROL] 9 | disable=fixme 10 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13.1 2 | -------------------------------------------------------------------------------- /ChangeLog.md: -------------------------------------------------------------------------------- 1 | # textual-canvas ChangeLog 2 | 3 | ## v0.4.0 4 | 5 | **Released: 2025-04-16** 6 | 7 | - Added `width` and `height` parameters to `Canvas.clear`. 8 | ([#17](https://github.com/davep/textual-canvas/pull/17)) 9 | 10 | ## v0.3.0 11 | 12 | **Released: 2025-04-15** 13 | 14 | - Renamed `color` `__init__` parameter to `canvas_color`. 15 | ([#7](https://github.com/davep/textual-canvas/pull/7)) 16 | - Added `pen_color` as an `__init__` parameter; defaults to the widget's 17 | currently-styled `color`. 18 | ([#7](https://github.com/davep/textual-canvas/pull/7)) 19 | - Made the "void"'s colour the widget's styled background colour. 20 | ([#7](https://github.com/davep/textual-canvas/pull/7)) 21 | - Made the canvas colour optional; defaulting to the widget's 22 | currently-styled background colour. 23 | ([#7](https://github.com/davep/textual-canvas/pull/7)) 24 | - Made all `color` parameters for drawing methods optional, defaulting to 25 | the current "pen colour". ([#7](https://github.com/davep/textual-canvas/pull/7)) 26 | - Added `set_pen`. ([#7](https://github.com/davep/textual-canvas/pull/7)) 27 | - Fixed off-by-one issue with `draw_rectangle`. 28 | ([#10](https://github.com/davep/textual-canvas/pull/10)) 29 | - Added `Canvas.clear_pixels`. 30 | ([#11](https://github.com/davep/textual-canvas/pull/11)) 31 | - Added `Canvas.clear_pixel`. 32 | ([#11](https://github.com/davep/textual-canvas/pull/11)) 33 | - Added an optional `refresh` parameter to all drawing methods. 34 | - Added a `Canvas.batch_refresh` context manager. 35 | 36 | ## v0.2.0 37 | 38 | **Released: 2023-07-16** 39 | 40 | - Dropped Python 3.7 as a supported Python version. 41 | - Added `Canvas.clear` 42 | 43 | ## v0.1.0 44 | 45 | **Released: 2023-04-01** 46 | 47 | - Allow for drawing shapes partially off the canvas. 48 | - Added support for drawing circles. 49 | 50 | ## v0.0.1 51 | 52 | **Released: 2023-03-27** 53 | 54 | Initial release. 55 | 56 | [//]: # (ChangeLog.md ends here) 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Dave Pearson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to 7 | deal in the Software without restriction, including without limitation the 8 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 9 | sell copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 21 | IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | lib := textual_canvas 2 | src := src/ 3 | examples := docs/examples 4 | run := rye run 5 | test := rye test 6 | python := $(run) python 7 | lint := rye lint -- --select I 8 | fmt := rye fmt 9 | mypy := $(run) mypy 10 | mkdocs := $(run) mkdocs 11 | spell := $(run) codespell 12 | 13 | ############################################################################## 14 | # Local "interactive testing" of the code. 15 | .PHONY: run 16 | run: # Run the code in a testing context 17 | $(python) -m $(lib) 18 | 19 | .PHONY: debug 20 | debug: # Run the code with Textual devtools enabled 21 | TEXTUAL=devtools make 22 | 23 | .PHONY: console 24 | console: # Run the textual console 25 | $(run) textual console 26 | 27 | ############################################################################## 28 | # Setup/update packages the system requires. 29 | .PHONY: setup 30 | setup: # Set up the repository for development 31 | rye sync 32 | $(run) pre-commit install 33 | 34 | .PHONY: update 35 | update: # Update all dependencies 36 | rye sync --update-all 37 | 38 | .PHONY: resetup 39 | resetup: realclean # Recreate the virtual environment from scratch 40 | make setup 41 | 42 | ############################################################################## 43 | # Checking/testing/linting/etc. 44 | .PHONY: lint 45 | lint: # Check the code for linting issues 46 | $(lint) $(src) $(examples) 47 | 48 | .PHONY: codestyle 49 | codestyle: # Is the code formatted correctly? 50 | $(fmt) --check $(src) $(examples) 51 | 52 | .PHONY: typecheck 53 | typecheck: # Perform static type checks with mypy 54 | $(mypy) --scripts-are-modules $(src) $(examples) 55 | 56 | .PHONY: stricttypecheck 57 | stricttypecheck: # Perform a strict static type checks with mypy 58 | $(mypy) --scripts-are-modules --strict $(src) $(examples) 59 | 60 | .PHONY: spellcheck 61 | spellcheck: # Spell check the code 62 | $(spell) *.md $(src) $(docs) 63 | 64 | .PHONY: checkall 65 | checkall: spellcheck codestyle lint stricttypecheck # Check all the things 66 | 67 | ############################################################################## 68 | # Documentation. 69 | .PHONY: docs 70 | docs: # Generate the system documentation 71 | $(mkdocs) build 72 | 73 | .PHONY: rtfm 74 | rtfm: # Locally read the library documentation 75 | $(mkdocs) serve 76 | 77 | .PHONY: publishdocs 78 | publishdocs: # Set up the docs for publishing 79 | $(mkdocs) gh-deploy 80 | 81 | ############################################################################## 82 | # Package/publish. 83 | .PHONY: package 84 | package: # Package the library 85 | rye build 86 | 87 | .PHONY: spackage 88 | spackage: # Create a source package for the library 89 | rye build --sdist 90 | 91 | .PHONY: testdist 92 | testdist: package # Perform a test distribution 93 | rye publish --yes --skip-existing --repository testpypi --repository-url https://test.pypi.org/legacy/ 94 | 95 | .PHONY: dist 96 | dist: package # Upload to pypi 97 | rye publish --yes --skip-existing 98 | 99 | ############################################################################## 100 | # Utility. 101 | .PHONY: repl 102 | repl: # Start a Python REPL in the venv. 103 | $(python) 104 | 105 | .PHONY: delint 106 | delint: # Fix linting issues. 107 | $(lint) --fix $(src) $(examples) 108 | 109 | .PHONY: pep8ify 110 | pep8ify: # Reformat the code to be as PEP8 as possible. 111 | $(fmt) $(src) $(examples) 112 | 113 | .PHONY: tidy 114 | tidy: delint pep8ify # Tidy up the code, fixing lint and format issues. 115 | 116 | .PHONY: clean-packaging 117 | clean-packaging: # Clean the package building files 118 | rm -rf dist 119 | 120 | .PHONY: clean-docs 121 | clean-docs: # Clean up the documentation building files 122 | rm -rf site .screenshot_cache 123 | 124 | .PHONY: clean 125 | clean: clean-packaging clean-docs # Clean the build directories 126 | 127 | .PHONY: realclean 128 | realclean: clean # Clean the venv and build directories 129 | rm -rf .venv 130 | 131 | .PHONY: help 132 | help: # Display this help 133 | @grep -Eh "^[a-z]+:.+# " $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.+# "}; {printf "%-20s %s\n", $$1, $$2}' 134 | 135 | ############################################################################## 136 | # Housekeeping tasks. 137 | .PHONY: housekeeping 138 | housekeeping: # Perform some git housekeeping 139 | git fsck 140 | git gc --aggressive 141 | git remote update --prune 142 | 143 | ### Makefile ends here 144 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # textual-canvas 2 | 3 | ![Being used for textual-mandelbrot](https://raw.githubusercontent.com/davep/textual-canvas/main/img/textual-mandelbrot.png) 4 | *An example of `textual-canvas` being used in a Textual application* 5 | 6 | ## Introduction 7 | 8 | `textual-canvas` provides a simple terminal-based drawing canvas widget for 9 | use with [Textual](https://textual.textualize.io/). Initially developed as a 10 | widget for building 11 | [`textual-mandelbrot`](https://github.com/davep/textual-mandelbrot), it made 12 | sense to spin it out into its own general-purpose library. 13 | 14 | ## Installing 15 | 16 | The package can be installed with `pip` or related tools, for example: 17 | 18 | ```sh 19 | $ pip install textual-canvas 20 | ``` 21 | 22 | ## The library 23 | 24 | The library provides one very simple widget for use in Textual: `Canvas`. 25 | This is a scrollable and focusable widget that can be used to colour 26 | "pixels", acting as a basic building block for drawing other things. The 27 | "pixels" themselves are half a character cell in height, hopefully coming 28 | out roughly square in most environments. 29 | 30 | See [the documentation](https://textual-canvas.davep.dev/) for an 31 | introduction, a usage guide and detailed API documentation. 32 | 33 | [//]: # (README.md ends here) 34 | -------------------------------------------------------------------------------- /docs/examples/clear_pixel.py: -------------------------------------------------------------------------------- 1 | from textual.app import App, ComposeResult 2 | from textual.color import Color 3 | 4 | from textual_canvas import Canvas 5 | 6 | 7 | class ClearPixelApp(App[None]): 8 | CSS = """ 9 | Canvas { 10 | background: $panel; 11 | color: blue; 12 | } 13 | """ 14 | 15 | def compose(self) -> ComposeResult: 16 | yield Canvas(30, 30, Color.parse("cornflowerblue")) 17 | 18 | def on_mount(self) -> None: 19 | self.query_one(Canvas).draw_line(10, 10, 15, 10).clear_pixel(12, 10) 20 | 21 | 22 | if __name__ == "__main__": 23 | ClearPixelApp().run() 24 | -------------------------------------------------------------------------------- /docs/examples/clear_pixels.py: -------------------------------------------------------------------------------- 1 | from textual.app import App, ComposeResult 2 | from textual.color import Color 3 | 4 | from textual_canvas import Canvas 5 | 6 | 7 | class ClearPixelsApp(App[None]): 8 | CSS = """ 9 | Canvas { 10 | background: $panel; 11 | color: blue; 12 | } 13 | """ 14 | 15 | def compose(self) -> ComposeResult: 16 | yield Canvas(30, 30, Color.parse("cornflowerblue")) 17 | 18 | def on_mount(self) -> None: 19 | self.query_one(Canvas).draw_line(10, 10, 16, 10).clear_pixels( 20 | ( 21 | (11, 10), 22 | (13, 10), 23 | (15, 10), 24 | ) 25 | ) 26 | 27 | 28 | if __name__ == "__main__": 29 | ClearPixelsApp().run() 30 | -------------------------------------------------------------------------------- /docs/examples/default_background.py: -------------------------------------------------------------------------------- 1 | from textual.app import App, ComposeResult 2 | 3 | from textual_canvas import Canvas 4 | 5 | 6 | class DefaultBackgroundApp(App[None]): 7 | CSS = """ 8 | Canvas { 9 | border: solid black; 10 | background: cornflowerblue; 11 | } 12 | """ 13 | 14 | def compose(self) -> ComposeResult: 15 | yield Canvas(30, 30) # (1)! 16 | 17 | 18 | if __name__ == "__main__": 19 | DefaultBackgroundApp().run() 20 | -------------------------------------------------------------------------------- /docs/examples/draw_circle.py: -------------------------------------------------------------------------------- 1 | from textual.app import App, ComposeResult 2 | from textual.color import Color 3 | 4 | from textual_canvas import Canvas 5 | 6 | 7 | class DrawCircleApp(App[None]): 8 | CSS = """ 9 | Canvas { 10 | background: $panel; 11 | color: blue; 12 | } 13 | """ 14 | 15 | def compose(self) -> ComposeResult: 16 | yield Canvas(30, 30, Color.parse("cornflowerblue")) 17 | 18 | def on_mount(self) -> None: 19 | self.query_one(Canvas).draw_circle(14, 14, 10) 20 | 21 | 22 | if __name__ == "__main__": 23 | DrawCircleApp().run() 24 | -------------------------------------------------------------------------------- /docs/examples/draw_line.py: -------------------------------------------------------------------------------- 1 | from textual.app import App, ComposeResult 2 | from textual.color import Color 3 | 4 | from textual_canvas import Canvas 5 | 6 | 7 | class DrawLineApp(App[None]): 8 | CSS = """ 9 | Canvas { 10 | background: $panel; 11 | color: blue; 12 | } 13 | """ 14 | 15 | def compose(self) -> ComposeResult: 16 | yield Canvas(30, 30, Color.parse("cornflowerblue")) 17 | 18 | def on_mount(self) -> None: 19 | self.query_one(Canvas).draw_line(2, 2, 27, 27) 20 | 21 | 22 | if __name__ == "__main__": 23 | DrawLineApp().run() 24 | -------------------------------------------------------------------------------- /docs/examples/draw_rectangle.py: -------------------------------------------------------------------------------- 1 | from textual.app import App, ComposeResult 2 | from textual.color import Color 3 | 4 | from textual_canvas import Canvas 5 | 6 | 7 | class DrawRectangleApp(App[None]): 8 | CSS = """ 9 | Canvas { 10 | background: $panel; 11 | color: blue; 12 | } 13 | """ 14 | 15 | def compose(self) -> ComposeResult: 16 | yield Canvas(30, 30, Color.parse("cornflowerblue")) 17 | 18 | def on_mount(self) -> None: 19 | self.query_one(Canvas).draw_rectangle(2, 2, 26, 26) 20 | 21 | 22 | if __name__ == "__main__": 23 | DrawRectangleApp().run() 24 | -------------------------------------------------------------------------------- /docs/examples/mandelbrot.py: -------------------------------------------------------------------------------- 1 | from typing import Iterator 2 | 3 | from textual.app import App, ComposeResult 4 | from textual.color import Color 5 | 6 | from textual_canvas.canvas import Canvas 7 | 8 | BLUE_BROWN = [ 9 | Color(66, 30, 15), 10 | Color(25, 7, 26), 11 | Color(9, 1, 47), 12 | Color(4, 4, 73), 13 | Color(0, 7, 100), 14 | Color(12, 44, 138), 15 | Color(24, 82, 177), 16 | Color(57, 125, 209), 17 | Color(134, 181, 229), 18 | Color(211, 236, 248), 19 | Color(241, 233, 191), 20 | Color(248, 201, 95), 21 | Color(255, 170, 0), 22 | Color(204, 128, 0), 23 | Color(153, 87, 0), 24 | Color(106, 52, 3), 25 | ] 26 | """https://stackoverflow.com/a/16505538/2123348""" 27 | 28 | 29 | def mandelbrot(x: float, y: float) -> int: 30 | c1 = complex(x, y) 31 | c2 = 0j 32 | for n in range(40): 33 | if abs(c2) > 2: 34 | return n 35 | c2 = c1 + (c2**2.0) 36 | return 0 37 | 38 | 39 | def frange(r_from: float, r_to: float, size: int) -> Iterator[tuple[int, float]]: 40 | steps = 0 41 | step = (r_to - r_from) / size 42 | n = r_from 43 | while n < r_to and steps < size: 44 | yield steps, n 45 | n += step 46 | steps += 1 47 | 48 | 49 | class MandelbrotApp(App[None]): 50 | def compose(self) -> ComposeResult: 51 | yield Canvas(120, 90) 52 | 53 | def on_mount(self) -> None: 54 | with (canvas := self.query_one(Canvas)).batch_refresh(): 55 | for x_pixel, x_point in frange(-2.5, 1.5, canvas.width): 56 | for y_pixel, y_point in frange(-1.5, 1.5, canvas.height): 57 | canvas.set_pixel( 58 | x_pixel, 59 | y_pixel, 60 | BLUE_BROWN[value % 16] 61 | if (value := mandelbrot(x_point, y_point)) 62 | else Color(0, 0, 0), 63 | ) 64 | 65 | 66 | if __name__ == "__main__": 67 | MandelbrotApp().run() 68 | -------------------------------------------------------------------------------- /docs/examples/own_background.py: -------------------------------------------------------------------------------- 1 | from textual.app import App, ComposeResult 2 | from textual.color import Color 3 | 4 | from textual_canvas import Canvas 5 | 6 | 7 | class OwnBackgroundApp(App[None]): 8 | CSS = """ 9 | Canvas { 10 | border: solid black; 11 | background: cornflowerblue; 12 | } 13 | """ 14 | 15 | def compose(self) -> ComposeResult: 16 | yield Canvas(30, 30, Color(80, 80, 255)) # (1)! 17 | 18 | 19 | if __name__ == "__main__": 20 | OwnBackgroundApp().run() 21 | -------------------------------------------------------------------------------- /docs/examples/set_pixel.py: -------------------------------------------------------------------------------- 1 | from textual.app import App, ComposeResult 2 | from textual.color import Color 3 | 4 | from textual_canvas import Canvas 5 | 6 | 7 | class SetPixelApp(App[None]): 8 | CSS = """ 9 | Canvas { 10 | background: $panel; 11 | color: red; 12 | } 13 | """ 14 | 15 | def compose(self) -> ComposeResult: 16 | yield Canvas(30, 30, Color.parse("cornflowerblue")) 17 | 18 | def on_mount(self) -> None: 19 | self.query_one(Canvas).set_pixel(10, 10) 20 | 21 | 22 | if __name__ == "__main__": 23 | SetPixelApp().run() 24 | -------------------------------------------------------------------------------- /docs/examples/set_pixel_colour.py: -------------------------------------------------------------------------------- 1 | from textual.app import App, ComposeResult 2 | from textual.color import Color 3 | 4 | from textual_canvas import Canvas 5 | 6 | 7 | class SetPixelApp(App[None]): 8 | CSS = """ 9 | Canvas { 10 | background: $panel; 11 | } 12 | """ 13 | 14 | def compose(self) -> ComposeResult: 15 | yield Canvas(30, 30, Color.parse("cornflowerblue")) 16 | 17 | def on_mount(self) -> None: 18 | for offset, colour in enumerate(("red", "green", "blue")): 19 | self.query_one(Canvas).set_pixel( 20 | 10 + offset, 21 | 10 + offset, 22 | Color.parse(colour), 23 | ) 24 | 25 | 26 | if __name__ == "__main__": 27 | SetPixelApp().run() 28 | -------------------------------------------------------------------------------- /docs/examples/set_pixels.py: -------------------------------------------------------------------------------- 1 | from textual.app import App, ComposeResult 2 | from textual.color import Color 3 | 4 | from textual_canvas import Canvas 5 | 6 | 7 | class SetPixelsApp(App[None]): 8 | CSS = """ 9 | Canvas { 10 | background: $panel; 11 | color: red; 12 | } 13 | """ 14 | 15 | def compose(self) -> ComposeResult: 16 | yield Canvas(30, 30, Color.parse("cornflowerblue")) 17 | 18 | def on_mount(self) -> None: 19 | self.query_one(Canvas).set_pixels( 20 | ( 21 | (10, 10), 22 | (15, 15), 23 | (10, 15), 24 | (15, 10), 25 | ) 26 | ) 27 | 28 | 29 | if __name__ == "__main__": 30 | SetPixelsApp().run() 31 | -------------------------------------------------------------------------------- /docs/examples/sizing.py: -------------------------------------------------------------------------------- 1 | from textual.app import App, ComposeResult 2 | from textual.color import Color 3 | 4 | from textual_canvas import Canvas 5 | 6 | 7 | class CanvasSizingApp(App[None]): 8 | CSS = """ 9 | Screen { 10 | layout: horizontal; 11 | } 12 | 13 | Canvas { 14 | background: $panel; 15 | border: solid cornflowerblue; 16 | width: 1fr; 17 | height: 1fr; 18 | } 19 | """ 20 | 21 | def compose(self) -> ComposeResult: 22 | yield Canvas(20, 20, Color(128, 0, 128), id="smaller") 23 | yield Canvas(60, 60, Color(128, 0, 128), id="bigger") 24 | 25 | def on_mount(self) -> None: 26 | self.query_one("#smaller").border_title = "Widget > Canvas" 27 | self.query_one("#bigger").border_title = "Canvas > Widget" 28 | 29 | 30 | if __name__ == "__main__": 31 | CanvasSizingApp().run() 32 | -------------------------------------------------------------------------------- /docs/source/CNAME: -------------------------------------------------------------------------------- 1 | textual-canvas.davep.dev 2 | -------------------------------------------------------------------------------- /docs/source/canvas.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: textual_canvas.canvas 3 | --- 4 | 5 | ::: textual_canvas.canvas 6 | 7 | [//]: # (canvas.md ends here) 8 | -------------------------------------------------------------------------------- /docs/source/changelog.md: -------------------------------------------------------------------------------- 1 | --8<-- "ChangeLog.md" 2 | 3 | [//]: # (changelog.md ends here) 4 | -------------------------------------------------------------------------------- /docs/source/guide.md: -------------------------------------------------------------------------------- 1 | # Usage Guide 2 | 3 | ## The `Canvas` widget 4 | 5 | The [`Canvas`][textual_canvas.canvas.Canvas] widget is used like any other 6 | Textual widget; it is imported as: 7 | 8 | ```python 9 | from textual_canvas import Canvas 10 | ``` 11 | 12 | and then can be [mounted][textual.screen.Screen.mount] or 13 | [composed][textual.screen.Screen.compose] like any other widget. 14 | 15 | ### Sizing 16 | 17 | When [creating it][textual_canvas.canvas.Canvas] you provide a width and a 18 | height of the canvas in "pixels". Note that these values are the dimensions 19 | of the canvas that the "pixels" are drawn on, not the size of the widget; 20 | the widget itself is sized using all the [normal Textual styling and 21 | geometry rules](https://textual.textualize.io/guide/layout/). 22 | 23 | To illustrate, here are two `Canvas` widgets, one where the widget is bigger 24 | than the canvas, and one where the canvas is bigger than the widget: 25 | 26 | === "Widget vs canvas sizing" 27 | 28 | ```{.textual path="docs/examples/sizing.py" lines=20 columns=80} 29 | ``` 30 | 31 | === "sizing.py" 32 | 33 | ```python 34 | --8<-- "docs/examples/sizing.py" 35 | ``` 36 | 37 | Note how the `Canvas` widget on the left is bigger than the canvas it is 38 | displaying; whereas the widget on the right is smaller than its canvas so it 39 | has scrollbars. 40 | 41 | ### Colours 42 | 43 | There are three main colours to consider when working with `Canvas`: 44 | 45 | - The widget background colour. 46 | - The canvas background colour. 47 | - The current "pen" colour. 48 | 49 | #### Widget vs canvas background 50 | 51 | The difference in the first two items listed above might not seem obvious to 52 | start with. The `Canvas` widget, like all other Textual widgets, has a 53 | [background](https://textual.textualize.io/styles/background/); you can 54 | style this with CSS just as you always would. But the canvas itself -- the 55 | area that you'll be drawing in inside the widget -- can have its own 56 | background colour. 57 | 58 | By default the canvas background colour will be the widget's background 59 | colour; but you can pass [`canvas_color`][textual_canvas.canvas.Canvas] as a 60 | parameter to change this. 61 | 62 | To illustrate, here is a `Canvas` widget where no background colour is 63 | specified, so the canvas background and the widget background are the same: 64 | 65 | === "30x30 canvas with widget's background" 66 | 67 | ```{.textual path="docs/examples/default_background.py"} 68 | ``` 69 | 70 | === "default_background.py" 71 | 72 | ```python 73 | --8<-- "docs/examples/default_background.py" 74 | ``` 75 | 76 | 1. The `Canvas` is created without a given colour, so the widget's 77 | `background` will be used as the canvas background colour. 78 | 79 | Note how the user won't be able to see what's canvas background and what's 80 | widget outside of the background. 81 | 82 | On the other hand, if we take the same code and give the `Canvas` its own 83 | background colour when we create it: 84 | 85 | === "30x30 canvas with its own background" 86 | 87 | ```{.textual path="docs/examples/own_background.py"} 88 | ``` 89 | 90 | === "own_background.py" 91 | 92 | ```python hl_lines="16" 93 | --8<-- "docs/examples/own_background.py" 94 | ``` 95 | 96 | 1. Note how `Canvas` is given its own background colour. 97 | 98 | #### The pen colour 99 | 100 | The `Canvas` widget has a "pen" colour; any time a drawing operation is 101 | performed, if no colour is given to the method, the "pen" colour is used. By 102 | default that colour is taken from the 103 | [`color`](https://textual.textualize.io/styles/color/) styling of the 104 | widget. 105 | 106 | ## Drawing on the canvas 107 | 108 | The canvas widget provides a number of methods for drawing on it. 109 | 110 | !!! note 111 | 112 | All coordinates used when drawing are relative to the top left corner of 113 | the canvas. 114 | 115 | ### Drawing a single pixel 116 | 117 | Use [`set_pixel`][textual_canvas.canvas.Canvas.set_pixel] to set the colour 118 | of a single pixel on the canvas. For example: 119 | 120 | === "Drawing a single pixel" 121 | 122 | ```{.textual path="docs/examples/set_pixel.py"} 123 | ``` 124 | 125 | === "set_pixel.py" 126 | 127 | ```python 128 | --8<-- "docs/examples/set_pixel.py" 129 | ``` 130 | 131 | That example is using the default pen colour, which in turn is defaulting 132 | the widget's [`color`](https://textual.textualize.io/styles/color/). Instead 133 | we can set pixels to specific colours: 134 | 135 | === "Drawing pixels with specific colours" 136 | 137 | ```{.textual path="docs/examples/set_pixel_colour.py"} 138 | ``` 139 | 140 | === "set_pixel_colour.py" 141 | 142 | ```python 143 | --8<-- "docs/examples/set_pixel_colour.py" 144 | ``` 145 | ### Drawing multiple pixels 146 | 147 | Use [`set_pixels`][textual_canvas.canvas.Canvas.set_pixels] to draw multiple 148 | pixels of the same colour at once. For example: 149 | 150 | === "Drawing multiple pixels" 151 | 152 | ```{.textual path="docs/examples/set_pixels.py"} 153 | ``` 154 | 155 | === "set_pixels.py" 156 | 157 | ```python 158 | --8<-- "docs/examples/set_pixels.py" 159 | ``` 160 | 161 | ### Drawing a line 162 | 163 | Use [`draw_line`][textual_canvas.canvas.Canvas.draw_line] to draw a line on 164 | the canvas. For example: 165 | 166 | === "Drawing a line" 167 | 168 | ```{.textual path="docs/examples/draw_line.py"} 169 | ``` 170 | 171 | === "draw_line.py" 172 | 173 | ```python 174 | --8<-- "docs/examples/draw_line.py" 175 | ``` 176 | 177 | ### Drawing a rectangle 178 | 179 | Use [`draw_rectangle`][textual_canvas.canvas.Canvas.draw_rectangle] to draw 180 | a rectangle on the canvas. For example: 181 | 182 | === "Drawing a rectangle" 183 | 184 | ```{.textual path="docs/examples/draw_rectangle.py"} 185 | ``` 186 | 187 | === "draw_rectangle.py" 188 | 189 | ```python 190 | --8<-- "docs/examples/draw_rectangle.py" 191 | ``` 192 | 193 | ### Drawing a circle 194 | 195 | Use [`draw_circle`][textual_canvas.canvas.Canvas.draw_circle] to draw a 196 | circle on the canvas. For example: 197 | 198 | === "Drawing a circle" 199 | 200 | ```{.textual path="docs/examples/draw_circle.py"} 201 | ``` 202 | 203 | === "draw_circle.py" 204 | 205 | ```python 206 | --8<-- "docs/examples/draw_circle.py" 207 | ``` 208 | 209 | ### Clearing a single pixel 210 | 211 | Use [`clear_pixel`][textual_canvas.Canvas.clear_pixel] to set a pixel's 212 | colour to the canvas' colour. For example: 213 | 214 | === "Clearing a single pixel" 215 | 216 | ```{.textual path="docs/examples/clear_pixel.py"} 217 | ``` 218 | 219 | === "clear_pixel.py" 220 | 221 | ```python 222 | --8<-- "docs/examples/clear_pixel.py" 223 | ``` 224 | 225 | ### Clearing multiple pixels 226 | 227 | Use [`clear_pixels`][textual_canvas.Canvas.clear_pixels] to set the colour 228 | of multiple pixels to the canvas' colour. For example: 229 | 230 | === "Clearing multiple pixels" 231 | 232 | ```{.textual path="docs/examples/clear_pixels.py"} 233 | ``` 234 | 235 | === "clear_pixels.py" 236 | 237 | ```python 238 | --8<-- "docs/examples/clear_pixels.py" 239 | ``` 240 | 241 | ## Further help 242 | 243 | You can find more detailed documentation of the API [in the next 244 | section](canvas.md). If you still have questions or have ideas for 245 | improvements please feel free to chat to me [in GitHub 246 | discussions](https://github.com/davep/textual-canvas/discussions); if you 247 | think you've found a problem, please feel free to [raise an 248 | issue](https://github.com/davep/textual-canvas/issues). 249 | 250 | [//]: # (guide.md ends here) 251 | -------------------------------------------------------------------------------- /docs/source/index.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | `textual-canvas` provides a simple terminal-based drawing canvas widget for 4 | use with [Textual](https://textual.textualize.io/). Initially developed as a 5 | widget for building 6 | [`textual-mandelbrot`](https://github.com/davep/textual-mandelbrot), it made 7 | sense to spin it out into its own general-purpose library. 8 | 9 | === "Textual Canvas Example" 10 | 11 | ```{.textual path="docs/examples/mandelbrot.py" lines=45 columns=120} 12 | ``` 13 | 14 | === "mandelbrot.py" 15 | 16 | ```py 17 | --8<-- "docs/examples/mandelbrot.py" 18 | ``` 19 | 20 | The widget is based around the use of half block characters; this has two 21 | main advantages: 22 | 23 | - The "pixels" are generally nice and square in most terminal setups. 24 | - You get to use the [full range of 25 | colours](https://textual.textualize.io/api/color/) for each pixel. 26 | 27 | ## Installing 28 | 29 | `textual-canvas` is [available from pypi](https://pypi.org/project/textual-canvas/) 30 | and can be installed with `pip` or similar Python package tools: 31 | 32 | ```shell 33 | pip install textual-canvas 34 | ``` 35 | 36 | ## Requirements 37 | 38 | The only requirements for this library, other than the standard Python 39 | library, are: 40 | 41 | - [`textual`](https://textual.textualize.io/) (obviously) 42 | - [`typing-extensions`](https://typing-extensions.readthedocs.io/en/latest/#). 43 | 44 | ## Supported Python versions 45 | 46 | `textual-canvas` is usable with [all supported Python 47 | versions](https://devguide.python.org/versions/) from 3.9 and above. 48 | 49 | [//]: # (index.md ends here) 50 | -------------------------------------------------------------------------------- /docs/source/licence.md: -------------------------------------------------------------------------------- 1 | ``` 2 | --8<-- "LICENSE" 3 | ``` 4 | 5 | [//]: # (licence.md ends here) 6 | -------------------------------------------------------------------------------- /img/textual-canvas.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davep/textual-canvas/297cb38d382e7fac1e1d3231ef32f016e83db389/img/textual-canvas.png -------------------------------------------------------------------------------- /img/textual-mandelbrot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davep/textual-canvas/297cb38d382e7fac1e1d3231ef32f016e83db389/img/textual-mandelbrot.png -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: textual-canvas 2 | docs_dir: docs/source 3 | repo_url: https://github.com/davep/textual-canvas 4 | 5 | nav: 6 | - Guide: 7 | - index.md 8 | - guide.md 9 | - Library Contents: 10 | - canvas.md 11 | - Change Log: changelog.md 12 | - Licence: licence.md 13 | 14 | watch: 15 | - src/textual_canvas 16 | - docs/examples 17 | 18 | markdown_extensions: 19 | - admonition 20 | - pymdownx.snippets 21 | - markdown.extensions.attr_list 22 | - pymdownx.superfences: 23 | custom_fences: 24 | - name: textual 25 | class: textual 26 | format: !!python/name:textual._doc.format_svg 27 | - pymdownx.tabbed: 28 | alternate_style: true 29 | 30 | plugins: 31 | search: 32 | autorefs: 33 | mkdocstrings: 34 | default_handler: python 35 | enable_inventory: true 36 | handlers: 37 | python: 38 | inventories: 39 | - https://docs.python.org/3/objects.inv 40 | - https://textual.textualize.io/objects.inv 41 | options: 42 | filters: 43 | - "!^_" 44 | - "^__.+__$" 45 | - "!^on_mount$" 46 | - "!^compose$" 47 | - "!^render_line" 48 | modernize_annotations: false 49 | show_symbol_type_heading: true 50 | show_symbol_type_toc: true 51 | show_signature_annotations: false 52 | separate_signature: true 53 | signature_crossrefs: true 54 | merge_init_into_class: true 55 | parameter_headings: true 56 | show_root_heading: false 57 | docstring_options: 58 | ignore_init_summary: true 59 | show_source: false 60 | 61 | theme: 62 | name: material 63 | icon: 64 | logo: fontawesome/solid/paintbrush 65 | features: 66 | - navigation.tabs 67 | - navigation.indexes 68 | - navigation.tabs.sticky 69 | - navigation.footer 70 | - content.code.annotate 71 | - content.code.copy 72 | palette: 73 | - media: "(prefers-color-scheme: light)" 74 | scheme: default 75 | accent: purple 76 | toggle: 77 | icon: material/weather-sunny 78 | name: Switch to dark mode 79 | - media: "(prefers-color-scheme: dark)" 80 | scheme: slate 81 | primary: black 82 | toggle: 83 | icon: material/weather-night 84 | name: Switch to light mode 85 | 86 | extra: 87 | social: 88 | - icon: fontawesome/brands/github 89 | link: https://github.com/davep 90 | - icon: fontawesome/brands/python 91 | link: https://pypi.org/user/davepearson/ 92 | - icon: fontawesome/brands/mastodon 93 | link: https://fosstodon.org/@davep 94 | - icon: fontawesome/brands/bluesky 95 | link: https://bsky.app/profile/davep.org 96 | - icon: fontawesome/brands/threads 97 | link: https://www.threads.net/@davepdotorg 98 | - icon: fontawesome/brands/youtube 99 | link: https://www.youtube.com/@DavePearson 100 | - icon: fontawesome/brands/steam 101 | link: https://steamcommunity.com/id/davepdotorg 102 | 103 | ### mkdocs.yml ends here 104 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "textual-canvas" 3 | version = "0.4.0" 4 | description = "A simple Textual canvas widget" 5 | authors = [ 6 | { name = "Dave Pearson", email = "davep@davep.org" } 7 | ] 8 | dependencies = [ 9 | "textual>=1.0.0", 10 | "typing-extensions>=4.13.2", 11 | ] 12 | readme = "README.md" 13 | requires-python = ">= 3.9" 14 | license = { text = "MIT License" } 15 | keywords = [ 16 | "terminal", 17 | "library", 18 | "canvas", 19 | "drawing", 20 | ] 21 | classifiers = [ 22 | "License :: OSI Approved :: MIT License", 23 | "Environment :: Console", 24 | "Development Status :: 4 - Beta", 25 | "Intended Audience :: Developers", 26 | "Operating System :: OS Independent", 27 | "Programming Language :: Python :: 3.9", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Programming Language :: Python :: 3.12", 31 | "Programming Language :: Python :: 3.13", 32 | "Topic :: Terminals", 33 | "Topic :: Software Development :: Libraries", 34 | "Typing :: Typed", 35 | ] 36 | 37 | [project.urls] 38 | Homepage = "https://textual-canvas.davep.dev/" 39 | Repository = "https://github.com/davep/textual-canvas" 40 | Documentation = "https://textual-canvas.davep.dev/" 41 | Source = "https://github.com/davep/textual-canvas" 42 | Issues = "https://github.com/davep/textual-canvas/issues" 43 | Discussions = "https://github.com/davep/textual-canvas/discussions" 44 | 45 | [build-system] 46 | # https://github.com/astral-sh/rye/issues/1446 47 | requires = ["hatchling==1.26.3", "hatch-vcs"] 48 | # requires = ["hatchling"] 49 | build-backend = "hatchling.build" 50 | 51 | [tool.rye] 52 | managed = true 53 | dev-dependencies = [ 54 | "pre-commit>=4.2.0", 55 | "mypy>=1.15.0", 56 | "ruff>=0.11.5", 57 | "codespell>=2.4.1", 58 | "mkdocs-material>=9.6.11", 59 | "mkdocstrings[python]>=0.29.1", 60 | ] 61 | 62 | [tool.hatch.metadata] 63 | allow-direct-references = true 64 | 65 | [tool.hatch.build.targets.wheel] 66 | packages = ["src/textual_canvas"] 67 | 68 | [tool.pyright] 69 | venvPath="." 70 | venv=".venv" 71 | exclude=[".venv"] 72 | -------------------------------------------------------------------------------- /requirements-dev.lock: -------------------------------------------------------------------------------- 1 | # generated by rye 2 | # use `rye lock` or `rye sync` to update this lockfile 3 | # 4 | # last locked with the following flags: 5 | # pre: false 6 | # features: [] 7 | # all-features: false 8 | # with-sources: false 9 | # generate-hashes: false 10 | # universal: false 11 | 12 | -e file:. 13 | babel==2.17.0 14 | # via mkdocs-material 15 | backrefs==5.8 16 | # via mkdocs-material 17 | certifi==2025.1.31 18 | # via requests 19 | cfgv==3.4.0 20 | # via pre-commit 21 | charset-normalizer==3.4.1 22 | # via requests 23 | click==8.1.8 24 | # via mkdocs 25 | codespell==2.4.1 26 | colorama==0.4.6 27 | # via griffe 28 | # via mkdocs-material 29 | distlib==0.3.9 30 | # via virtualenv 31 | filelock==3.18.0 32 | # via virtualenv 33 | ghp-import==2.1.0 34 | # via mkdocs 35 | griffe==1.7.2 36 | # via mkdocstrings-python 37 | identify==2.6.9 38 | # via pre-commit 39 | idna==3.10 40 | # via requests 41 | jinja2==3.1.6 42 | # via mkdocs 43 | # via mkdocs-material 44 | # via mkdocstrings 45 | linkify-it-py==2.0.3 46 | # via markdown-it-py 47 | markdown==3.8 48 | # via mkdocs 49 | # via mkdocs-autorefs 50 | # via mkdocs-material 51 | # via mkdocstrings 52 | # via pymdown-extensions 53 | markdown-it-py==3.0.0 54 | # via mdit-py-plugins 55 | # via rich 56 | # via textual 57 | markupsafe==3.0.2 58 | # via jinja2 59 | # via mkdocs 60 | # via mkdocs-autorefs 61 | # via mkdocstrings 62 | mdit-py-plugins==0.4.2 63 | # via markdown-it-py 64 | mdurl==0.1.2 65 | # via markdown-it-py 66 | mergedeep==1.3.4 67 | # via mkdocs 68 | # via mkdocs-get-deps 69 | mkdocs==1.6.1 70 | # via mkdocs-autorefs 71 | # via mkdocs-material 72 | # via mkdocstrings 73 | mkdocs-autorefs==1.4.1 74 | # via mkdocstrings 75 | # via mkdocstrings-python 76 | mkdocs-get-deps==0.2.0 77 | # via mkdocs 78 | mkdocs-material==9.6.11 79 | mkdocs-material-extensions==1.3.1 80 | # via mkdocs-material 81 | mkdocstrings==0.29.1 82 | # via mkdocstrings-python 83 | mkdocstrings-python==1.16.10 84 | # via mkdocstrings 85 | mypy==1.15.0 86 | mypy-extensions==1.0.0 87 | # via mypy 88 | nodeenv==1.9.1 89 | # via pre-commit 90 | packaging==24.2 91 | # via mkdocs 92 | paginate==0.5.7 93 | # via mkdocs-material 94 | pathspec==0.12.1 95 | # via mkdocs 96 | platformdirs==4.3.7 97 | # via mkdocs-get-deps 98 | # via textual 99 | # via virtualenv 100 | pre-commit==4.2.0 101 | pygments==2.19.1 102 | # via mkdocs-material 103 | # via rich 104 | pymdown-extensions==10.14.3 105 | # via mkdocs-material 106 | # via mkdocstrings 107 | python-dateutil==2.9.0.post0 108 | # via ghp-import 109 | pyyaml==6.0.2 110 | # via mkdocs 111 | # via mkdocs-get-deps 112 | # via pre-commit 113 | # via pymdown-extensions 114 | # via pyyaml-env-tag 115 | pyyaml-env-tag==0.1 116 | # via mkdocs 117 | requests==2.32.3 118 | # via mkdocs-material 119 | rich==14.0.0 120 | # via textual 121 | ruff==0.11.5 122 | six==1.17.0 123 | # via python-dateutil 124 | textual==3.1.0 125 | # via textual-canvas 126 | typing-extensions==4.13.2 127 | # via mypy 128 | # via textual 129 | # via textual-canvas 130 | uc-micro-py==1.0.3 131 | # via linkify-it-py 132 | urllib3==2.4.0 133 | # via requests 134 | virtualenv==20.30.0 135 | # via pre-commit 136 | watchdog==6.0.0 137 | # via mkdocs 138 | -------------------------------------------------------------------------------- /requirements.lock: -------------------------------------------------------------------------------- 1 | # generated by rye 2 | # use `rye lock` or `rye sync` to update this lockfile 3 | # 4 | # last locked with the following flags: 5 | # pre: false 6 | # features: [] 7 | # all-features: false 8 | # with-sources: false 9 | # generate-hashes: false 10 | # universal: false 11 | 12 | -e file:. 13 | linkify-it-py==2.0.3 14 | # via markdown-it-py 15 | markdown-it-py==3.0.0 16 | # via mdit-py-plugins 17 | # via rich 18 | # via textual 19 | mdit-py-plugins==0.4.2 20 | # via markdown-it-py 21 | mdurl==0.1.2 22 | # via markdown-it-py 23 | platformdirs==4.3.7 24 | # via textual 25 | pygments==2.19.1 26 | # via rich 27 | rich==14.0.0 28 | # via textual 29 | textual==3.1.0 30 | # via textual-canvas 31 | typing-extensions==4.13.2 32 | # via textual 33 | # via textual-canvas 34 | uc-micro-py==1.0.3 35 | # via linkify-it-py 36 | -------------------------------------------------------------------------------- /src/textual_canvas/__init__.py: -------------------------------------------------------------------------------- 1 | """A library that provides a simple canvas for drawing in Textual.""" 2 | 3 | ############################################################################## 4 | # Python imports. 5 | from importlib.metadata import version 6 | 7 | ###################################################################### 8 | # Main app information. 9 | __author__ = "Dave Pearson" 10 | __copyright__ = "Copyright 2023-2025, Dave Pearson" 11 | __credits__ = ["Dave Pearson"] 12 | __maintainer__ = "Dave Pearson" 13 | __email__ = "davep@davep.org" 14 | __version__ = version("textual_canvas") 15 | __licence__ = "MIT" 16 | 17 | ############################################################################## 18 | # Local imports. 19 | from .canvas import Canvas, CanvasError 20 | 21 | ############################################################################## 22 | # Export the imports. 23 | __all__ = ["Canvas", "CanvasError"] 24 | 25 | ### __init__.py ends here 26 | -------------------------------------------------------------------------------- /src/textual_canvas/__main__.py: -------------------------------------------------------------------------------- 1 | """A simple demonstration of the Canvas widget.""" 2 | 3 | ############################################################################## 4 | # Textual imports. 5 | from textual import on 6 | from textual.app import App, ComposeResult 7 | from textual.color import Color 8 | from textual.events import Mount 9 | 10 | ############################################################################## 11 | # Local imports. 12 | from .canvas import Canvas 13 | 14 | 15 | ############################################################################## 16 | class CanvasTestApp(App[None]): 17 | """The Canvas testing application.""" 18 | 19 | CSS = """ 20 | Canvas { 21 | border: round green; 22 | background: $panel; 23 | color: grey; 24 | width: auto; 25 | height: auto; 26 | max-width: 1fr; 27 | max-height: 1fr; 28 | } 29 | """ 30 | 31 | BINDINGS = [ 32 | ("r", "canvas(255, 0, 0)"), 33 | ("g", "canvas(0, 255, 0)"), 34 | ("b", "canvas(0, 0, 255)"), 35 | ] 36 | 37 | def compose(self) -> ComposeResult: 38 | yield Canvas(120, 120) 39 | 40 | @on(Mount) 41 | def its_all_dark(self) -> None: 42 | """Set up the display once the DOM is available.""" 43 | canvas = self.query_one(Canvas) 44 | 45 | canvas.draw_line(60, 40, 90, 80) 46 | canvas.draw_line(60, 40, 30, 80) 47 | canvas.draw_line(30, 80, 90, 80) 48 | 49 | canvas.draw_line(0, 70, 48, 55, Color(255, 255, 255)) 50 | 51 | for n in range(52, 59): 52 | canvas.draw_line(48, 55, 58, n) 53 | 54 | canvas.draw_line(70, 52, 119, 57, Color(255, 0, 0)) 55 | canvas.draw_line(71, 53, 119, 58, Color(255, 165, 0)) 56 | canvas.draw_line(72, 54, 119, 59, Color(255, 255, 0)) 57 | canvas.draw_line(72, 55, 119, 60, Color(0, 255, 0)) 58 | canvas.draw_line(73, 56, 119, 61, Color(0, 0, 255)) 59 | canvas.draw_line(74, 57, 119, 62, Color(75, 0, 130)) 60 | canvas.draw_line(75, 58, 119, 63, Color(143, 0, 255)) 61 | 62 | canvas.focus() 63 | 64 | def action_canvas(self, red: int, green: int, blue: int) -> None: 65 | """Change the canvas colour.""" 66 | self.query_one(Canvas).styles.background = Color(red, green, blue) 67 | 68 | 69 | if __name__ == "__main__": 70 | CanvasTestApp().run() 71 | 72 | ### __main__.py ends here 73 | -------------------------------------------------------------------------------- /src/textual_canvas/canvas.py: -------------------------------------------------------------------------------- 1 | """Provides a simple character cell-based canvas widget for Textual applications.""" 2 | 3 | ############################################################################## 4 | # Backward compatibility. 5 | from __future__ import annotations 6 | 7 | ############################################################################## 8 | # Python imports. 9 | from contextlib import contextmanager 10 | from functools import lru_cache 11 | from math import ceil 12 | from typing import Generator, Iterable 13 | 14 | ############################################################################## 15 | # Rich imports. 16 | from rich.segment import Segment 17 | from rich.style import Style 18 | 19 | ############################################################################## 20 | # Textual imports. 21 | from textual.color import Color 22 | from textual.geometry import Size 23 | from textual.scroll_view import ScrollView 24 | from textual.strip import Strip 25 | 26 | ############################################################################## 27 | # Typing extension imports. 28 | from typing_extensions import Self 29 | 30 | 31 | ############################################################################## 32 | class CanvasError(Exception): 33 | """Type of errors raised by the [`Canvas`][textual_canvas.canvas.Canvas] widget.""" 34 | 35 | 36 | ############################################################################## 37 | class Canvas(ScrollView, can_focus=True): 38 | """A simple character-cell canvas widget. 39 | 40 | The widget is designed such that there are two 'pixels' per character 41 | cell; one being the top half of the cell, the other being the bottom. 42 | While not exactly square, this will make it more square than using a 43 | whole cell as a simple pixel. 44 | 45 | The origin of the canvas is the top left corner. 46 | """ 47 | 48 | def __init__( 49 | self, 50 | width: int, 51 | height: int, 52 | canvas_color: Color | None = None, 53 | pen_color: Color | None = None, 54 | name: str | None = None, 55 | id: str | None = None, 56 | classes: str | None = None, 57 | disabled: bool = False, 58 | ): 59 | """Initialise the canvas. 60 | 61 | Args: 62 | width: The width of the canvas. 63 | height: The height of the canvas. 64 | canvas_color: An optional default colour for the canvas. 65 | pen_color: The optional default colour for the pen. 66 | name: The name of the canvas widget. 67 | id: The ID of the canvas widget in the DOM. 68 | classes: The CSS classes of the canvas widget. 69 | disabled: Whether the canvas widget is disabled or not. 70 | 71 | If `canvas_color` is omitted, the widget's `background` styling will 72 | be used. 73 | 74 | If `pen_color` is omitted, the widget's `color` styling will be used. 75 | """ 76 | super().__init__(name=name, id=id, classes=classes, disabled=disabled) 77 | self._width = width 78 | """The widget of the canvas.""" 79 | self._height = height 80 | """The height of the canvas.""" 81 | self._canvas_colour = canvas_color 82 | """The background colour of the canvas itself.""" 83 | self._pen_colour = pen_color 84 | """The default pen colour, used when drawing pixels.""" 85 | self._canvas: list[list[Color | None]] = [] 86 | """The canvas itself.""" 87 | self._refreshing = True 88 | """The current default refresh state.""" 89 | self.clear() 90 | 91 | @property 92 | def _blank_canvas(self) -> list[list[Color | None]]: 93 | """A blank canvas.""" 94 | return [ 95 | [self._canvas_colour for _ in range(self.width)] for _ in range(self.height) 96 | ] 97 | 98 | @property 99 | def width(self) -> int: 100 | """The width of the canvas in 'pixels'.""" 101 | return self._width 102 | 103 | @property 104 | def height(self) -> int: 105 | """The height of the canvas in 'pixels'.""" 106 | return self._height 107 | 108 | def notify_style_update(self) -> None: 109 | self.refresh() 110 | return super().notify_style_update() 111 | 112 | @contextmanager 113 | def batch_refresh(self) -> Generator[None, None, None]: 114 | """A context manager that suspends all calls to `refresh` until the end of the batch. 115 | 116 | Ordinarily [`set_pixels`][textual_canvas.canvas.Canvas.set_pixels] 117 | will call [`refresh`][textual.widget.Widget.refresh] once it has 118 | updated all of the pixels it has been given. Sometimes you may want 119 | to perform a number of draw operations and having `refresh` called 120 | between each one would be inefficient given you've not drawing. 121 | 122 | Use this context manager to batch up your drawing operations. 123 | 124 | Example: 125 | ```python 126 | canvas = self.query_one(Canvas) 127 | with canvas.batch_refresh(): 128 | canvas.draw_line(10, 10, 10, 20) 129 | canvas.draw_line(10, 20, 20, 20) 130 | canvas.draw_line(20, 20, 20, 10) 131 | canvas.draw_line(20, 10, 10, 10) 132 | ``` 133 | 134 | Note: 135 | All drawing methods have a `refresh` parameter. If that is set 136 | to [`True`][True] in any of your calls those calls will still 137 | force a refresh. 138 | """ 139 | refreshing = self._refreshing 140 | try: 141 | self._refreshing = False 142 | yield 143 | finally: 144 | self._refreshing = refreshing 145 | self.refresh() 146 | 147 | def _outwith_the_canvas(self, x: int, y: int) -> bool: 148 | """Is the location outwith the canvas? 149 | 150 | Args: 151 | x: The horizontal location of the pixel. 152 | y: The vertical location of the pixel. 153 | 154 | """ 155 | return x < 0 or y < 0 or x >= self._width or y >= self._height 156 | 157 | def _pixel_check(self, x: int, y: int) -> None: 158 | """Check that a location is within the canvas. 159 | 160 | Args: 161 | x: The horizontal location of the pixel. 162 | y: The vertical location of the pixel. 163 | 164 | Raises: 165 | CanvasError: If the pixel location is not within the canvas. 166 | """ 167 | if self._outwith_the_canvas(x, y): 168 | raise CanvasError( 169 | f"x={x}, x={y} is not within 0, 0, {self._width}, {self._height}" 170 | ) 171 | 172 | def clear( 173 | self, 174 | color: Color | None = None, 175 | width: int | None = None, 176 | height: int | None = None, 177 | ) -> Self: 178 | """Clear the canvas. 179 | 180 | Args: 181 | color: Optional default colour for the canvas. 182 | width: Optional width for the canvas. 183 | height: Optional height for the canvas. 184 | 185 | Returns: 186 | The canvas. 187 | 188 | If the color isn't provided, then the color used when first making 189 | the canvas is used, this in turn becomes the new default color (and 190 | will then be used for subsequent clears, unless another color is 191 | provided). 192 | 193 | Explicitly setting the colour to [`None`][None] will set the canvas 194 | colour to whatever the widget's `background` colour is. 195 | 196 | If `width` or `height` are omitted then the current value for those 197 | dimensions will be used. 198 | """ 199 | self._width = self._width if width is None else width 200 | self._height = self._height if height is None else height 201 | self.virtual_size = Size(self._width, ceil(self._height / 2)) 202 | self._canvas_colour = color or self._canvas_colour 203 | self._canvas = self._blank_canvas 204 | return self.refresh() 205 | 206 | def set_pen(self, color: Color | None) -> Self: 207 | """Set the default pen colour. 208 | 209 | Args: 210 | color: A colour to use by default when drawing a pixel. 211 | 212 | Returns: 213 | The canvas. 214 | 215 | Note: 216 | Setting the colour to [`None`][None] specifies that the widget's 217 | currently-styled [`color`](https://textual.textualize.io/guide/styles/#styles-object) 218 | should be used. 219 | """ 220 | self._pen_colour = color 221 | return self 222 | 223 | def set_pixels( 224 | self, 225 | locations: Iterable[tuple[int, int]], 226 | color: Color | None = None, 227 | refresh: bool | None = None, 228 | ) -> Self: 229 | """Set the colour of a collection of pixels on the canvas. 230 | 231 | Args: 232 | locations: An iterable of tuples of x and y location. 233 | color: The color to set the pixel to. 234 | refresh: Should the widget be refreshed? 235 | 236 | Returns: 237 | The canvas. 238 | 239 | Raises: 240 | CanvasError: If any pixel location is not within the canvas. 241 | 242 | Note: 243 | The origin of the canvas is the top left corner. 244 | """ 245 | color = color or self._pen_colour or self.styles.color 246 | for x, y in locations: 247 | self._pixel_check(x, y) 248 | self._canvas[y][x] = color 249 | if self._refreshing if refresh is None else refresh: 250 | self.refresh() 251 | return self 252 | 253 | def clear_pixels( 254 | self, locations: Iterable[tuple[int, int]], refresh: bool | None = None 255 | ) -> Self: 256 | """Clear the colour of a collection of pixels on the canvas. 257 | 258 | Args: 259 | locations: An iterable of tuples of x and y location. 260 | refresh: Should the widget be refreshed? 261 | 262 | Returns: 263 | The canvas. 264 | 265 | Raises: 266 | CanvasError: If any pixel location is not within the canvas. 267 | 268 | Note: 269 | The origin of the canvas is the top left corner. 270 | """ 271 | return self.set_pixels(locations, self._canvas_colour, refresh) 272 | 273 | def set_pixel( 274 | self, x: int, y: int, color: Color | None = None, refresh: bool | None = None 275 | ) -> Self: 276 | """Set the colour of a specific pixel on the canvas. 277 | 278 | Args: 279 | x: The horizontal location of the pixel. 280 | y: The vertical location of the pixel. 281 | color: The color to set the pixel to. 282 | refresh: Should the widget be refreshed? 283 | 284 | Raises: 285 | CanvasError: If the pixel location is not within the canvas. 286 | 287 | Note: 288 | The origin of the canvas is the top left corner. 289 | """ 290 | return self.set_pixels( 291 | ((x, y),), self._pen_colour if color is None else color, refresh 292 | ) 293 | 294 | def clear_pixel(self, x: int, y: int, refresh: bool | None = None) -> Self: 295 | """Clear the colour of a specific pixel on the canvas. 296 | 297 | Args: 298 | x: The horizontal location of the pixel. 299 | y: The vertical location of the pixel. 300 | refresh: Should the widget be refreshed? 301 | 302 | Raises: 303 | CanvasError: If the pixel location is not within the canvas. 304 | 305 | Note: 306 | The origin of the canvas is the top left corner. 307 | """ 308 | return self.clear_pixels(((x, y),), refresh) 309 | 310 | def get_pixel(self, x: int, y: int) -> Color: 311 | """Get the pixel at the given location. 312 | 313 | Args: 314 | x: The horizontal location of the pixel. 315 | y: The vertical location of the pixel. 316 | 317 | Returns: 318 | The colour of the pixel at that location. 319 | 320 | Raises: 321 | CanvasError: If the pixel location is not within the canvas. 322 | 323 | Note: 324 | The origin of the canvas is the top left corner. 325 | """ 326 | self._pixel_check(x, y) 327 | return self._canvas[y][x] or self.styles.background 328 | 329 | def draw_line( 330 | self, 331 | x0: int, 332 | y0: int, 333 | x1: int, 334 | y1: int, 335 | color: Color | None = None, 336 | refresh: bool | None = None, 337 | ) -> Self: 338 | """Draw a line between two points. 339 | 340 | Args: 341 | x0: Horizontal location of the starting position. 342 | y0: Vertical location of the starting position. 343 | x1: Horizontal location of the ending position. 344 | y1: Vertical location of the ending position. 345 | color: The color to set the pixel to. 346 | refresh: Should the widget be refreshed? 347 | 348 | Returns: 349 | The canvas. 350 | 351 | Note: 352 | The origin of the canvas is the top left corner. 353 | """ 354 | 355 | # Taken from https://en.wikipedia.org/wiki/Bresenham's_line_algorithm#All_cases. 356 | 357 | pixels: list[tuple[int, int]] = [] 358 | 359 | dx = abs(x1 - x0) 360 | sx = 1 if x0 < x1 else -1 361 | dy = -abs(y1 - y0) 362 | sy = 1 if y0 < y1 else -1 363 | err = dx + dy 364 | 365 | while True: 366 | if not self._outwith_the_canvas(x0, y0): 367 | pixels.append((x0, y0)) 368 | if x0 == x1 and y0 == y1: 369 | break 370 | e2 = 2 * err 371 | if e2 >= dy: 372 | if x0 == x1: 373 | break 374 | err += dy 375 | x0 += sx 376 | if e2 <= dx: 377 | if y0 == y1: 378 | break 379 | err += dx 380 | y0 += sy 381 | 382 | return self.set_pixels(pixels, color, refresh) 383 | 384 | def draw_rectangle( 385 | self, 386 | x: int, 387 | y: int, 388 | width: int, 389 | height: int, 390 | color: Color | None = None, 391 | refresh: bool | None = None, 392 | ) -> Self: 393 | """Draw a rectangle. 394 | 395 | Args: 396 | x: Horizontal location of the top left corner of the rectangle. 397 | y: Vertical location of the top left corner of the rectangle. 398 | width: The width of the rectangle. 399 | height: The height of the rectangle. 400 | color: The color to draw the rectangle in. 401 | refresh: Should the widget be refreshed? 402 | 403 | Returns: 404 | The canvas. 405 | 406 | Note: 407 | The origin of the canvas is the top left corner. 408 | """ 409 | if width < 1 or height < 1: 410 | return self 411 | width -= 1 412 | height -= 1 413 | return ( 414 | self.draw_line(x, y, x + width, y, color, False) 415 | .draw_line(x + width, y, x + width, y + height, color, False) 416 | .draw_line(x + width, y + height, x, y + height, color, False) 417 | .draw_line(x, y + height, x, y, color, refresh) 418 | ) 419 | 420 | @staticmethod 421 | def _circle_mirror(x: int, y: int) -> tuple[tuple[int, int], ...]: 422 | """Create an 8-way symmetry of the given points. 423 | 424 | Args: 425 | x: Horizontal location of the point to mirror. 426 | y: Vertical location of the point to mirror. 427 | 428 | Returns: 429 | The points needed to create an 8-way symmetry. 430 | """ 431 | return ((x, y), (y, x), (-x, y), (-y, x), (x, -y), (y, -x), (-x, -y), (-y, -x)) 432 | 433 | def draw_circle( 434 | self, 435 | center_x: int, 436 | center_y: int, 437 | radius: int, 438 | color: Color | None = None, 439 | refresh: bool | None = None, 440 | ) -> Self: 441 | """Draw a circle 442 | 443 | Args: 444 | center_x: The horizontal position of the center of the circle. 445 | center_y: The vertical position of the center of the circle. 446 | radius: The radius of the circle. 447 | color: The colour to draw circle in. 448 | refresh: Should the widget be refreshed? 449 | 450 | Returns: 451 | The canvas. 452 | 453 | Note: 454 | The origin of the canvas is the top left corner. 455 | """ 456 | 457 | # Taken from https://funloop.org/post/2021-03-15-bresenham-circle-drawing-algorithm.html. 458 | 459 | pixels: list[tuple[int, int]] = [] 460 | 461 | x = 0 462 | y = -radius 463 | f_m = 1 - radius 464 | d_e = 3 465 | d_ne = -(radius << 1) + 5 466 | pixels.extend(self._circle_mirror(x, y)) 467 | while x < -y: 468 | if f_m <= 0: 469 | f_m += d_e 470 | else: 471 | f_m += d_ne 472 | d_ne += 2 473 | y += 1 474 | d_e += 2 475 | d_ne += 2 476 | x += 1 477 | pixels.extend(self._circle_mirror(x, y)) 478 | 479 | return self.set_pixels( 480 | [ 481 | (center_x + x, center_y + y) 482 | for x, y in pixels 483 | if not self._outwith_the_canvas(center_x + x, center_y + y) 484 | ], 485 | color, 486 | refresh, 487 | ) 488 | 489 | _CELL = "\u2584" 490 | """The character to use to draw two pixels in one cell in the canvas.""" 491 | 492 | @lru_cache() 493 | def _segment_of(self, top: Color, bottom: Color) -> Segment: 494 | """Construct a segment to show the two colours in one cell. 495 | 496 | Args: 497 | top: The colour for the top pixel. 498 | bottom: The colour for the bottom pixel. 499 | 500 | Returns: 501 | A `Segment` that will display the two pixels. 502 | """ 503 | return Segment( 504 | self._CELL, style=Style.from_color(bottom.rich_color, top.rich_color) 505 | ) 506 | 507 | def render_line(self, y: int) -> Strip: 508 | """Render a line in the display. 509 | 510 | Args: 511 | y: The line to render. 512 | 513 | Returns: 514 | A [`Strip`][textual.strip.Strip] that is the line to render. 515 | """ 516 | 517 | # Get where we're scrolled to. 518 | scroll_x, scroll_y = self.scroll_offset 519 | 520 | # We're going to be drawing two lines from the canvas in one line in 521 | # the display. Let's work out the first line first. 522 | top_line = (scroll_y + y) * 2 523 | 524 | # Is this off the canvas already? 525 | if top_line >= self.height: 526 | # Yup. Don't bother drawing anything. 527 | return Strip([]) 528 | 529 | # Set up the two main background colours we need. 530 | background_colour = self.styles.background 531 | canvas_colour = self._canvas_colour or background_colour 532 | 533 | # Reduce some attribute lookups. 534 | height = self._height 535 | width = self._width 536 | canvas = self._canvas 537 | 538 | # Now, the bottom line is easy enough to work out. 539 | bottom_line = top_line + 1 540 | 541 | # Get the pixel values for the top line. 542 | top_pixels = canvas[top_line] 543 | 544 | # It's possible that the bottom line might be outwith the canvas 545 | # itself; so here we set the bottom line to the widget's background 546 | # colour if it is, otherwise we use the line form the canvas. 547 | bottom_pixels = ( 548 | [background_colour for _ in range(width)] 549 | if bottom_line >= height 550 | else canvas[bottom_line] 551 | ) 552 | 553 | # At this point we know what colours we're going to be mashing 554 | # together into the terminal line we're drawing. So let's get to it. 555 | # Note that in every case, if the colour we have is `None` that 556 | # means we're using the canvas colour. 557 | return ( 558 | Strip( 559 | [ 560 | self._segment_of( 561 | top_pixels[pixel] or canvas_colour, 562 | bottom_pixels[pixel] or canvas_colour, 563 | ) 564 | for pixel in range(width) 565 | ] 566 | ) 567 | .crop(scroll_x, scroll_x + self.scrollable_content_region.width) 568 | .simplify() 569 | ) 570 | 571 | 572 | ### canvas.py ends here 573 | -------------------------------------------------------------------------------- /src/textual_canvas/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davep/textual-canvas/297cb38d382e7fac1e1d3231ef32f016e83db389/src/textual_canvas/py.typed --------------------------------------------------------------------------------