├── .github ├── dependabot.yaml └── workflows │ ├── python-publish.yaml │ └── sphinx-docs.yaml ├── .gitignore ├── .mailmap ├── .pre-commit-config.yaml ├── LICENSE.Apache-2.0 ├── LICENSE.MIT ├── README-PYPI.md ├── README.md ├── docs ├── conf.py ├── index.rst ├── reference │ └── index.rst ├── requirements.txt └── user │ ├── batgrl_faq.rst │ ├── gadget_tree.rst │ ├── gadgets.rst │ ├── index.rst │ └── pong_tutorial.rst ├── examples ├── advanced │ ├── cloth.py │ ├── connect4 │ │ ├── README.md │ │ └── connect4 │ │ │ ├── __init__.py │ │ │ ├── __main__.py │ │ │ └── graphics.py │ ├── digital_clock.py │ ├── doom_fire.py │ ├── exploding_logo.py │ ├── exploding_logo_redux.py │ ├── figfonts.py │ ├── game_of_life.py │ ├── hack │ │ ├── README.md │ │ └── hack │ │ │ ├── __init__.py │ │ │ ├── __main__.py │ │ │ ├── colors.py │ │ │ ├── effects.py │ │ │ ├── memory.py │ │ │ ├── modal.py │ │ │ ├── output.py │ │ │ └── words.txt │ ├── infinite_tictactoe.py │ ├── io_events.py │ ├── isotiles.py │ ├── labyrinth.py │ ├── minesweeper │ │ ├── README.md │ │ └── minesweeper │ │ │ ├── __init__.py │ │ │ ├── __main__.py │ │ │ ├── colors.py │ │ │ ├── count.py │ │ │ ├── grid.py │ │ │ ├── minefield.py │ │ │ ├── minesweeper.py │ │ │ └── unicode_chars.py │ ├── moire.py │ ├── navier_stokes.py │ ├── palettes.py │ ├── pipes.py │ ├── pong.py │ ├── raycasting.py │ ├── reaction_diffusion.py │ ├── rubiks │ │ ├── README.md │ │ └── rubiks │ │ │ ├── __init__.py │ │ │ ├── __main__.py │ │ │ ├── camera.py │ │ │ ├── cube.py │ │ │ ├── face_colors.py │ │ │ ├── rotation.py │ │ │ └── rubiks_cube.py │ ├── sandbox │ │ ├── README.md │ │ └── sandbox │ │ │ ├── __init__.py │ │ │ ├── __main__.py │ │ │ ├── element_buttons.py │ │ │ ├── particles.py │ │ │ └── sandbox.py │ ├── shadow_casting.py │ ├── sliding_puzzle.py │ ├── slime.py │ ├── snake.py │ ├── sph │ │ ├── README.md │ │ └── sph │ │ │ ├── __init__.py │ │ │ ├── __main__.py │ │ │ └── solver.py │ ├── spinning_cube.py │ ├── stable_fluid.py │ └── tetris │ │ ├── README.md │ │ └── tetris │ │ ├── __init__.py │ │ ├── __main__.py │ │ ├── matrix.py │ │ ├── modal_screen.py │ │ ├── tetris.py │ │ ├── tetrominoes.py │ │ └── wall_kicks.py ├── assets │ ├── README.md │ ├── background.png │ ├── bliss.png │ ├── caveman │ │ ├── 0.png │ │ ├── 1.png │ │ ├── 2.png │ │ └── 3.png │ ├── checkered.png │ ├── crate.png │ ├── custom_button │ │ ├── off-mid.png │ │ ├── off.png │ │ ├── on-mid.png │ │ └── on.png │ ├── delta_corps_priest_1.flf │ ├── fallout_terminal.png │ ├── isometric_demo.png │ ├── logo_solo_flat_256.png │ ├── loudypixelsky.png │ ├── pixel_python.png │ ├── python_discord_logo.png │ ├── rustofat.tlf │ ├── soccer_ball.png │ ├── space_parallax │ │ ├── 00.png │ │ ├── 01.png │ │ ├── 02.png │ │ ├── 03.png │ │ ├── 04.png │ │ ├── 05.png │ │ ├── 06.png │ │ └── 07.png │ ├── spinner.gif │ ├── tg-bat.ans │ ├── tree.txt │ ├── wall.txt │ └── water │ │ ├── frame_00.png │ │ ├── frame_01.png │ │ ├── frame_02.png │ │ ├── frame_03.png │ │ ├── frame_04.png │ │ ├── frame_05.png │ │ ├── frame_06.png │ │ ├── frame_07.png │ │ ├── frame_08.png │ │ ├── frame_09.png │ │ └── frame_10.png ├── basic │ ├── animation.py │ ├── ans_viewer.py │ ├── bar_chart.py │ ├── binding.py │ ├── borders.py │ ├── buttons.py │ ├── color_picker.py │ ├── console.py │ ├── custom_button.py │ ├── data_table.py │ ├── easings.py │ ├── file_chooser.py │ ├── image.py │ ├── io_events.py │ ├── line_plot.py │ ├── markdown.py │ ├── menu.py │ ├── motion.py │ ├── parallax.py │ ├── progress_bar.py │ ├── recycle_view.py │ ├── scroll_view.py │ ├── slider.py │ ├── sparkline.py │ ├── spinners.py │ ├── split_layout.py │ ├── stack_layout.py │ ├── syntax_highlighting.py │ ├── tabs.py │ ├── text_effects.py │ ├── text_input.py │ ├── tiled.py │ ├── video_in_terminal.py │ └── windows.py └── pyproject.toml ├── preview_images ├── application.gif ├── application.png ├── game.gif ├── game.png ├── simulation.gif └── simulation.png ├── pyproject.toml ├── setup.py └── src └── batgrl ├── __init__.py ├── _batgrl_markdown.py ├── _rendering.pxd ├── _rendering.pyx ├── _sixel.pxd ├── _sixel.pyx ├── app.py ├── array_types.py ├── colors ├── __init__.py ├── color_types.py ├── colors.py └── gradients.py ├── emojis.py ├── figfont.py ├── gadgets ├── __init__.py ├── _raycasting.pyx ├── _root.py ├── _shadow_casting.pyx ├── animation.py ├── ans_viewer.py ├── bar_chart.py ├── behaviors │ ├── __init__.py │ ├── button_behavior.py │ ├── focusable.py │ ├── grabbable.py │ ├── movable.py │ ├── movable_children.py │ ├── themable.py │ └── toggle_button_behavior.py ├── button.py ├── color_picker.py ├── console.py ├── cursor.py ├── data_table.py ├── digital_display.py ├── file_chooser.py ├── flat_toggle.py ├── gadget.py ├── graphic_field.py ├── graphics.py ├── grid_layout.py ├── image.py ├── line_plot.py ├── markdown.py ├── menu.py ├── pane.py ├── parallax.py ├── progress_bar.py ├── raycaster.py ├── recycle_view.py ├── scroll_view.py ├── shadow_caster.py ├── slider.py ├── sparkline.py ├── split_layout.py ├── stack_layout.py ├── tabs.py ├── text.py ├── text_animation.py ├── text_effects │ ├── __init__.py │ ├── _particle.py │ ├── beams.py │ ├── black_hole.py │ ├── ring.py │ └── spotlights.py ├── text_field.py ├── text_pad.py ├── text_raycaster.py ├── textbox.py ├── tiled.py ├── toggle_button.py ├── tree_view.py ├── video.py └── window.py ├── geometry ├── __init__.py ├── basic.py ├── easings.py ├── motion.py ├── regions.pxd ├── regions.pyi └── regions.pyx ├── logging.py ├── py.typed ├── spinners.py ├── terminal ├── __init__.py ├── _fbuf.h ├── _fbuf.pxd ├── _fbuf.pyx ├── ansi_escapes.py ├── events.py ├── linux_terminal.py ├── vt100_terminal.pxd ├── vt100_terminal.pyi ├── vt100_terminal.pyx └── windows_terminal.py ├── text_tools.py └── texture_tools.py /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | ci-dependencies: 9 | patterns: 10 | - "*" 11 | 12 | - package-ecosystem: "pip" 13 | directory: "/" 14 | schedule: 15 | interval: "monthly" 16 | groups: 17 | python-dependencies: 18 | patterns: 19 | - "*" 20 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distributions 📦 to PyPI 2 | 3 | on: 4 | pull_request: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | build_wheels: 10 | name: Build wheels on ${{ matrix.os }} 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest, windows-latest, macos-latest] 15 | 16 | steps: 17 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 18 | 19 | - name: Build wheels 20 | uses: pypa/cibuildwheel@faf86a6ed7efa889faf6996aa23820831055001a # v2.23.3 21 | 22 | - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 23 | with: 24 | name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }} 25 | path: ./wheelhouse/*.whl 26 | 27 | build_sdist: 28 | name: Build source distribution 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 32 | 33 | - name: Build sdist 34 | run: pipx run build --sdist 35 | 36 | - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 37 | with: 38 | name: cibw-sdist 39 | path: dist/*.tar.gz 40 | 41 | upload_pypi: 42 | needs: [build_wheels, build_sdist] 43 | runs-on: ubuntu-latest 44 | environment: 45 | name: pypi 46 | url: https://pypi.org/project/batgrl/${{ github.ref_name }} 47 | permissions: 48 | attestations: write 49 | id-token: write 50 | if: github.event_name == 'release' && github.event.action == 'published' 51 | steps: 52 | - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 53 | with: 54 | # unpacks all CIBW artifacts into dist/ 55 | pattern: cibw-* 56 | path: dist 57 | merge-multiple: true 58 | 59 | - name: Publish distribution 📦 to PyPI 60 | uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 61 | with: 62 | attestations: true 63 | verbose: true 64 | print-hash: true 65 | -------------------------------------------------------------------------------- /.github/workflows/sphinx-docs.yaml: -------------------------------------------------------------------------------- 1 | name: Sphinx Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 15 | 16 | - name: Setup Python 17 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 18 | with: 19 | python-version: "3.12" 20 | cache: pip 21 | cache-dependency-path: pyproject.toml 22 | 23 | - name: Install dependencies 24 | run: | 25 | pip install . 26 | pip install -r docs/requirements.txt 27 | 28 | - name: Build docs 29 | run: | 30 | sphinx-build docs docs/_build/html 31 | 32 | - name: Upload artifact 33 | uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 34 | with: 35 | path: ./docs/_build/html 36 | 37 | deploy: 38 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 39 | permissions: 40 | contents: read 41 | pages: write 42 | id-token: write 43 | 44 | environment: 45 | name: github-pages 46 | url: ${{ steps.deployment.outputs.page_url }} 47 | 48 | runs-on: ubuntu-latest 49 | 50 | needs: build 51 | steps: 52 | - name: Deploy to GitHub Pages 53 | id: deployment 54 | uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.c 2 | *.so 3 | *.pyc 4 | *.pyd 5 | *.egg-info 6 | *.log 7 | .vscode/ 8 | build/ 9 | dist/ 10 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | salt-die <53280662+salt-die@users.noreply.github.com> 2 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: ^docs/|((.txt)|(.ans)$) 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.6.0 5 | hooks: 6 | - id: check-yaml 7 | - id: end-of-file-fixer 8 | - id: trailing-whitespace 9 | args: [--markdown-linebreak-ext=md] 10 | - repo: https://github.com/astral-sh/ruff-pre-commit 11 | rev: v0.6.4 12 | hooks: 13 | - id: ruff 14 | args: [--fix, --exit-non-zero-on-fix] 15 | - id: ruff-format 16 | - repo: https://github.com/MarcoGorelli/cython-lint 17 | rev: v0.16.2 18 | hooks: 19 | - id: cython-lint 20 | - id: double-quote-cython-strings 21 | -------------------------------------------------------------------------------- /LICENSE.MIT: -------------------------------------------------------------------------------- 1 | Copyright 2021-2025 salt-die 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README-PYPI.md: -------------------------------------------------------------------------------- 1 | # batgrl - badass terminal graphics library 2 | 3 | Create games: 4 | 5 | ![Tetris](https://raw.githubusercontent.com/salt-die/batgrl/main/preview_images/game.png) 6 | 7 | Simulations: 8 | 9 | ![Simulation](https://raw.githubusercontent.com/salt-die/batgrl/main/preview_images/simulation.png) 10 | 11 | Or entire feature-filled applications: 12 | 13 | ![Application](https://raw.githubusercontent.com/salt-die/batgrl/main/preview_images/application.png) 14 | 15 | [See it in action](https://youtu.be/q1K6_P1COTI) 16 | 17 | [Read the docs](https://salt-die.github.io/batgrl/index.html) 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # batgrl - badass terminal graphics library 2 | 3 | Create games: 4 | 5 | ![Tetris](https://raw.githubusercontent.com/salt-die/batgrl/main/preview_images/game.gif) 6 | 7 | Simulations: 8 | 9 | ![Simulation](https://raw.githubusercontent.com/salt-die/batgrl/main/preview_images/simulation.gif) 10 | 11 | Or entire feature-filled applications: 12 | 13 | ![Application](https://raw.githubusercontent.com/salt-die/batgrl/main/preview_images/application.gif) 14 | 15 | [See it in action](https://youtu.be/q1K6_P1COTI) 16 | 17 | [Read the docs](https://salt-die.github.io/batgrl/index.html) 18 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | """Sphinx documentation builder configuration.""" 2 | 3 | from datetime import datetime 4 | 5 | from batgrl import __version__ 6 | 7 | project = "batgrl" 8 | author = "salt-die" 9 | today = f"{datetime.now():%B %d, %Y}" 10 | copyright = f"{datetime.now().year}, {author}" 11 | release = __version__ 12 | extensions = [ 13 | "sphinx.ext.autodoc", 14 | "sphinx.ext.autosummary", 15 | "numpydoc", 16 | ] 17 | autosummary_generate = True 18 | autodoc_default_options = { 19 | "members": True, 20 | "show-inheritance": True, 21 | "undoc-members": True, 22 | "inherited-members": True, 23 | "ignore-module-all": True, 24 | } 25 | 26 | # autodoc_mock_imports = [] 27 | 28 | html_theme = "pydata_sphinx_theme" 29 | html_sidebars = {"**": ["search-field", "sidebar-nav-bs"]} 30 | html_theme_options = { 31 | "footer_start": ["copyright"], 32 | "github_url": "https://github.com/salt-die/batgrl", 33 | "navigation_with_keys": False, 34 | "show_prev_next": False, 35 | } 36 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 37 | numpydoc_show_inherited_class_members = { 38 | "batgrl.colors.color_types.Color": False, 39 | "batgrl.colors.color_types.AColor": False, 40 | "batgrl.colors.color_types.ColorTheme": False, 41 | "batgrl.gadgets.gadget.PosHint": False, 42 | "batgrl.gadgets.gadget.SizeHint": False, 43 | "batgrl.geometry.basic.Point": False, 44 | "batgrl.geometry.basic.Size": False, 45 | "batgrl.figfont.FullLayout": False, 46 | } 47 | 48 | 49 | def skip_builtin_methods(app, what, name, obj, skip, options): 50 | """Skip documentation of builtin methods.""" 51 | try: 52 | obj.__objclass__ 53 | except AttributeError: 54 | return "built-in method" in repr(obj) or skip 55 | else: 56 | return True 57 | 58 | 59 | def setup(sphinx): # noqa: D103 60 | sphinx.connect("autodoc-skip-member", skip_builtin_methods) 61 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ********************** 2 | batgrl Documentation 3 | ********************** 4 | 5 | Welcome to the batgrl docs. batgrl stands for badass terminal graphics library. 6 | It can render transparent images, animations, or videos. Perfect for simulation 7 | visualizations, games, or GUIs. 8 | 9 | .. toctree:: 10 | :maxdepth: 1 11 | 12 | User Guide 13 | API reference 14 | 15 | **Version**: |release| 16 | 17 | **Links**: 18 | `Source `_ | 19 | `Examples `_ 20 | -------------------------------------------------------------------------------- /docs/reference/index.rst: -------------------------------------------------------------------------------- 1 | .. module:: batgrl 2 | 3 | ################ 4 | batgrl Reference 5 | ################ 6 | 7 | :Release: |release| 8 | :Date: |today| 9 | 10 | .. autosummary:: 11 | :toctree: generated 12 | :recursive: 13 | 14 | app 15 | array_types 16 | colors 17 | figfont 18 | gadgets 19 | geometry 20 | logging 21 | terminal.events 22 | text_tools 23 | texture_tools 24 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | pydata-sphinx-theme 3 | numpydoc -------------------------------------------------------------------------------- /docs/user/batgrl_faq.rst: -------------------------------------------------------------------------------- 1 | ########## 2 | batgrl FAQ 3 | ########## 4 | 5 | Why a library for terminal graphics? 6 | ------------------------------------ 7 | I like to create visualizations in the terminal. Mostly for the yearly programming event, 8 | `Advent of Code `_ 9 | (`See my AoC visualizations! `_), 10 | but also just for fun. 11 | 12 | Can you add or change X? 13 | ----------------------------- 14 | Probably. Open an issue or find me (salt-die) on the `python discord `_. 15 | -------------------------------------------------------------------------------- /docs/user/gadget_tree.rst: -------------------------------------------------------------------------------- 1 | .. _gadget_tree: 2 | 3 | ############### 4 | The Gadget Tree 5 | ############### 6 | 7 | Gadgets in your app are organized in a tree. The root of the tree is a special gadget that always 8 | matches your terminal's size. The root has child gadgets and these gadget can have children 9 | of their own. Gadgets that are part of the gadget tree can access the root gadget with the `root` 10 | property. 11 | 12 | A gadget has the following methods that modify the gadget tree: 13 | 14 | * `add_gadget()`: Add a gadget as a child. 15 | * `add_gadgets()`: Add an iterable of gadgets or add multiple gadgets as children. 16 | * `remove_gadget()`: Remove a child gadget. 17 | * `prolicide()`: Recursively remove all child gadgets. 18 | * `destroy()`: Remove a gadget from its parent and recursively remove all its children. 19 | 20 | When a gadget is added to the gadget tree (there must be a path from the gadget to the root), the 21 | `on_add()` method is called. Similarly, when a gadget is removed from the gadget tree, `on_remove()` is called. 22 | 23 | You can iterate over all descendents of a gadget with `walk()` (preorder traversal) or `walk_reverse()` (reverse 24 | postorder traversal) or iterate over all ancestors of a gadget with `ancestors()`. 25 | 26 | Rendering 27 | --------- 28 | The gadgets are drawn based on their position in the gadget tree. Children are drawn on top of their parents and 29 | in the order of `children`. The `pull_to_front()` method will move a gadget to the end of `children` making sure 30 | it is drawn after all its siblings. 31 | 32 | Dispatching 33 | ----------- 34 | Input is dispatched across the entire gadget tree. If a gadget has children, events will first 35 | be dispatched to its children in reversed order. For the following tree:: 36 | 37 | Root 38 | / \ 39 | A B 40 | / \ | 41 | a b c 42 | 43 | dispatching would visit *c B b a A Root*. If any gadget returns True from its event handler, 44 | the dispatching will stop. The event handlers are `on_key()`, `on_mouse()`, `on_paste()`, and `on_terminal_focus()`. 45 | They handle key events, mouse events, paste events, and focus events respectively. The structure of the different 46 | input events can be found `here `_. 47 | -------------------------------------------------------------------------------- /docs/user/gadgets.rst: -------------------------------------------------------------------------------- 1 | ####### 2 | Gadgets 3 | ####### 4 | 5 | Gadgets are the building blocks of your app. All gadgets have a size, position, 6 | and :ref:`children `. Additionally, gadgets have attributes `is_transparent`, 7 | `is_visible` and `is_enabled` that determine whether the gadget is transparent (whether gadgets 8 | beneath are rendered), whether the gadget is rendered, or whether input events are dispatched, 9 | respectively. 10 | 11 | The base gadget is little more than a container for other gadgets. Some other more interesting 12 | gadgets include: 13 | 14 | * `Pane`, a gadget with a background color. An `alpha` attribute can modify its transparency. 15 | * `Graphics`, a gadget for arbitrary RGBA textures. 16 | * `Text`, the most general gadget. Its state is an array of cells where each cell carries 17 | attributes a terminal character can have such as its character, whether it's bold, or the 18 | color of its foreground. 19 | 20 | Size and Pos Hints 21 | ------------------ 22 | If a gadget has a non-None size hint, it will have a size that is some proportion 23 | (given by the hint) of its parent. If the parent is resized, the gadget will update its 24 | size to follow the size hint. Similarly, for non-None position hints, a gadget will position 25 | itself at some proportion of its parent's size. Position hints can be further controlled 26 | with the `anchor` which determines which point in the gadget is aligned with the position hint 27 | (the default is `"center"`). 28 | 29 | Responding to Events 30 | -------------------- 31 | Each gadget has `on_key()`, `on_mouse()`, and `on_paste()` methods to enable responding to input 32 | events. Input events are dispatched to every gadget until one of these methods return True to 33 | signal that the event was handled. 34 | 35 | 36 | Collisions 37 | ---------- 38 | `collides_point()` will determina if a point is within a gadget's visible region. `to_local()` 39 | converts a point from absolute coordinates to the gadget's local coordinates. `collides_gadget()` 40 | can determine if one gadget overlaps another. 41 | -------------------------------------------------------------------------------- /docs/user/index.rst: -------------------------------------------------------------------------------- 1 | ################# 2 | batgrl User Guide 3 | ################# 4 | 5 | .. toctree:: 6 | :maxdepth: 1 7 | 8 | batgrl_faq 9 | gadgets 10 | gadget_tree 11 | pong_tutorial 12 | -------------------------------------------------------------------------------- /examples/advanced/connect4/README.md: -------------------------------------------------------------------------------- 1 | # Connect4 2 | 3 | The classic Connect4 board game with batgrl. `python -m connect4` to run. 4 | -------------------------------------------------------------------------------- /examples/advanced/connect4/connect4/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/advanced/connect4/connect4/__init__.py -------------------------------------------------------------------------------- /examples/advanced/connect4/connect4/graphics.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from itertools import cycle 3 | 4 | import cv2 5 | from batgrl.colors import AWHITE, TRANSPARENT, AColor, gradient 6 | from batgrl.gadgets.graphics import Graphics, Size 7 | from batgrl.gadgets.grid_layout import GridLayout 8 | 9 | BOARD_COLOR = AColor.from_hex("4bade5") 10 | SELECTED_COLOR = AColor(*(2 * (i // 3) + (255 // 3) for i in BOARD_COLOR)) 11 | CHECKER_SIZE = Size(6, 13) 12 | 13 | 14 | def x_to_column(x): 15 | """Convert x coordinate of mouse position to Connect4 column.""" 16 | for i in range(1, 8): 17 | if x < i * CHECKER_SIZE.width: 18 | return i - 1 19 | 20 | 21 | class BoardPiece(Graphics): 22 | """A single square of a Connect4 board.""" 23 | 24 | def __init__(self): 25 | super().__init__(size=CHECKER_SIZE, default_color=BOARD_COLOR) 26 | h, w = self._size 27 | center = h, w // 2 28 | radius = w // 3 29 | cv2.circle(self.texture, center, radius, TRANSPARENT, -1) 30 | 31 | def select(self): 32 | texture = self.texture 33 | texture[(texture != TRANSPARENT).all(axis=2)] = SELECTED_COLOR 34 | 35 | def unselect(self): 36 | texture = self.texture 37 | texture[(texture != TRANSPARENT).all(axis=2)] = BOARD_COLOR 38 | 39 | 40 | class Checker(Graphics): 41 | def __init__(self, color): 42 | super().__init__(size=CHECKER_SIZE) 43 | h, w = self._size 44 | center = h, w // 2 45 | radius = w // 3 46 | cv2.circle(self.texture, center, radius, color, -1) 47 | self._color = color 48 | 49 | def on_add(self): 50 | super().on_add() 51 | self._flash_task = asyncio.create_task(asyncio.sleep(0)) # dummy task 52 | 53 | def flash(self): 54 | self._flash_task = asyncio.create_task(self._flash()) 55 | 56 | def stop_flash(self): 57 | self._flash_task.cancel() 58 | 59 | async def _flash(self): 60 | flash_gradient = cycle( 61 | gradient(self._color, AWHITE, n=20) + gradient(AWHITE, self._color, n=10) 62 | ) 63 | for self.texture[:] in flash_gradient: 64 | await asyncio.sleep(0.05) 65 | 66 | async def fall(self, target, on_complete): 67 | velocity = 0 68 | gravity = 1 / 36 69 | y = self.y 70 | 71 | while self.y != target: 72 | velocity += gravity 73 | y += velocity 74 | self.y = min(target, int(y)) 75 | 76 | await asyncio.sleep(0) 77 | 78 | on_complete() 79 | 80 | 81 | class Board(GridLayout): 82 | def __init__(self): 83 | super().__init__(grid_rows=6, grid_columns=7, pos=(2, 0), is_transparent=True) 84 | self.add_gadgets(BoardPiece() for _ in range(42)) 85 | self.size = self.min_grid_size 86 | self._last_col = -1 87 | 88 | def on_mouse(self, mouse_event): 89 | if not self.collides_point(mouse_event.pos): 90 | for i in range(6): 91 | self.children[self.index_at(i, self._last_col)].unselect() 92 | self._last_col = -1 93 | return False 94 | 95 | col = x_to_column(self.to_local(mouse_event.pos).x) 96 | if col != self._last_col: 97 | for i in range(6): 98 | if self._last_col != -1: 99 | self.children[self.index_at(i, self._last_col)].unselect() 100 | self.children[self.index_at(i, col)].select() 101 | self._last_col = col 102 | -------------------------------------------------------------------------------- /examples/advanced/digital_clock.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | 4 | from batgrl.app import App 5 | from batgrl.gadgets.digital_display import BRIGHT_GREEN, DigitalDisplay 6 | from batgrl.gadgets.text import Text, new_cell 7 | 8 | 9 | class DigitalClock(Text): 10 | def __init__( 11 | self, 12 | pos=(0, 0), 13 | twelve_hour=False, 14 | default_cell=new_cell(fg_color=BRIGHT_GREEN), 15 | **kwargs, 16 | ): 17 | super().__init__( 18 | size=(7, 52), 19 | pos=pos, 20 | default_cell=default_cell, 21 | **kwargs, 22 | ) 23 | self.twelve_hour = twelve_hour 24 | 25 | for i in range(6): 26 | self.add_gadget(DigitalDisplay(pos=(0, i * 8 + i // 2 * 2))) 27 | 28 | self.chars[[2, -3], 16] = self.chars[[2, -3], 34] = "●" 29 | 30 | def on_add(self): 31 | super().on_add() 32 | self._update_task = asyncio.create_task(self._update_time()) 33 | 34 | def on_remove(self): 35 | super().on_remove() 36 | self._update_task.cancel() 37 | 38 | def _formatted_time(self): 39 | hours, minutes, seconds = time.localtime()[3:6] 40 | 41 | if self.twelve_hour: 42 | hours %= 12 43 | 44 | for unit in (hours, minutes, seconds): 45 | for n in divmod(unit, 10): 46 | yield n 47 | 48 | async def _update_time(self): 49 | while True: 50 | for display, digit in zip(self.children, self._formatted_time()): 51 | display.show_char(str(digit)) 52 | 53 | await asyncio.sleep(1) 54 | 55 | 56 | class TestDisplay(DigitalDisplay): 57 | """Display any key pressed.""" 58 | 59 | def on_key(self, key_event): 60 | if len(key_event.key) == 1: 61 | self.show_char(key_event.key) 62 | return True 63 | 64 | 65 | class DigitalClockApp(App): 66 | async def on_start(self): 67 | self.add_gadgets(DigitalClock(twelve_hour=True), TestDisplay(pos=(10, 0))) 68 | 69 | 70 | if __name__ == "__main__": 71 | DigitalClockApp(title="Digital Clock Example").run() 72 | -------------------------------------------------------------------------------- /examples/advanced/game_of_life.py: -------------------------------------------------------------------------------- 1 | """ 2 | Conway's Game of Life, but new cells are given the average color of their parents. 3 | 4 | Press `r` to reset. Click to create new live cells with random colors. 5 | """ 6 | 7 | import asyncio 8 | 9 | import numpy as np 10 | from batgrl.app import run_gadget_as_app 11 | from batgrl.gadgets.graphics import Graphics, scale_geometry 12 | from cv2 import BORDER_CONSTANT, filter2D 13 | 14 | KERNEL = np.array( 15 | [ 16 | [1, 1, 1], 17 | [1, 0, 1], 18 | [1, 1, 1], 19 | ] 20 | ) 21 | UPDATE_SPEED = 1 / 60 22 | 23 | 24 | class Life(Graphics): 25 | def on_add(self): 26 | super().on_add() 27 | self._update_task = asyncio.create_task(self._update()) 28 | 29 | def on_remove(self): 30 | super().on_remove() 31 | self._update_task.cancel() 32 | 33 | def on_size(self): 34 | super().on_size() 35 | self._reset() 36 | 37 | def _reset(self): 38 | h, w = scale_geometry(self._blitter, self._size) 39 | self.universe = np.random.randint(0, 2, (h, w), dtype=bool) 40 | self.texture[..., :3] = np.random.randint(0, 256, (h, w, 3)) 41 | self.texture[~self.universe] = 0 42 | 43 | async def _update(self): 44 | while True: 45 | neighbors = filter2D( 46 | self.universe.astype(np.uint8), -1, KERNEL, borderType=BORDER_CONSTANT 47 | ) 48 | still_alive = self.universe & np.isin(neighbors, (2, 3)) 49 | new_borns = ~self.universe & (neighbors == 3) 50 | self.universe = new_borns | still_alive 51 | 52 | rgb = self.texture[..., :3] 53 | new_colors = filter2D(rgb, -1, KERNEL / 3) 54 | rgb[~still_alive] = 0 55 | rgb[new_borns] = new_colors[new_borns] 56 | self.texture[..., 3] = 0 57 | self.texture[..., 3][self.universe] = 255 58 | 59 | await asyncio.sleep(UPDATE_SPEED) 60 | 61 | def on_key(self, key_event): 62 | if key_event.key == "r": 63 | self._reset() 64 | return True 65 | 66 | def on_mouse(self, mouse_event): 67 | if mouse_event.button != "no_button" and self.collides_point(mouse_event.pos): 68 | h, w = scale_geometry(self._blitter, mouse_event.pos) 69 | self.universe[h - 1 : h + 3, w - 1 : w + 2] = 1 70 | self.texture[h - 1 : h + 3, w - 1 : w + 2, :3] = np.random.randint( 71 | 0, 256, 3 72 | ) 73 | 74 | 75 | if __name__ == "__main__": 76 | run_gadget_as_app( 77 | Life(size_hint={"height_hint": 1.0, "width_hint": 1.0}, blitter="braille"), 78 | title="Game of Life", 79 | ) 80 | -------------------------------------------------------------------------------- /examples/advanced/hack/README.md: -------------------------------------------------------------------------------- 1 | # Hack 2 | 3 | A recreation of Fallout's hacking minigame. 4 | -------------------------------------------------------------------------------- /examples/advanced/hack/hack/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/advanced/hack/hack/__init__.py -------------------------------------------------------------------------------- /examples/advanced/hack/hack/__main__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from batgrl.app import App 4 | from batgrl.gadgets.image import Image 5 | from batgrl.gadgets.pane import Pane 6 | from batgrl.gadgets.text import Text, add_text, new_cell 7 | 8 | from .colors import BRIGHT_GREEN, DARK_GREEN 9 | from .effects import BOLDCRT 10 | from .memory import MemoryGadget 11 | from .modal import Modal 12 | from .output import Output 13 | 14 | TERMINAL = ( 15 | Path(__file__).parent.parent.parent.parent / "assets" / "fallout_terminal.png" 16 | ) 17 | HEADER = """\ 18 | ROBCO INDUSTRIES TERMLINK PROTOCOL 19 | ENTER PASSWORD NOW 20 | 21 | 4 ATTEMPT(S) LEFT: █ █ █ █ 22 | """ 23 | 24 | 25 | class HackApp(App): 26 | async def on_start(self): 27 | default_cell = new_cell(fg_color=BRIGHT_GREEN, bg_color=DARK_GREEN) 28 | 29 | header = Text(size=(5, 39), default_cell=default_cell) 30 | add_text(header.canvas, HEADER) 31 | 32 | modal = Modal( 33 | size_hint={"height_hint": 1.0, "width_hint": 1.0}, 34 | is_enabled=False, 35 | is_transparent=True, 36 | ) 37 | 38 | output = Output( 39 | header, 40 | modal, 41 | size=(17, 13), 42 | pos=(5, 40), 43 | default_cell=default_cell, 44 | ) 45 | 46 | memory = MemoryGadget( 47 | output, 48 | size=(17, 39), 49 | pos=(5, 0), 50 | default_cell=default_cell, 51 | ) 52 | 53 | modal.memory = memory 54 | 55 | terminal = Image( 56 | path=TERMINAL, 57 | size=(36, 63), 58 | pos_hint={"y_hint": 0.5, "x_hint": 0.5}, 59 | blitter="sixel", 60 | ) 61 | container = Pane( 62 | size=(22, 53), pos=(5, 5), bg_color=DARK_GREEN, is_transparent=False 63 | ) 64 | crt = BOLDCRT(size=(22, 53), is_transparent=True) 65 | 66 | terminal.add_gadget(container) 67 | container.add_gadgets(header, memory, output, modal, crt) 68 | self.add_gadget(terminal) 69 | 70 | memory.init_memory() 71 | 72 | 73 | if __name__ == "__main__": 74 | HackApp(title="Hack", bg_color=DARK_GREEN).run() 75 | -------------------------------------------------------------------------------- /examples/advanced/hack/hack/colors.py: -------------------------------------------------------------------------------- 1 | from batgrl.colors import BLACK, GREEN, WHITE, lerp_colors 2 | 3 | BRIGHT_GREEN = lerp_colors(GREEN, WHITE, 0.30) 4 | DARK_GREEN = lerp_colors(GREEN, BLACK, 0.96) 5 | -------------------------------------------------------------------------------- /examples/advanced/hack/hack/effects.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from batgrl.gadgets.gadget import Gadget 3 | from batgrl.geometry import rect_slice 4 | from batgrl.text_tools import Style 5 | 6 | 7 | class Darken(Gadget): 8 | """Darken view.""" 9 | 10 | def _render(self, cells, graphics, kind): 11 | super()._render(cells, graphics, kind) 12 | for pos, size in self._region.rects(): 13 | s = rect_slice(pos, size) 14 | cells["fg_color"][s] >>= 1 15 | cells["bg_color"][s] >>= 1 16 | 17 | 18 | class BOLDCRT(Gadget): 19 | """Bold all text and apply a crt effect.""" 20 | 21 | def on_add(self): 22 | super().on_add() 23 | self._i = 0 24 | self.pct = np.ones((*self.size, 1), float) 25 | 26 | def _render(self, cells, graphics, kind): 27 | py, px = self.absolute_pos 28 | h, w = self.size 29 | size = h * w 30 | 31 | self.pct *= 0.9995 32 | for _ in range(20): 33 | y, x = divmod(self._i, w) 34 | 35 | dst = slice(py, py + h), slice(px, px + w) 36 | cells[dst]["style"] |= Style.BOLD 37 | 38 | self.pct[y, x] = 1 39 | cells["fg_color"][dst] = (cells["fg_color"][dst] * self.pct).astype( 40 | np.uint8 41 | ) 42 | cells["bg_color"][dst] = (cells["bg_color"][dst] * self.pct).astype( 43 | np.uint8 44 | ) 45 | self._i += 1 46 | self._i %= size 47 | -------------------------------------------------------------------------------- /examples/advanced/hack/hack/memory.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from random import choice, randrange, sample 3 | 4 | import numpy as np 5 | from batgrl.gadgets.text import Text 6 | 7 | from .colors import BRIGHT_GREEN, DARK_GREEN 8 | 9 | NOISE = np.array(list("!\"#$%'()*+,-./:;<>?@[\\]^_`{|}=")) 10 | 11 | WORD_COUNT = 17 12 | WORD_LENGTH = 9 13 | 14 | 15 | def create_word_list() -> list[str]: 16 | path = Path(__file__).parent / "words.txt" 17 | words = path.read_text().splitlines() 18 | return [word for word in words if len(word) == WORD_LENGTH] 19 | 20 | 21 | WORDS = create_word_list() 22 | 23 | 24 | def memory_to_pos(i): 25 | y, x = divmod(i, 12) 26 | if y >= 17: 27 | y -= 17 28 | x += 27 29 | else: 30 | x += 7 31 | return y, x 32 | 33 | 34 | def pos_to_memory(pos): 35 | y, x = pos 36 | 37 | if y < 0 or y >= 17: 38 | return 39 | 40 | if x in range(7, 19): 41 | return y * 12 + x - 7 42 | 43 | if x in range(27, 39): 44 | return (17 + y) * 12 + x - 27 45 | 46 | 47 | class MemoryGadget(Text): 48 | def __init__(self, output, **kwargs): 49 | super().__init__(**kwargs) 50 | self.output = output 51 | 52 | def init_memory(self): 53 | # Memory addresses 54 | start_address = randrange(0, 0xFE38, 12) 55 | for i in range(34): 56 | self.add_str( 57 | f"0x{start_address + 12 * i:04X}", 58 | pos=(i % 17, 0 if i < 17 else 20), 59 | ) 60 | 61 | # Create a list of random characters and 62 | # insert words at random indices. 63 | total_chars = 12 * 34 64 | word_chars = WORD_COUNT * WORD_LENGTH 65 | noise_chars = total_chars - word_chars 66 | 67 | words = sample(WORDS, k=WORD_COUNT) 68 | 69 | word_indices = sample(range(0, noise_chars + 1, 4), k=WORD_COUNT) 70 | word_indices.sort(reverse=True) 71 | word_indices = np.array(word_indices) 72 | 73 | memory = list(NOISE[np.random.randint(0, len(NOISE), noise_chars)]) 74 | for i, word in zip(word_indices, words): 75 | memory[i:] = list(word) + memory[i:] 76 | 77 | word_indices += np.arange(WORD_COUNT)[::-1] * WORD_LENGTH 78 | 79 | self.words = words 80 | self.word_indices = {i + j: i for i in word_indices for j in range(WORD_LENGTH)} 81 | self.memory = "".join(memory) 82 | 83 | for i, char in enumerate(self.memory): 84 | self.chars[memory_to_pos(i)] = char 85 | 86 | self.output.password = choice(words) 87 | self.output.chars[:] = " " 88 | self.output.add_str(">█".ljust(13), pos=(-1, 0)) 89 | 90 | def on_mouse(self, mouse_event): 91 | if mouse_event.event_type == "mouse_up": 92 | return 93 | 94 | self.canvas["fg_color"] = BRIGHT_GREEN 95 | self.canvas["bg_color"] = DARK_GREEN 96 | 97 | i = pos_to_memory(self.to_local(mouse_event.pos)) 98 | if i is None: 99 | self.output.slow_add_str("") 100 | return 101 | 102 | if (start := self.word_indices.get(i)) is not None: 103 | end = start + WORD_LENGTH 104 | for j in range(start, end): 105 | self.canvas["fg_color"][memory_to_pos(j)] = DARK_GREEN 106 | self.canvas["bg_color"][memory_to_pos(j)] = BRIGHT_GREEN 107 | 108 | guess = self.memory[start:end] 109 | if mouse_event.button == "left" and mouse_event.event_type == "mouse_down": 110 | self.output.attempt(guess) 111 | else: 112 | self.output.slow_add_str(guess) 113 | else: 114 | self.canvas["fg_color"][memory_to_pos(i)] = DARK_GREEN 115 | self.canvas["bg_color"][memory_to_pos(i)] = BRIGHT_GREEN 116 | self.output.slow_add_str(self.memory[i]) 117 | 118 | return True 119 | -------------------------------------------------------------------------------- /examples/advanced/hack/hack/modal.py: -------------------------------------------------------------------------------- 1 | from batgrl.gadgets.behaviors.button_behavior import ButtonBehavior 2 | from batgrl.gadgets.gadget import Gadget 3 | from batgrl.gadgets.text import Text, new_cell 4 | 5 | from .colors import BRIGHT_GREEN, DARK_GREEN 6 | from .effects import Darken 7 | 8 | DEFAULT_CELL = new_cell(fg_color=BRIGHT_GREEN, bg_color=DARK_GREEN) 9 | 10 | 11 | class OKButton(ButtonBehavior, Text): 12 | def __init__(self): 13 | super().__init__(size=(1, 6), pos=(3, 7), default_cell=DEFAULT_CELL) 14 | self.add_str("[ OK ]") 15 | 16 | def update_hover(self): 17 | self.canvas["fg_color"] = DARK_GREEN 18 | self.canvas["bg_color"] = BRIGHT_GREEN 19 | 20 | def update_normal(self): 21 | self.canvas["fg_color"] = BRIGHT_GREEN 22 | self.canvas["bg_color"] = DARK_GREEN 23 | 24 | def on_release(self): 25 | modal = self.parent.parent 26 | modal.memory.init_memory() 27 | modal.is_enabled = False 28 | 29 | 30 | class Modal(Gadget): 31 | def __init__(self, **kwargs): 32 | super().__init__(**kwargs) 33 | self.background = Darken( 34 | size_hint={"height_hint": 1.0, "width_hint": 1.0}, is_transparent=True 35 | ) 36 | self.message_box = Text( 37 | size=(6, 20), 38 | pos_hint={"y_hint": 0.5, "x_hint": 0.5}, 39 | default_cell=DEFAULT_CELL, 40 | ) 41 | self.message_box.add_border("heavy") 42 | self.message_box.add_gadget(OKButton()) 43 | self.add_gadgets(self.background, self.message_box) 44 | 45 | def show(self, is_win): 46 | message = " System Accessed. " if is_win else " System Locked. " 47 | self.message_box.add_str(message, pos=(2, 1)) 48 | self.is_enabled = True 49 | 50 | def on_mouse(self, mouse_event): 51 | """Stop mouse dispatching while modal is enabled.""" 52 | return True 53 | -------------------------------------------------------------------------------- /examples/advanced/hack/hack/output.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from batgrl.gadgets.text import Text, add_text 4 | 5 | CORRECT_GUESS = """\ 6 | >{} 7 | >Exact match! 8 | >Please wait 9 | >while system 10 | >is accessed. 11 | 12 | >""" 13 | INCORRECT_GUESS = """\ 14 | >{} 15 | >Entry denied 16 | >{}/9 correct. 17 | 18 | >█""" 19 | 20 | 21 | class Output(Text): 22 | def __init__(self, header, modal, **kwargs): 23 | super().__init__(**kwargs) 24 | self.header = header 25 | self.modal = modal 26 | 27 | @property 28 | def password(self): 29 | return self._password 30 | 31 | @password.setter 32 | def password(self, password): 33 | self._password = password 34 | self.tries = 4 35 | 36 | @property 37 | def tries(self): 38 | return self._tries 39 | 40 | @tries.setter 41 | def tries(self, tries): 42 | self._tries = tries 43 | self.header.add_str( 44 | f"{tries} ATTEMPT(S) LEFT:{' █' * tries}".ljust(27), 45 | pos=(3, 0), 46 | ) 47 | 48 | def attempt(self, password): 49 | self._slow_add_task.cancel() 50 | 51 | if password == self.password: 52 | self.canvas[:-5] = self.canvas[5:] 53 | self.chars[-7:] = " " 54 | add_text( 55 | self.canvas[-7:], 56 | CORRECT_GUESS.format(password), 57 | ) 58 | self.modal.show(is_win=True) 59 | else: 60 | self.canvas[:-3] = self.canvas[3:] 61 | self.chars[-5:] = " " 62 | likeness = sum(a == b for a, b in zip(password, self.password)) 63 | add_text( 64 | self.canvas[-5:], 65 | INCORRECT_GUESS.format(password, likeness), 66 | ) 67 | self.tries -= 1 68 | if self.tries == 0: 69 | self.modal.show(is_win=False) 70 | 71 | def on_add(self): 72 | super().on_add() 73 | self._slow_add_task = asyncio.create_task(asyncio.sleep(0)) # dummy task 74 | 75 | def slow_add_str(self, s): 76 | self._slow_add_task.cancel() 77 | self._slow_add_task = asyncio.create_task(self._slow_add_str(s)) 78 | 79 | async def _slow_add_str(self, s): 80 | self.add_str("█".ljust(12), pos=(-1, 1)) 81 | for i, char in enumerate(s): 82 | await asyncio.sleep(0.04) 83 | self.add_str(char + "█", pos=(-1, 1 + i)) 84 | -------------------------------------------------------------------------------- /examples/advanced/minesweeper/README.md: -------------------------------------------------------------------------------- 1 | # Minesweeper 2 | 3 | `python -m minesweeper` to run. 4 | -------------------------------------------------------------------------------- /examples/advanced/minesweeper/minesweeper/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/advanced/minesweeper/minesweeper/__init__.py -------------------------------------------------------------------------------- /examples/advanced/minesweeper/minesweeper/__main__.py: -------------------------------------------------------------------------------- 1 | from batgrl.app import run_gadget_as_app 2 | 3 | from .minesweeper import MineSweeper 4 | 5 | if __name__ == "__main__": 6 | run_gadget_as_app(MineSweeper(), title="MineSweeper") 7 | -------------------------------------------------------------------------------- /examples/advanced/minesweeper/minesweeper/colors.py: -------------------------------------------------------------------------------- 1 | from batgrl.colors import Color 2 | 3 | ZERO = Color.from_hex("aba1ad") 4 | ONE = Color.from_hex("272ae5") 5 | TWO = Color.from_hex("25ba0b") 6 | THREE = Color.from_hex("8e2222") 7 | FOUR = Color.from_hex("0a2b99") 8 | FIVE = Color.from_hex("7f1b07") 9 | SIX = Color.from_hex("0ba9c1") 10 | SEVEN = Color.from_hex("c013db") 11 | EIGHT = Color.from_hex("140116") 12 | 13 | BORDER = Color.from_hex("85698c") 14 | HIDDEN_SQUARE = Color.from_hex("56365e") 15 | COUNT_SQUARE = Color.from_hex("b0d9e5") 16 | FLAG_COLOR = Color.from_hex("6d1004") 17 | -------------------------------------------------------------------------------- /examples/advanced/minesweeper/minesweeper/count.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | import numpy as np 4 | 5 | from .colors import ( 6 | BORDER, 7 | COUNT_SQUARE, 8 | EIGHT, 9 | FIVE, 10 | FOUR, 11 | ONE, 12 | SEVEN, 13 | SIX, 14 | THREE, 15 | TWO, 16 | ZERO, 17 | ) 18 | from .grid import Grid 19 | from .unicode_chars import BOMB 20 | 21 | 22 | @np.vectorize 23 | def stringify(n): 24 | return " " if n == 0 else str(n) 25 | 26 | 27 | @partial(np.vectorize, otypes=[np.uint8, np.uint8, np.uint8]) 28 | def colorify(n): 29 | return ( 30 | ZERO, 31 | ONE, 32 | TWO, 33 | THREE, 34 | FOUR, 35 | FIVE, 36 | SIX, 37 | SEVEN, 38 | EIGHT, 39 | ZERO, 40 | )[n] 41 | 42 | 43 | class Count(Grid): 44 | """ 45 | A gadget that displays the number of adjacent bombs of each cell. This gadget will 46 | be initially hidden by the `MineField` gadget. 47 | """ 48 | 49 | def __init__(self, count, minefield, **kwargs): 50 | super().__init__(size=count.shape, is_light=True, **kwargs) 51 | v_center, h_center = self.cell_center_indices 52 | 53 | self.canvas["fg_color"] = BORDER 54 | self.canvas["bg_color"] = COUNT_SQUARE 55 | self.chars[v_center, h_center] = stringify(count) 56 | self.chars[v_center, h_center][minefield == 1] = BOMB 57 | self.canvas["fg_color"][v_center, h_center] = np.dstack(colorify(count)) 58 | 59 | ys, xs = (self.chars == BOMB).nonzero() 60 | self.chars[ys, xs + 1] = "" 61 | -------------------------------------------------------------------------------- /examples/advanced/minesweeper/minesweeper/grid.py: -------------------------------------------------------------------------------- 1 | from batgrl.gadgets.text import Text 2 | 3 | from .unicode_chars import HEAVY_BOX, LIGHT_BOX 4 | 5 | 6 | class Grid(Text): 7 | V_SPACING = 2 8 | H_SPACING = 4 9 | 10 | def __init__(self, size, is_light: bool, **kwargs): 11 | h, w = size 12 | vs, hs = self.V_SPACING, self.H_SPACING 13 | 14 | super().__init__(pos=(1, 0), size=(vs * h + 1, hs * w + 1), **kwargs) 15 | 16 | chars = self.chars 17 | 18 | h, v, tl, tm, tr, bl, bm, br, ml, mm, mr = LIGHT_BOX if is_light else HEAVY_BOX 19 | 20 | chars[::vs] = h 21 | chars[:, ::hs] = v 22 | chars[vs:-vs:vs, hs:-hs:hs] = mm 23 | 24 | # Top 25 | chars[0, hs:-hs:hs] = tm 26 | # Bottom 27 | chars[-1, hs:-hs:hs] = bm 28 | # Left 29 | chars[vs:-vs:vs, 0] = ml 30 | # Right 31 | chars[vs:-vs:vs, -1] = mr 32 | 33 | # Corners 34 | chars[0, 0] = tl 35 | chars[0, -1] = tr 36 | chars[-1, 0] = bl 37 | chars[-1, -1] = br 38 | 39 | @property 40 | def cell_center_indices(self): 41 | vs, hs = self.V_SPACING, self.H_SPACING 42 | 43 | return slice(vs // 2, None, vs), slice(hs // 2, None, hs) 44 | -------------------------------------------------------------------------------- /examples/advanced/minesweeper/minesweeper/unicode_chars.py: -------------------------------------------------------------------------------- 1 | BAD_FLAG = "✗" 2 | BOMB = "💣" 3 | COOL = "😎" 4 | EXPLODED = "💥" 5 | FLAG = "⚑" 6 | HAPPY = "😀" 7 | KNOCKED_OUT = "😵" 8 | SURPRISED = "😮" 9 | 10 | LIGHT_BOX = "─│┌┬┐└┴┘├┼┤" 11 | HEAVY_BOX = "━┃┏┳┓┗┻┛┣╋┫" 12 | -------------------------------------------------------------------------------- /examples/advanced/moire.py: -------------------------------------------------------------------------------- 1 | """Moire patterns in foreground colors. Click to change patterns.""" 2 | 3 | import asyncio 4 | from math import cos, sin 5 | from time import monotonic 6 | from typing import Literal 7 | 8 | import numpy as np 9 | from batgrl.app import App 10 | from batgrl.colors import BLACK, WHITE, gradient 11 | from batgrl.gadgets.text import Text 12 | 13 | PALETTE = np.array(gradient(BLACK, WHITE, n=100)) 14 | 15 | 16 | def map_into(v, ina, inb, outa, outb): 17 | return outa + (outb - outa) * ((v - ina) / (inb - ina)) 18 | 19 | 20 | class Moire(Text): 21 | def __init__(self, **kwargs): 22 | super().__init__(**kwargs) 23 | self.mode: Literal[0, 1, 2] = 0 24 | 25 | def on_size(self): 26 | super().on_size() 27 | for y in range(self.height): 28 | for x in range(self.width): 29 | self.chars[y, x] = "batgrl "[(y + x) % 7] 30 | 31 | def on_mouse(self, mouse_event) -> bool | None: 32 | if mouse_event.event_type == "mouse_down": 33 | self.mode = (self.mode + 1) % 3 34 | 35 | def update(self): 36 | t = monotonic() * 0.2 37 | m = min(self.height, self.width) 38 | aspect = self.height / self.width 39 | ys, xs = np.indices(self.size) 40 | sty = 2 * (ys - self.height / 2) / m 41 | stx = 2 * (xs - self.width / 2) / m * aspect 42 | center_ay = 0.5 * sin(7 * t) 43 | center_ax = 0.5 * cos(3 * t) 44 | center_by = 0.5 * sin(4 * t) 45 | center_bx = 0.5 * cos(3 * t) 46 | 47 | A = ( 48 | np.arctan2(center_ay - sty, center_ax - stx) 49 | if self.mode % 2 == 0 50 | else ((center_ay - sty) ** 2 + (center_ax - stx) ** 2) ** 0.5 51 | ) 52 | 53 | B = ( 54 | np.arctan2(center_by - sty, center_bx - stx) 55 | if self.mode == 0 56 | else ((center_by - sty) ** 2 + (center_bx - stx) ** 2) ** 0.5 57 | ) 58 | 59 | A_mod = map_into(cos(2.12 * t), -1, 1, 6, 60) 60 | B_mod = map_into(cos(3.33 * t), -1, 1, 6, 60) 61 | 62 | a = np.cos(A * A_mod) 63 | b = np.cos(B * B_mod) 64 | i = (a * b + 1) / 2 65 | idx = (i * len(PALETTE)).astype(int) 66 | self.canvas["fg_color"] = PALETTE[idx] 67 | 68 | 69 | class MoireApp(App): 70 | async def on_start(self): 71 | moire = Moire(size_hint={"height_hint": 1.0, "width_hint": 1.0}) 72 | self.add_gadget(moire) 73 | 74 | while True: 75 | moire.update() 76 | await asyncio.sleep(0) 77 | 78 | 79 | if __name__ == "__main__": 80 | MoireApp(title="Moire Example").run() 81 | -------------------------------------------------------------------------------- /examples/advanced/pipes.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from random import choice, random, randrange 3 | from time import perf_counter 4 | 5 | from batgrl.app import run_gadget_as_app 6 | from batgrl.gadgets.text import Text 7 | 8 | CURVY = { 9 | 0: "╷", 10 | 1: "╴", 11 | 2: "╵", 12 | 3: "╶", 13 | (0, 0): "│", 14 | (0, 1): "╭", 15 | (0, 3): "╮", 16 | (1, 0): "╯", 17 | (1, 1): "─", 18 | (1, 2): "╮", 19 | (2, 1): "╰", 20 | (2, 2): "│", 21 | (2, 3): "╯", 22 | (3, 0): "╰", 23 | (3, 2): "╭", 24 | (3, 3): "─", 25 | } 26 | 27 | HEAVY = { 28 | 0: "╻", 29 | 1: "╸", 30 | 2: "╹", 31 | 3: "╺", 32 | (0, 0): "┃", 33 | (0, 1): "┏", 34 | (0, 3): "┓", 35 | (1, 0): "┛", 36 | (1, 1): "━", 37 | (1, 2): "┓", 38 | (2, 1): "┗", 39 | (2, 2): "┃", 40 | (2, 3): "┛", 41 | (3, 0): "┗", 42 | (3, 2): "┏", 43 | (3, 3): "━", 44 | } 45 | 46 | 47 | class Pipes(Text): 48 | def __init__(self, npipes: int = 5, **kwargs): 49 | super().__init__(**kwargs) 50 | self._npipes = npipes 51 | 52 | def reset_pipes(self): 53 | if self.root: 54 | self._pipe_task.cancel() 55 | self._pipe_task = asyncio.create_task(self.run_pipes()) 56 | 57 | @property 58 | def npipes(self) -> int: 59 | return self._npipes 60 | 61 | @npipes.setter 62 | def npipes(self, value: int): 63 | self._npipes = value 64 | self.reset_pipes() 65 | 66 | def on_size(self): 67 | super().on_size() 68 | self.reset_pipes() 69 | 70 | def on_add(self): 71 | self._pipe_task = asyncio.create_task(self.run_pipes()) 72 | super().on_add() 73 | 74 | def on_remove(self): 75 | self._pipe_task.cancel() 76 | super().on_remove() 77 | 78 | async def run_pipes(self): 79 | while True: 80 | self.clear() 81 | await asyncio.gather(*(self.pipe() for _ in range(self.npipes))) 82 | 83 | async def pipe(self): 84 | end = perf_counter() + 10 85 | sleep = 0.02 + random() * 0.05 86 | color = randrange(255), randrange(255), randrange(255) 87 | y, x = randrange(self.height), randrange(self.width) 88 | last_dir = randrange(4) 89 | pipe_chars = choice([HEAVY, CURVY]) 90 | 91 | while perf_counter() < end: 92 | self.chars[y, x] = pipe_chars[last_dir] 93 | self.canvas["fg_color"][y, x] = color 94 | await asyncio.sleep(sleep) 95 | 96 | current_dir = (last_dir + randrange(-1, 2)) % 4 97 | self.chars[y, x] = pipe_chars[last_dir, current_dir] 98 | self.canvas["fg_color"][y, x] = color 99 | 100 | match current_dir: 101 | case 0: 102 | y = (y - 1) % self.height 103 | case 1: 104 | x = (x + 1) % self.width 105 | case 2: 106 | y = (y + 1) % self.height 107 | case 3: 108 | x = (x - 1) % self.width 109 | 110 | await asyncio.sleep(sleep) 111 | last_dir = current_dir 112 | 113 | 114 | if __name__ == "__main__": 115 | run_gadget_as_app( 116 | Pipes(npipes=5, size_hint={"height_hint": 1.0, "width_hint": 1.0}), 117 | title="Pipe Dreams", 118 | ) 119 | -------------------------------------------------------------------------------- /examples/advanced/pong.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from batgrl.app import App 4 | from batgrl.colors import BLUE, GREEN 5 | from batgrl.gadgets.pane import Pane 6 | from batgrl.gadgets.text import Text 7 | 8 | FIELD_HEIGHT = 25 9 | FIELD_WIDTH = 100 10 | PADDLE_HEIGHT = 5 11 | PADDLE_WIDTH = 1 12 | 13 | 14 | class Paddle(Pane): 15 | def __init__(self, up, down, **kwargs): 16 | self.up = up 17 | self.down = down 18 | super().__init__(**kwargs) 19 | 20 | def on_key(self, key_event): 21 | if key_event.key == self.up: 22 | self.y -= 1 23 | elif key_event.key == self.down: 24 | self.y += 1 25 | 26 | if self.y < 0: 27 | self.y = 0 28 | elif self.y > FIELD_HEIGHT - PADDLE_HEIGHT: 29 | self.y = FIELD_HEIGHT - PADDLE_HEIGHT 30 | 31 | 32 | class Pong(App): 33 | async def on_start(self): 34 | game_field = Pane(size=(FIELD_HEIGHT, FIELD_WIDTH), bg_color=GREEN) 35 | center = FIELD_HEIGHT // 2 - PADDLE_HEIGHT // 2 36 | left_paddle = Paddle( 37 | up="w", 38 | down="s", 39 | size=(PADDLE_HEIGHT, PADDLE_WIDTH), 40 | pos=(center, 1), 41 | bg_color=BLUE, 42 | ) 43 | right_paddle = Paddle( 44 | up="up", 45 | down="down", 46 | size=(PADDLE_HEIGHT, PADDLE_WIDTH), 47 | pos=(center, FIELD_WIDTH - 2), 48 | bg_color=BLUE, 49 | ) 50 | divider = Pane( 51 | size=(1, 1), 52 | size_hint={"height_hint": 1.0}, 53 | pos_hint={"x_hint": 0.5}, 54 | bg_color=BLUE, 55 | ) 56 | left_score_label = Text( 57 | size=(1, 5), 58 | pos=(1, 1), 59 | pos_hint={"x_hint": 0.25}, 60 | ) 61 | right_score_label = Text( 62 | size=(1, 5), 63 | pos=(1, 1), 64 | pos_hint={"x_hint": 0.75}, 65 | ) 66 | ball = Pane(size=(1, 2), bg_color=BLUE) 67 | 68 | game_field.add_gadgets( 69 | left_paddle, 70 | right_paddle, 71 | divider, 72 | left_score_label, 73 | right_score_label, 74 | ball, 75 | ) 76 | self.add_gadget(game_field) 77 | 78 | left_score = right_score = 0 79 | y_pos = FIELD_HEIGHT / 2 80 | x_pos = FIELD_WIDTH / 2 - 1 81 | y_vel = 0.0 82 | x_vel = 1.0 83 | speed = 0.04 84 | 85 | def reset(): 86 | nonlocal y_pos, x_pos, y_vel, x_vel, speed 87 | y_pos = FIELD_HEIGHT / 2 88 | x_pos = FIELD_WIDTH / 2 - 1 89 | y_vel = 0.0 90 | x_vel = 1.0 91 | speed = 0.04 92 | left_score_label.add_str(f"{left_score:^5}") 93 | right_score_label.add_str(f"{right_score:^5}") 94 | 95 | def bounce_paddle(paddle): 96 | nonlocal x_pos, y_vel, x_vel, speed 97 | x_pos -= 2 * x_vel 98 | x_sgn = 1 if x_vel > 0 else -1 99 | center_y = paddle.height // 2 100 | intersect = max(min(paddle.y + center_y - ball.y, 0.95), -0.95) 101 | normalized = intersect / center_y 102 | y_vel = -normalized 103 | x_vel = -x_sgn * (1 - normalized**2) ** 0.5 104 | speed = max(0, speed - 0.001) 105 | 106 | reset() 107 | while True: 108 | # Update ball position. 109 | y_pos += y_vel 110 | x_pos += x_vel 111 | 112 | # Does ball collide with a paddle? 113 | if ball.collides_gadget(left_paddle): 114 | bounce_paddle(left_paddle) 115 | elif ball.collides_gadget(right_paddle): 116 | bounce_paddle(right_paddle) 117 | 118 | # Bounce off the top or bottom of the play field. 119 | if y_pos < 0 or y_pos >= FIELD_HEIGHT: 120 | y_vel *= -1 121 | y_pos += 2 * y_vel 122 | 123 | # If out of bounds, update the score. 124 | if x_pos < 0: 125 | right_score += 1 126 | reset() 127 | elif x_pos >= FIELD_WIDTH: 128 | left_score += 1 129 | reset() 130 | 131 | ball.y = int(y_pos) 132 | ball.x = int(x_pos) 133 | 134 | await asyncio.sleep(speed) 135 | 136 | 137 | if __name__ == "__main__": 138 | Pong().run() 139 | -------------------------------------------------------------------------------- /examples/advanced/reaction_diffusion.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import cv2 4 | import numpy as np 5 | from batgrl.app import App 6 | from batgrl.colors import Color 7 | from batgrl.gadgets.text import Text 8 | 9 | BLOCKS = np.array(list(" .,:;<+*LtCa4U80dQM@▁▂▃▄▅▆▇█▉▊▋▌▍▏░▒▓█▙▚▖")) 10 | KERNEL = np.array([[0.05, 0.2, 0.05], [0.2, -1, 0.2], [0.05, 0.2, 0.05]]) 11 | PALETTE = np.array( 12 | [ 13 | Color.from_hex(hexcode) 14 | for hexcode in ["0606fa", "4c1bbd", "5a2280", "3f1a43", "0f080e"] 15 | ] 16 | ) 17 | 18 | 19 | class ReactionDiffusion(Text): 20 | def __init__(self, **kwargs): 21 | super().__init__(**kwargs) 22 | self.diffusion_A = 1.0 23 | self.diffusion_B = 0.5 24 | self.feed = 0.01624 25 | self.kill = 0.04465 26 | self.on_size() 27 | 28 | def on_size(self): 29 | super().on_size() 30 | self.clear() 31 | h, w = self.size 32 | self.A = np.ones((h, w), dtype=float) 33 | self.B = np.zeros_like(self.A) 34 | self.B[: h // 5, : w // 5] = 1 35 | 36 | def on_mouse(self, mouse_event): 37 | if mouse_event.button == "left" and self.collides_point(mouse_event.pos): 38 | y, x = self.to_local(mouse_event.pos) 39 | self.B[y - 1 : y + 3, x - 1 : x + 2] += 0.5 40 | return True 41 | 42 | def update(self): 43 | laplace_A = cv2.filter2D( 44 | self.A, ddepth=-1, kernel=self.diffusion_A * KERNEL, borderType=2 45 | ) 46 | laplace_B = cv2.filter2D( 47 | self.B, ddepth=-1, kernel=self.diffusion_B * KERNEL, borderType=2 48 | ) 49 | react = self.A * self.B**2 50 | 51 | self.A *= 1 - self.feed 52 | self.A += laplace_A 53 | self.A -= react 54 | self.A += self.feed 55 | np.clip(self.A, 0, 0.9999999, out=self.A) 56 | 57 | self.B *= 1 - self.kill - self.feed 58 | self.B += laplace_B 59 | self.B += react 60 | np.clip(self.B, 0, 0.9999999, out=self.B) 61 | 62 | to_palette = self.A * len(PALETTE) 63 | to_char = (to_palette % 1) * len(BLOCKS) 64 | self.canvas["fg_color"] = PALETTE[to_palette.astype(int)] 65 | self.chars[:] = BLOCKS[to_char.astype(int)] 66 | 67 | 68 | class ReactionDiffusionApp(App): 69 | async def on_start(self): 70 | reaction_diffusion = ReactionDiffusion( 71 | size_hint={"height_hint": 1.0, "width_hint": 1.0} 72 | ) 73 | self.add_gadget(reaction_diffusion) 74 | while True: 75 | reaction_diffusion.update() 76 | await asyncio.sleep(0) 77 | 78 | 79 | if __name__ == "__main__": 80 | ReactionDiffusionApp(title="Reaction Diffusion Example").run() 81 | -------------------------------------------------------------------------------- /examples/advanced/rubiks/README.md: -------------------------------------------------------------------------------- 1 | # 3-D Rubik's Cube 2 | 3 | Shameless port of [Robust Reindeer's Codejam 8 Project](https://github.com/bjoseru/pdcj8-robust-reindeer) to `batgrl`. `python -m rubiks` to run. 4 | 5 | ## Controls 6 | 7 | `r` to rotate selected plane counter-clockwise 8 | 9 | `R` to rotate selected plane clockwise 10 | 11 | `up` / `down` to change planes 12 | 13 | `left` / `right` to change axis 14 | 15 | `s` to scramble the cube 16 | 17 | Drag mouse to rotate entire cube. 18 | -------------------------------------------------------------------------------- /examples/advanced/rubiks/rubiks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/advanced/rubiks/rubiks/__init__.py -------------------------------------------------------------------------------- /examples/advanced/rubiks/rubiks/__main__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from batgrl.app import App 4 | from batgrl.gadgets.image import Image 5 | 6 | from .rubiks_cube import RubiksCube 7 | 8 | ASSETS = Path(__file__).parent.parent.parent.parent / "assets" 9 | PATH_TO_BACKGROUND = ASSETS / "background.png" 10 | 11 | 12 | class RubiksApp(App): 13 | async def on_start(self): 14 | background = Image( 15 | path=PATH_TO_BACKGROUND, size_hint={"height_hint": 1.0, "width_hint": 1.0} 16 | ) 17 | cube = RubiksCube(size_hint={"height_hint": 1.0, "width_hint": 1.0}) 18 | self.add_gadgets(background, cube) 19 | 20 | 21 | if __name__ == "__main__": 22 | RubiksApp(title="Rubiks 3-D").run() 23 | -------------------------------------------------------------------------------- /examples/advanced/rubiks/rubiks/cube.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | CUBE_WIDTH = 0.9 4 | 5 | 6 | class Cube: 7 | """A 1 x 1 x 1 cube.""" 8 | 9 | __slots__ = ( 10 | "pos", 11 | "face_pos", 12 | "vertices", 13 | "normals", 14 | "is_selected", 15 | ) 16 | 17 | BASE = np.full( 18 | (2, 2, 2, 3), CUBE_WIDTH / 2 19 | ) # Each axis represents two faces of the cube 20 | 21 | BASE[..., 0, 0] *= -1 # Left of cube, x-coordinates are negative 22 | BASE[:, 1, :, 1] *= -1 # Bottom of cube, y-coordinates are negative 23 | BASE[1, ..., 2] *= -1 # Back of cube, z-coordinates are negative 24 | 25 | NORMALS = np.array( 26 | [ 27 | [0, 0, 1], # Front 28 | [0, 0, -1], # Back 29 | [0, 1, 0], # Top 30 | [0, -1, 0], # Bottom 31 | [-1, 0, 0], # Left 32 | [1, 0, 0], # Right 33 | ], 34 | dtype=float, 35 | ) 36 | 37 | def __init__(self, pos): 38 | self.pos = np.array(pos, dtype=float) 39 | self.face_pos = self.pos + self.NORMALS * CUBE_WIDTH / 2 40 | self.vertices = self.BASE + pos 41 | self.normals = self.NORMALS.copy() 42 | self.is_selected = False 43 | 44 | def __matmul__(self, r): 45 | np.matmul(self.pos, r, out=self.pos) 46 | np.matmul(self.face_pos, r, out=self.face_pos) 47 | np.matmul(self.vertices, r, out=self.vertices) 48 | np.matmul(self.normals, r, out=self.normals) 49 | 50 | @property 51 | def faces(self): 52 | """ 53 | Return indices that represent the faces of the cube, i.e., 54 | ``` 55 | self.vertices[faces[0]] 56 | ``` 57 | is the front face. 58 | 59 | Faces are returned in (front, back, top, bottom, right, left)-order. 60 | 61 | Vertices on one axis are swapped so that all vertices are in clockwise-order. 62 | This is required for the `fillConvexPoly` function in `cv2`. 63 | """ 64 | return ( 65 | # Normal # # Swapped # 66 | (0, (0, 0, 1, 1), (0, 1, 1, 0)), # Front 67 | (1, (0, 0, 1, 1), (0, 1, 1, 0)), # Back 68 | # Swapped # # Normal # 69 | ((0, 1, 1, 0), 0, (0, 0, 1, 1)), # Top 70 | ((0, 1, 1, 0), 1, (0, 0, 1, 1)), # Bottom 71 | # Normal # # Swapped # 72 | ((0, 0, 1, 1), (0, 1, 1, 0), 0), # Left 73 | ((0, 0, 1, 1), (0, 1, 1, 0), 1), # Right 74 | ) 75 | -------------------------------------------------------------------------------- /examples/advanced/rubiks/rubiks/face_colors.py: -------------------------------------------------------------------------------- 1 | from batgrl.colors import AWHITE, AColor, lerp_colors 2 | 3 | RED = AColor.from_hex("cc2808") # Front 4 | ORANGE = AColor.from_hex("f46e07") # Back 5 | GREEN = AColor.from_hex("3edd08") # Top 6 | BLUE = AColor.from_hex("083add") # Bottom 7 | YELLOW = AColor.from_hex("e8ef13") # Left 8 | WHITE = AColor.from_hex("efefe8") # Right 9 | 10 | FACE_COLORS = RED, ORANGE, GREEN, BLUE, YELLOW, WHITE 11 | SELECTED_COLORS = tuple(lerp_colors(color, AWHITE, 0.5) for color in FACE_COLORS) 12 | -------------------------------------------------------------------------------- /examples/advanced/rubiks/rubiks/rotation.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions that return 3-dimensional rotation arrays around some axis with a given angle. 3 | 4 | Warnings 5 | -------- 6 | All functions re-use the same buffer array. To create new arrays from rotation 7 | functions, use `copy`. 8 | """ 9 | 10 | import numpy as np 11 | 12 | _ROTATION_BUFFER = np.zeros((3, 3), dtype=float) 13 | 14 | 15 | def x(theta): 16 | """Rotation around x-axis.""" 17 | cos = np.cos(theta) 18 | sin = np.sin(theta) 19 | 20 | _ROTATION_BUFFER[:] = ( 21 | (1, 0, 0), 22 | (0, cos, sin), 23 | (0, -sin, cos), 24 | ) 25 | 26 | return _ROTATION_BUFFER 27 | 28 | 29 | def y(theta): 30 | """Rotation around y-axis.""" 31 | cos = np.cos(theta) 32 | sin = np.sin(theta) 33 | 34 | _ROTATION_BUFFER[:] = ( 35 | (cos, 0, -sin), 36 | (0, 1, 0), 37 | (sin, 0, cos), 38 | ) 39 | 40 | return _ROTATION_BUFFER 41 | 42 | 43 | def z(theta): 44 | """Rotation around z-axis.""" 45 | cos = np.cos(theta) 46 | sin = np.sin(theta) 47 | 48 | _ROTATION_BUFFER[:] = ( 49 | (cos, sin, 0), 50 | (-sin, cos, 0), 51 | (0, 0, 1), 52 | ) 53 | 54 | return _ROTATION_BUFFER 55 | -------------------------------------------------------------------------------- /examples/advanced/sandbox/README.md: -------------------------------------------------------------------------------- 1 | # Sandbox 2 | 3 | A tiny terminal particle simulator. `python -m sandbox` to run. 4 | -------------------------------------------------------------------------------- /examples/advanced/sandbox/sandbox/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/advanced/sandbox/sandbox/__init__.py -------------------------------------------------------------------------------- /examples/advanced/sandbox/sandbox/__main__.py: -------------------------------------------------------------------------------- 1 | from batgrl.app import run_gadget_as_app 2 | 3 | from .sandbox import Sandbox 4 | 5 | if __name__ == "__main__": 6 | run_gadget_as_app(Sandbox(size=(31, 100)), title="Sandbox") 7 | -------------------------------------------------------------------------------- /examples/advanced/sandbox/sandbox/element_buttons.py: -------------------------------------------------------------------------------- 1 | from batgrl.colors import BLACK, WHITE, Color, lerp_colors 2 | from batgrl.gadgets.behaviors.button_behavior import ButtonBehavior 3 | from batgrl.gadgets.pane import Pane 4 | from batgrl.gadgets.text import Text, new_cell 5 | 6 | from .particles import Element 7 | 8 | MENU_BACKGROUND_COLOR = Color(222, 224, 127) # Mustard 9 | 10 | 11 | class ElementButton(ButtonBehavior, Text): 12 | """Button which selects an element when pressed and updates the element display.""" 13 | 14 | def __init__(self, pos, element): 15 | self.element = element 16 | self.hover_color = lerp_colors(element.COLOR, WHITE, 0.25) 17 | self.down_color = lerp_colors(element.COLOR, WHITE, 0.5) 18 | super().__init__( 19 | size=(2, 4), 20 | pos=pos, 21 | default_cell=new_cell(fg_color=BLACK, bg_color=element.COLOR), 22 | always_release=True, 23 | ) 24 | 25 | def update_down(self): 26 | self.canvas["bg_color"] = self.down_color 27 | 28 | def update_normal(self): 29 | self.canvas["bg_color"] = self.default_bg_color 30 | 31 | def update_hover(self): 32 | self.canvas["bg_color"] = self.hover_color 33 | 34 | def on_release(self): 35 | element = self.element 36 | sandbox = self.parent.parent 37 | 38 | sandbox.particle_type = element 39 | sandbox.display.add_str(f"{element.__name__:^{sandbox.display.width}}") 40 | 41 | 42 | class ButtonContainer(Pane): 43 | """Container gadget of `ElementButton`s.""" 44 | 45 | def __init__(self): 46 | nelements = len(Element.all_elements) 47 | super().__init__(size=(3 * nelements + 1, 8), bg_color=MENU_BACKGROUND_COLOR) 48 | 49 | for i, element in enumerate(Element.all_elements.values()): 50 | self.add_gadget(ElementButton(pos=(3 * i + 1, 2), element=element)) 51 | 52 | def on_mouse(self, mouse_event): 53 | return self.collides_point(mouse_event.pos) 54 | -------------------------------------------------------------------------------- /examples/advanced/sandbox/sandbox/sandbox.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | import numpy as np 4 | from batgrl.colors import ABLACK 5 | from batgrl.gadgets.graphics import Graphics, Size, scale_geometry 6 | from batgrl.gadgets.text import Text, new_cell 7 | 8 | from .element_buttons import MENU_BACKGROUND_COLOR, ButtonContainer 9 | from .particles import Air 10 | 11 | 12 | @partial(np.vectorize, otypes=[np.uint8, np.uint8, np.uint8]) 13 | def particles_to_colors(particle): 14 | """Convert array of particles to array of colors.""" 15 | return particle.COLOR 16 | 17 | 18 | class Sandbox(Graphics): 19 | """Sandbox gadget.""" 20 | 21 | def __init__(self, size: Size): 22 | super().__init__( 23 | size=size, 24 | pos_hint={"y_hint": 0.5, "x_hint": 0.5}, 25 | default_color=ABLACK, 26 | blitter="full", 27 | ) 28 | 29 | def on_add(self): 30 | super().on_add() 31 | # Build array of particles -- Initially all Air 32 | h, w = scale_geometry(self._blitter, self._size) 33 | self.world = world = np.full((h, w), None, dtype=object) 34 | for y in range(h): 35 | for x in range(w): 36 | world[y, x] = Air(world, (y, x)) 37 | 38 | self.display = Text( 39 | size=(1, 9), 40 | pos=(1, 0), 41 | pos_hint={"x_hint": 0.5}, 42 | default_cell=new_cell(fg_color=Air.COLOR, bg_color=MENU_BACKGROUND_COLOR), 43 | ) 44 | self.add_gadgets(self.display, ButtonContainer()) 45 | 46 | # Press the Stone button setting particle type. 47 | self.children[1].children[1].on_release() 48 | 49 | def on_remove(self): 50 | super().on_remove() 51 | for particle in self.world.flatten(): 52 | particle.sleep() 53 | 54 | def _render(self, cells, graphics, kind): 55 | # Color of each particle in `self.world` is written into color array. 56 | self.texture[..., :3] = np.dstack(particles_to_colors(self.world)) 57 | super()._render(cells, graphics, kind) 58 | 59 | def on_mouse(self, mouse_event): 60 | if mouse_event.button != "left" or not self.collides_point(mouse_event.pos): 61 | return 62 | 63 | world = self.world 64 | particle_type = self.particle_type 65 | y, x = scale_geometry(self._blitter, self.to_local(mouse_event.pos)) 66 | h, w = scale_geometry(self._blitter, Size(1, 1)) 67 | for i in range(h): 68 | for j in range(w): 69 | world[y + i, x + j].replace(particle_type) 70 | return True 71 | -------------------------------------------------------------------------------- /examples/advanced/sliding_puzzle.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | 4 | from batgrl.app import run_gadget_as_app 5 | from batgrl.colors import ABLACK, AWHITE 6 | from batgrl.gadgets.graphics import Graphics 7 | from batgrl.texture_tools import read_texture, resize_texture 8 | 9 | ASSETS = Path(__file__).parent.parent / "assets" 10 | PATH_TO_LOGO = ASSETS / "python_discord_logo.png" 11 | 12 | SIZE = H, W = 40, 80 # Each dimension should be divisible by 4 13 | EMPTY_PIECE = object() 14 | 15 | 16 | class _SlidingPiece(Graphics): 17 | def on_mouse(self, mouse_event): 18 | if ( 19 | not self.parent._sliding 20 | and mouse_event.button == "left" 21 | and mouse_event.event_type == "mouse_down" 22 | and self.collides_point(mouse_event.pos) 23 | ): 24 | y, x = self._grid_pos 25 | grid = self.parent._grid 26 | 27 | for ey, ex in ((y + 1, x), (y - 1, x), (y, x + 1), (y, x - 1)): 28 | if grid.get((ey, ex)) is EMPTY_PIECE: 29 | self._grid_pos = ey, ex 30 | grid[ey, ex] = self 31 | grid[y, x] = EMPTY_PIECE 32 | self.parent._sliding = True 33 | asyncio.create_task( 34 | self.tween( 35 | duration=0.5, 36 | on_complete=lambda: setattr(self.parent, "_sliding", False), 37 | pos=(ey * self.height, ex * self.width), 38 | ) 39 | ) 40 | break 41 | 42 | return True 43 | 44 | 45 | class SlidingPuzzle(Graphics): 46 | def __init__(self, path: Path, **kwargs): 47 | super().__init__(**kwargs) 48 | 49 | sprite = resize_texture(read_texture(path), (H * 2, W)) 50 | 51 | self._grid = {} 52 | 53 | h, w = H // 4, W // 4 54 | for y in range(4): 55 | for x in range(4): 56 | if y == 3 and x == 3: 57 | self._grid[y, x] = EMPTY_PIECE 58 | else: 59 | piece = _SlidingPiece(size=(h, w), pos=(y * h, x * w)) 60 | piece._grid_pos = y, x 61 | piece.texture[:] = sprite[ 62 | piece.top * 2 : piece.bottom * 2, piece.left : piece.right 63 | ] 64 | piece.texture[0] = piece.texture[:, 0] = AWHITE 65 | piece.texture[-1] = piece.texture[:, -1] = ABLACK 66 | self._grid[y, x] = piece 67 | self.add_gadget(piece) 68 | 69 | self._sliding = False 70 | 71 | 72 | if __name__ == "__main__": 73 | run_gadget_as_app(SlidingPuzzle(PATH_TO_LOGO, size=SIZE), title="Sliding Puzzle") 74 | -------------------------------------------------------------------------------- /examples/advanced/sph/README.md: -------------------------------------------------------------------------------- 1 | # sph 2 | 3 | A Smoothed-Particle Hydrodynamics (SPH) simulation in `batgrl`. `python -m sph` to run. 4 | 5 | Click simulation to poke the particles. `r` to reset particle positions. 6 | -------------------------------------------------------------------------------- /examples/advanced/sph/sph/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/advanced/sph/sph/__init__.py -------------------------------------------------------------------------------- /examples/advanced/sph/sph/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import numpy as np 4 | from batgrl.app import App 5 | from batgrl.colors import AColor, Color 6 | from batgrl.gadgets.graphics import Graphics, scale_geometry 7 | from batgrl.gadgets.slider import Slider 8 | from batgrl.gadgets.text import Text 9 | 10 | from .solver import SPHSolver 11 | 12 | WATER_COLOR = AColor.from_hex("1e1ea8ff") 13 | FILL_COLOR = Color.from_hex("2fa399") 14 | 15 | 16 | class SPH(Graphics): 17 | def __init__(self, nparticles, **kwargs): 18 | super().__init__(**kwargs) 19 | self.sph_solver = SPHSolver( 20 | nparticles, scale_geometry(self._blitter, self.size) 21 | ) 22 | 23 | def on_add(self): 24 | super().on_add() 25 | self._update_task = asyncio.create_task(self._update()) 26 | 27 | def on_remove(self): 28 | super().on_remove() 29 | self._update_task.cancel() 30 | 31 | def on_key(self, key_event): 32 | if key_event.key == "r": 33 | self.sph_solver.init_dam() 34 | return True 35 | return False 36 | 37 | def on_mouse(self, mouse_event): 38 | if mouse_event.button == "no_button" or not self.collides_point( 39 | mouse_event.pos 40 | ): 41 | return False 42 | 43 | # Apply a force from click to every particle in the solver. 44 | my, mx = scale_geometry(self._blitter, self.to_local(mouse_event.pos)) 45 | relative_positions = self.sph_solver.state[:, :2] - (my, mx) 46 | self.sph_solver.state[:, 2:4] += ( 47 | 1e2 48 | * relative_positions 49 | / np.linalg.norm(relative_positions, axis=-1, keepdims=True) 50 | ) 51 | return True 52 | 53 | async def _update(self): 54 | while True: 55 | h, w = scale_geometry(self._blitter, self.size) 56 | solver = self.sph_solver 57 | solver.step() 58 | positions = solver.state[:, :2] 59 | 60 | ys, xs = positions.astype(int).T 61 | xs = xs + (w - solver.WIDTH) // 2 # Center the particles. 62 | 63 | # Some solver configurations are unstable. Clip positions to prevent errors. 64 | ys = np.clip(ys, 0, h - 1) 65 | xs = np.clip(xs, 0, w - 1) 66 | 67 | self.clear() 68 | self.texture[ys, xs] = WATER_COLOR 69 | 70 | await asyncio.sleep(0) 71 | 72 | 73 | class SPHApp(App): 74 | async def on_start(self): 75 | height, width = 26, 51 76 | slider_settings = ( 77 | ("H", "Smoothing Length", 0.4, 3.5), 78 | ("GAS_CONST", "Gas Constant", 500.0, 4000.0), 79 | ("REST_DENS", "Rest Density", 150.0, 500.0), 80 | ("VISC", "Viscosity", 0.0, 5000.0), 81 | ("MASS", "Mass", 10.0, 500.0), 82 | ("DT", "DT", 0.001, 0.03), 83 | ("GRAVITY", "Gravity", 0.0, 1e5), 84 | ("WIDTH", "Width", 5, width), 85 | ) 86 | sliders_height = (len(slider_settings) + 1) // 2 * 2 87 | 88 | container = Text(size=(height, width), pos_hint={"y_hint": 0.5, "x_hint": 0.5}) 89 | 90 | fluid = SPH( 91 | nparticles=300, 92 | pos=(sliders_height, 0), 93 | size=(height - sliders_height, width), 94 | ) 95 | 96 | def create_callback(caption, attr, y, x): 97 | def update(value): 98 | setattr(fluid.sph_solver, attr, value) 99 | if isinstance(v := getattr(fluid.sph_solver, attr), int): 100 | value = f"{v}" 101 | else: 102 | value = f"{v:.4}" 103 | container.add_str(f"{caption}: {value}".ljust(width // 2), pos=(y, x)) 104 | 105 | return update 106 | 107 | container.add_gadget(fluid) 108 | for i, (attr, caption, min, max) in enumerate(slider_settings): 109 | y = i // 2 * 2 110 | x = (i % 2) * (width // 2 + 1) 111 | slider = Slider( 112 | pos=(y + 1, x), 113 | min=min, 114 | max=max, 115 | start_value=getattr(fluid.sph_solver, attr), 116 | callback=create_callback(caption, attr, y, x), 117 | size=(1, width // 2), 118 | fill_color=FILL_COLOR, 119 | slider_color=WATER_COLOR[:3], 120 | ) 121 | container.add_gadget(slider) 122 | self.add_gadget(container) 123 | 124 | 125 | if __name__ == "__main__": 126 | SPHApp(title="Smoothed-Particle Hydrodynamics").run() 127 | -------------------------------------------------------------------------------- /examples/advanced/sph/sph/solver.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | class SPHSolver: 5 | def __init__(self, nparticles, size): 6 | self.nparticles = nparticles 7 | self.size = size 8 | self.state = np.zeros((self.nparticles, 4), dtype=float) 9 | self.init_dam() 10 | 11 | self.H = 1.1 12 | self.GAS_CONST = 2300.0 13 | self.REST_DENS = 300.0 14 | self.VISC = 500.0 15 | self.MASS = 250.0 16 | self.DT = 0.01 17 | self.GRAVITY = 5e4 18 | 19 | @property 20 | def H(self): 21 | return self._H 22 | 23 | @H.setter 24 | def H(self, H): 25 | self._H = H 26 | self.POLY6 = 4.0 / (np.pi * H**8.0) 27 | self.SPIKY_GRAD = -10.0 / (np.pi * H**5.0) 28 | self.VISC_LAP = 40.0 / (np.pi * H**5.0) 29 | 30 | @property 31 | def WIDTH(self): 32 | return self.size[1] 33 | 34 | @WIDTH.setter 35 | def WIDTH(self, WIDTH): 36 | h, w = self.size 37 | self.state[:, :2] += (WIDTH - w) / 2 38 | self.size = h, int(WIDTH) 39 | 40 | def init_dam(self): 41 | """Position particles in a verticle column.""" 42 | h, w = self.size 43 | dam_width = w / 5 44 | 45 | self.state[:] = 0 46 | positions = self.state[:, :2] 47 | 48 | positions[:] = np.random.random((self.nparticles, 2)) 49 | positions *= h, dam_width 50 | positions[:, 1] += (w - dam_width) / 2 51 | 52 | def step(self): 53 | """ 54 | For each particle, compute densities and pressures, then forces, and 55 | finally integrate to obtain new positions. 56 | """ 57 | H = self.H 58 | MASS = self.MASS 59 | positions = self.state[:, :2] 60 | velocities = self.state[:, 2:4] 61 | 62 | relative_distances = positions[:, None, :] - positions[None, :, :] 63 | distances_sq = (relative_distances**2).sum(axis=-1) 64 | distances = distances_sq**0.5 65 | not_neighbors = distances >= H 66 | 67 | # Set density / pressure of all particles. 68 | strength = (H**2 - distances_sq) ** 3 69 | strength[not_neighbors] = 0 70 | densities = MASS * self.POLY6 * strength.sum(axis=-1) 71 | pressure = self.GAS_CONST * (densities - self.REST_DENS) 72 | 73 | # Calculate forces due to pressure. 74 | with np.errstate(divide="ignore", invalid="ignore"): 75 | normals = relative_distances / distances[..., None] 76 | normals[distances == 0] = 0 77 | 78 | weight = H - distances 79 | weight[not_neighbors] = 0 80 | weight[np.diag_indices_from(weight)] = 0 81 | 82 | f_pressure = ( 83 | self.SPIKY_GRAD 84 | * -normals 85 | * (pressure[:, None] + pressure[None, :])[..., None] 86 | / 2 87 | * weight[..., None] ** 3 88 | ).sum(axis=1) 89 | 90 | f_visc = ( 91 | self.VISC 92 | * self.VISC_LAP 93 | * (velocities[:, None, :] - velocities[None, :, :]) 94 | * weight[..., None] 95 | ).sum(axis=1) 96 | 97 | forces = MASS * (f_pressure - f_visc + (self.GRAVITY, 0)) / densities[:, None] 98 | 99 | # Integrate 100 | velocities += self.DT * forces / densities[:, None] 101 | positions += self.DT * velocities 102 | 103 | # Boundary conditions 104 | h, w = self.size 105 | 106 | ys, xs = positions.T 107 | vys, vxs = velocities.T 108 | 109 | top = ys < 0 110 | left = xs < 0 111 | bottom = ys >= h 112 | right = xs >= w 113 | 114 | ys[top] *= -1 115 | xs[left] *= -1 116 | ys[bottom] = 2 * h - ys[bottom] 117 | xs[right] = 2 * w - xs[right] 118 | 119 | vys[top] *= -0.5 120 | vxs[left] *= -0.5 121 | vys[bottom] *= -0.5 122 | vxs[right] *= -0.5 123 | -------------------------------------------------------------------------------- /examples/advanced/spinning_cube.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | 4 | import cv2 5 | import numpy as np 6 | from batgrl.app import App 7 | from batgrl.colors import Color 8 | from batgrl.gadgets.graphics import Graphics 9 | from batgrl.gadgets.image import Image 10 | from batgrl.geometry import lerp 11 | 12 | ASSETS = Path(__file__).parent.parent / "assets" 13 | BACKGROUND = ASSETS / "loudypixelsky.png" 14 | POINTS = np.array( 15 | [ 16 | [-1, -1, -1], 17 | [-1, -1, 1], 18 | [-1, 1, -1], 19 | [-1, 1, 1], 20 | [1, -1, -1], 21 | [1, -1, 1], 22 | [1, 1, -1], 23 | [1, 1, 1], 24 | ] 25 | ) 26 | LINES = [ 27 | [0, 1], 28 | [0, 2], 29 | [0, 4], 30 | [1, 3], 31 | [1, 5], 32 | [2, 3], 33 | [2, 6], 34 | [3, 7], 35 | [4, 5], 36 | [4, 6], 37 | [5, 7], 38 | [6, 7], 39 | ] 40 | R, G, B = Color.from_hex("4ce05d") 41 | RADIUS = 3**0.5 # The cube of points is inscribed in a circle with this radius 42 | DIAMETER = 2 * RADIUS 43 | MIN_BRIGHTNESS = 0.15 44 | MAX_BRIGHTNESS = 1.0 45 | 46 | 47 | def rotate_x(theta): 48 | cos = np.cos(theta) 49 | sin = np.sin(theta) 50 | 51 | return np.array( 52 | [ 53 | [1, 0, 0], 54 | [0, cos, sin], 55 | [0, -sin, cos], 56 | ] 57 | ) 58 | 59 | 60 | def rotate_y(theta): 61 | cos = np.cos(theta) 62 | sin = np.sin(theta) 63 | 64 | return np.array( 65 | [ 66 | [cos, 0, -sin], 67 | [0, 1, 0], 68 | [sin, 0, cos], 69 | ] 70 | ) 71 | 72 | 73 | def rotate_z(theta): 74 | cos = np.cos(theta) 75 | sin = np.sin(theta) 76 | 77 | return np.array( 78 | [ 79 | [cos, sin, 0], 80 | [-sin, cos, 0], 81 | [0, 0, 1], 82 | ] 83 | ) 84 | 85 | 86 | def darken(depth): 87 | normalized_depth = (depth + RADIUS) / DIAMETER 88 | brightness = lerp(MIN_BRIGHTNESS, MAX_BRIGHTNESS, normalized_depth) 89 | return int(brightness * R), int(brightness * G), int(brightness * B), 255 90 | 91 | 92 | class SpinningCube(Graphics): 93 | def on_add(self): 94 | super().on_add() 95 | self._spin_task = asyncio.create_task(self.spin_forever()) 96 | 97 | def on_remove(self): 98 | self._spin_task.cancel() 99 | super().on_remove() 100 | 101 | async def spin_forever(self): 102 | x_angle = y_angle = z_angle = 0 103 | 104 | while True: 105 | self.clear() 106 | 107 | h, w = self.size 108 | scale = w // 4, h // 2 109 | offset = w // 2, h 110 | 111 | points = POINTS @ rotate_x(x_angle) @ rotate_y(y_angle) @ rotate_z(z_angle) 112 | points_2D = (points[:, 1::-1] * scale + offset).astype(int) 113 | depths = points[:, 2] 114 | 115 | lines = [] 116 | for a, b in LINES: 117 | depth = (depths[a] + depths[b]) / 2 118 | 119 | lines.append( 120 | ( 121 | points_2D[a], 122 | points_2D[b], 123 | darken(depth), 124 | depth, 125 | ) 126 | ) 127 | lines.sort(key=lambda p: p[3]) 128 | 129 | for a, b, color, _ in lines: 130 | cv2.line(self.texture, a, b, color, 2) 131 | 132 | x_angle += 0.001333 133 | y_angle += 0.002 134 | z_angle += 0.002666 135 | 136 | await asyncio.sleep(0) 137 | 138 | 139 | class SpinApp(App): 140 | async def on_start(self): 141 | self.add_gadgets( 142 | Image(path=BACKGROUND, size_hint={"height_hint": 1.0, "width_hint": 1.0}), 143 | SpinningCube(size_hint={"height_hint": 1.0, "width_hint": 1.0}), 144 | ) 145 | 146 | 147 | if __name__ == "__main__": 148 | SpinApp(title="Spinning Cube").run() 149 | -------------------------------------------------------------------------------- /examples/advanced/tetris/README.md: -------------------------------------------------------------------------------- 1 | # Tetris 2 | 3 | A Tetris clone. `python -m tetris` to run. 4 | 5 | keybinds | action 6 | ---|--- 7 | `f1`|pause 8 | `enter`|new game 9 | `right`, `6`|move right 10 | `left`, `4`|move left 11 | `down`, `2`|move down 12 | `space`, `8`|drop 13 | `c`, `0`|hold 14 | `z`, `1`, `5`, `9`|rotate counter-clockwise 15 | `x`, `up`, `3`, `7`|rotate clockwise 16 | -------------------------------------------------------------------------------- /examples/advanced/tetris/tetris/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/advanced/tetris/tetris/__init__.py -------------------------------------------------------------------------------- /examples/advanced/tetris/tetris/__main__.py: -------------------------------------------------------------------------------- 1 | from batgrl.app import App 2 | 3 | from .tetris import Tetris 4 | 5 | 6 | class TetrisApp(App): 7 | async def on_start(self): 8 | tetris = Tetris(pos_hint={"y_hint": 0.5, "x_hint": 0.5}) 9 | self.add_gadget(tetris) 10 | 11 | tetris.modal_screen.enable(callback=tetris.new_game, is_game_over=True) 12 | 13 | 14 | if __name__ == "__main__": 15 | TetrisApp(title="Tetris").run() 16 | -------------------------------------------------------------------------------- /examples/advanced/tetris/tetris/matrix.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import numpy as np 4 | from batgrl.gadgets.graphics import Graphics 5 | from batgrl.geometry import rect_slice 6 | 7 | 8 | class MatrixGadget(Graphics): 9 | def on_add(self): 10 | super().on_add() 11 | self._glow = 0 12 | self._glow_task = asyncio.create_task(self.glow()) 13 | 14 | def on_remove(self): 15 | super().on_remove() 16 | self._glow_task.cancel() 17 | 18 | async def glow(self): 19 | while self.parent is None: 20 | await asyncio.sleep(0) 21 | 22 | while True: 23 | level = self.parent.level 24 | glow = np.linspace(0, min(1, 0.05 * level), 30) 25 | 26 | brighten_delay = 0.04 * 0.8**level 27 | darken_delay = 2 * brighten_delay 28 | sleep = 20 * darken_delay 29 | 30 | for self._glow in glow: 31 | await asyncio.sleep(brighten_delay) 32 | 33 | for self._glow in glow[::-1]: 34 | await asyncio.sleep(darken_delay) 35 | 36 | await asyncio.sleep(sleep) 37 | 38 | def _render(self, cells, graphics, kind): 39 | super()._render(cells, graphics, kind) 40 | glow = self._glow 41 | abs_pos = self.absolute_pos 42 | for pos, size in self._region.rects(): 43 | dst_y, dst_x = rect_slice(pos, size) 44 | src_y, src_x = rect_slice(pos - abs_pos, size) 45 | 46 | visible = ( 47 | self.texture[ 48 | 2 * src_y.start : 2 * src_y.stop, 49 | 2 * src_x.start : 2 * src_x.stop, 50 | 3, 51 | ] 52 | == 255 53 | ) 54 | fg = cells["fg_color"][dst_y, dst_x] 55 | fg[visible[::2]] = (fg[visible[::2]] * (1 - glow) + glow * 255).astype( 56 | np.uint8 57 | ) 58 | bg = cells["bg_color"][dst_y, dst_x] 59 | bg[visible[1::2]] = (bg[visible[1::2]] * (1 - glow) + glow * 255).astype( 60 | np.uint8 61 | ) 62 | -------------------------------------------------------------------------------- /examples/advanced/tetris/tetris/modal_screen.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import numpy as np 4 | from batgrl.colors import Color, gradient 5 | from batgrl.gadgets.text import Text 6 | 7 | LIGHT_PURPLE = Color.from_hex("8d46dd") 8 | DARK_PURPLE = Color.from_hex("190c54") 9 | GRADIENT = gradient(DARK_PURPLE, LIGHT_PURPLE, n=9) 10 | LINE_GLOW_DURATION = 0.09 11 | 12 | ONE = """ 13 | ▄▄▄▄ 14 | █ █ 15 | █ █ 16 | █ █ 17 | █ █ 18 | █ █ 19 | █▄▄▄█ 20 | """.splitlines()[1:] 21 | 22 | TWO = """ 23 | ▄▄▄▄▄▄▄ 24 | █ █ 25 | █▄▄▄▄ █ 26 | ▄▄▄▄█ █ 27 | █ ▄▄▄▄▄▄█ 28 | █ █▄▄▄▄▄ 29 | █▄▄▄▄▄▄▄█ 30 | """.splitlines()[1:] 31 | 32 | THREE = """ 33 | ▄▄▄▄▄▄▄ 34 | █ █ 35 | █▄▄▄ █ 36 | ▄▄▄█ █ 37 | █▄▄▄ █ 38 | ▄▄▄█ █ 39 | █▄▄▄▄▄▄▄█ 40 | """.splitlines()[1:] 41 | 42 | GAME_OVER = """ 43 | ▄▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄ ▄▄ ▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄ ▄▄ ▄▄ ▄▄▄▄▄▄▄ ▄▄▄▄▄▄ 44 | █ █ █ █▄█ █ █ █ █ █ █ █ █ ▄ █ 45 | █ ▄▄▄▄█ ▄ █ █ ▄▄▄█ █ ▄ █ █▄█ █ ▄▄▄█ █ █ █ 46 | █ █ ▄▄█ █▄█ █ █ █▄▄▄ █ █ █ █ █ █▄▄▄█ █▄▄█▄ 47 | █ █ █ █ █ █ ▄▄▄█ █ █▄█ █ █ ▄▄▄█ ▄▄ █ 48 | █ █▄▄█ █ ▄ █ ██▄██ █ █▄▄▄ █ ██ ██ █▄▄▄█ █ █ █ 49 | █▄▄▄▄▄▄▄█▄█ █▄▄█▄█ █▄█▄▄▄▄▄▄▄█ █▄▄▄▄▄▄▄█ █▄▄▄█ █▄▄▄▄▄▄▄█▄▄▄█ █▄█ 50 | """.splitlines()[1:] 51 | 52 | PAUSED = """ 53 | ▄▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄ ▄▄ ▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄ ▄▄▄▄▄▄ 54 | █ █ █ █ █ █ █ █ █ 55 | █ ▄ █ ▄ █ █ █ █ ▄▄▄▄▄█ ▄▄▄█ ▄ █ 56 | █ █▄█ █ █▄█ █ █▄█ █ █▄▄▄▄▄█ █▄▄▄█ █ █ █ 57 | █ ▄▄▄█ █ █▄▄▄▄▄ █ ▄▄▄█ █▄█ █ 58 | █ █ █ ▄ █ █▄▄▄▄▄█ █ █▄▄▄█ █ 59 | █▄▄▄█ █▄█ █▄▄█▄▄▄▄▄▄▄█▄▄▄▄▄▄▄█▄▄▄▄▄▄▄█▄▄▄▄▄▄█ 60 | """.splitlines()[1:] 61 | 62 | 63 | class ModalScreen(Text): 64 | def __init__( 65 | self, 66 | pos_hint={"y_hint": 0.5, "x_hint": 0.5}, 67 | is_enabled=False, 68 | **kwargs, 69 | ): 70 | super().__init__( 71 | size=(10, 70), 72 | pos_hint=pos_hint, 73 | is_enabled=is_enabled, 74 | **kwargs, 75 | ) 76 | self.canvas["fg_color"][:9].swapaxes(0, 1)[:] = GRADIENT 77 | 78 | def on_add(self): 79 | super().on_add() 80 | self._countdown_task = asyncio.create_task(asyncio.sleep(0)) # dummy task 81 | self._line_glow_task = asyncio.create_task(asyncio.sleep(0)) # dummy task 82 | 83 | def on_remove(self): 84 | super().on_remove() 85 | self._countdown_task.cancel() 86 | self._line_glow_task.cancel() 87 | 88 | def on_key(self, key_event): 89 | if self._countdown_task.done(): 90 | self._countdown_task = asyncio.create_task(self.countdown()) 91 | 92 | return True 93 | 94 | def enable(self, callback, is_game_over): 95 | self.callback = callback 96 | 97 | for i, line in enumerate(GAME_OVER if is_game_over else PAUSED, start=1): 98 | self.add_str(line, pos=(i, 0)) 99 | 100 | self._line_glow_task = asyncio.create_task(self._line_glow()) 101 | self.is_enabled = True 102 | 103 | async def countdown(self): 104 | for number in (THREE, TWO, ONE): 105 | self.chars[:] = " " 106 | 107 | for i, line in enumerate(number, start=1): 108 | self.add_str(line, pos=(i, 31)) 109 | 110 | await asyncio.sleep(1) 111 | 112 | self.is_enabled = False 113 | self._line_glow_task.cancel() 114 | 115 | self.callback() 116 | 117 | async def _line_glow(self): 118 | colors = self.canvas["fg_color"] 119 | 120 | h = colors.shape[0] 121 | buffer = colors.copy() 122 | white = np.array([127, 189, 127]) 123 | alpha = np.array([2, 4, 2]) 124 | 125 | while True: 126 | for i in range(15): 127 | start, stop, _ = slice(max(0, h - 3 - i), max(0, h - i)).indices(h) 128 | lines = stop - start 129 | 130 | colors[start:stop].T[:] = ( 131 | colors[start:stop].T // alpha[:lines] + white[:lines] 132 | ) 133 | 134 | try: 135 | await asyncio.sleep(LINE_GLOW_DURATION) 136 | finally: 137 | colors[start:stop] = buffer[start:stop] 138 | -------------------------------------------------------------------------------- /examples/advanced/tetris/tetris/tetrominoes.py: -------------------------------------------------------------------------------- 1 | from enum import IntFlag 2 | 3 | import numpy as np 4 | from batgrl.colors import Color 5 | 6 | from .wall_kicks import ARIKA_I_WALL_KICKS, I_WALL_KICKS, JLSTZ_WALL_KICKS, O_WALL_KICKS 7 | 8 | 9 | class Orientation(IntFlag): 10 | """Orientation of a tetromino.""" 11 | 12 | UP = 0 13 | RIGHT = 1 14 | DOWN = 2 15 | LEFT = 3 16 | 17 | def rotate(self, clockwise=True): 18 | return Orientation((self + (1 if clockwise else -1)) % 4) 19 | 20 | 21 | class Tetromino: 22 | def __init__(self, base, color, kicks): 23 | base = np.array(base, dtype=np.uint8) 24 | self.shapes = { 25 | Orientation.UP: base, 26 | Orientation.RIGHT: np.rot90(base, 3), 27 | Orientation.DOWN: np.rot90(base, 2), 28 | Orientation.LEFT: np.rot90(base, 1), 29 | } 30 | self.mino_positions = { 31 | orientation: np.argwhere(shape) 32 | for orientation, shape in self.shapes.items() 33 | } 34 | 35 | self.textures = {} 36 | for orientation, shape in self.shapes.items(): 37 | texture = np.repeat( 38 | np.kron(shape, np.ones((2, 2), int))[..., None], 4, axis=-1 39 | ) 40 | texture *= (*color, 255) 41 | self.textures[orientation] = texture.astype(np.uint8) 42 | 43 | self.kicks = kicks 44 | 45 | 46 | J = Tetromino( 47 | [[1, 0, 0], [1, 1, 1], [0, 0, 0]], Color.from_hex("130bea"), JLSTZ_WALL_KICKS 48 | ) 49 | L = Tetromino( 50 | [[0, 0, 1], [1, 1, 1], [0, 0, 0]], Color.from_hex("f46e07"), JLSTZ_WALL_KICKS 51 | ) 52 | S = Tetromino( 53 | [[0, 1, 1], [1, 1, 0], [0, 0, 0]], Color.from_hex("0fea0b"), JLSTZ_WALL_KICKS 54 | ) 55 | T = Tetromino( 56 | [[0, 1, 0], [1, 1, 1], [0, 0, 0]], Color.from_hex("6900d3"), JLSTZ_WALL_KICKS 57 | ) 58 | Z = Tetromino( 59 | [[1, 1, 0], [0, 1, 1], [0, 0, 0]], Color.from_hex("f92504"), JLSTZ_WALL_KICKS 60 | ) 61 | I = Tetromino( # noqa: E741 62 | [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]], 63 | Color.from_hex("10f2e3"), 64 | I_WALL_KICKS, 65 | ) 66 | ARIKA_I = Tetromino( 67 | [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]], 68 | Color.from_hex("10f2e3"), 69 | ARIKA_I_WALL_KICKS, 70 | ) 71 | O = Tetromino([[1, 1], [1, 1]], Color.from_hex("eded0e"), O_WALL_KICKS) # noqa: E741 72 | 73 | TETROMINOS = J, L, S, T, Z, I, O 74 | ARIKA_TETROMINOS = J, L, S, T, Z, ARIKA_I, O 75 | -------------------------------------------------------------------------------- /examples/advanced/tetris/tetris/wall_kicks.py: -------------------------------------------------------------------------------- 1 | """ 2 | Wall kick data for all tetrominos. 3 | 4 | When a tetromino rotates, wall kicks will be attempted until the tetromino 5 | no longer collides with the stack or grid boundaries. If all wall kicks fail, 6 | the tetromino will not rotate. 7 | 8 | Notes 9 | ----- 10 | The dictionary keys are (current_orientation, target_orientation) where the 11 | orientations are UP, RIGHT, DOWN, LEFT for 0, 1, 2, 3 respectively. 12 | 13 | The values are tuples of positional offsets in (dy, dx) format with 14 | positive dy moving down and positive dx moving right. 15 | 16 | See Also 17 | -------- 18 | https://tetris.wiki/Super_Rotation_System#Wall_Kicks 19 | """ 20 | 21 | JLSTZ_WALL_KICKS = { 22 | # Clockwise rotations 23 | (0, 1): ((0, 0), (0, -1), (-1, -1), (2, 0), (2, -1)), 24 | (1, 2): ((0, 0), (0, 1), (1, 1), (-2, 0), (-2, 1)), 25 | (2, 3): ((0, 0), (0, 1), (-1, 1), (2, 0), (2, 1)), 26 | (3, 0): ((0, 0), (0, -1), (1, -1), (-2, 0), (-2, -1)), 27 | # Counter-clockwise rotations 28 | (1, 0): ((0, 0), (0, 1), (1, 1), (-2, 0), (-2, 1)), 29 | (2, 1): ((0, 0), (0, -1), (-1, -1), (2, 0), (2, -1)), 30 | (3, 2): ((0, 0), (0, -1), (1, -1), (-2, 0), (-2, -1)), 31 | (0, 3): ((0, 0), (0, 1), (-1, 1), (2, 0), (2, 1)), 32 | } 33 | 34 | I_WALL_KICKS = { 35 | # Clockwise rotations 36 | (0, 1): ((0, 0), (0, -2), (0, 1), (1, -2), (-2, 1)), 37 | (1, 2): ((0, 0), (0, -1), (0, 2), (-2, -1), (1, 2)), 38 | (2, 3): ((0, 0), (0, 2), (0, -1), (-1, 2), (2, -1)), 39 | (3, 0): ((0, 0), (0, 1), (0, -2), (2, 1), (-1, -2)), 40 | # Counter-clockwise rotations 41 | (1, 0): ((0, 0), (0, 2), (0, -1), (-1, 2), (2, -1)), 42 | (2, 1): ((0, 0), (0, 1), (0, -2), (2, 1), (-1, -2)), 43 | (3, 2): ((0, 0), (0, -2), (0, 1), (1, -2), (-2, 1)), 44 | (0, 3): ((0, 0), (0, -1), (0, 2), (-2, -1), (1, 2)), 45 | } 46 | 47 | # Alternative wall kicks for I pieces used in ARIKA Tetris games 48 | ARIKA_I_WALL_KICKS = { 49 | # Clockwise rotations 50 | (0, 1): ((0, 0), (0, -2), (0, 1), (-2, 1), (1, -2)), 51 | (1, 2): ((0, 0), (0, -1), (0, 2), (-2, -1), (1, 2)), 52 | (2, 3): ((0, 0), (0, 2), (0, -1), (-1, 2), (1, -1)), 53 | (3, 0): ((0, 0), (0, -2), (0, 1), (-1, -2), (2, 1)), 54 | # Counter-clockwise rotations 55 | (1, 0): ((0, 0), (0, 2), (0, -1), (-1, 2), (2, -1)), 56 | (2, 1): ((0, 0), (0, 2), (0, 1), (-1, -2), (1, 1)), 57 | (3, 2): ((0, 0), (0, 1), (0, -2), (-2, 1), (1, -2)), 58 | (0, 3): ((0, 0), (0, 2), (0, -1), (-2, -1), (1, 2)), 59 | } 60 | 61 | O_WALL_KICKS = { 62 | (0, 1): ((0, 0),), 63 | (1, 2): ((0, 0),), 64 | (2, 3): ((0, 0),), 65 | (3, 0): ((0, 0),), 66 | (1, 0): ((0, 0),), 67 | (2, 1): ((0, 0),), 68 | (3, 2): ((0, 0),), 69 | (0, 3): ((0, 0),), 70 | } 71 | -------------------------------------------------------------------------------- /examples/assets/README.md: -------------------------------------------------------------------------------- 1 | Asset Attribution 2 | ================= 3 | 4 | * `python_discord_logo.png`, `logo_solo_flat_256.png`, and `spinner.gif` are licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. (See [Pydis Branding Repo](https://github.com/python-discord/branding)) 5 | * `loudypixelsky.png` created by [SavvyCow](https://savvycow.itch.io/loudypixelsky). 6 | * `space_parallax/*.png` created by `LuminousDragon`. (See [LuminousDragon's YouTube](https://www.youtube.com/channel/UCfRciEQe7JlbxuzP5WbJW4Q)) 7 | * `pixel_python.png` found at . 8 | * `bluestone.png` and `greystone.png` come from Wolfenstein 3D and are copyright by ID Software. 9 | * `isometric_demo.png` is modified from the original found at [OneLoneCoder's PixelGameEngine Repo](https://github.com/OneLoneCoder/olcPixelGameEngine) and is licensed under [OLC-3](https://github.com/OneLoneCoder/olcPixelGameEngine/blob/master/LICENCE.md). 10 | * `fallout_terminal.png` comes from the Fallout series and is copyright by Bethesda. 11 | * `tg-bat.ans` by `Toon Goon` from the art pack `Blocky Horror` by the group `Blocktronics`. 12 | -------------------------------------------------------------------------------- /examples/assets/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/background.png -------------------------------------------------------------------------------- /examples/assets/bliss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/bliss.png -------------------------------------------------------------------------------- /examples/assets/caveman/0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/caveman/0.png -------------------------------------------------------------------------------- /examples/assets/caveman/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/caveman/1.png -------------------------------------------------------------------------------- /examples/assets/caveman/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/caveman/2.png -------------------------------------------------------------------------------- /examples/assets/caveman/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/caveman/3.png -------------------------------------------------------------------------------- /examples/assets/checkered.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/checkered.png -------------------------------------------------------------------------------- /examples/assets/crate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/crate.png -------------------------------------------------------------------------------- /examples/assets/custom_button/off-mid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/custom_button/off-mid.png -------------------------------------------------------------------------------- /examples/assets/custom_button/off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/custom_button/off.png -------------------------------------------------------------------------------- /examples/assets/custom_button/on-mid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/custom_button/on-mid.png -------------------------------------------------------------------------------- /examples/assets/custom_button/on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/custom_button/on.png -------------------------------------------------------------------------------- /examples/assets/fallout_terminal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/fallout_terminal.png -------------------------------------------------------------------------------- /examples/assets/isometric_demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/isometric_demo.png -------------------------------------------------------------------------------- /examples/assets/logo_solo_flat_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/logo_solo_flat_256.png -------------------------------------------------------------------------------- /examples/assets/loudypixelsky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/loudypixelsky.png -------------------------------------------------------------------------------- /examples/assets/pixel_python.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/pixel_python.png -------------------------------------------------------------------------------- /examples/assets/python_discord_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/python_discord_logo.png -------------------------------------------------------------------------------- /examples/assets/soccer_ball.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/soccer_ball.png -------------------------------------------------------------------------------- /examples/assets/space_parallax/00.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/space_parallax/00.png -------------------------------------------------------------------------------- /examples/assets/space_parallax/01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/space_parallax/01.png -------------------------------------------------------------------------------- /examples/assets/space_parallax/02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/space_parallax/02.png -------------------------------------------------------------------------------- /examples/assets/space_parallax/03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/space_parallax/03.png -------------------------------------------------------------------------------- /examples/assets/space_parallax/04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/space_parallax/04.png -------------------------------------------------------------------------------- /examples/assets/space_parallax/05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/space_parallax/05.png -------------------------------------------------------------------------------- /examples/assets/space_parallax/06.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/space_parallax/06.png -------------------------------------------------------------------------------- /examples/assets/space_parallax/07.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/space_parallax/07.png -------------------------------------------------------------------------------- /examples/assets/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/spinner.gif -------------------------------------------------------------------------------- /examples/assets/tg-bat.ans: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/tg-bat.ans -------------------------------------------------------------------------------- /examples/assets/tree.txt: -------------------------------------------------------------------------------- 1 | 00000000000000000000000000000000000000000000.000000000 2 | 0000000000000000000000000000000000.000000000;000000000 3 | 00000.00000000000000.00000000000000;%00000;;0000000000 4 | 0000000,00000000000,0000000000000000:;%00%;00000000000 5 | 00000000:000000000;0000000000000000000:;%;'00000.,0000 6 | ,.00000000%;00000%;000000000000;00000000%;'0000,;00000 7 | 0;0000000;%;00%%;00000000,00000%;0000;%;0000,%'0000000 8 | 00%;0000000%;%;000000,00;0000000%;00;%;000,%;'00000000 9 | 000;%;000000%;00000000;%;00000000%0;%;00,%;'0000000000 10 | 0000`%;.00000;%;00000%;'000000000`;%%;.%;'000000000000 11 | 00000`:;%.0000;%%.0%@;00000000%;0;@%;%'000000000000000 12 | 00000000`:%;.00:;bd%;0000000000%;@%;'00000000000000000 13 | 0000000000`@%:.00:;%.000000000;@@%;'000000000000000000 14 | 000000000000`@%.00`;@%.000000;@@%;00000000000000000000 15 | 00000000000000`@%%.0`@%%0000;@@%;000000000000000000000 16 | 0000000000000000;@%.0:@%%00%@@%;0000000000000000000000 17 | 000000000000000000%@bd%%%bd%%:;00000000000000000000000 18 | 00000000000000000000#@%%%%%:;;000000000000000000000000 19 | 00000000000000000000%@@%%%::;0000000000000000000000000 20 | 00000000000000000000%@@@%(o);00.0'00000000000000000000 21 | 00000000000000000000%@@@o%;:(.,'0000000000000000000000 22 | 0000000000000000`..0%@@@o%::;0000000000000000000000000 23 | 0000000000000000000`)@@@o%::;0000000000000000000000000 24 | 00000000000000000000%@@(o)::;0000000000000000000000000 25 | 0000000000000000000.%@@@@%::;0000000000000000000000000 26 | 0000000000000000000;%@@@@%::;.000000000000000000000000 27 | 000000000000000000;%@@@@%%:;;;.00000000000000000000000 28 | 00000000000000...;%@@@@@%%:;;;;,..00000000000000000000 -------------------------------------------------------------------------------- /examples/assets/wall.txt: -------------------------------------------------------------------------------- 1 | 55555555555555555555555555555 2 | 55555555555555555555555555555 3 | 55555555000000000000055555555 4 | 55555500000000000000000555555 5 | 55555500055555555555000555555 6 | 55555500055555555555000555555 7 | 55555500055555555555000555555 8 | 55555500055555555555000555555 9 | 55555555555555555555555555555 10 | 55555500055555555555000555555 11 | 55555500055555555555000555555 12 | 55555500055555555555000555555 13 | 55555500055555555555000555555 14 | 55555555000000000000055555555 15 | 55555555550000000005555555555 16 | 55555555555555555555555555555 17 | 55555555555555555555555555555 18 | 55555555555555555555555555555 19 | 55555555555555555555555555555 20 | 55555555555555555555555555555 21 | 55555555555555555555555555555 -------------------------------------------------------------------------------- /examples/assets/water/frame_00.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/water/frame_00.png -------------------------------------------------------------------------------- /examples/assets/water/frame_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/water/frame_01.png -------------------------------------------------------------------------------- /examples/assets/water/frame_02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/water/frame_02.png -------------------------------------------------------------------------------- /examples/assets/water/frame_03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/water/frame_03.png -------------------------------------------------------------------------------- /examples/assets/water/frame_04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/water/frame_04.png -------------------------------------------------------------------------------- /examples/assets/water/frame_05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/water/frame_05.png -------------------------------------------------------------------------------- /examples/assets/water/frame_06.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/water/frame_06.png -------------------------------------------------------------------------------- /examples/assets/water/frame_07.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/water/frame_07.png -------------------------------------------------------------------------------- /examples/assets/water/frame_08.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/water/frame_08.png -------------------------------------------------------------------------------- /examples/assets/water/frame_09.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/water/frame_09.png -------------------------------------------------------------------------------- /examples/assets/water/frame_10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/examples/assets/water/frame_10.png -------------------------------------------------------------------------------- /examples/basic/animation.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from batgrl.app import App 4 | from batgrl.gadgets.animation import Animation 5 | 6 | ASSETS = Path(__file__).parent.parent / "assets" 7 | PATH_TO_FRAMES = ASSETS / "caveman" 8 | 9 | 10 | class AnimationApp(App): 11 | async def on_start(self): 12 | animation = Animation( 13 | size_hint={"height_hint": 0.5, "width_hint": 0.5}, 14 | pos_hint={"y_hint": 0.5, "x_hint": 0.5}, 15 | path=PATH_TO_FRAMES, 16 | interpolation="nearest", 17 | ) 18 | 19 | self.add_gadget(animation) 20 | animation.play() 21 | 22 | 23 | if __name__ == "__main__": 24 | AnimationApp(title="Animation Example").run() 25 | -------------------------------------------------------------------------------- /examples/basic/ans_viewer.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from batgrl.app import App 4 | from batgrl.gadgets.ans_viewer import AnsViewer 5 | 6 | ASSETS = Path(__file__).parent.parent / "assets" 7 | ANS_PATH = ASSETS / "tg-bat.ans" 8 | 9 | 10 | class AnsApp(App): 11 | async def on_start(self): 12 | ans_viewer = AnsViewer( 13 | path=ANS_PATH, size_hint={"height_hint": 1.0, "width_hint": 1.0} 14 | ) 15 | self.add_gadget(ans_viewer) 16 | 17 | 18 | if __name__ == "__main__": 19 | AnsApp(inline=True, inline_height=20).run() 20 | -------------------------------------------------------------------------------- /examples/basic/bar_chart.py: -------------------------------------------------------------------------------- 1 | from batgrl.app import App 2 | from batgrl.colors import NEPTUNE_PRIMARY_BG, NEPTUNE_PRIMARY_FG 3 | from batgrl.gadgets.bar_chart import BarChart 4 | from batgrl.gadgets.text import Text, new_cell 5 | 6 | 7 | class BarChartApp(App): 8 | async def on_start(self): 9 | bar_chart_data = { 10 | "Python": 1, 11 | "Java": 0.588, 12 | "C++": 0.538, 13 | "C": 0.4641, 14 | "Javascript": 0.4638, 15 | "C#": 0.3973, 16 | "SQL": 0.3397, 17 | "Go": 0.2157, 18 | "Typescript": 0.1794, 19 | "HTML": 0.139, 20 | "R": 0.1316, 21 | "Shell": 0.1286, 22 | "PHP": 0.1186, 23 | } 24 | 25 | bar_chart = BarChart( 26 | bar_chart_data, 27 | min_y=0, 28 | y_label="Popularity", 29 | size=(25, 75), 30 | pos_hint={"y_hint": 0.5, "x_hint": 0.5}, 31 | ) 32 | label = Text( 33 | default_cell=new_cell( 34 | fg_color=NEPTUNE_PRIMARY_FG, bg_color=NEPTUNE_PRIMARY_BG 35 | ), 36 | pos_hint={"x_hint": 0.5}, 37 | ) 38 | label.set_text("Top Programming Languages 2023") 39 | bar_chart.bind("pos", lambda: setattr(label, "bottom", bar_chart.top)) 40 | text_bg = Text(size=(25, 75), pos_hint={"y_hint": 0.5, "x_hint": 0.5}) 41 | 42 | self.add_gadgets(text_bg, label, bar_chart) 43 | 44 | 45 | if __name__ == "__main__": 46 | BarChartApp(title="Bar Chart Example", bg_color=NEPTUNE_PRIMARY_BG).run() 47 | -------------------------------------------------------------------------------- /examples/basic/binding.py: -------------------------------------------------------------------------------- 1 | """ 2 | An example to showcase binding. 3 | 4 | After binding a callback to gadget property, the callback will be called anytime the 5 | property is updated. 6 | """ 7 | 8 | from batgrl.app import App 9 | from batgrl.colors import NEPTUNE_PRIMARY_BG, NEPTUNE_PRIMARY_FG 10 | from batgrl.gadgets.text import Text, new_cell 11 | from batgrl.gadgets.window import Window 12 | 13 | 14 | class BindingApp(App): 15 | async def on_start(self): 16 | window = Window(title="Move/Resize Me") 17 | label = Text( 18 | default_cell=new_cell( 19 | fg_color=NEPTUNE_PRIMARY_FG, bg_color=NEPTUNE_PRIMARY_BG 20 | ), 21 | ) 22 | 23 | def update_label(): 24 | if label.height > 0: 25 | label.add_str(f"{window.pos}".ljust(30), truncate_str=True) 26 | if label.height > 1: 27 | label.add_str(f"{window.size}".ljust(30), pos=(1, 0), truncate_str=True) 28 | 29 | window.bind("pos", update_label) 30 | window.bind("size", update_label) 31 | 32 | window.view = label 33 | self.add_gadget(window) 34 | window.size = 15, 30 35 | window.pos = 10, 10 36 | 37 | 38 | if __name__ == "__main__": 39 | BindingApp(title="Binding Example").run() 40 | -------------------------------------------------------------------------------- /examples/basic/borders.py: -------------------------------------------------------------------------------- 1 | from batgrl.app import App 2 | from batgrl.colors import rainbow_gradient 3 | from batgrl.gadgets.grid_layout import GridLayout 4 | from batgrl.gadgets.text import Border, Text 5 | 6 | border_colors = rainbow_gradient(len(Border.__args__)) 7 | 8 | 9 | class BordersApp(App): 10 | async def on_start(self): 11 | grid_layout = GridLayout(grid_columns=6, grid_rows=4) 12 | for border, color in zip(Border.__args__, border_colors): 13 | gadget = Text(size=(3, 17)) 14 | gadget.add_border(border, fg_color=color) 15 | gadget.add_str(f"{f'*{border}*':^17}", pos=(1, 1), markdown=True) 16 | grid_layout.add_gadget(gadget) 17 | 18 | grid_layout.size = grid_layout.min_grid_size 19 | self.add_gadget(grid_layout) 20 | 21 | 22 | if __name__ == "__main__": 23 | BordersApp(title="Borders").run() 24 | -------------------------------------------------------------------------------- /examples/basic/buttons.py: -------------------------------------------------------------------------------- 1 | """Button showcase.""" 2 | 3 | from batgrl.app import App 4 | from batgrl.gadgets.button import Button 5 | from batgrl.gadgets.flat_toggle import FlatToggle 6 | from batgrl.gadgets.gadget import Gadget 7 | from batgrl.gadgets.grid_layout import GridLayout 8 | from batgrl.gadgets.text import Text 9 | from batgrl.gadgets.toggle_button import ToggleButton 10 | 11 | 12 | class ButtonApp(App): 13 | async def on_start(self): 14 | display = Text(size=(1, 20), pos_hint={"x_hint": 0.5}, is_transparent=True) 15 | 16 | def button_callback(i): 17 | def callback(): 18 | display.add_str(f"Button {i + 1} pressed!".center(20)) 19 | 20 | return callback 21 | 22 | def toggle_button_callback(i): 23 | def callback(state): 24 | prefix = "un" if state == "off" else "" 25 | display.add_str(f"Button {i + 1} {prefix}toggled!".center(20)) 26 | 27 | return callback 28 | 29 | grid_layout = GridLayout( 30 | grid_rows=5, 31 | grid_columns=3, 32 | pos=(1, 0), 33 | orientation="tb-lr", 34 | padding_left=1, 35 | padding_right=1, 36 | padding_top=1, 37 | padding_bottom=1, 38 | horizontal_spacing=1, 39 | ) 40 | 41 | grid_layout.add_gadgets( 42 | Button(size=(1, 10), label=f"Button {i + 1}", callback=button_callback(i)) 43 | for i in range(5) 44 | ) 45 | grid_layout.children[-1].button_state = "disallowed" 46 | grid_layout.add_gadgets( 47 | ToggleButton( 48 | size=(1, 12), 49 | label=f"Button {i + 1}", 50 | callback=toggle_button_callback(i), 51 | ) 52 | for i in range(5, 10) 53 | ) 54 | grid_layout.children[-1].button_state = "disallowed" 55 | grid_layout.add_gadgets( 56 | ToggleButton( 57 | size=(1, 12), 58 | group=0, 59 | label=f"Button {i + 1}", 60 | callback=toggle_button_callback(i), 61 | ) 62 | for i in range(10, 15) 63 | ) 64 | grid_layout.children[-1].button_state = "disallowed" 65 | grid_layout.size = grid_layout.min_grid_size 66 | 67 | flat_grid = GridLayout( 68 | grid_rows=2, 69 | grid_columns=5, 70 | pos=(grid_layout.bottom, 0), 71 | pos_hint={"x_hint": 0.5}, 72 | orientation="lr-tb", 73 | horizontal_spacing=1, 74 | vertical_spacing=1, 75 | ) 76 | flat_grid.add_gadgets( 77 | FlatToggle(size=(1, 3), callback=toggle_button_callback(i)) 78 | for i in range(15, 20) 79 | ) 80 | flat_grid.children[-1].toggle_state = "on" 81 | flat_grid.children[-1].button_state = "disallowed" 82 | flat_grid.add_gadgets( 83 | FlatToggle(size=(1, 3), group=1, callback=toggle_button_callback(i)) 84 | for i in range(20, 25) 85 | ) 86 | flat_grid.children[-1].button_state = "disallowed" 87 | flat_grid.size = flat_grid.min_grid_size 88 | container = Gadget(pos_hint={"x_hint": 0.5}) 89 | container.add_gadgets(display, grid_layout, flat_grid) 90 | container.width = grid_layout.width 91 | container.height = flat_grid.bottom 92 | self.add_gadget(container) 93 | 94 | 95 | if __name__ == "__main__": 96 | ButtonApp(title="Buttons Example", inline=True, inline_height=11).run() 97 | -------------------------------------------------------------------------------- /examples/basic/color_picker.py: -------------------------------------------------------------------------------- 1 | from batgrl.app import run_gadget_as_app 2 | from batgrl.gadgets.color_picker import ColorPicker 3 | 4 | if __name__ == "__main__": 5 | run_gadget_as_app( 6 | ColorPicker(size_hint={"height_hint": 1.0, "width_hint": 1.0}), 7 | title="Color Picker", 8 | ) 9 | -------------------------------------------------------------------------------- /examples/basic/console.py: -------------------------------------------------------------------------------- 1 | from batgrl.app import App 2 | from batgrl.gadgets.console import Console 3 | 4 | 5 | class ConsoleApp(App): 6 | async def on_start(self): 7 | console = Console(size_hint={"height_hint": 1.0, "width_hint": 1.0}) 8 | self.add_gadget(console) 9 | 10 | 11 | if __name__ == "__main__": 12 | ConsoleApp(title="batgrl Console").run() 13 | -------------------------------------------------------------------------------- /examples/basic/custom_button.py: -------------------------------------------------------------------------------- 1 | """A custom animated button example. Terminal font should support octants.""" 2 | 3 | import asyncio 4 | from pathlib import Path 5 | 6 | from batgrl.app import App 7 | from batgrl.gadgets.behaviors.toggle_button_behavior import ToggleButtonBehavior 8 | from batgrl.gadgets.gadget import Gadget 9 | from batgrl.gadgets.image import Image 10 | from batgrl.texture_tools import read_texture 11 | 12 | ASSETS = Path(__file__).parent.parent / "assets" / "custom_button" 13 | BUTTON_ON = read_texture(ASSETS / "on.png") 14 | BUTTON_OFF = read_texture(ASSETS / "off.png") 15 | BUTTON_ON_MID = read_texture(ASSETS / "on-mid.png") 16 | BUTTON_OFF_MID = read_texture(ASSETS / "off-mid.png") 17 | 18 | 19 | class CustomToggleButton(ToggleButtonBehavior, Gadget): 20 | def __init__(self, *args, **kwargs): 21 | self._animate_task = None 22 | super().__init__(*args, **kwargs) 23 | self.image = Image( 24 | size=(2, 8), 25 | blitter="octant", 26 | pos_hint={"x_hint": 0.5, "y_hint": 0.5}, 27 | is_transparent=False, 28 | ) 29 | self.add_gadget(self.image) 30 | 31 | def update_on(self): 32 | if self._animate_task is not None: 33 | self._animate_task.cancel() 34 | self._animate_task = asyncio.create_task( 35 | self._animate_toggle(BUTTON_ON_MID, BUTTON_ON) 36 | ) 37 | 38 | def update_off(self): 39 | if self._animate_task is not None: 40 | self._animate_task.cancel() 41 | self._animate_task = asyncio.create_task( 42 | self._animate_toggle(BUTTON_OFF_MID, BUTTON_OFF) 43 | ) 44 | 45 | async def _animate_toggle(self, image_1, image_2): 46 | self.image.texture = image_1 47 | await asyncio.sleep(0.1) 48 | self.image.texture = image_2 49 | 50 | 51 | class CustomButtonApp(App): 52 | async def on_start(self): 53 | toggle_button = CustomToggleButton(size=(2, 8)) 54 | self.add_gadget(toggle_button) 55 | 56 | 57 | if __name__ == "__main__": 58 | CustomButtonApp().run() 59 | -------------------------------------------------------------------------------- /examples/basic/data_table.py: -------------------------------------------------------------------------------- 1 | """An example usage of data tables.""" 2 | 3 | from batgrl.app import App 4 | from batgrl.gadgets.data_table import ColumnStyle, DataTable 5 | 6 | TABLE = { 7 | "Name": [ 8 | "George Lucas", 9 | "Steven Spielberg", 10 | "James Cameron", 11 | "Peter Jackson", 12 | "Ridley Scott", 13 | ], 14 | "Net Worth (Millions)": [7_620, 5_410, 700, 450, 400], 15 | "Age": [75, 74, 66, 58, 82], 16 | "Country": [ 17 | "United States", 18 | "United States", 19 | "Canada", 20 | "New Zealand", 21 | "United Kingdom", 22 | ], 23 | } 24 | 25 | 26 | class TableApp(App): 27 | async def on_start(self): 28 | table_1 = DataTable(data=TABLE, select_items="row", size=(7, 60)) 29 | table_2 = DataTable( 30 | select_items="column", 31 | zebra_stripes=False, 32 | allow_sorting=False, 33 | default_style=ColumnStyle(alignment="center", padding=3), 34 | size=(7, 60), 35 | ) 36 | table_2.top = table_1.bottom + 1 37 | for column_label in TABLE: 38 | table_2.add_column(column_label) 39 | for i in range(len(TABLE["Name"])): 40 | table_2.add_row([column[i] for column in TABLE.values()]) 41 | 42 | self.add_gadgets(table_1, table_2) 43 | 44 | 45 | if __name__ == "__main__": 46 | TableApp(title="Data Table Example").run() 47 | -------------------------------------------------------------------------------- /examples/basic/easings.py: -------------------------------------------------------------------------------- 1 | from itertools import cycle 2 | from pathlib import Path 3 | 4 | from batgrl.app import App 5 | from batgrl.colors import NEPTUNE_PRIMARY_BG, NEPTUNE_PRIMARY_FG 6 | from batgrl.gadgets.image import Image 7 | from batgrl.gadgets.text import Text, new_cell 8 | from batgrl.geometry import Easing 9 | 10 | ASSETS = Path(__file__).parent.parent / "assets" 11 | PATH_TO_LOGO = ASSETS / "logo_solo_flat_256.png" 12 | 13 | ALPHAS = cycle((0.1, 1.0)) 14 | POS_HINTS = cycle( 15 | ( 16 | {"y_hint": 0.0, "x_hint": 0.0, "anchor": "top-left"}, 17 | {"y_hint": 0.5, "x_hint": 0.5, "anchor": "top-left"}, 18 | ) 19 | ) 20 | SIZE_HINTS = cycle( 21 | ({"height_hint": 0.25, "width_hint": 0.25}, {"height_hint": 0.5, "width_hint": 0.5}) 22 | ) 23 | 24 | 25 | class EasingsApp(App): 26 | async def on_start(self): 27 | logo = Image( 28 | path=PATH_TO_LOGO, 29 | size_hint=next(SIZE_HINTS), 30 | pos_hint=next(POS_HINTS), 31 | ) 32 | 33 | label = Text( 34 | size=(1, 30), 35 | pos_hint={"x_hint": 0.5, "anchor": "top"}, 36 | default_cell=new_cell( 37 | fg_color=NEPTUNE_PRIMARY_FG, bg_color=NEPTUNE_PRIMARY_BG 38 | ), 39 | ) 40 | 41 | self.add_gadgets(logo, label) 42 | 43 | for easing in Easing.__args__: 44 | label.add_str(f"{easing:^30}") 45 | 46 | await logo.tween( 47 | pos_hint=next(POS_HINTS), 48 | alpha=next(ALPHAS), 49 | size_hint=next(SIZE_HINTS), 50 | easing=easing, 51 | duration=3.0, 52 | ) 53 | 54 | self.exit() 55 | 56 | 57 | if __name__ == "__main__": 58 | EasingsApp(title="Easings Example", bg_color=NEPTUNE_PRIMARY_BG).run() 59 | -------------------------------------------------------------------------------- /examples/basic/file_chooser.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | 4 | from batgrl.app import App 5 | from batgrl.gadgets.file_chooser import FileChooser 6 | from batgrl.gadgets.text import Text 7 | 8 | ASSETS = Path(__file__).parent.parent / "assets" 9 | 10 | 11 | class FileApp(App): 12 | async def on_start(self): 13 | label = Text(size=(1, 50), pos=(0, 26), is_transparent=True) 14 | 15 | def select_callback(path): 16 | label.add_str(f"{f'{path.name} selected!':<50}"[:50]) 17 | 18 | fc = FileChooser( 19 | root_dir=ASSETS, 20 | size=(20, 25), 21 | size_hint={"height_hint": 1.0}, 22 | select_callback=select_callback, 23 | ) 24 | self.add_gadgets(label, fc) 25 | 26 | await asyncio.sleep(5) 27 | 28 | fc.root_dir = ASSETS.parent.parent 29 | 30 | 31 | if __name__ == "__main__": 32 | FileApp(title="File Chooser Example").run() 33 | -------------------------------------------------------------------------------- /examples/basic/image.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from batgrl.app import App 4 | from batgrl.gadgets.behaviors.movable import Movable 5 | from batgrl.gadgets.image import Image 6 | 7 | ASSETS = Path(__file__).parent.parent / "assets" 8 | PATH_TO_LOGO_FLAT = ASSETS / "logo_solo_flat_256.png" 9 | PATH_TO_LOGO_FULL = ASSETS / "python_discord_logo.png" 10 | PATH_TO_BACKGROUND = ASSETS / "background.png" 11 | 12 | 13 | class MovableImage(Movable, Image): 14 | pass 15 | 16 | 17 | class ImageApp(App): 18 | async def on_start(self): 19 | background = Image( 20 | size_hint={"height_hint": 1.0, "width_hint": 1.0}, 21 | path=PATH_TO_BACKGROUND, 22 | is_transparent=False, 23 | ) 24 | 25 | logo_flat = MovableImage( 26 | size_hint={"height_hint": 0.5, "width_hint": 0.5}, 27 | path=PATH_TO_LOGO_FLAT, 28 | ptf_on_grab=True, 29 | ) 30 | 31 | logo_full = MovableImage( 32 | size_hint={"height_hint": 0.5, "width_hint": 0.5}, 33 | pos_hint={"y_hint": 0.5, "x_hint": 0.5}, 34 | path=PATH_TO_LOGO_FULL, 35 | blitter="sixel", 36 | alpha=0.8, 37 | ptf_on_grab=True, 38 | ) 39 | 40 | self.add_gadgets(background, logo_flat, logo_full) 41 | 42 | 43 | if __name__ == "__main__": 44 | ImageApp(title="Image Example").run() 45 | -------------------------------------------------------------------------------- /examples/basic/io_events.py: -------------------------------------------------------------------------------- 1 | """Move/click mouse, press keys, paste, or gain/lose focus to show IO events.""" 2 | 3 | from dataclasses import fields 4 | 5 | from batgrl.app import App 6 | from batgrl.gadgets.text import Text 7 | from batgrl.terminal.events import FocusEvent, KeyEvent, MouseEvent, PasteEvent 8 | from batgrl.text_tools import add_text 9 | 10 | 11 | class ShowIOEvents(Text): 12 | def __init__(self, **kwargs): 13 | super().__init__(**kwargs) 14 | 15 | def on_key(self, key_event: KeyEvent) -> bool | None: 16 | self._on_io(key_event) 17 | 18 | def on_mouse(self, mouse_event: MouseEvent) -> bool | None: 19 | self._on_io(mouse_event) 20 | 21 | def on_paste(self, paste_event: PasteEvent) -> bool | None: 22 | self.clear() 23 | add_text(self.canvas, "PasteEvent:\n" + paste_event.paste, truncate_text=True) 24 | 25 | def on_terminal_focus(self, focus_event: FocusEvent) -> bool | None: 26 | self._on_io(focus_event) 27 | 28 | def _on_io(self, event): 29 | self.clear() 30 | event_repr = str(event) 31 | if len(event_repr) <= self.width: 32 | self.add_str(event_repr) 33 | else: 34 | fields_repr = "".join( 35 | f" {field.name}={getattr(event, field.name)!r},\n" 36 | for field in fields(event) 37 | ) 38 | full_repr = f"{type(event).__name__}(\n{fields_repr})" 39 | add_text(self.canvas, full_repr, truncate_text=True) 40 | 41 | 42 | class IoApp(App): 43 | async def on_start(self): 44 | events = ShowIOEvents(size_hint={"height_hint": 1.0, "width_hint": 1.0}) 45 | self.add_gadget(events) 46 | 47 | 48 | if __name__ == "__main__": 49 | IoApp().run() 50 | -------------------------------------------------------------------------------- /examples/basic/line_plot.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from batgrl.app import App 3 | from batgrl.gadgets.gadget import Gadget 4 | from batgrl.gadgets.line_plot import Blitter, LinePlot 5 | from batgrl.gadgets.toggle_button import ToggleButton 6 | 7 | XS = np.arange(20) 8 | YS_1 = np.random.randint(0, 100, 20) 9 | YS_2 = np.random.randint(0, 100, 20) 10 | YS_3 = np.random.randint(0, 100, 20) 11 | 12 | 13 | class PlotApp(App): 14 | async def on_start(self): 15 | BUTTON_WIDTH = 17 16 | 17 | plot = LinePlot( 18 | xs=[XS, XS, XS], 19 | ys=[YS_1, YS_2, YS_3], 20 | x_label="X Values", 21 | y_label="Y Values", 22 | legend_labels=["Before", "During", "After"], 23 | size_hint={"height_hint": 1.0, "width_hint": 1.0}, 24 | blitter="braille", 25 | ) 26 | 27 | def set_mode(mode): 28 | def inner(toggle_state): 29 | if toggle_state == "on": 30 | plot.blitter = mode 31 | 32 | return inner 33 | 34 | buttons = [ 35 | ToggleButton( 36 | size=(1, BUTTON_WIDTH), 37 | pos=(i, 0), 38 | label=f"{blitter.capitalize()} Blitter", 39 | callback=set_mode(blitter), 40 | group=0, 41 | ) 42 | for i, blitter in enumerate(Blitter.__args__) 43 | ] 44 | 45 | container = Gadget( 46 | size=(len(buttons), BUTTON_WIDTH), 47 | pos_hint={"x_hint": 1.0, "anchor": "top-right"}, 48 | ) 49 | container.add_gadgets(buttons) 50 | self.add_gadgets(plot, container) 51 | 52 | 53 | if __name__ == "__main__": 54 | PlotApp(title="Line Plot Example").run() 55 | -------------------------------------------------------------------------------- /examples/basic/markdown.py: -------------------------------------------------------------------------------- 1 | """A markdown example.""" 2 | 3 | from batgrl.app import App 4 | from batgrl.gadgets.markdown import Markdown 5 | 6 | MARKDOWN_TEXT = """\ 7 | Markdown 8 | ======== 9 | A showcase of batgrl markdown rendering. 10 | 11 | Headings 12 | -------- 13 | # Heading 1 14 | ## Heading 2 15 | ### Heading 3 16 | #### Heading 4 17 | ##### Heading 5 18 | ###### Heading 6 19 | 20 | Ordered List 21 | ------------ 22 | 1. one 23 | 2. two 24 | 3. three 25 | 26 | Unordered List 27 | -------------- 28 | - first bullet 29 | - second bullet 30 | - third bullet 31 | - fourth bullet 32 | - fifth bullet 33 | - sixth bullet 34 | - seventh bullet 35 | - eighth bullet 36 | 37 | Task List 38 | --------- 39 | - [x] Item done 40 | - [ ] Item not done 41 | 42 | Inline Tokens 43 | ------------- 44 | Emoji codes: :+1: :100: :smiley: 45 | 46 | Inline code: `2 + 2 = 4` 47 | 48 | Links: [batgrl](https://github.com/salt-die/batgrl "badass terminal graphics library") 49 | 50 | Inline images: ![A spinning python logo.](../assets/spinner.gif "Weeeeee!") \ 51 | ![Image can't be displayed.](not_found.png "A non-displayable image.") 52 | 53 | Code Block 54 | ---------- 55 | ```python 56 | # This is a comment 57 | 1 + 1 58 | print("hello world") 59 | ``` 60 | 61 | Quotes 62 | ------ 63 | > No wise fish would go anywhere without a porpoise. 64 | >> Why, sometimes I’ve believed as many as six impossible things before breakfast. 65 | 66 | Tables 67 | ------ 68 | Title | Author | Date 69 | :-------------------------- | :-------------: | ---: 70 | A Line-storm Song | Robert Frost | 1913 71 | The Weary Blues | Langston Hughes | 1926 72 | Morning in the Burned House | Margaret Atwood | 1995 73 | """ 74 | 75 | 76 | class MarkdownApp(App): 77 | """A markdown app.""" 78 | 79 | async def on_start(self): 80 | markdown = Markdown( 81 | markdown=MARKDOWN_TEXT, 82 | size_hint={"height_hint": 1.0, "width_hint": 1.0}, 83 | blitter="sixel", 84 | ) 85 | self.add_gadget(markdown) 86 | 87 | 88 | if __name__ == "__main__": 89 | MarkdownApp(title="Markdown Example").run() 90 | -------------------------------------------------------------------------------- /examples/basic/menu.py: -------------------------------------------------------------------------------- 1 | """A simple menu example.""" 2 | 3 | from batgrl.app import App 4 | from batgrl.gadgets.menu import MenuBar 5 | from batgrl.gadgets.text import Text 6 | 7 | 8 | class MenuApp(App): 9 | async def on_start(self): 10 | label = Text(size=(1, 50), is_transparent=True) 11 | 12 | def add_label(text): 13 | def inner(): 14 | label.add_str(f"{text:<50}"[:50]) 15 | 16 | return inner 17 | 18 | def add_label_toggle(text): 19 | def inner(toggle_state): 20 | label.add_str(f"{f'{text} {toggle_state}':<50}"[:50]) 21 | 22 | return inner 23 | 24 | # These "keybinds" aren't implemented. 25 | file_menu = { 26 | ("New File", "Ctrl+N"): add_label("New File"), 27 | ("Open File...", "Ctrl+O"): add_label("Open File..."), 28 | ("Save", "Ctrl+S"): add_label("Save"), 29 | ("Save as...", "Ctrl+Shift+S"): add_label("Save as..."), 30 | ("Preferences", ""): { 31 | ("Settings", "Ctrl+,"): add_label("Settings"), 32 | ("Keyboard Shortcuts", "Ctrl+K Ctrl+S"): add_label( 33 | "Keyboard Shortcuts" 34 | ), 35 | ("Toggle Item 1", ""): add_label_toggle("Toggle Item 1"), 36 | ("Toggle Item 2", ""): add_label_toggle("Toggle Item 2"), 37 | }, 38 | } 39 | 40 | edit_menu = { 41 | ("Undo", "Ctrl+Z"): add_label("Undo"), 42 | ("Redo", "Ctrl+Y"): add_label("Redo"), 43 | ("Cut", "Ctrl+X"): add_label("Cut"), 44 | ("Copy", "Ctrl+C"): add_label("Copy"), 45 | ("Paste", "Ctrl+V"): add_label("Paste"), 46 | } 47 | 48 | self.add_gadget(label) 49 | self.add_gadgets( 50 | MenuBar.from_iterable( 51 | (("File", file_menu), ("Edit", edit_menu)), pos=(2, 0) 52 | ) 53 | ) 54 | 55 | self.children[-2].children[1].button_state = "disallowed" 56 | 57 | 58 | if __name__ == "__main__": 59 | MenuApp(title="Menu Example").run() 60 | -------------------------------------------------------------------------------- /examples/basic/motion.py: -------------------------------------------------------------------------------- 1 | """An example showcasing movement along a path made up of Bezier curves.""" 2 | 3 | import asyncio 4 | from itertools import cycle 5 | from pathlib import Path 6 | 7 | import numpy as np 8 | from batgrl.app import App 9 | from batgrl.colors import ( 10 | ABLUE, 11 | AGREEN, 12 | ARED, 13 | AYELLOW, 14 | NEPTUNE_PRIMARY_BG, 15 | NEPTUNE_PRIMARY_FG, 16 | gradient, 17 | ) 18 | from batgrl.gadgets.graphics import Graphics 19 | from batgrl.gadgets.image import Image 20 | from batgrl.gadgets.text import Text, new_cell 21 | from batgrl.geometry import BezierCurve, Easing, move_along_path 22 | 23 | LOGO = Path(__file__).parent / ".." / "assets" / "python_discord_logo.png" 24 | BG_SIZE = (30, 80) 25 | GRADIENTS = [ 26 | gradient(ARED, AGREEN, n=100), 27 | gradient(AGREEN, ABLUE, n=100), 28 | gradient(ABLUE, AYELLOW, n=100), 29 | ] 30 | 31 | 32 | class PathApp(App): 33 | async def on_start(self): 34 | bg = Graphics(size=BG_SIZE, default_color=(*NEPTUNE_PRIMARY_BG, 255)) 35 | image = Image(path=LOGO, size=(15, 30), alpha=0.85) 36 | label = Text( 37 | default_cell=new_cell( 38 | fg_color=NEPTUNE_PRIMARY_FG, bg_color=NEPTUNE_PRIMARY_BG 39 | ), 40 | is_transparent=True, 41 | ) 42 | 43 | self.add_gadgets(bg, image, label) 44 | 45 | for easing in cycle(Easing.__args__): 46 | label.set_text(f"Easing: {easing}") 47 | control_points = np.random.random((7, 2)) * BG_SIZE 48 | path = [ 49 | BezierCurve(control_points[:3]), 50 | BezierCurve(control_points[2:5]), 51 | BezierCurve(control_points[4:]), 52 | ] 53 | 54 | # Draw curve: 55 | bg.clear() 56 | for curve, gradient_ in zip(path, GRADIENTS): 57 | points = curve.evaluate(np.linspace(0, 1, 100)).astype(int) 58 | points[:, 0] *= 2 59 | for (y, x), color in zip(points, gradient_): 60 | bg.texture[y : y + 2, x] = color 61 | 62 | await move_along_path(image, path=path, speed=20, easing=easing) 63 | await asyncio.sleep(1) 64 | 65 | 66 | if __name__ == "__main__": 67 | PathApp(title="Motion Example", bg_color=NEPTUNE_PRIMARY_BG).run() 68 | -------------------------------------------------------------------------------- /examples/basic/parallax.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from itertools import cycle 3 | from pathlib import Path 4 | 5 | from batgrl.app import App 6 | from batgrl.gadgets.parallax import Parallax 7 | from batgrl.geometry import points_on_circle 8 | 9 | ASSETS = Path(__file__).parent.parent / "assets" 10 | PARALLAX = ASSETS / "space_parallax" 11 | 12 | 13 | class ParallaxApp(App): 14 | async def on_start(self): 15 | parallax = Parallax( 16 | path=PARALLAX, size_hint={"height_hint": 1.0, "width_hint": 1.0} 17 | ) 18 | self.add_gadget(parallax) 19 | 20 | for parallax.offset in cycle(points_on_circle(100, radius=50)): 21 | await asyncio.sleep(0) 22 | 23 | 24 | if __name__ == "__main__": 25 | ParallaxApp(title="Parallax Example").run() 26 | -------------------------------------------------------------------------------- /examples/basic/progress_bar.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from batgrl.app import App 4 | from batgrl.colors import NEPTUNE_PRIMARY_BG, NEPTUNE_PRIMARY_FG 5 | from batgrl.gadgets.button import Button 6 | from batgrl.gadgets.progress_bar import ProgressBar 7 | from batgrl.gadgets.text import Text, new_cell 8 | from batgrl.gadgets.text_animation import TextAnimation 9 | 10 | 11 | class ProgressBarApp(App): 12 | async def on_start(self): 13 | default_cell = new_cell( 14 | fg_color=NEPTUNE_PRIMARY_FG, bg_color=NEPTUNE_PRIMARY_BG 15 | ) 16 | label_a = Text(default_cell=default_cell) 17 | horizontal_a = ProgressBar(pos=(0, 10), size=(1, 50)) 18 | 19 | label_b = TextAnimation( 20 | frames=["Loading", "Loading.", "Loading..", "Loading..."], 21 | frame_durations=1 / 12, 22 | size=(1, 10), 23 | pos=(2, 0), 24 | default_cell=default_cell, 25 | ) 26 | label_b.play() 27 | 28 | horizontal_b = ProgressBar(pos=(2, 10), size=(1, 50), animation_delay=0) 29 | 30 | vertical_a = ProgressBar(size=(5, 1), is_horizontal=False) 31 | vertical_a.top = horizontal_b.bottom + 1 32 | 33 | vertical_b = ProgressBar(size=(5, 1), is_horizontal=False) 34 | vertical_b.pos = vertical_a.top, vertical_a.right + 1 35 | 36 | async def update_progress(): 37 | for i in range(500): 38 | progress = (i + 1) / 500 39 | horizontal_a.progress = progress 40 | vertical_a.progress = progress 41 | label_a.set_text(f"{int(progress * 100)}%".rjust(9)) 42 | await asyncio.sleep(1 / 40) 43 | 44 | update_task = asyncio.create_task(update_progress()) 45 | 46 | def reset_progress(): 47 | nonlocal update_task 48 | update_task.cancel() 49 | update_task = asyncio.create_task(update_progress()) 50 | 51 | button = Button( 52 | pos=(4, 5), size=(5, 55), label="Reset", callback=reset_progress 53 | ) 54 | 55 | self.add_gadgets( 56 | horizontal_a, horizontal_b, vertical_a, vertical_b, label_a, label_b, button 57 | ) 58 | 59 | 60 | if __name__ == "__main__": 61 | ProgressBarApp(title="Progress Bar Example", bg_color=NEPTUNE_PRIMARY_BG).run() 62 | -------------------------------------------------------------------------------- /examples/basic/recycle_view.py: -------------------------------------------------------------------------------- 1 | from batgrl.app import App 2 | from batgrl.colors import NEPTUNE_PRIMARY_BG, NEPTUNE_PRIMARY_FG 3 | from batgrl.gadgets.gadget import Point, Size 4 | from batgrl.gadgets.recycle_view import RecycleView 5 | from batgrl.gadgets.text import Text, new_cell 6 | 7 | DEFAULT_CELL = new_cell(fg_color=NEPTUNE_PRIMARY_FG, bg_color=NEPTUNE_PRIMARY_BG) 8 | 9 | 10 | class MyRecycleView(RecycleView): 11 | def new_data_view(self) -> Text: 12 | return Text(default_cell=DEFAULT_CELL) 13 | 14 | def update_data_view(self, data_view: Text, text: str) -> None: 15 | data_view.clear() 16 | data_view.add_border() 17 | data_view.add_str(text, pos=(1, 1)) 18 | 19 | def get_layout(self, i: int) -> tuple[Size, Point]: 20 | return Size(3, 36), Point(3 * i, 0) 21 | 22 | 23 | class RecycleApp(App): 24 | async def on_start(self): 25 | recycle_view = MyRecycleView( 26 | recycle_view_data=[ 27 | f"This is a view of the {i:03d}th datum." for i in range(1000) 28 | ], 29 | size_hint={"height_hint": 1.0, "width_hint": 1.0}, 30 | dynamic_bars=True, 31 | ) 32 | label = Text(pos_hint={"x_hint": 1.0, "x_offset": -2, "anchor": "right"}) 33 | 34 | def update_label(): 35 | label.set_text( 36 | f"RecycleView data has {len(recycle_view.recycle_view_data)} items,\n" 37 | f"but only {len(recycle_view.view.children)} data-view gadget children." 38 | ) 39 | 40 | update_label() 41 | recycle_view.view.bind("pos", update_label) 42 | self.add_gadgets(recycle_view, label) 43 | 44 | 45 | if __name__ == "__main__": 46 | RecycleApp(title="Recycle-view example.").run() 47 | -------------------------------------------------------------------------------- /examples/basic/scroll_view.py: -------------------------------------------------------------------------------- 1 | """ScrollView example.""" 2 | 3 | from batgrl.app import App 4 | from batgrl.colors import BLUE, GREEN, RED, gradient 5 | from batgrl.gadgets.scroll_view import ScrollView 6 | from batgrl.gadgets.text import Size, Text 7 | 8 | N = 20 # Number of coordinate pairs on each line. 9 | BIG_GADGET_SIZE = Size(50, 8 * N + N - 1) 10 | 11 | LEFT_GRADIENT = gradient(RED, GREEN, n=BIG_GADGET_SIZE.rows) 12 | RIGHT_GRADIENT = gradient(GREEN, BLUE, n=BIG_GADGET_SIZE.rows) 13 | 14 | 15 | class ScrollViewApp(App): 16 | async def on_start(self): 17 | big_gadget = Text(size=BIG_GADGET_SIZE) 18 | 19 | for y in range(BIG_GADGET_SIZE.rows): 20 | big_gadget.add_str( 21 | " ".join(f"({y:<2}, {x:<2})" for x in range(N)), pos=(y, 0) 22 | ) 23 | big_gadget.canvas["bg_color"][y] = gradient( 24 | LEFT_GRADIENT[y], RIGHT_GRADIENT[y], n=BIG_GADGET_SIZE.columns 25 | ) 26 | 27 | scroll_view = ScrollView(size=(20, 50), pos_hint={"y_hint": 0.5, "x_hint": 0.5}) 28 | scroll_view.view = big_gadget 29 | 30 | self.add_gadget(scroll_view) 31 | 32 | 33 | if __name__ == "__main__": 34 | ScrollViewApp(title="Scroll View Example").run() 35 | -------------------------------------------------------------------------------- /examples/basic/slider.py: -------------------------------------------------------------------------------- 1 | """Example slider gadget.""" 2 | 3 | from batgrl.app import App 4 | from batgrl.colors import NEPTUNE_PRIMARY_BG, NEPTUNE_PRIMARY_FG 5 | from batgrl.gadgets.slider import Slider 6 | from batgrl.gadgets.text import Text, new_cell 7 | 8 | 9 | class SliderApp(App): 10 | async def on_start(self): 11 | display = Text( 12 | size=(3, 30), 13 | default_cell=new_cell( 14 | fg_color=NEPTUNE_PRIMARY_FG, bg_color=NEPTUNE_PRIMARY_BG 15 | ), 16 | ) 17 | slider_1 = Slider( 18 | size=(1, 20), 19 | pos=(1, 0), 20 | min=0, 21 | max=100, 22 | callback=lambda value: display.add_str( 23 | f"{round(value, 3):<10}", pos=(0, 7) 24 | ), 25 | bg_color=NEPTUNE_PRIMARY_BG, 26 | ) 27 | slider_2 = Slider( 28 | size=(3, 16), 29 | pos=(3, 2), 30 | min=-20, 31 | max=50, 32 | callback=lambda value: display.add_str( 33 | f"{round(value, 3):<10}", pos=(2, 7) 34 | ), 35 | bg_color=(55, 55, 55), 36 | fill_color=(220, 120, 0), 37 | ) 38 | self.add_gadgets(display, slider_1, slider_2) 39 | 40 | 41 | if __name__ == "__main__": 42 | SliderApp(title="Slider Example", bg_color=NEPTUNE_PRIMARY_BG).run() 43 | -------------------------------------------------------------------------------- /examples/basic/sparkline.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from batgrl.app import App 3 | from batgrl.colors import NEPTUNE_PRIMARY_BG 4 | from batgrl.gadgets.button import Button 5 | from batgrl.gadgets.sparkline import Sparkline 6 | 7 | 8 | class SparklineApp(App): 9 | async def on_start(self): 10 | sparkline_a = Sparkline(size=(4, 30)) 11 | sparkline_b = Sparkline(size=(4, 30), pos=(5, 0)) 12 | 13 | i = 0 14 | 15 | def new_data(): 16 | nonlocal i 17 | sparkline_a.data = np.sin(np.linspace(i, 2 * np.pi + i, num=200)) 18 | sparkline_b.data = np.random.random(200) 19 | i += 0.5 20 | 21 | new_data() 22 | 23 | button = Button( 24 | size=(9, 20), 25 | pos=(0, sparkline_a.right + 1), 26 | label="New Data", 27 | callback=new_data, 28 | ) 29 | 30 | self.add_gadgets(sparkline_a, sparkline_b, button) 31 | 32 | 33 | if __name__ == "__main__": 34 | SparklineApp(title="Sparkline Example", bg_color=NEPTUNE_PRIMARY_BG).run() 35 | -------------------------------------------------------------------------------- /examples/basic/spinners.py: -------------------------------------------------------------------------------- 1 | from math import ceil 2 | 3 | from batgrl.app import App 4 | from batgrl.colors import NEPTUNE_PRIMARY_BG, NEPTUNE_PRIMARY_FG 5 | from batgrl.gadgets.gadget import Gadget 6 | from batgrl.gadgets.grid_layout import GridLayout 7 | from batgrl.gadgets.scroll_view import ScrollView 8 | from batgrl.gadgets.text import Text, new_cell 9 | from batgrl.gadgets.text_animation import TextAnimation 10 | from batgrl.spinners import SPINNERS 11 | 12 | COLUMNS = 2 13 | 14 | 15 | class SpinnersApp(App): 16 | async def on_start(self): 17 | sv = ScrollView( 18 | size_hint={"height_hint": 1.0, "width_hint": 1.0}, 19 | allow_horizontal_scroll=False, 20 | show_horizontal_bar=False, 21 | ) 22 | grid = GridLayout( 23 | grid_rows=ceil(len(SPINNERS) / COLUMNS), 24 | grid_columns=COLUMNS, 25 | horizontal_spacing=1, 26 | orientation="tb-lr", 27 | is_transparent=True, 28 | ) 29 | default_cell = new_cell( 30 | fg_color=NEPTUNE_PRIMARY_FG, bg_color=NEPTUNE_PRIMARY_BG 31 | ) 32 | 33 | for name, frames in SPINNERS.items(): 34 | label = Text( 35 | pos_hint={"y_hint": 0.5, "anchor": "left"}, 36 | default_cell=default_cell, 37 | ) 38 | label.set_text(f"{name}: ") 39 | 40 | animation = TextAnimation( 41 | pos=(0, label.right), frames=frames, default_cell=default_cell 42 | ) 43 | animation.size = animation.min_animation_size 44 | animation.play() 45 | 46 | container = Gadget( 47 | size=(animation.height, label.width + animation.width), 48 | is_transparent=True, 49 | ) 50 | container.add_gadgets(label, animation) 51 | 52 | grid.add_gadget(container) 53 | 54 | grid.size = grid.min_grid_size 55 | sv.view = grid 56 | self.add_gadget(sv) 57 | 58 | 59 | if __name__ == "__main__": 60 | SpinnersApp(title="Spinners").run() 61 | -------------------------------------------------------------------------------- /examples/basic/split_layout.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from batgrl.app import App 4 | from batgrl.gadgets.image import Image 5 | from batgrl.gadgets.split_layout import HSplitLayout, VSplitLayout 6 | 7 | ASSETS = Path(__file__).parent.parent / "assets" 8 | PATH_TO_LOGO_FLAT = ASSETS / "logo_solo_flat_256.png" 9 | PATH_TO_LOGO_FULL = ASSETS / "python_discord_logo.png" 10 | 11 | 12 | class SplitLayoutApp(App): 13 | async def on_start(self): 14 | image_tl = Image( 15 | path=PATH_TO_LOGO_FLAT, size_hint={"height_hint": 1.0, "width_hint": 1.0} 16 | ) 17 | image_tr = Image( 18 | path=PATH_TO_LOGO_FULL, size_hint={"height_hint": 1.0, "width_hint": 1.0} 19 | ) 20 | image_bl = Image( 21 | path=PATH_TO_LOGO_FULL, size_hint={"height_hint": 1.0, "width_hint": 1.0} 22 | ) 23 | image_br = Image( 24 | path=PATH_TO_LOGO_FLAT, size_hint={"height_hint": 1.0, "width_hint": 1.0} 25 | ) 26 | 27 | split_layout = HSplitLayout( 28 | split_row=10, 29 | top_min_height=5, 30 | bottom_min_height=5, 31 | size_hint={"height_hint": 1.0, "width_hint": 1.0}, 32 | ) 33 | top_split_layout = VSplitLayout( 34 | right_min_width=10, 35 | left_min_width=10, 36 | size_hint={"height_hint": 1.0, "width_hint": 1.0}, 37 | ) 38 | bottom_split_layout = VSplitLayout( 39 | right_min_width=10, 40 | left_min_width=10, 41 | size_hint={"height_hint": 1.0, "width_hint": 1.0}, 42 | ) 43 | 44 | split_layout.top_pane.add_gadget(top_split_layout) 45 | split_layout.bottom_pane.add_gadget(bottom_split_layout) 46 | 47 | top_split_layout.left_pane.add_gadget(image_tl) 48 | top_split_layout.right_pane.add_gadget(image_tr) 49 | 50 | bottom_split_layout.left_pane.add_gadget(image_bl) 51 | bottom_split_layout.right_pane.add_gadget(image_br) 52 | 53 | self.add_gadget(split_layout) 54 | 55 | 56 | if __name__ == "__main__": 57 | SplitLayoutApp(title="Split Layout Example").run() 58 | -------------------------------------------------------------------------------- /examples/basic/stack_layout.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from batgrl.app import App 4 | from batgrl.gadgets.image import Image 5 | from batgrl.gadgets.stack_layout import HStackLayout, VStackLayout 6 | 7 | ASSETS = Path(__file__).parent.parent / "assets" 8 | PATH_TO_LOGO_FLAT = ASSETS / "logo_solo_flat_256.png" 9 | PATH_TO_LOGO_FULL = ASSETS / "python_discord_logo.png" 10 | 11 | 12 | class StackLayoutApp(App): 13 | async def on_start(self): 14 | images = [ 15 | Image(path=PATH_TO_LOGO_FLAT if i % 2 else PATH_TO_LOGO_FULL) 16 | for i in range(9) 17 | ] 18 | hstacks = [HStackLayout() for i in range(3)] 19 | hstacks[0].add_gadgets(images[:3]) 20 | hstacks[1].add_gadgets(images[3:6]) 21 | hstacks[2].add_gadgets(images[6:]) 22 | vstack = VStackLayout(size_hint={"height_hint": 1.0, "width_hint": 1.0}) 23 | vstack.add_gadgets(hstacks) 24 | self.add_gadget(vstack) 25 | 26 | 27 | if __name__ == "__main__": 28 | StackLayoutApp(title="Stack Layout Example").run() 29 | -------------------------------------------------------------------------------- /examples/basic/syntax_highlighting.py: -------------------------------------------------------------------------------- 1 | """Syntax highlighting example.""" 2 | 3 | from pathlib import Path 4 | 5 | from batgrl.app import App 6 | from batgrl.colors import NEPTUNE_PRIMARY_BG, NEPTUNE_PRIMARY_FG, Neptune 7 | from batgrl.gadgets.menu import MenuBar 8 | from batgrl.gadgets.scroll_view import ScrollView 9 | from batgrl.gadgets.text import Text, new_cell 10 | from pygments.styles import get_style_by_name 11 | 12 | DARK_STYLES = [ 13 | "dracula", 14 | "fruity", 15 | "github-dark", 16 | "gruvbox-dark", 17 | "inkpot", 18 | "lightbulb", 19 | "material", 20 | "monokai", 21 | "native", 22 | "neptune", 23 | "nord-darker", 24 | "nord", 25 | "one-dark", 26 | "paraiso-dark", 27 | "rrt", 28 | "solarized-dark", 29 | "stata-dark", 30 | "vim", 31 | "zenburn", 32 | ] 33 | LIGHT_STYLES = [ 34 | "abap", 35 | "algol_nu", 36 | "algol", 37 | "arduino", 38 | "autumn", 39 | "borland", 40 | "bw", 41 | "colorful", 42 | "default", 43 | "emacs", 44 | "friendly_grayscale", 45 | "friendly", 46 | "gruvbox-light", 47 | "igor", 48 | "lilypond", 49 | "lovelace", 50 | "manni", 51 | "murphy", 52 | "paraiso-light", 53 | "pastie", 54 | "perldoc", 55 | "rainbow_dash", 56 | "sas", 57 | "solarized-light", 58 | "staroffice", 59 | "stata-light", 60 | "stata", 61 | "tango", 62 | "trac", 63 | "vs", 64 | "xcode", 65 | ] 66 | 67 | 68 | def get_style_by_name_(name): 69 | if name == "neptune": 70 | return Neptune 71 | return get_style_by_name(name) 72 | 73 | 74 | class SyntaxApp(App): 75 | async def on_start(self): 76 | code = Path(__file__).read_bytes().decode("utf-8").replace("\r", "") 77 | text = Text() 78 | text.set_text(code) 79 | text.size_hint = { 80 | "width_hint": 1.0, 81 | "min_width": text.width, 82 | "width_offset": -2, 83 | } 84 | last_style = "neptune" 85 | 86 | def callback_for(name): 87 | def callback(): 88 | nonlocal last_style 89 | last_style = name 90 | text.add_syntax_highlighting(style=get_style_by_name_(name)) 91 | 92 | return callback 93 | 94 | def repaint(): 95 | text.add_syntax_highlighting(style=get_style_by_name_(last_style)) 96 | 97 | text.bind("size", repaint) 98 | 99 | dark_menu = {(style, ""): callback_for(style) for style in DARK_STYLES} 100 | light_menu = {(style, ""): callback_for(style) for style in LIGHT_STYLES} 101 | 102 | sep = Text( 103 | default_cell=new_cell( 104 | ord=ord("━"), fg_color=NEPTUNE_PRIMARY_FG, bg_color=NEPTUNE_PRIMARY_BG 105 | ), 106 | pos=(1, 0), 107 | size=(1, 1), 108 | size_hint={"width_hint": 1.0}, 109 | ) 110 | sv = ScrollView( 111 | pos=(2, 0), 112 | size_hint={ 113 | "height_hint": 1.0, 114 | "width_hint": 1.0, 115 | "height_offset": -2, 116 | }, 117 | show_horizontal_bar=False, 118 | ) 119 | sv.view = text 120 | 121 | self.add_gadgets(sep, sv) 122 | self.add_gadgets( 123 | MenuBar.from_iterable( 124 | [("Dark Styles", dark_menu), ("Light Styles", light_menu)] 125 | ) 126 | ) 127 | 128 | 129 | if __name__ == "__main__": 130 | SyntaxApp(title="Syntax Highlighting Example", bg_color=NEPTUNE_PRIMARY_BG).run() 131 | -------------------------------------------------------------------------------- /examples/basic/tabs.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import numpy as np 4 | from batgrl.app import App 5 | from batgrl.gadgets.animation import Animation 6 | from batgrl.gadgets.color_picker import ColorPicker 7 | from batgrl.gadgets.file_chooser import FileChooser 8 | from batgrl.gadgets.line_plot import LinePlot 9 | from batgrl.gadgets.tabs import Tabs 10 | 11 | ASSETS = Path(__file__).parent.parent / "assets" 12 | CAVEMAN_PATH = ASSETS / "caveman" 13 | XS = np.arange(20) 14 | YS_1 = np.random.randint(0, 100, 20) 15 | YS_2 = np.random.randint(0, 100, 20) 16 | YS_3 = np.random.randint(0, 100, 20) 17 | 18 | 19 | class TabApp(App): 20 | async def on_start(self): 21 | tabs = Tabs(size_hint={"height_hint": 1.0, "width_hint": 1.0}) 22 | 23 | animation = Animation( 24 | path=CAVEMAN_PATH, 25 | interpolation="nearest", 26 | size_hint={"height_hint": 1.0, "width_hint": 1.0}, 27 | ) 28 | animation.play() 29 | tabs.add_tab("Animation", animation) 30 | tabs.add_tab( 31 | "File Chooser", 32 | FileChooser( 33 | root_dir=ASSETS, size_hint={"height_hint": 1.0, "width_hint": 1.0} 34 | ), 35 | ) 36 | tabs.add_tab( 37 | "Color Picker", 38 | ColorPicker(size_hint={"height_hint": 1.0, "width_hint": 1.0}), 39 | ) 40 | tabs.add_tab( 41 | "Line Plot", 42 | LinePlot( 43 | xs=[XS, XS, XS], 44 | ys=[YS_1, YS_2, YS_3], 45 | x_label="X Values", 46 | y_label="Y Values", 47 | legend_labels=("Before", "During", "After"), 48 | size_hint={"height_hint": 1.0, "width_hint": 1.0}, 49 | ), 50 | ) 51 | self.add_gadget(tabs) 52 | 53 | 54 | if __name__ == "__main__": 55 | TabApp(title="Tabbed Gadget").run() 56 | -------------------------------------------------------------------------------- /examples/basic/text_effects.py: -------------------------------------------------------------------------------- 1 | """ 2 | An example of text effects. 3 | 4 | Text effects are recreations of the effects from https://github.com/ChrisBuilds/terminaltexteffects. 5 | To use a text effect simply pass in a `Text` gadget and await the effect. 6 | """ 7 | 8 | import asyncio 9 | from pathlib import Path 10 | 11 | import numpy as np 12 | from batgrl.app import App 13 | from batgrl.colors import NEPTUNE_PRIMARY_BG, NEPTUNE_PRIMARY_FG 14 | from batgrl.figfont import FIGFont 15 | from batgrl.gadgets.text import Text, new_cell 16 | from batgrl.gadgets.text_effects import ( 17 | beams_effect, 18 | black_hole_effect, 19 | ring_effect, 20 | spotlights_effect, 21 | ) 22 | 23 | 24 | def make_logo(): 25 | assets = Path(__file__).parent.parent / "assets" 26 | font = FIGFont.from_path(assets / "delta_corps_priest_1.flf") 27 | logo = font.render_array("batgrl") 28 | return np.append( 29 | logo, [list("badass terminal graphics library".center(logo.shape[1]))], axis=0 30 | ) 31 | 32 | 33 | LOGO = make_logo() 34 | 35 | 36 | class TextEffectsApp(App): 37 | async def on_start(self): 38 | text = Text( 39 | size=(30, 80), 40 | default_cell=new_cell( 41 | fg_color=NEPTUNE_PRIMARY_FG, bg_color=NEPTUNE_PRIMARY_BG 42 | ), 43 | ) 44 | text.chars[10:20, 3:77] = LOGO 45 | self.add_gadget(text) 46 | 47 | # Note: Do not modify text's size during effects. 48 | await beams_effect(text) 49 | await asyncio.sleep(2) 50 | await black_hole_effect(text) 51 | await asyncio.sleep(2) 52 | await ring_effect(text) 53 | await asyncio.sleep(2) 54 | await spotlights_effect(text) 55 | 56 | 57 | if __name__ == "__main__": 58 | TextEffectsApp(title="Text Effects", bg_color=NEPTUNE_PRIMARY_BG).run() 59 | -------------------------------------------------------------------------------- /examples/basic/text_input.py: -------------------------------------------------------------------------------- 1 | from batgrl.app import App 2 | from batgrl.colors import ( 3 | NEPTUNE_PRIMARY_BG, 4 | NEPTUNE_PRIMARY_FG, 5 | NEPTUNE_THEME, 6 | Color, 7 | ) 8 | from batgrl.gadgets.text import Text, new_cell 9 | from batgrl.gadgets.text_pad import TextPad 10 | from batgrl.gadgets.textbox import Textbox 11 | 12 | SECONDARY_FG = Color.from_hex(NEPTUNE_THEME["data_table_selected_fg"]) 13 | SECONDARY_BG = Color.from_hex(NEPTUNE_THEME["data_table_selected_bg"]) 14 | ACTIVE_COLOR = Color.from_hex(NEPTUNE_THEME["titlebar_normal_fg"]) 15 | INACTIVE_COLOR = Color.from_hex(NEPTUNE_THEME["titlebar_normal_bg"]) 16 | 17 | JABBERWOCKY = """ 18 | Jabberwocky 19 | By Lewis Carroll 20 | 21 | ’Twas brillig, and the slithy toves 22 | Did gyre and gimble in the wabe: 23 | All mimsy were the borogoves, 24 | And the mome raths outgrabe. 25 | 26 | “Beware the Jabberwock, my son! 27 | The jaws that bite, the claws that catch! 28 | Beware the Jubjub bird, and shun 29 | The frumious Bandersnatch!” 30 | 31 | He took his vorpal sword in hand; 32 | Long time the manxome foe he sought— 33 | So rested he by the Tumtum tree 34 | And stood awhile in thought. 35 | 36 | And, as in uffish thought he stood, 37 | The Jabberwock, with eyes of flame, 38 | Came whiffling through the tulgey wood, 39 | And burbled as it came! 40 | 41 | One, two! One, two! And through and through 42 | The vorpal blade went snicker-snack! 43 | He left it dead, and with its head 44 | He went galumphing back. 45 | 46 | “And hast thou slain the Jabberwock? 47 | Come to my arms, my beamish boy! 48 | O frabjous day! Callooh! Callay!” 49 | He chortled in his joy. 50 | 51 | ’Twas brillig, and the slithy toves 52 | Did gyre and gimble in the wabe: 53 | All mimsy were the borogoves, 54 | And the mome raths outgrabe. 55 | """ 56 | 57 | 58 | class BorderOnFocus: 59 | def on_focus(self): 60 | super().on_focus() 61 | self.parent.add_border("near", bold=True, fg_color=ACTIVE_COLOR) 62 | 63 | def on_blur(self): 64 | super().on_blur() 65 | self.parent.add_border("near", bold=False, fg_color=INACTIVE_COLOR) 66 | 67 | 68 | class BorderedOnFocusTextbox(BorderOnFocus, Textbox): ... 69 | 70 | 71 | class BorderedOnFocusTextPad(BorderOnFocus, TextPad): ... 72 | 73 | 74 | class TextPadApp(App): 75 | async def on_start(self): 76 | textbox = BorderedOnFocusTextbox( 77 | pos=(1, 4), 78 | size=(1, 30), 79 | enter_callback=lambda box: setattr(box, "text", ""), 80 | placeholder="Search...", 81 | max_chars=50, 82 | ) 83 | default_cell = new_cell(fg_color=NEPTUNE_PRIMARY_FG, bg_color=SECONDARY_BG) 84 | textbox_border = Text(pos=(2, 2), size=(3, 35), default_cell=default_cell) 85 | textbox_border.add_gadget(textbox) 86 | textbox_border.add_str("🔍 ", bg_color=NEPTUNE_PRIMARY_BG, pos=(1, 1)) 87 | textbox_border.canvas["bg_color"][1, 1] = NEPTUNE_PRIMARY_BG 88 | 89 | text_pad = BorderedOnFocusTextPad(pos=(1, 1), size=(13, 33)) 90 | text_pad.text = JABBERWOCKY 91 | text_pad_border = Text(pos=(6, 2), size=(15, 35), default_cell=default_cell) 92 | text_pad_border.add_gadget(text_pad) 93 | 94 | labels = Text( 95 | size=(22, 39), 96 | pos_hint={"y_hint": 0.5, "x_hint": 0.5}, 97 | default_cell=new_cell(fg_color=SECONDARY_FG, bg_color=SECONDARY_BG), 98 | ) 99 | labels.add_str("__Textbox__", pos=(1, 16), markdown=True) 100 | labels.add_str("__Text Pad__", pos=(5, 16), markdown=True) 101 | labels.add_gadgets(textbox_border, text_pad_border) 102 | self.add_gadget(labels) 103 | 104 | 105 | if __name__ == "__main__": 106 | TextPadApp(title="Text Input Example", bg_color=NEPTUNE_PRIMARY_BG).run() 107 | -------------------------------------------------------------------------------- /examples/basic/tiled.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | 4 | from batgrl.app import App 5 | from batgrl.gadgets.image import Image 6 | from batgrl.gadgets.tiled import Tiled 7 | 8 | ASSETS = Path(__file__).parent.parent / "assets" 9 | LOGO_PATH = ASSETS / "python_discord_logo.png" 10 | LOGO_FLAT = ASSETS / "logo_solo_flat_256.png" 11 | 12 | 13 | class TiledApp(App): 14 | async def on_start(self): 15 | tile_1 = Image(size=(10, 25), path=LOGO_PATH) 16 | tile_2 = Image(size=(9, 19), path=LOGO_FLAT) 17 | 18 | tiled_image = Tiled(size=(25, 50), tile=tile_1) 19 | 20 | self.add_gadget(tiled_image) 21 | 22 | await asyncio.sleep(5) 23 | 24 | tiled_image.tile = tile_2 25 | 26 | 27 | if __name__ == "__main__": 28 | TiledApp(title="Tile Example").run() 29 | -------------------------------------------------------------------------------- /examples/basic/video_in_terminal.py: -------------------------------------------------------------------------------- 1 | """ 2 | An example of how to play videos with batgrl. 3 | 4 | `Video.source` can be a `pathlib.Path` to a video, an URL as a string, or an int 5 | for a capturing device. 6 | """ 7 | 8 | from pathlib import Path 9 | 10 | from batgrl.app import App 11 | from batgrl.gadgets.video import Video 12 | 13 | ASSETS = Path(__file__).parent.parent / "assets" 14 | SPINNER = ASSETS / "spinner.gif" 15 | 16 | 17 | class VideoApp(App): 18 | async def on_start(self): 19 | half_video = Video( 20 | source=SPINNER, 21 | size_hint={"height_hint": 1.0, "width_hint": 0.5}, 22 | ) # Try `source=0` to capture a webcam. 23 | sixel_video = Video( 24 | source=SPINNER, 25 | size_hint={"height_hint": 1.0, "width_hint": 0.5}, 26 | pos_hint={"x_hint": 0.5, "anchor": "top-left"}, 27 | blitter="sixel", 28 | ) 29 | self.add_gadgets(half_video, sixel_video) 30 | half_video.play() 31 | sixel_video.play() 32 | 33 | 34 | if __name__ == "__main__": 35 | VideoApp(title="Video Example").run() 36 | -------------------------------------------------------------------------------- /examples/basic/windows.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import numpy as np 4 | from batgrl.app import App 5 | from batgrl.gadgets.color_picker import ColorPicker 6 | from batgrl.gadgets.image import Image 7 | from batgrl.gadgets.line_plot import LinePlot 8 | from batgrl.gadgets.text_pad import TextPad 9 | from batgrl.gadgets.video import Video 10 | from batgrl.gadgets.window import Window 11 | 12 | ASSETS = Path(__file__).parent.parent / "assets" 13 | SPINNER = ASSETS / "spinner.gif" 14 | BLISS = ASSETS / "bliss.png" 15 | 16 | XS = np.arange(20) 17 | 18 | YS_1 = np.random.randint(0, 100, 20) 19 | YS_2 = np.random.randint(0, 100, 20) 20 | YS_3 = np.random.randint(0, 100, 20) 21 | 22 | 23 | class WindowsApp(App): 24 | async def on_start(self): 25 | background = Image( 26 | path=BLISS, size_hint={"height_hint": 1.0, "width_hint": 1.0} 27 | ) 28 | window_kwargs = dict(size=(25, 50), alpha=0.8) 29 | 30 | animation = Video( 31 | source=SPINNER, 32 | interpolation="nearest", 33 | is_transparent=True, 34 | alpha=0.5, 35 | blitter="sixel", 36 | ) 37 | window_1 = Window(title=SPINNER.name, **window_kwargs) 38 | window_1.view = animation 39 | 40 | window_2 = Window(title="Note Pad", **window_kwargs) 41 | window_2.view = TextPad() 42 | 43 | window_3 = Window(title="Color Picker", **window_kwargs) 44 | window_3.view = ColorPicker(is_transparent=True, alpha=0.7) 45 | 46 | window_4 = Window(title="Line Plot", **window_kwargs) 47 | window_4.view = LinePlot( 48 | xs=[XS, XS, XS], 49 | ys=[YS_1, YS_2, YS_3], 50 | x_label="X Values", 51 | y_label="Y Values", 52 | legend_labels=("Before", "During", "After"), 53 | alpha=0.5, 54 | is_transparent=True, 55 | ) 56 | 57 | self.add_gadgets(background, window_1, window_2, window_3, window_4) 58 | animation.play() 59 | 60 | 61 | if __name__ == "__main__": 62 | WindowsApp(title="Windows Example").run() 63 | -------------------------------------------------------------------------------- /examples/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | extend = "../pyproject.toml" 3 | lint.extend-ignore = [ 4 | "D100", # Missing doc string in public module. 5 | "D101", # Missing doc string in public class. 6 | "D102", # Missing doc string in public method. 7 | "D103", # Missing doc string in public function. 8 | "D104", # Missing doc string in public package. 9 | ] 10 | -------------------------------------------------------------------------------- /preview_images/application.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/preview_images/application.gif -------------------------------------------------------------------------------- /preview_images/application.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/preview_images/application.png -------------------------------------------------------------------------------- /preview_images/game.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/preview_images/game.gif -------------------------------------------------------------------------------- /preview_images/game.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/preview_images/game.png -------------------------------------------------------------------------------- /preview_images/simulation.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/preview_images/simulation.gif -------------------------------------------------------------------------------- /preview_images/simulation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/preview_images/simulation.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "Cython>=3.0.3", 4 | "numpy>=2.0.0", 5 | "packaging>=25.0", 6 | "setuptools>=80.7.1", 7 | "uwcwidth>=1.0.0", 8 | ] 9 | build-backend = "setuptools.build_meta" 10 | 11 | [project] 12 | name = "batgrl" 13 | description = "badass terminal graphics library" 14 | readme = "README-PYPI.md" 15 | requires-python = ">=3.12" 16 | license = "MIT AND Apache-2.0" 17 | authors = [{name = "salt-die", email = "salt-die@protonmail.com"}] 18 | classifiers = [ 19 | "Programming Language :: Python :: 3", 20 | "Operating System :: OS Independent", 21 | ] 22 | dependencies = [ 23 | "mistletoe>=1.3.0", 24 | "numpy>=2.0.0", 25 | "opencv-python>=4.10.0", 26 | "Pygments>=2.17.2", 27 | "ugrapheme>=0.8", 28 | "uwcwidth>=1.0.0", 29 | ] 30 | dynamic = ["version"] 31 | 32 | [project.urls] 33 | "repository" = "https://github.com/salt-die/batgrl" 34 | "documentation" = "https://salt-die.github.io/batgrl/index.html" 35 | 36 | [tool.setuptools.dynamic] 37 | version = {attr = "batgrl.__version__"} 38 | 39 | [tool.ruff.lint] 40 | select = [ 41 | "D", # pydocstyle 42 | "F", # pyflakes 43 | "E", # pycodestyle - error 44 | "W", # pycodestyle - warning 45 | "I", # isort 46 | ] 47 | ignore = [ 48 | "D105", # undocumented-magic-method 49 | "D205", # blank-line-after-summary -- This rule seems bugged for summaries that need more than one line. 50 | ] 51 | fixable = ["ALL"] 52 | 53 | [tool.ruff.lint.pydocstyle] 54 | convention = "numpy" 55 | 56 | [tool.ruff.lint.pycodestyle] 57 | max-doc-length=88 58 | 59 | [tool.cython-lint] 60 | max-line-length = 88 61 | 62 | [tool.pyright] 63 | ignore = ["**/examples/*"] 64 | pythonPlatform = "All" 65 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Build cython extensions.""" 2 | 3 | import numpy as np 4 | from Cython.Build import cythonize 5 | from setuptools import setup 6 | 7 | setup( 8 | ext_modules=cythonize( 9 | [ 10 | "src/batgrl/_rendering.pyx", 11 | "src/batgrl/_sixel.pyx", 12 | "src/batgrl/gadgets/_raycasting.pyx", 13 | "src/batgrl/gadgets/_shadow_casting.pyx", 14 | "src/batgrl/geometry/regions.pyx", 15 | "src/batgrl/terminal/_fbuf.pyx", 16 | "src/batgrl/terminal/vt100_terminal.pyx", 17 | ] 18 | ), 19 | include_dirs=[np.get_include()], 20 | ) 21 | -------------------------------------------------------------------------------- /src/batgrl/__init__.py: -------------------------------------------------------------------------------- 1 | """batgrl, the badass terminal graphics library.""" 2 | 3 | __version__ = "0.47.1" 4 | -------------------------------------------------------------------------------- /src/batgrl/_rendering.pxd: -------------------------------------------------------------------------------- 1 | cdef packed struct Cell: 2 | unsigned long ord 3 | unsigned char style 4 | unsigned char[3] fg_color 5 | unsigned char[3] bg_color 6 | -------------------------------------------------------------------------------- /src/batgrl/_sixel.pxd: -------------------------------------------------------------------------------- 1 | from .terminal._fbuf cimport fbuf 2 | 3 | cdef: 4 | struct qnode: 5 | unsigned char[3] srgb 6 | unsigned long pop 7 | unsigned int qlink 8 | unsigned int cidx 9 | 10 | struct onode: 11 | (qnode *)[8] q 12 | 13 | struct qstate: 14 | qnode *qnodes 15 | onode *onodes 16 | unsigned int dynnodes_free 17 | unsigned int dynnodes_total 18 | unsigned onodes_free 19 | unsigned onodes_total 20 | unsigned long ncolors 21 | unsigned char *table 22 | 23 | int sixel( 24 | fbuf *f, 25 | qstate *qs, 26 | unsigned char[:, :, ::1] texture, 27 | unsigned char[:, :, ::1] stexture, 28 | unsigned int aspect_h, 29 | unsigned int aspect_w, 30 | size_t oy, 31 | size_t ox, 32 | size_t h, 33 | size_t w, 34 | ) 35 | 36 | class OctTree: 37 | cdef qstate qs 38 | -------------------------------------------------------------------------------- /src/batgrl/array_types.py: -------------------------------------------------------------------------------- 1 | """Type annotations for numpy arrays.""" 2 | 3 | from typing import Literal 4 | 5 | from numpy import dtype, float64, intc, ndarray, str_, uint8, ulong 6 | 7 | __all__ = [ 8 | "Cell", 9 | "Cell0D", 10 | "Cell1D", 11 | "Cell2D", 12 | "Coords", 13 | "Enum2D", 14 | "Float1D", 15 | "Float2D", 16 | "Int1D", 17 | "Int2D", 18 | "RGB_1D", 19 | "RGB_2D", 20 | "RGBA_1D", 21 | "RGBA_2D", 22 | "RGBM_2D", 23 | "ULong1D", 24 | "ULong2D", 25 | "Unicode1D", 26 | "Unicode2D", 27 | "cell_dtype", 28 | ] 29 | 30 | cell_dtype = dtype( 31 | [ 32 | ("ord", "ulong"), 33 | ("style", "u1"), 34 | ("fg_color", "u1", (3,)), 35 | ("bg_color", "u1", (3,)), 36 | ] 37 | ) 38 | """A structured array type that represents a single cell in a terminal.""" 39 | 40 | Cell = ndarray[tuple[int, ...], cell_dtype] 41 | """An array of ``cell_dtype``.""" 42 | 43 | Cell0D = ndarray[tuple[()], cell_dtype] 44 | """A 0-dimensional array of ``cell_dtype``.""" 45 | 46 | Cell1D = ndarray[tuple[int], cell_dtype] 47 | """A 1-dimensional array of ``cell_dtype``.""" 48 | 49 | Cell2D = ndarray[tuple[int, int], cell_dtype] 50 | """A 2-dimensional array of ``cell_dtype``.""" 51 | 52 | Float1D = ndarray[tuple[int], dtype[float64]] 53 | """A 1-dimensional array of floats.""" 54 | 55 | Float2D = ndarray[tuple[int, int], dtype[float64]] 56 | """A 2-dimensional array of floats.""" 57 | 58 | _Coord = ndarray[tuple[Literal[2]] | tuple[Literal[1], Literal[2]], dtype[float64]] 59 | """2-dimensional coordinates.""" 60 | 61 | Coords = ndarray[tuple[int, Literal[2]], dtype[float64]] 62 | """An array of 2-dimensional coordinates.""" 63 | 64 | Int1D = ndarray[tuple[int], dtype[intc]] 65 | """A 1-dimensional array of integers.""" 66 | 67 | Int2D = ndarray[tuple[int, int], dtype[intc]] 68 | """A 2-dimensional array of integers.""" 69 | 70 | ULong1D = ndarray[tuple[int], dtype[ulong]] 71 | """A 1-dimensional array of unsigned long.""" 72 | 73 | ULong2D = ndarray[tuple[int, int], dtype[ulong]] 74 | """A 2-dimensional array of unsigned long.""" 75 | 76 | RGB_1D = ndarray[tuple[int, Literal[3]], dtype[uint8]] 77 | """A 1-dimensional array of RGB 24-bit colors.""" 78 | 79 | RGB_2D = ndarray[tuple[int, int, Literal[3]], dtype[uint8]] 80 | """A 2-dimensional array of RGB 24-bit colors.""" 81 | 82 | RGBA_1D = ndarray[tuple[int, Literal[4]], dtype[uint8]] 83 | """A 1-dimensional array of RGBA 32-bit colors.""" 84 | 85 | RGBA_2D = ndarray[tuple[int, int, Literal[4]], dtype[uint8]] 86 | """A 2-dimensional array of RGBA 32-bit colors.""" 87 | 88 | Unicode1D = ndarray[tuple[int], dtype[str_]] 89 | """A 1-dimensional array of unicode characters.""" 90 | 91 | Unicode2D = ndarray[tuple[int, int], dtype[str_]] 92 | """A 2-dimensional array of unicode characters.""" 93 | 94 | # Rendering array types: 95 | 96 | Enum2D = ndarray[tuple[int, int], dtype[uint8]] 97 | """A 2-dimensional array of bytes used for enumeration.""" 98 | 99 | RGBM_2D = ndarray[tuple[int, int, Literal[4]], dtype[uint8]] 100 | """ 101 | A 2-dimensional array of RGB 24-bit colors plus a fourth channel ``M`` where non-zero 102 | values indicate opaque pixels and zeros indicate fully transparent pixels. 103 | """ 104 | -------------------------------------------------------------------------------- /src/batgrl/colors/__init__.py: -------------------------------------------------------------------------------- 1 | """Color-related functions and data structures.""" 2 | 3 | from .color_types import AColor, AHexcode, Color, ColorTheme, Hexcode 4 | from .colors import ( 5 | ABLACK, 6 | ABLUE, 7 | ACYAN, 8 | AGREEN, 9 | AMAGENTA, 10 | ARED, 11 | AWHITE, 12 | AYELLOW, 13 | BLACK, 14 | BLUE, 15 | CYAN, 16 | GREEN, 17 | MAGENTA, 18 | NEPTUNE_PRIMARY_BG, 19 | NEPTUNE_PRIMARY_FG, 20 | NEPTUNE_THEME, 21 | RED, 22 | TRANSPARENT, 23 | WHITE, 24 | YELLOW, 25 | Neptune, 26 | ) 27 | from .gradients import ( 28 | darken_only, 29 | gradient, 30 | lerp_colors, 31 | lighten_only, 32 | rainbow_gradient, 33 | ) 34 | 35 | __all__ = [ 36 | "ABLACK", 37 | "ABLUE", 38 | "ACYAN", 39 | "AGREEN", 40 | "AMAGENTA", 41 | "ARED", 42 | "AWHITE", 43 | "AYELLOW", 44 | "BLACK", 45 | "BLUE", 46 | "CYAN", 47 | "NEPTUNE_PRIMARY_BG", 48 | "NEPTUNE_PRIMARY_FG", 49 | "NEPTUNE_THEME", 50 | "GREEN", 51 | "MAGENTA", 52 | "RED", 53 | "TRANSPARENT", 54 | "WHITE", 55 | "YELLOW", 56 | "AColor", 57 | "AHexcode", 58 | "Color", 59 | "ColorTheme", 60 | "Hexcode", 61 | "Neptune", 62 | "darken_only", 63 | "gradient", 64 | "lerp_colors", 65 | "lighten_only", 66 | "rainbow_gradient", 67 | ] 68 | -------------------------------------------------------------------------------- /src/batgrl/colors/gradients.py: -------------------------------------------------------------------------------- 1 | """Functions for blending colors and creating color gradients.""" 2 | 3 | from itertools import pairwise 4 | from math import sin, tau 5 | 6 | from ..geometry import lerp 7 | from ..geometry.easings import EASINGS, Easing 8 | from .color_types import AColor, Color 9 | 10 | __all__ = ["darken_only", "gradient", "lerp_colors", "lighten_only", "rainbow_gradient"] 11 | 12 | 13 | def darken_only(a: Color, b: Color) -> Color: 14 | """ 15 | Return a color that is the minimum of each channel in ``a`` and ``b``. 16 | 17 | Parameters 18 | ---------- 19 | a : Color 20 | A color. 21 | b : Color 22 | A color. 23 | 24 | Returns 25 | ------- 26 | Color 27 | A color with smallest components of ``a`` and ``b``. 28 | """ 29 | color = (min(c1, c2) for c1, c2 in zip(a, b)) 30 | return Color(*color) 31 | 32 | 33 | def lighten_only(a: Color, b: Color) -> Color: 34 | """ 35 | Return a color that is the maximum of each channel in ``a`` and ``b``. 36 | 37 | Parameters 38 | ---------- 39 | a : Color 40 | A color. 41 | b : Color 42 | A color. 43 | 44 | Returns 45 | ------- 46 | Color 47 | A color with largest components of ``a`` and ``b``. 48 | """ 49 | color = (max(c1, c2) for c1, c2 in zip(a, b)) 50 | return Color(*color) 51 | 52 | 53 | def lerp_colors[T: (Color, AColor, tuple[int, ...])](a: T, b: T, p: float) -> T: 54 | """ 55 | Linear interpolation from ``a`` to ``b`` with proportion ``p``. 56 | 57 | Parameters 58 | ---------- 59 | a : (Color, AColor, tuple[int, ...]) 60 | A color. 61 | b : (Color, AColor, tuple[int, ...]) 62 | A color. 63 | p : float 64 | Proportion from a to b. 65 | 66 | Returns 67 | ------- 68 | (Color, AColor, tuple[int, ...]) 69 | The linear interpolation of ``a`` and ``b``. 70 | """ 71 | color = (round(lerp(c1, c2, p)) for c1, c2 in zip(a, b)) 72 | if isinstance(a, (Color, AColor)): 73 | return type(a)(*color) 74 | return tuple(color) # type: ignore 75 | 76 | 77 | def gradient[T: (Color, AColor, tuple[int, ...])]( 78 | *color_stops: T, n: int, easing: Easing = "linear" 79 | ) -> list[T]: 80 | r""" 81 | Return a smooth gradient of length ``n`` between all colors in ``color_stops``. 82 | 83 | Parameters 84 | ---------- 85 | \*color_stops : (Color, AColor, tuple[int, ...]) 86 | Colors between each gradient. 87 | n : int 88 | Length of gradient. Must be equal to or larger than ``color_stops``. 89 | easing : Easing, default: "linear" 90 | Easing applied to interpolations between ``color stops``. 91 | 92 | Returns 93 | ------- 94 | list[(Color, AColor, tuple[int, ...])] 95 | A smooth gradient between all colors in ``color_stops``. 96 | """ 97 | ncolors = len(color_stops) 98 | if n < ncolors: 99 | raise ValueError(f"gradient too small to contain all color stops ({n=})") 100 | if ncolors == 1: 101 | return [color_stops[0]] * n 102 | 103 | ease = EASINGS[easing] 104 | d, r = divmod(n - ncolors, ncolors - 1) 105 | gradient: list[T] = [] 106 | b = color_stops[0] 107 | for i, (a, b) in enumerate(pairwise(color_stops)): 108 | gradient.append(a) 109 | len_ = d + (i < r) 110 | gradient.extend( 111 | lerp_colors(a, b, ease((j + 1) / (len_ + 1))) for j in range(len_) 112 | ) 113 | gradient.append(b) 114 | return gradient 115 | 116 | 117 | def rainbow_gradient(n: int, *, alpha: int | None = None) -> list[Color] | list[AColor]: 118 | """ 119 | Return a rainbow gradient of ``n`` colors. 120 | 121 | Parameters 122 | ---------- 123 | n : int 124 | Number of colors in gradient. 125 | alpha : int | None, default: None 126 | If ``alpha`` is not given, gradient colors will have no alpha channel. 127 | Otherwise, the color's alpha channel is given by ``alpha``. 128 | 129 | Returns 130 | ------- 131 | list[Color | AColor] 132 | A rainbow gradient of colors. 133 | """ 134 | theta = tau / n 135 | offsets: list[float] = [0, tau / 3, 2 * tau / 3] 136 | 137 | def color(i: int): 138 | return Color(*(int(sin(i * theta + offset) * 127 + 128) for offset in offsets)) 139 | 140 | if alpha is None: 141 | return [color(i) for i in range(n)] 142 | 143 | return [AColor(*color(i), alpha) for i in range(n)] 144 | -------------------------------------------------------------------------------- /src/batgrl/gadgets/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | :mod:`batgrl` gadgets. 3 | 4 | Gadgets are basic UI components. :mod:`batgrl.gadgets` module contains classes and 5 | functions for creating and managing gadgets. 6 | """ 7 | -------------------------------------------------------------------------------- /src/batgrl/gadgets/behaviors/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Inheritable gadget behaviors. 3 | 4 | A `Behavior` is an inheritable class that modifies a gadget. 5 | 6 | It should be inherited *before* the base gadget, e.g.,:: 7 | 8 | class MovableImage(Movable, Image): ... 9 | 10 | Where `Movable` is a `Behavior` and `Image` is the base gadget. In this case, 11 | `MovableImage` is now an `Image` that can be moved around the terminal by clicking and 12 | dragging. 13 | """ 14 | 15 | from typing import cast 16 | 17 | from ..gadget import Gadget 18 | 19 | Behavior = cast(type[Gadget], object) 20 | """ 21 | A `Behavior` is an inheritable class that modifies a gadget. 22 | 23 | It should be inherited *before* the base gadget, e.g.,:: 24 | 25 | class MovableImage(Movable, Image): ... 26 | 27 | Where `Movable` is a `Behavior` and `Image` is the base gadget. In this case, 28 | `MovableImage` is now an `Image` that can be moved around the terminal by clicking and 29 | dragging. 30 | """ 31 | -------------------------------------------------------------------------------- /src/batgrl/gadgets/behaviors/button_behavior.py: -------------------------------------------------------------------------------- 1 | """Button behavior for a gadget.""" 2 | 3 | from typing import Literal 4 | 5 | from . import Behavior 6 | 7 | __all__ = ["ButtonBehavior", "ButtonState"] 8 | 9 | ButtonState = Literal["normal", "hover", "down", "disallowed"] 10 | """Button behavior states.""" 11 | 12 | 13 | class ButtonBehavior(Behavior): 14 | """ 15 | Button behavior for a gadget. 16 | 17 | A button has four states: "normal", "hover", "down", and "disallowed". 18 | 19 | When a button's state changes one of the following methods are called: 20 | - :meth:`update_normal` 21 | - :meth:`update_hover` 22 | - :meth:`update_down` 23 | - :meth:`update_disallowed` 24 | 25 | When a button is released, the :meth:`on_release` method is called. 26 | 27 | Parameters 28 | ---------- 29 | always_release : bool, default: False 30 | Whether a mouse up event outside the button will trigger it. 31 | 32 | Attributes 33 | ---------- 34 | always_release : bool 35 | Whether a mouse up event outside the button will trigger it. 36 | button_state : ButtonState 37 | Current button state. 38 | 39 | Methods 40 | ------- 41 | on_release() 42 | Triggered when a button is released. 43 | update_normal() 44 | Paint the normal state. 45 | update_hover() 46 | Paint the hover state. 47 | update_down() 48 | Paint the down state. 49 | update_disallowed() 50 | Paint the disallowed state. 51 | """ 52 | 53 | def __init__(self, *, always_release: bool = False, **kwargs): 54 | super().__init__(**kwargs) 55 | self.always_release = always_release 56 | self._button_state: ButtonState = "normal" 57 | 58 | @property 59 | def button_state(self) -> ButtonState: 60 | """Current button state.""" 61 | return self._button_state 62 | 63 | @button_state.setter 64 | def button_state(self, button_state: ButtonState): 65 | dispatch = { 66 | "normal": self.update_normal, 67 | "hover": self.update_hover, 68 | "down": self.update_down, 69 | "disallowed": self.update_disallowed, 70 | } 71 | if button_state not in dispatch: 72 | button_state = "normal" 73 | 74 | self._button_state = button_state 75 | if self.root: 76 | dispatch[button_state]() 77 | 78 | def on_add(self): 79 | """Paint normal state on add.""" 80 | super().on_add() 81 | self.update_normal() 82 | 83 | def on_mouse(self, mouse_event) -> bool | None: 84 | """Determine button state from mouse event.""" 85 | if self.button_state == "disallowed": 86 | return False 87 | 88 | if super().on_mouse(mouse_event): 89 | return True 90 | 91 | collides = self.collides_point(mouse_event.pos) 92 | 93 | if mouse_event.event_type == "mouse_down": 94 | if collides: 95 | self.button_state = "down" 96 | return True 97 | 98 | elif mouse_event.event_type == "mouse_up" and self.button_state == "down": 99 | if collides: 100 | self.on_release() 101 | self.button_state = "hover" 102 | return True 103 | 104 | self.button_state = "normal" 105 | 106 | if self.always_release: 107 | self.on_release() 108 | return True 109 | 110 | if not collides and self.button_state == "hover": 111 | self.button_state = "normal" 112 | elif collides and self.button_state == "normal": 113 | self.button_state = "hover" 114 | 115 | def on_release(self) -> None: 116 | """Triggered when button is released.""" 117 | 118 | def update_normal(self) -> None: 119 | """Paint the normal state.""" 120 | 121 | def update_hover(self) -> None: 122 | """Paint the hover state.""" 123 | 124 | def update_down(self) -> None: 125 | """Paint the down state.""" 126 | 127 | def update_disallowed(self) -> None: 128 | """Paint the disallowed state.""" 129 | -------------------------------------------------------------------------------- /src/batgrl/gadgets/behaviors/grabbable.py: -------------------------------------------------------------------------------- 1 | """Grabbable behavior for a gadget.""" 2 | 3 | from ...terminal.events import MouseButton, MouseEvent 4 | from . import Behavior 5 | 6 | __all__ = ["Grabbable"] 7 | 8 | 9 | class Grabbable(Behavior): 10 | """ 11 | Grabbable behavior for a gadget. 12 | 13 | Mouse down events that collide with the gadget will "grab" it, calling :meth:`grab`. 14 | While grabbed, each mouse event will call :meth:`grab_update` until the gadget is 15 | ungrabbed, i.e., a mouse up event is received (which calls :meth:`ungrab`). 16 | 17 | To customize grabbable behavior, implement any of :meth:`grab`, :meth:`grab_update`, 18 | or :meth:`ungrab`. 19 | 20 | Parameters 21 | ---------- 22 | is_grabbable : bool, default: True 23 | Whether grabbable behavior is enabled. 24 | ptf_on_grab : bool, default: False 25 | Whether the gadget will be pulled to front when grabbed. 26 | mouse_button : MouseButton, default: "left" 27 | Mouse button used for grabbing. 28 | 29 | Attributes 30 | ---------- 31 | is_grabbable : bool 32 | Whether grabbable behavior is enabled. 33 | ptf_on_grab : bool 34 | Whether the gadget will be pulled to front when grabbed. 35 | mouse_button : MouseButton 36 | Mouse button used for grabbing. 37 | is_grabbed : bool 38 | Whether gadget is grabbed. 39 | 40 | Methods 41 | ------- 42 | grab(mouse_event) 43 | Grab the gadget. 44 | ungrab(mouse_event) 45 | Ungrab the gadget. 46 | grab_update(mouse_event) 47 | Update gadget with incoming mouse events while grabbed. 48 | """ 49 | 50 | def __init__( 51 | self, 52 | *, 53 | is_grabbable: bool = True, 54 | ptf_on_grab: bool = False, 55 | mouse_button: MouseButton = "left", 56 | **kwargs, 57 | ) -> None: 58 | super().__init__(**kwargs) 59 | 60 | self.is_grabbable = is_grabbable 61 | self.ptf_on_grab = ptf_on_grab 62 | self.mouse_button = mouse_button 63 | self._is_grabbed = False 64 | 65 | def on_mouse(self, mouse_event) -> bool | None: 66 | """Determine if mouse event grabs or ungrabs gadget.""" 67 | if self.is_grabbable: 68 | if self.is_grabbed: 69 | if mouse_event.event_type == "mouse_up": 70 | self.ungrab(mouse_event) 71 | else: 72 | self.grab_update(mouse_event) 73 | 74 | return True 75 | 76 | if ( 77 | self.collides_point(mouse_event.pos) 78 | and mouse_event.event_type == "mouse_down" 79 | and mouse_event.button == self.mouse_button 80 | ): 81 | self.grab(mouse_event) 82 | return True 83 | 84 | return super().on_mouse(mouse_event) 85 | 86 | @property 87 | def is_grabbed(self) -> bool: 88 | """Whether gadget is grabbed.""" 89 | return self._is_grabbed 90 | 91 | def grab(self, mouse_event: MouseEvent) -> None: 92 | """ 93 | Grab gadget. 94 | 95 | Parameters 96 | ---------- 97 | mouse_event : MouseEvent 98 | The mouse event that grabbed the gadget. 99 | """ 100 | self._is_grabbed = True 101 | 102 | if self.ptf_on_grab: 103 | self.pull_to_front() 104 | 105 | def ungrab(self, mouse_event: MouseEvent) -> None: 106 | """ 107 | Ungrab gadget. 108 | 109 | Parameters 110 | ---------- 111 | mouse_event : MouseEvent 112 | The mouse event that ungrabbed the gadget. 113 | """ 114 | self._is_grabbed = False 115 | 116 | def grab_update(self, mouse_event: MouseEvent) -> None: 117 | """ 118 | Update grabbed gadget with incoming mouse event. 119 | 120 | Parameters 121 | ---------- 122 | mouse_event : MouseEvent 123 | The mouse event that updates the grabbed gadget. 124 | """ 125 | -------------------------------------------------------------------------------- /src/batgrl/gadgets/behaviors/movable.py: -------------------------------------------------------------------------------- 1 | """Movable behavior for a gadget.""" 2 | 3 | from ...geometry import clamp 4 | from ...terminal.events import MouseButton 5 | from .grabbable import Grabbable 6 | 7 | __all__ = ["Movable"] 8 | 9 | 10 | class Movable(Grabbable): 11 | """ 12 | Movable behavior for a gadget. 13 | 14 | Translate a gadget by clicking and dragging it. 15 | 16 | Parameters 17 | ---------- 18 | allow_oob : bool, default: False 19 | Whether gadget can be dragged out of parent's bounding box. 20 | allow_vertical_translation : bool, default: True 21 | Whether to allow vertical translation. 22 | allow_horizontal_translation : bool, default: True 23 | Whether to allow horizontal translation. 24 | is_grabbable : bool, default: True 25 | Whether grabbable behavior is enabled. 26 | ptf_on_grab : bool, default: False 27 | Whether the gadget will be pulled to front when grabbed. 28 | mouse_button : MouseButton, default: "left" 29 | Mouse button used for grabbing. 30 | 31 | Attributes 32 | ---------- 33 | allow_oob : bool 34 | Whether gadget can be dragged out of parent's bounding box. 35 | allow_vertical_translation : bool 36 | Whether to allow vertical translation. 37 | allow_horizontal_translation : bool 38 | Whether to allow horizontal translation. 39 | is_grabbable : bool 40 | Whether grabbable behavior is enabled. 41 | ptf_on_grab : bool 42 | Whether the gadget will be pulled to front when grabbed. 43 | mouse_button : MouseButton 44 | Mouse button used for grabbing. 45 | is_grabbed : bool 46 | Whether gadget is grabbed. 47 | 48 | Methods 49 | ------- 50 | grab(mouse_event) 51 | Grab the gadget. 52 | ungrab(mouse_event) 53 | Ungrab the gadget. 54 | grab_update(mouse_event) 55 | Update gadget with incoming mouse events while grabbed. 56 | """ 57 | 58 | def __init__( 59 | self, 60 | *, 61 | allow_oob=True, 62 | allow_vertical_translation=True, 63 | allow_horizontal_translation=True, 64 | is_grabbable: bool = True, 65 | ptf_on_grab: bool = False, 66 | mouse_button: MouseButton = "left", 67 | **kwargs, 68 | ) -> None: 69 | super().__init__( 70 | is_grabbable=is_grabbable, 71 | ptf_on_grab=ptf_on_grab, 72 | mouse_button=mouse_button, 73 | **kwargs, 74 | ) 75 | self.allow_oob: bool = allow_oob 76 | """Whether gadget can be dragged out of parent's bounding box.""" 77 | self.allow_vertical_translation: bool = allow_vertical_translation 78 | """Whether to allow vertical translation.""" 79 | self.allow_horizontal_translation: bool = allow_horizontal_translation 80 | """Whether to allow horizontal translation.""" 81 | 82 | def grab_update(self, mouse_event) -> None: 83 | """Translate movable on grab update.""" 84 | if self.allow_vertical_translation: 85 | self.top += mouse_event.dy 86 | if self.allow_horizontal_translation: 87 | self.left += mouse_event.dx 88 | 89 | if self.parent is None: 90 | # Should be unreachable. 91 | return 92 | 93 | if not self.allow_oob: 94 | self.top = clamp(self.top, 0, self.parent.height - self.height) 95 | self.left = clamp(self.left, 0, self.parent.width - self.width) 96 | -------------------------------------------------------------------------------- /src/batgrl/gadgets/behaviors/movable_children.py: -------------------------------------------------------------------------------- 1 | """ 2 | Movable children behavior for a gadget. 3 | 4 | Translate movable's children by dragging them. 5 | """ 6 | 7 | from ...geometry import clamp 8 | from ...terminal.events import MouseButton 9 | from .grabbable import Grabbable 10 | 11 | __all__ = ["MovableChildren"] 12 | 13 | 14 | class MovableChildren(Grabbable): 15 | """ 16 | Movable children behavior for a gadget. 17 | 18 | Translate a gadget's child by clicking and dragging it. 19 | 20 | Parameters 21 | ---------- 22 | allow_child_oob : bool, default: True 23 | Whether child gadgets can be dragged out of parent's bounding box. 24 | ptf_child_on_grab : bool, default: False 25 | Whether child gadgets are pulled-to-front when clicked. 26 | is_grabbable : bool, default: True 27 | Whether grabbable behavior is enabled. 28 | ptf_on_grab : bool, default: False 29 | Whether the gadget will be pulled to front when grabbed. 30 | mouse_button : MouseButton, default: "left" 31 | Mouse button used for grabbing. 32 | 33 | Attributes 34 | ---------- 35 | allow_child_oob : bool 36 | Whether child gadgets can be dragged out of parent's bounding box. 37 | ptf_child_on_grab : bool 38 | Whether child gadgets are pulled-to-front when clicked. 39 | is_grabbable : bool 40 | Whether grabbable behavior is enabled. 41 | ptf_on_grab : bool 42 | Whether the gadget will be pulled to front when grabbed. 43 | mouse_button : MouseButton 44 | Mouse button used for grabbing. 45 | is_grabbed : bool 46 | Whether gadget is grabbed. 47 | 48 | Methods 49 | ------- 50 | grab(mouse_event) 51 | Grab the gadget. 52 | ungrab(mouse_event) 53 | Ungrab the gadget. 54 | grab_update(mouse_event) 55 | Update gadget with incoming mouse events while grabbed. 56 | """ 57 | 58 | def __init__( 59 | self, 60 | *, 61 | allow_child_oob=True, 62 | ptf_child_on_grab=False, 63 | is_grabbable: bool = True, 64 | ptf_on_grab: bool = False, 65 | mouse_button: MouseButton = "left", 66 | **kwargs, 67 | ): 68 | super().__init__( 69 | is_grabbable=is_grabbable, 70 | ptf_on_grab=ptf_on_grab, 71 | mouse_button=mouse_button, 72 | **kwargs, 73 | ) 74 | self.allow_child_oob: bool = allow_child_oob 75 | """Whether child gadgets can be dragged out of parent's bounding box.""" 76 | self.ptf_child_on_grab: bool = ptf_child_on_grab 77 | """Whether child gadgets are pulled-to-front when clicked.""" 78 | self._grabbed_child = None 79 | 80 | def grab(self, mouse_event) -> None: 81 | """Grab the gadget.""" 82 | for child in reversed(self.children): 83 | if child.collides_point(mouse_event.pos): 84 | self._is_grabbed = True 85 | self._grabbed_child = child 86 | 87 | if self.ptf_child_on_grab: 88 | child.pull_to_front() 89 | 90 | break 91 | else: 92 | super().grab(mouse_event) 93 | 94 | def ungrab(self, mouse_event) -> None: 95 | """Ungrab the gadget.""" 96 | self._grabbed_child = None 97 | super().ungrab(mouse_event) 98 | 99 | def grab_update(self, mouse_event) -> None: 100 | """Update gadget with incoming mouse events while grabbed.""" 101 | if grabbed_child := self._grabbed_child: 102 | h, w = self.size 103 | ch, cw = grabbed_child.size 104 | ct, cl = grabbed_child.pos 105 | 106 | if self.allow_child_oob: 107 | grabbed_child.top = ct + mouse_event.dy 108 | grabbed_child.left = cl + mouse_event.dx 109 | else: 110 | grabbed_child.top = clamp(ct + mouse_event.dy, 0, h - ch) 111 | grabbed_child.left = clamp(cl + mouse_event.dx, 0, w - cw) 112 | else: 113 | super().grab_update(mouse_event) 114 | -------------------------------------------------------------------------------- /src/batgrl/gadgets/behaviors/themable.py: -------------------------------------------------------------------------------- 1 | """Themable behavior for gadgets.""" 2 | 3 | from abc import ABC, abstractmethod 4 | 5 | from ...colors import NEPTUNE_THEME, Color, ColorTheme, Hexcode 6 | from . import Behavior 7 | 8 | __all__ = ["Themable"] 9 | 10 | 11 | class Themable(ABC, Behavior): 12 | """ 13 | Themable behavior for a gadget. 14 | 15 | Themable gadgets share a color theme. They must implement :meth:`update_theme` 16 | which paints the gadget with current theme. 17 | 18 | Whenever the running app's theme is changed, `update_theme` will be called 19 | for all :class:`Themable` gadgets. 20 | 21 | Methods 22 | ------- 23 | get_color() 24 | Get a color by name from the current color theme. 25 | update_theme() 26 | Paint the gadget with current theme. 27 | """ 28 | 29 | color_theme: ColorTheme 30 | 31 | @abstractmethod 32 | def update_theme(self) -> None: 33 | """Paint the gadget with current theme.""" 34 | 35 | @classmethod 36 | def get_color(cls, color_name: str) -> Color: 37 | """Get a color by name from the current color theme.""" 38 | hexcode: Hexcode 39 | if color_name not in cls.color_theme: 40 | if color_name not in NEPTUNE_THEME: 41 | raise KeyError(f"There is no color {color_name!r}.") 42 | hexcode = NEPTUNE_THEME[color_name] 43 | else: 44 | hexcode = cls.color_theme[color_name] 45 | return Color.from_hex(hexcode) 46 | 47 | def on_add(self) -> None: 48 | """Update theme.""" 49 | super().on_add() 50 | self.update_theme() 51 | -------------------------------------------------------------------------------- /src/batgrl/gadgets/text_effects/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Text effects are coroutines that create some effect on a text gadget. 3 | 4 | This module is an approximate recreation of some effects from terminaltexteffects. 5 | 6 | References 7 | ---------- 8 | https://github.com/ChrisBuilds/terminaltexteffects 9 | 10 | Warnings 11 | -------- 12 | Modifying the text size while effect is running will break the effect. 13 | """ 14 | 15 | from .beams import beams_effect 16 | from .black_hole import black_hole_effect 17 | from .ring import ring_effect 18 | from .spotlights import spotlights_effect 19 | 20 | __all__ = ["beams_effect", "black_hole_effect", "ring_effect", "spotlights_effect"] 21 | -------------------------------------------------------------------------------- /src/batgrl/gadgets/text_effects/_particle.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterator 2 | from dataclasses import dataclass 3 | from typing import Self 4 | 5 | from ...geometry.motion import Coord, HasPosProp 6 | from ...text_tools import Cell0D 7 | from ..text_field import Point, TextParticleField 8 | 9 | 10 | @dataclass 11 | class Particle(HasPosProp): 12 | """ 13 | A wrapper around an index into a text particle field's particle arrays. 14 | 15 | Parameters 16 | ---------- 17 | field : TextParticleField 18 | The field which the particle belongs. 19 | index : int 20 | The index of the particle in the field. 21 | 22 | Attributes 23 | ---------- 24 | field : TextParticleField 25 | The field which the particle belongs. 26 | index : int 27 | The index of the particle in the field. 28 | cell : Cell0D 29 | The particle's cell. 30 | pos : Point 31 | The particle's position. 32 | 33 | Methods 34 | ------- 35 | iter_from_field(field) 36 | Yield all particles from a text particle field. 37 | """ 38 | 39 | field: TextParticleField 40 | index: int 41 | 42 | @property 43 | def cell(self) -> Cell0D: 44 | """The particle's cell.""" 45 | return self.field.particle_cells[self.index] 46 | 47 | @cell.setter 48 | def cell(self, cell: Cell0D): 49 | self.field.particle_cells[self.index] = cell 50 | 51 | @property 52 | def pos(self) -> Point: 53 | """The particle's position.""" 54 | return Point(*self.field.particle_coords[self.index].tolist()) 55 | 56 | @pos.setter 57 | def pos(self, pos: Coord): 58 | self.field.particle_coords[self.index] = pos 59 | 60 | @classmethod 61 | def iter_from_field(cls, field: TextParticleField) -> Iterator[Self]: 62 | """Yield all particles from a text particle field.""" 63 | for i in range(len(field.particle_coords)): 64 | yield cls(field, i) 65 | -------------------------------------------------------------------------------- /src/batgrl/geometry/__init__.py: -------------------------------------------------------------------------------- 1 | """Data structures and functions for :mod:`batgrl` geometry.""" 2 | 3 | from .basic import ( 4 | Point, 5 | Pointlike, 6 | Size, 7 | Sizelike, 8 | clamp, 9 | lerp, 10 | points_on_circle, 11 | rect_slice, 12 | round_down, 13 | ) 14 | from .easings import EASINGS, Easing 15 | from .motion import BezierCurve, move_along_path 16 | from .regions import Region 17 | 18 | __all__ = [ 19 | "EASINGS", 20 | "BezierCurve", 21 | "Easing", 22 | "Point", 23 | "Pointlike", 24 | "Region", 25 | "Size", 26 | "Sizelike", 27 | "clamp", 28 | "lerp", 29 | "move_along_path", 30 | "points_on_circle", 31 | "rect_slice", 32 | "round_down", 33 | ] 34 | -------------------------------------------------------------------------------- /src/batgrl/geometry/regions.pxd: -------------------------------------------------------------------------------- 1 | cdef struct Band: 2 | int y1, y2 3 | size_t size, len 4 | int *walls 5 | 6 | 7 | cdef struct CRegion: 8 | size_t size, len 9 | Band *bands 10 | 11 | 12 | cdef class Region: 13 | cdef CRegion cregion 14 | 15 | 16 | cdef bint contains(CRegion *cregion, int y, int x) 17 | cdef void bounding_rect(CRegion *cregion, int *y, int *x, size_t *h, size_t *w) 18 | -------------------------------------------------------------------------------- /src/batgrl/geometry/regions.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | Functions and classes for determining gadget regions. 3 | 4 | Notes 5 | ----- 6 | A gadget's region is calculated as a first step in compositing to determine its visible 7 | area in the terminal. 8 | 9 | Let's say we have the following gadgets: 10 | 11 | .. code-block:: text 12 | 13 | +--------+------+---------+ 14 | | | B | | 15 | | +------+ | 16 | | | 17 | | A +-------+ 18 | | | C | 19 | +----------------------+-------+ 20 | 21 | And we want to represent the visible region of ``A``: 22 | 23 | .. code-block:: text 24 | 25 | +--------+ +---------+ 26 | | | | | 27 | | +------+ | 28 | | | 29 | | +--+ 30 | | | 31 | +----------------------+ 32 | 33 | One method is to divide the area into a series of mutually exclusive horizontal bands: 34 | 35 | .. code-block:: text 36 | 37 | +--------+ +---------+ 38 | | a | b | c | d - Top band with walls at a, b, c, d 39 | |--------+------+---------+ 40 | | e | f - Middle band with walls at e, f 41 | +----------------------+--+ 42 | | g | h - Bottom band with walls at g, h 43 | +----------------------+ 44 | 45 | Walls are the x-coordinates of the rects in a band. Two contiguous walls indicate a new 46 | rect. Bands are a sorted list of walls with each band having a top y-coordinate and 47 | bottom y-coordinate. And finally, Regions are a sorted list of non-intersecting Bands. 48 | """ 49 | 50 | from collections.abc import Iterator 51 | 52 | from .basic import Point, Pointlike, Size, Sizelike 53 | 54 | __all__ = ["Region"] 55 | 56 | class Region: 57 | """ 58 | Collection of mutually exclusive bands of rects. 59 | 60 | Methods 61 | ------- 62 | rects() 63 | Yield position and size of rects that make up the region. 64 | from_rect(pos, size) 65 | Return a new region from a rect position and size. 66 | """ 67 | 68 | def __and__(self, other: Region) -> Region: 69 | """Return the intersection of self and other.""" 70 | 71 | def __or__(self, other: Region) -> Region: 72 | """Return the union of self and other.""" 73 | 74 | def __add__(self, other: Region) -> Region: 75 | """Return the union of self and other.""" 76 | 77 | def __sub__(self, other: Region) -> Region: 78 | """Return the difference of self and other.""" 79 | 80 | def __xor__(self, other: Region) -> Region: 81 | """Return the symmetric difference of self and other.""" 82 | 83 | def __bool__(self) -> bool: 84 | """Whether region is non-empty.""" 85 | 86 | def __eq__(self, other: object) -> bool: 87 | """Whether two regions are equal.""" 88 | 89 | def __contains__(self, point: Point) -> bool: 90 | """Return whether point is in region.""" 91 | 92 | def rects(self) -> Iterator[tuple[Point, Size]]: 93 | """ 94 | Yield position and size of rects that make up the region. 95 | 96 | Yields 97 | ------ 98 | tuple[Point, Size] 99 | A position and size of a rect in the region. 100 | """ 101 | 102 | @classmethod 103 | def from_rect(cls, pos: Pointlike, size: Sizelike) -> Region: 104 | """ 105 | Return a region from a rect position and size. 106 | 107 | Returns 108 | ------- 109 | Region 110 | A new region. 111 | """ 112 | -------------------------------------------------------------------------------- /src/batgrl/logging.py: -------------------------------------------------------------------------------- 1 | """ 2 | Logging related classes and functions. 3 | 4 | Because default logging opens stdout/stderr which disables batgrl output, logging must 5 | be setup with a file handler instead. To ensure a file handler has been added, all 6 | library logging should use ``get_logger`` (essentially, just an alias of stdlib 7 | ``logging.getLogger``) from this module. 8 | 9 | Additional log levels, ``ANSI`` and ``EVENTS``, are added to standard logging levels. 10 | """ 11 | # Inspiration taken from: 12 | # . 13 | # python-discord/bot-core is licensed under the MIT License. 14 | 15 | import logging 16 | import typing 17 | from enum import IntEnum 18 | from pathlib import Path 19 | from typing import Final 20 | 21 | __all__ = ["LogLevel", "get_logger"] 22 | 23 | LOG_FILE = Path() / "batgrl.log" # TODO: Customize path. 24 | ANSI_LEVEL: Final = 3 25 | """ 26 | ``ANSI`` log level. 27 | 28 | Show generated ANSI in logs. 29 | """ 30 | EVENTS_LEVEL: Final = 5 31 | """ 32 | ``EVENTS`` log level. 33 | 34 | Show generated input events. 35 | """ 36 | 37 | 38 | class LogLevel(IntEnum): 39 | """Standard logging levels with additional ``ANSI`` and ``EVENTS`` levels.""" 40 | 41 | ANSI = ANSI_LEVEL 42 | """Show generated ANSI in logs.""" 43 | EVENTS = EVENTS_LEVEL 44 | """Show generated input events.""" 45 | DEBUG = logging.DEBUG 46 | INFO = logging.INFO 47 | WARNING = logging.WARNING 48 | ERROR = logging.ERROR 49 | CRITICAL = logging.CRITICAL 50 | 51 | 52 | if typing.TYPE_CHECKING: 53 | LoggerClass = logging.Logger 54 | else: 55 | LoggerClass = logging.getLoggerClass() 56 | 57 | 58 | class CustomLogger(LoggerClass): 59 | """A standard logger with methods for ``ANSI`` and ``EVENTS`` logging.""" 60 | 61 | def ansi(self, msg: str, *args, **kwargs) -> None: 62 | """Log the given message with the severity ``"ANSI"``.""" 63 | if self.isEnabledFor(ANSI_LEVEL): 64 | self.log(ANSI_LEVEL, msg, *args, **kwargs) 65 | 66 | def events(self, msg: str, *args, **kwargs) -> None: 67 | """Log the given message with the severity ``"EVENTS"``.""" 68 | if self.isEnabledFor(EVENTS_LEVEL): 69 | self.log(EVENTS_LEVEL, msg, *args, **kwargs) 70 | 71 | def is_enabled_for(self, log_level: LogLevel | str) -> bool: 72 | """Similar to ``Logger.isEnabledFor``, but also accepts level names.""" 73 | if isinstance(log_level, str): 74 | return self.isEnabledFor(logging._nameToLevel[log_level]) 75 | return self.isEnabledFor(log_level) 76 | 77 | 78 | def get_logger(name: str | None = None) -> CustomLogger: 79 | """ 80 | Return a logger with a specified name. 81 | 82 | Because there are several entry points into batgrl and default logging opens 83 | stderr/stdout which will disable batgrl output, all library logging must use this 84 | alias of ``logging.getLogger`` to ensure logging has been set to output to a file. 85 | 86 | Parameters 87 | ---------- 88 | name : str | None, default: None 89 | Name of logger. If not specified, return the root logger. 90 | 91 | Returns 92 | ------- 93 | CustomLogger 94 | The specified logger. 95 | """ 96 | return typing.cast(CustomLogger, logging.getLogger(name)) 97 | 98 | 99 | logging.ANSI_LEVEL: Final = ANSI_LEVEL # type: ignore 100 | logging.EVENTS: Final = EVENTS_LEVEL # type: ignore 101 | logging.addLevelName(ANSI_LEVEL, "ANSI") 102 | logging.addLevelName(EVENTS_LEVEL, "EVENTS") 103 | logging.setLoggerClass(CustomLogger) 104 | 105 | log_format = logging.Formatter( 106 | "{asctime} | {levelname} | {name} | {message}", style="{" 107 | ) 108 | 109 | log_handler = logging.FileHandler(LOG_FILE, mode="w", encoding="utf-8") 110 | log_handler.setFormatter(log_format) 111 | 112 | logger = logging.getLogger("batgrl") 113 | logger.addHandler(log_handler) 114 | logger.setLevel(LogLevel.INFO) 115 | -------------------------------------------------------------------------------- /src/batgrl/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/src/batgrl/py.typed -------------------------------------------------------------------------------- /src/batgrl/terminal/_fbuf.pxd: -------------------------------------------------------------------------------- 1 | """A growable string buffer.""" 2 | 3 | cdef extern from "_fbuf.h": 4 | ctypedef unsigned long uint32_t 5 | ctypedef unsigned long long uint64_t 6 | 7 | struct fbuf: 8 | uint64_t size, len 9 | char *buf 10 | 11 | ssize_t write(ssize_t, const void*, size_t) 12 | ssize_t fbuf_init(fbuf *f) 13 | ssize_t fbuf_small_init(fbuf *f) 14 | void fbuf_free(fbuf *f) 15 | ssize_t fbuf_grow(fbuf *f, size_t n) 16 | ssize_t fbuf_put_char(fbuf *f, const char s) 17 | ssize_t fbuf_putn(fbuf *f, const char *s, size_t len) 18 | ssize_t fbuf_puts(fbuf *f, const char *s) 19 | ssize_t fbuf_printf(fbuf *f, const char *fmt, ...) 20 | ssize_t fbuf_putucs4(fbuf *f, uint32_t wc) 21 | unsigned int fbuf_equals(fbuf *f, const char *string, size_t len) 22 | unsigned int fbuf_endswith(fbuf *f, const char *suffix, size_t len) 23 | ssize_t fbuf_flush_fd(fbuf *f, int fd) 24 | ssize_t fbuf_read_fd(fbuf *f, int fd, int *size) 25 | -------------------------------------------------------------------------------- /src/batgrl/terminal/_fbuf.pyx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salt-die/batgrl/5788b0885d98ddd3b7437a9b2b70f0bd508c88a6/src/batgrl/terminal/_fbuf.pyx -------------------------------------------------------------------------------- /src/batgrl/terminal/vt100_terminal.pxd: -------------------------------------------------------------------------------- 1 | from ._fbuf cimport fbuf 2 | from .events import Event 3 | 4 | ctypedef unsigned char uint8 5 | ctypedef unsigned int uint 6 | ctypedef enum ParserState: 7 | CSI, 8 | CSI_PARAMS, 9 | DECRPM, 10 | ESCAPE, 11 | EXECUTE_NEXT, 12 | GROUND, 13 | OSC, 14 | PASTE, 15 | 16 | 17 | cdef class Vt100Terminal: 18 | cdef: 19 | fbuf read_buf, in_buf, out_buf 20 | ParserState state 21 | int last_y, last_x 22 | bint skip_newline 23 | bint sum_supported 24 | bint sgr_pixels_mode 25 | 26 | cdef void add_event(Vt100Terminal, Event) 27 | cdef void feed1(Vt100Terminal, uint8) 28 | cdef void execute_ansi_escapes(Vt100Terminal) 29 | cdef void execute_csi(Vt100Terminal) 30 | cdef void execute_csi_params(Vt100Terminal) 31 | cdef void execute_mouse(Vt100Terminal, uint*, char) 32 | cdef void execute_osc(Vt100Terminal) 33 | cdef void execute_decrpm(Vt100Terminal) 34 | cdef void dsr_request(Vt100Terminal, bytes) 35 | cpdef void process_stdin(Vt100Terminal) 36 | -------------------------------------------------------------------------------- /src/batgrl/texture_tools.py: -------------------------------------------------------------------------------- 1 | """Tools for graphics.""" 2 | 3 | from pathlib import Path 4 | from typing import Literal 5 | 6 | import cv2 7 | import numpy as np 8 | 9 | from .array_types import RGBA_2D 10 | from .geometry import Point, Region, Sizelike, rect_slice 11 | 12 | __all__ = ["Interpolation", "composite", "read_texture", "resize_texture"] 13 | 14 | Interpolation = Literal["nearest", "linear", "cubic", "area", "lanczos"] 15 | """Interpolation methods for resizing graphic gadgets.""" 16 | 17 | _INTERPOLATION_TO_CV_ENUM = { 18 | "linear": cv2.INTER_LINEAR, 19 | "cubic": cv2.INTER_CUBIC, 20 | "area": cv2.INTER_AREA, 21 | "lanczos": cv2.INTER_LANCZOS4, 22 | "nearest": cv2.INTER_NEAREST, 23 | } 24 | 25 | 26 | def read_texture(path: Path) -> RGBA_2D: 27 | """ 28 | Return a uint8 RGBA numpy array from a path to an image. 29 | 30 | Parameters 31 | ---------- 32 | path : Path 33 | Path to image. 34 | 35 | Returns 36 | ------- 37 | RGBA_2D 38 | An uint8 RGBA array of the image. 39 | """ 40 | image = cv2.imread(str(path.absolute()), cv2.IMREAD_UNCHANGED) 41 | 42 | if image.dtype == np.dtype(np.uint16): 43 | image = (image // 257).astype(np.uint8) 44 | elif image.dtype == np.dtype(np.float32): 45 | image = (image * 255).astype(np.uint8) 46 | 47 | # Add an alpha channel if there isn't one. 48 | h, w, c = image.shape 49 | if c == 3: 50 | default_alpha_channel = np.full((h, w, 1), 255, dtype=np.uint8) 51 | image = np.dstack((image, default_alpha_channel)) 52 | 53 | return cv2.cvtColor(image, cv2.COLOR_BGRA2RGBA) # type: ignore 54 | 55 | 56 | def resize_texture( 57 | texture: RGBA_2D, 58 | size: Sizelike, 59 | interpolation: Interpolation = "linear", 60 | out: RGBA_2D | None = None, 61 | ) -> RGBA_2D: 62 | """ 63 | Resize texture. 64 | 65 | Parameters 66 | ---------- 67 | texture : RGBA_2D 68 | An RGBA texture to resize. 69 | size : Sizelike 70 | The new size of the texture. 71 | interpolation : Interpolation, default: "linear" 72 | Interpolation used when resizing texture. 73 | out : RGBA_2D | None, default: None 74 | Optional output array. If None, a new array is created. 75 | 76 | Returns 77 | ------- 78 | RGBA_2D 79 | A new uint8 RGBA array. 80 | """ 81 | old_h, old_w = texture.shape[:2] 82 | h, w = size 83 | if old_h == 0 or old_w == 0 or h == 0 or w == 0: 84 | return np.zeros((h, w, 4), np.uint8) 85 | return cv2.resize( 86 | texture, (w, h), dst=out, interpolation=_INTERPOLATION_TO_CV_ENUM[interpolation] 87 | ) # type: ignore 88 | 89 | 90 | def composite( 91 | source: RGBA_2D, 92 | dest: RGBA_2D, 93 | pos: Point = Point(0, 0), 94 | mask_mode: bool = False, 95 | ) -> None: 96 | """ 97 | Composite source texture onto destination texture at given position. 98 | 99 | If `mask_mode` is true, source alpha values less than 255 are ignored. 100 | 101 | Parameters 102 | ---------- 103 | source : RGBA_2D 104 | The texture to composite. 105 | dest : RGBA_2D 106 | The texture on which the source is painted. 107 | pos : Pointlike, default: Point(0, 0) 108 | Position of the source on the destination. 109 | mask_mode : bool, default: False 110 | Whether to ignore alpha values less than 255. 111 | """ 112 | sh, sw, _ = source.shape 113 | dh, dw, _ = dest.shape 114 | 115 | dest_reg = Region.from_rect((0, 0), (dh, dw)) 116 | source_reg = Region.from_rect(pos, (sh, sw)) 117 | 118 | if intersection := source_reg & dest_reg: 119 | rpos, size = next(intersection.rects()) 120 | dest_tex = dest[rect_slice(rpos, size)] 121 | source_tex = source[rect_slice(rpos - pos, size)] 122 | source_alpha = source_tex[..., 3] 123 | 124 | if mask_mode: 125 | mask = source_alpha == 255 126 | dest_tex[mask] = source_tex[mask] 127 | else: 128 | buffer = np.subtract(source_tex, dest_tex, dtype=float) 129 | buffer *= source_alpha[..., None] 130 | buffer /= 255 131 | np.add(buffer, dest_tex, out=dest_tex, casting="unsafe") 132 | --------------------------------------------------------------------------------