├── .gitignore ├── .pre-commit-config.yaml ├── .pylintrc ├── .python-version ├── ChangeLog.md ├── LICENSE ├── Makefile ├── README.md ├── img ├── mandelexp01.png ├── mandelexp02.png ├── mandelexp03.png └── mandelexp04.png ├── pyproject.toml ├── requirements-dev.lock ├── requirements.lock └── src └── textual_mandelbrot ├── __init__.py ├── __main__.py ├── colouring.py ├── commands.py └── mandelbrot.py /.gitignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | .coverage 3 | .coverage_report/ 4 | .DS_Store 5 | *.egg-info/ 6 | build/ 7 | dist/ 8 | __pycache__ 9 | -------------------------------------------------------------------------------- /.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 | [FORMAT] 2 | good-names=x,y,c1,c2,n,by 3 | max-line-length=120 4 | max-args=10 5 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13.1 2 | -------------------------------------------------------------------------------- /ChangeLog.md: -------------------------------------------------------------------------------- 1 | # textual-mandelbrot ChangeLog 2 | 3 | ## v0.8.2 4 | 5 | **Released: 2025-04-15** 6 | 7 | - Unpinned Textual now that it's mostly started to stabilise again. 8 | 9 | ## v0.8.1 10 | 11 | **Released: 2024-05-26** 12 | 13 | - Pinned the upper version of Textual due to its current period of 14 | instability. 15 | 16 | ## v0.8.0 17 | 18 | **Released: 2024-03-10** 19 | 20 | - Bumped minimum Textual version to v0.52.1. 21 | - Made the command palette commands appear when opening the command palette. 22 | 23 | ## v0.7.0 24 | 25 | **Released: 2024-01-07** 26 | 27 | - Dropped Python 3.7 support. 28 | - Bumped minimum Textual version to v0.47.1. 29 | 30 | ## v0.6.0 31 | 32 | **Released: 2023-09-29** 33 | 34 | - Added some application-specific commands to the Textual Command Palette. 35 | 36 | ## v0.5.1 37 | 38 | **Released: 2023-09-28** 39 | 40 | - Various internal tweaks and updated Textual. 41 | 42 | ## v0.5.0 43 | 44 | **Released: 2023-08-15** 45 | 46 | - Modified the way that the dimensions of the terminal are tested. 47 | 48 | ## v0.4.0 49 | 50 | **Released: 2023-04-15** 51 | 52 | - Bumped minimum Textual version to v0.19.1. 53 | - Internal changes. 54 | 55 | ## v0.3.0 56 | 57 | **Released: 2023-04-03** 58 | 59 | - Added bindings to change the "multibrot" value by smaller steps. 60 | - The colour mapping is now configurable at the code level. 61 | - Added key bindings for swapping between the current colour maps. 62 | - Added the calculation time to the `Changed` message and the display. 63 | 64 | ## v0.2.0 65 | 66 | **Released: 2023-04-02** 67 | 68 | - Fixed overly-aggressive caching of the Mandelbrot calculation. 69 | - Added a reset action. 70 | - Cosmetic changes to the UI. 71 | - Improved the key bindings, giving more options (for example, vi and WASD 72 | keys are supported for movement now, also shift+movement moves slower). 73 | - Added "multibrot" support. 74 | 75 | ## v0.1.0 76 | 77 | **Released: 2023-04-01** 78 | 79 | Initial release. 80 | 81 | [//]: # (ChangeLog.md ends here) 82 | -------------------------------------------------------------------------------- /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 | app := textual_mandelbrot 2 | src := src/ 3 | run := rye run 4 | test := rye test 5 | python := $(run) python 6 | lint := rye lint -- --select I 7 | fmt := rye fmt 8 | mypy := $(run) mypy 9 | spell := $(run) codespell 10 | 11 | ############################################################################## 12 | # Local "interactive testing" of the code. 13 | .PHONY: run 14 | run: # Run the code in a testing context 15 | $(run) python -m $(app) 16 | 17 | .PHONY: serve 18 | serve: # Run in server mode for use in the browser 19 | $(run) textual serve $(app) 20 | 21 | .PHONY: debug 22 | debug: # Run the code with Textual devtools enabled 23 | TEXTUAL=devtools make 24 | 25 | .PHONY: console 26 | console: # Run the textual console 27 | $(run) textual console 28 | 29 | ############################################################################## 30 | # Setup/update packages the system requires. 31 | .PHONY: setup 32 | setup: # Set up the repository for development 33 | rye sync 34 | $(run) pre-commit install 35 | 36 | .PHONY: update 37 | update: # Update all dependencies 38 | rye sync --update-all 39 | 40 | .PHONY: resetup 41 | resetup: realclean # Recreate the virtual environment from scratch 42 | make setup 43 | 44 | ############################################################################## 45 | # Checking/testing/linting/etc. 46 | .PHONY: lint 47 | lint: # Check the code for linting issues 48 | $(lint) $(src) 49 | 50 | .PHONY: codestyle 51 | codestyle: # Is the code formatted correctly? 52 | $(fmt) --check $(src) 53 | 54 | .PHONY: typecheck 55 | typecheck: # Perform static type checks with mypy 56 | $(mypy) --scripts-are-modules $(src) 57 | 58 | .PHONY: stricttypecheck 59 | stricttypecheck: # Perform a strict static type checks with mypy 60 | $(mypy) --scripts-are-modules --strict $(src) 61 | 62 | .PHONY: spellcheck 63 | spellcheck: # Spell check the code 64 | $(spell) *.md $(src) 65 | 66 | .PHONY: checkall 67 | checkall: spellcheck codestyle lint stricttypecheck # Check all the things 68 | 69 | ############################################################################## 70 | # Package/publish. 71 | .PHONY: package 72 | package: # Package the library 73 | rye build 74 | 75 | .PHONY: spackage 76 | spackage: # Create a source package for the library 77 | rye build --sdist 78 | 79 | .PHONY: testdist 80 | testdist: package # Perform a test distribution 81 | rye publish --yes --skip-existing --repository testpypi --repository-url https://test.pypi.org/legacy/ 82 | 83 | .PHONY: dist 84 | dist: package # Upload to pypi 85 | rye publish --yes --skip-existing 86 | 87 | ############################################################################## 88 | # Utility. 89 | .PHONY: repl 90 | repl: # Start a Python REPL in the venv 91 | $(python) 92 | 93 | .PHONY: delint 94 | delint: # Fix linting issues 95 | $(lint) --fix $(src) 96 | 97 | .PHONY: pep8ify 98 | pep8ify: # Reformat the code to be as PEP8 as possible 99 | $(fmt) $(src) 100 | 101 | .PHONY: tidy 102 | tidy: delint pep8ify # Tidy up the code, fixing lint and format issues 103 | 104 | .PHONY: clean-packaging 105 | clean-packaging: # Clean the package building files 106 | rm -rf dist 107 | 108 | .PHONY: clean 109 | clean: clean-packaging # Clean the build directories 110 | 111 | .PHONY: realclean 112 | realclean: clean # Clean the venv and build directories 113 | rm -rf .venv 114 | 115 | .PHONY: help 116 | help: # Display this help 117 | @grep -Eh "^[a-z]+:.+# " $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.+# "}; {printf "%-20s %s\n", $$1, $$2}' 118 | 119 | ############################################################################## 120 | # Housekeeping tasks. 121 | .PHONY: housekeeping 122 | housekeeping: # Perform some git housekeeping 123 | git fsck 124 | git gc --aggressive 125 | git remote update --prune 126 | 127 | ### Makefile ends here 128 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # textual-mandelbrot 2 | 3 | ![mandelexp in action](https://raw.githubusercontent.com/davep/textual-mandelbrot/main/img/mandelexp01.png) 4 | ![mandelexp in action](https://raw.githubusercontent.com/davep/textual-mandelbrot/main/img/mandelexp02.png) 5 | 6 | ## Introduction 7 | 8 | > [!NOTE] 9 | > 10 | > This repository is unlikely to get future updates; I've created a 11 | > from-scratch reimplementation of the application called 12 | > [Complexitty](https://github.com/davep/complexitty). I suggest installing 13 | > and playing with that instead. 14 | 15 | This package provides a simple Mandelbrot set widget that can be used in 16 | [Textual](https://textual.textualize.io/) applications, and also provides an 17 | application that can be used to explore the classic Mandelbrot set in the 18 | terminal. 19 | 20 | ## Installing 21 | 22 | ### pipx 23 | 24 | The package can be installed using [`pipx`](https://pypa.github.io/pipx/): 25 | 26 | ```sh 27 | $ pipx install textual-mandelbrot 28 | ``` 29 | 30 | ### Homebrew 31 | 32 | The package is available via Homebrew. Use the following commands to install: 33 | 34 | ```sh 35 | $ brew tap davep/homebrew 36 | $ brew install textual-mandelbrot 37 | ``` 38 | 39 | ## Running 40 | 41 | Once installed you should be able to run the command `mandelexp` and the 42 | application will run. 43 | 44 | ![mandelexp in action](https://raw.githubusercontent.com/davep/textual-mandelbrot/main/img/mandelexp03.png) 45 | ![mandelexp in action](https://raw.githubusercontent.com/davep/textual-mandelbrot/main/img/mandelexp04.png) 46 | 47 | ## Exploring 48 | 49 | If you use `mandelexp` to run up the display, the following keys are 50 | available: 51 | 52 | | Keys | Action | 53 | |-------------------|---------------------------------------| 54 | | Up, w, k | Move up | 55 | | Shift+Up, W, K | Move up slowly | 56 | | Down, s, j | Move down | 57 | | Shift+Down, S, J | Move down slowly | 58 | | Left, a, h | Move left | 59 | | Shift+Left, A, H | Move left slowly | 60 | | Right, d, l | Move right | 61 | | Shift+Right, D, L | Move right slowly | 62 | | PageUp, ] | Zoom in | 63 | | PageDown, [ | Zoom out | 64 | | Ctrl+PageUp, } | Zoom in deeper | 65 | | Ctrl+PageDown, { | Zoom out wider | 66 | | *, Ctrl+Up | Increase "multobrot" | 67 | | /, Ctrl+Down | Decrease "multibrot" | 68 | | Ctrl+Shift+Up | Increase "multibrot" in smaller steps | 69 | | Ctrl+Shift+Down | Decrease "multibrot" in smaller steps | 70 | | Home | Center 0,0 in the display | 71 | | , | Decrease iterations by 10 | 72 | | < | Decrease iterations by 100 | 73 | | . | Increase iterations by 10 | 74 | | > | Increase iterations by 100 | 75 | | Ctrl+r | Reset to initial state | 76 | | Escape | Quit the application | 77 | | 1 | Colour set 1 | 78 | | 2 | Colour set 2 | 79 | | 3 | Colour set 3 | 80 | 81 | [//]: # (README.md ends here) 82 | -------------------------------------------------------------------------------- /img/mandelexp01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davep/textual-mandelbrot/aa2e8bf0880bc26992dd60e9d504cbf2bcc74a31/img/mandelexp01.png -------------------------------------------------------------------------------- /img/mandelexp02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davep/textual-mandelbrot/aa2e8bf0880bc26992dd60e9d504cbf2bcc74a31/img/mandelexp02.png -------------------------------------------------------------------------------- /img/mandelexp03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davep/textual-mandelbrot/aa2e8bf0880bc26992dd60e9d504cbf2bcc74a31/img/mandelexp03.png -------------------------------------------------------------------------------- /img/mandelexp04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davep/textual-mandelbrot/aa2e8bf0880bc26992dd60e9d504cbf2bcc74a31/img/mandelexp04.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "textual-mandelbrot" 3 | version = "0.8.2" 4 | description = "A simple Mandelbrot explorer for the terminal" 5 | authors = [ 6 | { name = "Dave Pearson", email = "davep@davep.org" } 7 | ] 8 | dependencies = [ 9 | "textual>=3.1.0", 10 | "textual-canvas>=0.2.1", 11 | ] 12 | readme = "README.md" 13 | requires-python = ">= 3.10" 14 | license = { text = "MIT License" } 15 | keywords = [ 16 | "terminal", 17 | "library", 18 | "mandelbrot", "maths" 19 | ] 20 | classifiers = [ 21 | "License :: OSI Approved :: MIT License", 22 | "Environment :: Console", 23 | "Development Status :: 3 - Alpha", 24 | "Intended Audience :: Developers", 25 | "Operating System :: OS Independent", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Programming Language :: Python :: 3.13", 30 | "Topic :: Terminals", 31 | "Topic :: Software Development :: Libraries", 32 | "Typing :: Typed", 33 | ] 34 | 35 | [project.urls] 36 | Homepage = "https://github.com/davep/textual-mandelbrot" 37 | Repository = "https://github.com/davep/textual-mandelbrot" 38 | Documentation = "https://github.com/davep/textual-mandelbrot" 39 | Source = "https://github.com/davep/textual-mandelbrot" 40 | Issues = "https://github.com/davep/textual-mandelbrot/issues" 41 | Discussions = "https://github.com/davep/textual-mandelbrot/discussions" 42 | 43 | [project.scripts] 44 | mandelexp = "textual_mandelbrot.__main__:main" 45 | 46 | [build-system] 47 | # https://github.com/astral-sh/rye/issues/1446 48 | requires = ["hatchling==1.26.3", "hatch-vcs"] 49 | # requires = ["hatchling"] 50 | build-backend = "hatchling.build" 51 | 52 | [tool.rye] 53 | managed = true 54 | dev-dependencies = [ 55 | "codespell>=2.4.1", 56 | "textual-dev>=1.7.0", 57 | "mypy>=1.15.0", 58 | "pre-commit>=4.2.0", 59 | ] 60 | 61 | [tool.hatch.metadata] 62 | allow-direct-references = true 63 | 64 | [tool.hatch.build.targets.wheel] 65 | packages = ["src/textual_mandelbrot"] 66 | 67 | [tool.pyright] 68 | venvPath="." 69 | venv=".venv" 70 | exclude=[".venv"] 71 | -------------------------------------------------------------------------------- /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 | aiohappyeyeballs==2.6.1 14 | # via aiohttp 15 | aiohttp==3.11.16 16 | # via aiohttp-jinja2 17 | # via textual-dev 18 | # via textual-serve 19 | aiohttp-jinja2==1.6 20 | # via textual-serve 21 | aiosignal==1.3.2 22 | # via aiohttp 23 | attrs==25.3.0 24 | # via aiohttp 25 | cfgv==3.4.0 26 | # via pre-commit 27 | click==8.1.8 28 | # via textual-dev 29 | codespell==2.4.1 30 | distlib==0.3.9 31 | # via virtualenv 32 | filelock==3.18.0 33 | # via virtualenv 34 | frozenlist==1.5.0 35 | # via aiohttp 36 | # via aiosignal 37 | identify==2.6.9 38 | # via pre-commit 39 | idna==3.10 40 | # via yarl 41 | jinja2==3.1.6 42 | # via aiohttp-jinja2 43 | # via textual-serve 44 | linkify-it-py==2.0.3 45 | # via markdown-it-py 46 | markdown-it-py==3.0.0 47 | # via mdit-py-plugins 48 | # via rich 49 | # via textual 50 | markupsafe==3.0.2 51 | # via jinja2 52 | mdit-py-plugins==0.4.2 53 | # via markdown-it-py 54 | mdurl==0.1.2 55 | # via markdown-it-py 56 | msgpack==1.1.0 57 | # via textual-dev 58 | multidict==6.4.3 59 | # via aiohttp 60 | # via yarl 61 | mypy==1.15.0 62 | mypy-extensions==1.0.0 63 | # via mypy 64 | nodeenv==1.9.1 65 | # via pre-commit 66 | platformdirs==4.3.7 67 | # via textual 68 | # via virtualenv 69 | pre-commit==4.2.0 70 | propcache==0.3.1 71 | # via aiohttp 72 | # via yarl 73 | pygments==2.19.1 74 | # via rich 75 | pyyaml==6.0.2 76 | # via pre-commit 77 | rich==14.0.0 78 | # via textual 79 | # via textual-serve 80 | textual==3.1.0 81 | # via textual-canvas 82 | # via textual-dev 83 | # via textual-mandelbrot 84 | # via textual-serve 85 | textual-canvas==0.2.1 86 | # via textual-mandelbrot 87 | textual-dev==1.7.0 88 | textual-serve==1.1.1 89 | # via textual-dev 90 | typing-extensions==4.13.2 91 | # via mypy 92 | # via textual 93 | # via textual-dev 94 | uc-micro-py==1.0.3 95 | # via linkify-it-py 96 | virtualenv==20.30.0 97 | # via pre-commit 98 | yarl==1.19.0 99 | # via aiohttp 100 | -------------------------------------------------------------------------------- /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 | # via textual-mandelbrot 32 | textual-canvas==0.2.1 33 | # via textual-mandelbrot 34 | typing-extensions==4.13.2 35 | # via textual 36 | uc-micro-py==1.0.3 37 | # via linkify-it-py 38 | -------------------------------------------------------------------------------- /src/textual_mandelbrot/__init__.py: -------------------------------------------------------------------------------- 1 | """A library that provides a widget for plotting a Mandelbrot set.""" 2 | 3 | ###################################################################### 4 | # Main app information. 5 | __author__ = "Dave Pearson" 6 | __copyright__ = "Copyright 2023-2025, Dave Pearson" 7 | __credits__ = ["Dave Pearson"] 8 | __maintainer__ = "Dave Pearson" 9 | __email__ = "davep@davep.org" 10 | __version__ = "0.8.1" 11 | __licence__ = "MIT" 12 | 13 | ############################################################################## 14 | # Local imports. 15 | from .colouring import blue_brown_map, default_map, shades_of_green 16 | from .mandelbrot import Mandelbrot 17 | 18 | ############################################################################## 19 | # Export the imports. 20 | __all__ = ["Mandelbrot", "default_map", "blue_brown_map", "shades_of_green"] 21 | 22 | ### __init__.py ends here 23 | -------------------------------------------------------------------------------- /src/textual_mandelbrot/__main__.py: -------------------------------------------------------------------------------- 1 | """Mandelbrot plotting application for the terminal.""" 2 | 3 | ############################################################################## 4 | # Backward compatibility. 5 | from __future__ import annotations 6 | 7 | ############################################################################## 8 | # Textual imports. 9 | from textual import on 10 | from textual.app import App, ComposeResult 11 | from textual.binding import Binding 12 | from textual.widgets import Footer, Header 13 | 14 | ############################################################################## 15 | # Local imports. 16 | from . import __version__ 17 | from .colouring import blue_brown_map, default_map, shades_of_green 18 | from .commands import MandelbrotCommands 19 | from .mandelbrot import Mandelbrot 20 | 21 | 22 | ############################################################################## 23 | class MandelbrotApp(App[None]): 24 | """A Textual-based Mandelbrot set plotting application for the terminal.""" 25 | 26 | TITLE = "Mandelbrot" 27 | """The title for the application.""" 28 | 29 | SUB_TITLE = f"v{__version__}" 30 | """The sub-title for the application.""" 31 | 32 | CSS = """ 33 | Screen { 34 | align: center middle; 35 | } 36 | 37 | Mandelbrot { 38 | border: round grey; 39 | } 40 | """ 41 | 42 | BINDINGS = [ 43 | Binding("1", "colour( 0 )", "Colours 1", show=False), 44 | Binding("2", "colour( 1 )", "Colours 2", show=False), 45 | Binding("3", "colour( 2 )", "Colours 2", show=False), 46 | ] 47 | """Keyboard bindings for the application.""" 48 | 49 | COMMANDS = App.COMMANDS | {MandelbrotCommands} 50 | 51 | def _best_size(self) -> tuple[tuple[int, int], tuple[int, int]]: 52 | """Figure out the best initial size for the plot and the widget. 53 | 54 | Returns: 55 | A `tuple` of `tuple`s if two `int`. The first `tuple` is the 56 | suggested width and height of the canvas itself. The second is 57 | the suggested width and height for the widget. 58 | """ 59 | 60 | # Get the initial width/height of the display. Note the little bit 61 | # of "magic number" work here; 2 is allowing for the border of the 62 | # Canvas widget; 4 is allowing for the border of the Canvas widget 63 | # and the Header and Footer widgets. 64 | display_width = self.app.size.width - 2 65 | display_height = self.app.size.height - 4 66 | 67 | # Go for 4:3 based off the width. 68 | best_width = display_width 69 | best_height = ((display_width // 4) * 3) // 2 70 | 71 | # If that looks like it isn't going to fit nicely... 72 | if best_height >= display_height: 73 | # ...let's try and make it fit from the height first. 74 | best_height = display_height 75 | best_width = ((best_height // 3) * 4) * 2 76 | 77 | # Final choice. 78 | return (best_width - 2, (best_height - 2) * 2), (best_width, best_height) 79 | 80 | def _mandelbrot(self) -> Mandelbrot: 81 | """Create the Mandelbrot plotting widget. 82 | 83 | Returns: 84 | The widget. 85 | """ 86 | (canvas_width, canvas_height), (widget_width, widget_height) = self._best_size() 87 | plot = Mandelbrot(canvas_width, canvas_height) 88 | plot.styles.width = widget_width 89 | plot.styles.height = widget_height 90 | return plot 91 | 92 | def compose(self) -> ComposeResult: 93 | """Compose the child widgets.""" 94 | yield Header() 95 | yield self._mandelbrot() 96 | yield Footer() 97 | 98 | def on_mount(self) -> None: 99 | """Set things up once the DOM is available.""" 100 | self.query_one(Mandelbrot).focus() 101 | 102 | @on(Mandelbrot.Changed) 103 | def update_titles(self, event: Mandelbrot.Changed) -> None: 104 | """Handle the parameters of the Mandelbrot being changed. 105 | 106 | Args: 107 | event: The event with the change details. 108 | """ 109 | plot = self.query_one(Mandelbrot) 110 | plot.border_title = ( 111 | f"{event.mandelbrot.from_x:.10f}, {event.mandelbrot.from_y:.10f}" 112 | " -> " 113 | f"{event.mandelbrot.to_x:.10f}, {event.mandelbrot.to_y:.10f}" 114 | ) 115 | plot.border_subtitle = ( 116 | f"{event.mandelbrot.multibrot:0.2f} multibrot | " 117 | f"{event.mandelbrot.max_iteration:0.2f} iterations | " 118 | f"{event.elapsed:0.4f} seconds" 119 | ) 120 | 121 | def action_colour(self, colour: int) -> None: 122 | """Set a colour scheme for the plot. 123 | 124 | Args: 125 | colour: The number of the colour scheme to use. 126 | """ 127 | self.query_one(Mandelbrot).set_colour_source( 128 | [default_map, blue_brown_map, shades_of_green][colour] 129 | ) 130 | 131 | 132 | ############################################################################## 133 | def main() -> None: 134 | """Main entry point for the console script version.""" 135 | MandelbrotApp().run() 136 | 137 | 138 | ############################################################################## 139 | # Run the main application if we're being called on as main. 140 | if __name__ == "__main__": 141 | main() 142 | 143 | ### __main__.py ends here 144 | -------------------------------------------------------------------------------- /src/textual_mandelbrot/colouring.py: -------------------------------------------------------------------------------- 1 | """Functions that provide various colour maps for plotting a Mandelbrot set.""" 2 | 3 | ############################################################################## 4 | # Python imports. 5 | from functools import lru_cache 6 | 7 | ############################################################################## 8 | # Textual imports. 9 | from textual.color import Color 10 | 11 | 12 | ############################################################################## 13 | @lru_cache() 14 | def default_map(value: int, max_iteration: int) -> Color: 15 | """Calculate a colour for an escape value. 16 | 17 | Args: 18 | value: An escape value from a Mandelbrot set. 19 | 20 | Returns: 21 | The colour to plot the point with. 22 | """ 23 | return Color.from_hsl(value / max_iteration, 1, 0.5 if value else 0) 24 | 25 | 26 | ############################################################################## 27 | # https://stackoverflow.com/a/16505538/2123348 28 | BLUE_BROWN = [ 29 | Color(66, 30, 15), 30 | Color(25, 7, 26), 31 | Color(9, 1, 47), 32 | Color(4, 4, 73), 33 | Color(0, 7, 100), 34 | Color(12, 44, 138), 35 | Color(24, 82, 177), 36 | Color(57, 125, 209), 37 | Color(134, 181, 229), 38 | Color(211, 236, 248), 39 | Color(241, 233, 191), 40 | Color(248, 201, 95), 41 | Color(255, 170, 0), 42 | Color(204, 128, 0), 43 | Color(153, 87, 0), 44 | Color(106, 52, 3), 45 | ] 46 | 47 | 48 | ############################################################################## 49 | @lru_cache() 50 | def blue_brown_map(value: int, _: int) -> Color: 51 | """Calculate a colour for an escape value. 52 | 53 | Args: 54 | value: An escape value from a Mandelbrot set. 55 | 56 | Returns: 57 | The colour to plot the point with. 58 | """ 59 | return BLUE_BROWN[value % 16] if value else Color(0, 0, 0) 60 | 61 | 62 | ############################################################################## 63 | GREENS = [Color(0, n * 16, 0) for n in range(16)] 64 | 65 | 66 | @lru_cache() 67 | def shades_of_green(value: int, _: int) -> Color: 68 | """Calculate a colour for an escape value. 69 | 70 | Args: 71 | value: An escape value from a Mandelbrot set. 72 | 73 | Returns: 74 | The colour to plot the point with. 75 | """ 76 | return GREENS[value % 16] 77 | 78 | 79 | ### colouring.py ends here 80 | -------------------------------------------------------------------------------- /src/textual_mandelbrot/commands.py: -------------------------------------------------------------------------------- 1 | """Command palette commands for the Mandelbrot application.""" 2 | 3 | ############################################################################## 4 | # Python imports. 5 | from functools import partial 6 | from typing import Callable, Iterator 7 | 8 | ############################################################################## 9 | # Textual imports. 10 | from textual.color import Color 11 | from textual.command import DiscoveryHit, Hit, Hits, Provider 12 | 13 | ############################################################################## 14 | # Local imports. 15 | from .colouring import blue_brown_map, default_map, shades_of_green 16 | from .mandelbrot import Mandelbrot 17 | 18 | 19 | ############################################################################## 20 | class MandelbrotCommands(Provider): 21 | """A source of command palette commands for the Mandelbrot widget.""" 22 | 23 | PREFIX = "Mandelbrot: " 24 | """Prefix that is common to all the commands.""" 25 | 26 | @classmethod 27 | def _colour_commands(cls) -> Iterator[tuple[str, Callable[[int, int], Color], str]]: 28 | """The colour commands.""" 29 | for colour, source in ( 30 | ("default", default_map), 31 | ("blue/brown", blue_brown_map), 32 | ("green", shades_of_green), 33 | ): 34 | yield ( 35 | f"{cls.PREFIX}Set the colour map to {colour} ", 36 | source, 37 | f"Set the Mandelbrot colour palette to {colour}", 38 | ) 39 | 40 | @classmethod 41 | def _action_commands(cls) -> Iterator[tuple[str, str, str]]: 42 | # Spin out some commands based around available actions. 43 | for command, action, help_text in ( 44 | ( 45 | "Fast zoom in", 46 | "zoom(-2.0)", 47 | "Faster zoom further into the Mandelbrot set", 48 | ), 49 | ( 50 | "Fast zoom out", 51 | "zoom(2.0)", 52 | "Faster zoom further out of the Mandelbrot set", 53 | ), 54 | ("Go home", "zero", "Go to 0, 0 in the Mandelbrot set"), 55 | ("Reset", "reset", "Reset the Mandelbrot set"), 56 | ("Zoom in", "zoom(-1.2)", "Zoom further into the Mandelbrot set"), 57 | ("Zoom out", "zoom(1.2)", "Zoom further out of the Mandelbrot set"), 58 | ( 59 | "More detail", 60 | "max_iter(10)", 61 | "Add more detail to the Mandelbrot set (will run slower)", 62 | ), 63 | ( 64 | "Less detail", 65 | "max_iter(-10)", 66 | "Remove detail from the Mandelbrot set (will run faster)", 67 | ), 68 | ( 69 | "Lots more detail", 70 | "max_iter(100)", 71 | "Add more detail to the Mandelbrot set (will run slower)", 72 | ), 73 | ( 74 | "Lots less detail", 75 | "max_iter(-100)", 76 | "Remove detail from the Mandelbrot set (will run faster)", 77 | ), 78 | ): 79 | yield (f"{cls.PREFIX}{command}", action, help_text) 80 | 81 | async def discover(self) -> Hits: 82 | """Handle a request to discover commands. 83 | 84 | Yields: 85 | Command discovery hits for the command palette. 86 | """ 87 | 88 | # Nothing in here makes sense if the user isn't current on a 89 | # Mandelbrot set. 90 | if not isinstance(self.focused, Mandelbrot): 91 | return 92 | 93 | # Spin out some commands for setting the colours. 94 | for command, source, help_text in self._colour_commands(): 95 | yield DiscoveryHit( 96 | command, 97 | partial(self.focused.set_colour_source, source), 98 | command, 99 | help_text, 100 | ) 101 | 102 | # Spin out the action commands. 103 | for command, action, help_text in self._action_commands(): 104 | yield DiscoveryHit( 105 | command, 106 | partial(self.focused.run_action, action), 107 | command, 108 | help_text, 109 | ) 110 | 111 | async def search(self, query: str) -> Hits: 112 | """Handle a request to search for system commands that match the query. 113 | 114 | Args: 115 | user_input: The user input to be matched. 116 | 117 | Yields: 118 | Command hits for use in the command palette. 119 | """ 120 | 121 | # Nothing in here makes sense if the user isn't current on a 122 | # Mandelbrot set. 123 | if not isinstance(self.focused, Mandelbrot): 124 | return 125 | 126 | # Get a fuzzy matcher for looking for hits. 127 | matcher = self.matcher(query) 128 | 129 | # Spin out some commands for setting the colours. 130 | for command, source, help_text in self._colour_commands(): 131 | match = matcher.match(command) 132 | if match: 133 | yield Hit( 134 | match, 135 | matcher.highlight(command), 136 | partial(self.focused.set_colour_source, source), 137 | help=help_text, 138 | ) 139 | 140 | # Spin out the action commands. 141 | for command, action, help_text in self._action_commands(): 142 | match = matcher.match(command) 143 | if match: 144 | yield Hit( 145 | match, 146 | matcher.highlight(command), 147 | partial(self.focused.run_action, action), 148 | help=help_text, 149 | ) 150 | 151 | 152 | ### commands.py ends here 153 | -------------------------------------------------------------------------------- /src/textual_mandelbrot/mandelbrot.py: -------------------------------------------------------------------------------- 1 | """Provides a Textual widget for plotting a Mandelbrot set.""" 2 | 3 | ############################################################################## 4 | # Backward compatibility. 5 | from __future__ import annotations 6 | 7 | ############################################################################## 8 | # Python imports. 9 | from decimal import Decimal 10 | from operator import mul, truediv 11 | from time import monotonic 12 | from typing import Callable, Iterator 13 | 14 | ############################################################################## 15 | # Textual imports. 16 | from textual.binding import Binding 17 | from textual.color import Color 18 | from textual.message import Message 19 | 20 | ############################################################################## 21 | # Textual-canvas imports. 22 | from textual_canvas import Canvas 23 | 24 | ############################################################################## 25 | # Typing extension imports. 26 | from typing_extensions import Self 27 | 28 | ############################################################################## 29 | # Local imports. 30 | from .colouring import default_map 31 | 32 | 33 | ############################################################################## 34 | def _mandelbrot(x: Decimal, y: Decimal, multibrot: float, max_iteration: int) -> int: 35 | """Return the Mandelbrot calculation for the point. 36 | 37 | Args: 38 | x: The x location of the point to calculate. 39 | y: The y location of the point to calculate. 40 | multibrot: The 'multibrot' value to use in the calculation. 41 | max_iteration: The maximum number of iterations to calculate for. 42 | 43 | Returns: 44 | The number of loops to escape, or 0 if it didn't. 45 | 46 | Note: 47 | The point is considered to be stable, considered to have not 48 | escaped, if the `max_iteration` has been hit without the calculation 49 | going above 2.0. 50 | """ 51 | c1 = complex(x, y) 52 | c2 = 0j 53 | for n in range(max_iteration): 54 | if abs(c2) > 2: 55 | return n 56 | c2 = c1 + (c2**multibrot) 57 | return 0 58 | 59 | 60 | ############################################################################## 61 | class Mandelbrot(Canvas): 62 | """A Mandelbrot-plotting widget.""" 63 | 64 | DEFAULT_CSS = """ 65 | Mandelbrot { 66 | width: 1fr; 67 | height: 1fr; 68 | } 69 | """ 70 | 71 | BINDINGS = [ 72 | Binding("up, w, k", "move( 0, -1 )", "Up", show=False), 73 | Binding("shift+up, W, K", "move( 0, -1, 50 )", "Up", show=False), 74 | Binding("down, s, j", "move( 0, 1 )", "Down", show=False), 75 | Binding("shift+down, S, J", "move( 0, 1, 50 )", "Down", show=False), 76 | Binding("left, a, h", "move( -1, 0 )", "Left", show=False), 77 | Binding("shift+left, A, H", "move( -1, 0, 50 )", "Left", show=False), 78 | Binding("right, d, l", "move( 1, 0 )", "Right", show=False), 79 | Binding("shift+right, D, L", "move( 1, 0, 50 )", "Right", show=False), 80 | Binding("pageup, right_square_bracket", "zoom( -1.2 )", "In"), 81 | Binding("pagedown, left_square_bracket", "zoom( 1.2 )", "Out"), 82 | Binding( 83 | "ctrl+pageup, right_curly_bracket", 84 | "zoom( -2.0 )", 85 | "In+", 86 | ), 87 | Binding( 88 | "ctrl+pagedown, left_curly_bracket", 89 | "zoom( 2.0 )", 90 | "Out+", 91 | ), 92 | Binding("*, ctrl+up", "multibrot( 1 )", "Mul+"), 93 | Binding("/, ctrl+down", "multibrot( -1 )", "Mul-"), 94 | Binding("ctrl+shift+up", "multibrot( 0.05 )", "Mul+", show=False), 95 | Binding("ctrl+shift+down", "multibrot( -0.05 )", "Mul-", show=False), 96 | Binding("home", "zero", "0, 0"), 97 | Binding("comma", "max_iter( -10 )", "Res-"), 98 | Binding("less_than_sign", "max_iter( -100 )", "Res--"), 99 | Binding("full_stop", "max_iter( 10 )", "Res+"), 100 | Binding("greater_than_sign", "max_iter( 100 )", "Res++"), 101 | Binding("ctrl+r", "reset", "Reset"), 102 | Binding("escape", "app.quit", "Exit"), 103 | ] 104 | """Keyboard bindings for the widget.""" 105 | 106 | class Changed(Message): 107 | """Message sent when the range of the display changes. 108 | 109 | This will be sent if the user (un)zooms or moves the display. 110 | """ 111 | 112 | def __init__(self, mandelbrot: Mandelbrot, elapsed: float) -> None: 113 | """Initialise the message. 114 | 115 | Args: 116 | mandelbrot: The Mandelbrot causing the message. 117 | elapsed: The time elapsed while calculating the plot. 118 | """ 119 | super().__init__() 120 | self.mandelbrot: Mandelbrot = mandelbrot 121 | """The Mandelbrot widget that caused the event.""" 122 | self.elapsed = elapsed 123 | """The time that elapsed during the drawing of the current view.""" 124 | 125 | @property 126 | def control(self) -> Mandelbrot: 127 | """Alias for the reference to the Mandelbrot widget.""" 128 | return self.mandelbrot 129 | 130 | def __init__( 131 | self, 132 | width: int, 133 | height: int, 134 | colour_source: Callable[[int, int], Color] = default_map, 135 | name: str | None = None, 136 | id: str | None = None, # pylint:disable=redefined-builtin 137 | classes: str | None = None, 138 | disabled: bool = False, 139 | ): 140 | """Initialise the canvas. 141 | 142 | Args: 143 | width: The width of the Mandelbrot set canvas. 144 | height: The height of the Mandelbrot set canvas. 145 | colour_source: Optional function for providing colours. 146 | name: The name of the Mandelbrot widget. 147 | id: The ID of the Mandelbrot widget in the DOM. 148 | classes: The CSS classes of the Mandelbrot widget. 149 | disabled: Whether the Mandelbrot widget is disabled or not. 150 | """ 151 | super().__init__( 152 | width, height, name=name, id=id, classes=classes, disabled=disabled 153 | ) 154 | self._max_iteration: int = 80 155 | """Maximum number of iterations to perform.""" 156 | self._multibrot: Decimal = Decimal(2.0) 157 | """The 'multibrot' value.""" 158 | self._from_x: Decimal = Decimal(-2.5) 159 | """Start X position for the plot.""" 160 | self._to_x: Decimal = Decimal(1.5) 161 | """End X position for the plot.""" 162 | self._from_y: Decimal = Decimal(-1.5) 163 | """Start Y position for the plot.""" 164 | self._to_y: Decimal = Decimal(1.5) 165 | """End Y position for the plot.""" 166 | self._colour_source = colour_source 167 | """Source of colour for the plot.""" 168 | 169 | @property 170 | def max_iteration(self) -> int: 171 | """Maximum number of iterations to perform.""" 172 | return self._max_iteration 173 | 174 | @property 175 | def multibrot(self) -> Decimal: 176 | """The 'multibrot' value.""" 177 | return self._multibrot 178 | 179 | @property 180 | def from_x(self) -> Decimal: 181 | """Start X position for the plot.""" 182 | return self._from_x 183 | 184 | @property 185 | def to_x(self) -> Decimal: 186 | """End X position for the plot.""" 187 | return self._to_x 188 | 189 | @property 190 | def from_y(self) -> Decimal: 191 | """Start Y position for the plot.""" 192 | return self._from_y 193 | 194 | @property 195 | def to_y(self) -> Decimal: 196 | """End Y position for the plot.""" 197 | return self._to_y 198 | 199 | def reset(self) -> Self: 200 | """Reset the plot. 201 | 202 | Returns: 203 | Self. 204 | """ 205 | self._max_iteration = 80 206 | self._multibrot = Decimal(2) 207 | self._from_x = Decimal(-2.5) 208 | self._to_x = Decimal(1.5) 209 | self._from_y = Decimal(-1.5) 210 | self._to_y = Decimal(1.5) 211 | return self 212 | 213 | def set_colour_source(self, colour_source: Callable[[int, int], Color]) -> Self: 214 | """Set a new colour source. 215 | 216 | Args: 217 | colour_source: The new colour source. 218 | 219 | Returns: 220 | Self. 221 | """ 222 | self._colour_source = colour_source 223 | return self.plot() 224 | 225 | def _frange(self, r_from: Decimal, r_to: Decimal, size: int) -> Iterator[Decimal]: 226 | """Generate a float range for the plot. 227 | 228 | Args: 229 | r_from: The value to generate from. 230 | r_to: The value to generate to. 231 | size: The size of canvas in the desired direction. 232 | 233 | Yields: 234 | Values between the range to fit the plot. 235 | """ 236 | steps = 0 237 | step = Decimal(r_to - r_from) / Decimal(size) 238 | n = Decimal(r_from) 239 | while n < r_to and steps < size: 240 | yield n 241 | n += step 242 | steps += 1 243 | 244 | def plot(self) -> Self: 245 | """Plot the Mandelbrot set using the current conditions. 246 | 247 | Returns: 248 | Self. 249 | """ 250 | start = monotonic() 251 | with self.app.batch_update(): 252 | for x_pixel, x_point in enumerate( 253 | self._frange(self._from_x, self._to_x, self.width) 254 | ): 255 | for y_pixel, y_point in enumerate( 256 | self._frange(self._from_y, self._to_y, self.height) 257 | ): 258 | self.set_pixel( 259 | x_pixel, 260 | y_pixel, 261 | self._colour_source( 262 | _mandelbrot( 263 | x_point, 264 | y_point, 265 | float(self._multibrot), 266 | self._max_iteration, 267 | ), 268 | self._max_iteration, 269 | ), 270 | ) 271 | self.post_message(self.Changed(self, monotonic() - start)) 272 | return self 273 | 274 | def on_mount(self) -> None: 275 | """Get the plotter going once the DOM is ready.""" 276 | self.plot() 277 | 278 | def action_move(self, x: int, y: int, steps: int = 5) -> None: 279 | """Move the Mandelbrot Set within the view. 280 | 281 | Args: 282 | x: The amount and direction to move in X. 283 | y: The amount and direction to move in Y. 284 | """ 285 | 286 | x_step = Decimal(x * ((self._to_x - self._from_x) / steps)) 287 | y_step = Decimal(y * ((self._to_y - self._from_y) / steps)) 288 | 289 | self._from_x += x_step 290 | self._to_x += x_step 291 | self._from_y += y_step 292 | self._to_y += y_step 293 | 294 | self.plot() 295 | 296 | def action_zero(self) -> None: 297 | """Move the view to 0, 0.""" 298 | width = (self._to_x - self._from_x) / Decimal(2) 299 | height = (self._to_y - self._from_y) / Decimal(2) 300 | self._from_x = -width 301 | self._to_x = width 302 | self._from_y = -height 303 | self._to_y = height 304 | self.plot() 305 | 306 | @staticmethod 307 | def _scale( 308 | from_pos: Decimal, to_pos: Decimal, zoom: Decimal 309 | ) -> tuple[Decimal, Decimal]: 310 | """Scale a dimension. 311 | 312 | Args: 313 | from_pos: The start position of the dimension. 314 | to_pos: The end position of the dimension. 315 | 316 | Returns: 317 | The new start and end positions. 318 | """ 319 | 320 | # Figure the operator from the sign. 321 | by = truediv if zoom < 0 else mul 322 | 323 | # We don't need the sign anymore. 324 | zoom = Decimal(abs(zoom)) 325 | 326 | # Calculate the old and new dimensions. 327 | old_dim = to_pos - from_pos 328 | new_dim = Decimal(by(old_dim, zoom)) 329 | 330 | # Return the adjusted points. 331 | return ( 332 | from_pos + Decimal((old_dim - new_dim) / 2), 333 | to_pos - Decimal((old_dim - new_dim) / 2), 334 | ) 335 | 336 | def action_zoom(self, zoom: Decimal) -> None: 337 | """Zoom in our out. 338 | 339 | Args: 340 | zoom: The amount to zoom by. 341 | """ 342 | self._from_x, self._to_x = self._scale(self._from_x, self._to_x, zoom) 343 | self._from_y, self._to_y = self._scale(self._from_y, self._to_y, zoom) 344 | self.plot() 345 | 346 | def action_max_iter(self, change: int) -> None: 347 | """Change the maximum number of iterations for a calculation. 348 | 349 | Args: 350 | change: The amount to change by. 351 | """ 352 | # Keep a lower bound for the max iteration. 353 | if (self._max_iteration + change) >= 10: 354 | self._max_iteration += change 355 | self.plot() 356 | else: 357 | self.app.bell() 358 | 359 | def action_multibrot(self, change: Decimal) -> None: 360 | """Change the 'multibrot' modifier. 361 | 362 | Args: 363 | change: The amount to change by. 364 | """ 365 | if (self._multibrot + Decimal(change)) > 0: 366 | self._multibrot += Decimal(change) 367 | self.plot() 368 | else: 369 | self.app.bell() 370 | 371 | def action_reset(self) -> None: 372 | """Reset the display of the Mandelbrot set back to initial conditions.""" 373 | self.reset().plot() 374 | 375 | 376 | ### mandelbrot.py ends here 377 | --------------------------------------------------------------------------------