├── tests ├── __init__.py ├── cli │ ├── __init__.py │ ├── test_help.py │ └── test_version.py ├── hooks │ ├── __init__.py │ └── test_use_state.py ├── utils │ ├── __init__.py │ ├── test_forever.py │ ├── test_maybe_await.py │ ├── test_drain_queue.py │ ├── test_halve_integer.py │ ├── test_unordered_range.py │ ├── test_cancel.py │ └── test_partition_int.py ├── controls │ ├── __init__.py │ └── test_screenshot.py ├── elements │ ├── __init__.py │ └── test_text.py ├── inputs │ ├── __init__.py │ ├── test_on_key.py │ └── test_on_mouse.py ├── output │ ├── __init__.py │ ├── test_sgr_from_cell_style.py │ └── test_move_to.py ├── styles │ ├── __init__.py │ ├── test_utilities.py │ └── test_merging.py ├── conftest.py ├── test_app.py └── test_vt_parsing.py ├── codegen └── __init__.py ├── counterweight ├── py.typed ├── __main__.py ├── __init__.py ├── constants.py ├── types.py ├── hooks │ ├── types.py │ ├── __init__.py │ ├── impls.py │ └── hooks.py ├── styles │ └── __init__.py ├── _context_vars.py ├── components.py ├── logging.py ├── output.py ├── elements.py ├── events.py ├── input.py ├── border_healing.py ├── cli.py ├── geometry.py ├── controls.py ├── _utils.py ├── shadow.py └── keys.py ├── examples ├── __init__.py ├── borders.py ├── border_healing.py ├── table.py ├── stopwatch.py ├── suspend.py ├── canvas.py ├── end_to_end.py ├── chess.py └── mouse.py ├── docs ├── CNAME ├── elements │ ├── index.md │ ├── div.md │ └── text.md ├── input-handling │ ├── index.md │ ├── controls.md │ └── events.md ├── assets │ ├── favicon.png │ ├── style.css │ ├── box-model.svg │ ├── border-titles.svg │ └── z.svg ├── components │ └── index.md ├── hooks │ ├── use_ref.md │ ├── use_hovered.md │ ├── use_state.md │ ├── index.md │ ├── use_mouse.md │ ├── use_rects.md │ └── use_effect.md ├── examples │ ├── generate-screenshots.sh │ ├── box_model.py │ ├── fixed_positioning.py │ ├── z.py │ ├── border_titles.py │ ├── absolute_positioning.py │ ├── relative_positioning.py │ ├── absolute_positioning_insets.py │ └── border_healing.py ├── cookbook │ ├── border-titles.md │ └── border-healing.md ├── styles │ ├── index.md │ ├── utilities.md │ └── layout.md ├── index.md ├── under-the-hood │ └── index.md └── changelog.md ├── .github ├── pull_request_template.md ├── dependabot.yml └── workflows │ ├── publish-docs.yml │ ├── publish-package.yml │ └── quality-check.yml ├── codecov.yml ├── .coveragerc ├── justfile ├── synth.yaml ├── LICENSE ├── README.md ├── .pre-commit-config.yaml ├── .gitignore ├── CLAUDE.md ├── mkdocs.yml └── pyproject.toml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /codegen/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /counterweight/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/hooks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/controls/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/elements/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/inputs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/output/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/styles/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | www.counterweight.dev 2 | -------------------------------------------------------------------------------- /docs/elements/index.md: -------------------------------------------------------------------------------- 1 | # Elements 2 | -------------------------------------------------------------------------------- /docs/input-handling/index.md: -------------------------------------------------------------------------------- 1 | # Input Handling 2 | -------------------------------------------------------------------------------- /counterweight/__main__.py: -------------------------------------------------------------------------------- 1 | from counterweight.cli import cli 2 | 3 | cli() 4 | -------------------------------------------------------------------------------- /docs/elements/div.md: -------------------------------------------------------------------------------- 1 | # `Div` 2 | 3 | ## API 4 | 5 | ::: counterweight.elements.Div 6 | -------------------------------------------------------------------------------- /docs/elements/text.md: -------------------------------------------------------------------------------- 1 | # `Text` 2 | 3 | ## API 4 | 5 | ::: counterweight.elements.Text 6 | -------------------------------------------------------------------------------- /counterweight/__init__.py: -------------------------------------------------------------------------------- 1 | from counterweight.app import app 2 | 3 | __all__ = [ 4 | "app", 5 | ] 6 | -------------------------------------------------------------------------------- /docs/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoshKarpel/counterweight/HEAD/docs/assets/favicon.png -------------------------------------------------------------------------------- /docs/components/index.md: -------------------------------------------------------------------------------- 1 | # Components 2 | 3 | ## API 4 | 5 | ::: counterweight.components.component 6 | -------------------------------------------------------------------------------- /docs/hooks/use_ref.md: -------------------------------------------------------------------------------- 1 | # `use_ref` 2 | 3 | ## API 4 | 5 | ::: counterweight.hooks.use_ref 6 | ::: counterweight.hooks.Ref 7 | -------------------------------------------------------------------------------- /docs/hooks/use_hovered.md: -------------------------------------------------------------------------------- 1 | # `use_hovered` 2 | 3 | ## API 4 | 5 | ::: counterweight.hooks.use_hovered 6 | ::: counterweight.hooks.Hovered 7 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from typer.testing import CliRunner 3 | 4 | 5 | @pytest.fixture 6 | def runner() -> CliRunner: 7 | return CliRunner() 8 | -------------------------------------------------------------------------------- /docs/hooks/use_state.md: -------------------------------------------------------------------------------- 1 | # `use_state` 2 | 3 | ## API 4 | 5 | ::: counterweight.hooks.use_state 6 | ::: counterweight.hooks.Getter 7 | ::: counterweight.hooks.Setter 8 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Tasks 4 | ----- 5 | 6 | - [ ] Updated changelog. 7 | - [ ] Updated documentation. 8 | -------------------------------------------------------------------------------- /docs/hooks/index.md: -------------------------------------------------------------------------------- 1 | # Hooks 2 | 3 | Counterweight uses "hooks", 4 | inspired by [React hooks](https://react.dev/reference/react/hooks), 5 | to manage state and side effects in components. 6 | -------------------------------------------------------------------------------- /docs/examples/generate-screenshots.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | THIS_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) 6 | 7 | echo ${THIS_DIR}/*.py | xargs -n 1 -P 8 python 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: "wednesday" 9 | open-pull-requests-limit: 1 10 | -------------------------------------------------------------------------------- /counterweight/constants.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from importlib import metadata 5 | 6 | PACKAGE_NAME = "counterweight" 7 | __version__ = metadata.version(PACKAGE_NAME) 8 | __python_version__ = ".".join(map(str, sys.version_info)) 9 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: 90..100 3 | round: down 4 | precision: 1 5 | status: 6 | project: 7 | default: 8 | target: auto 9 | informational: true 10 | patch: 11 | default: 12 | target: auto 13 | informational: true 14 | -------------------------------------------------------------------------------- /docs/cookbook/border-titles.md: -------------------------------------------------------------------------------- 1 | # Border Titles 2 | 3 | To add title text to a border, 4 | use an absolutely-positioned `Text` 5 | inside a `Div` that provides the border. 6 | 7 | ```python 8 | --8<-- "border_titles.py:example" 9 | ``` 10 | 11 | ![Border Titles](../assets/border-titles.svg) 12 | -------------------------------------------------------------------------------- /tests/utils/test_forever.py: -------------------------------------------------------------------------------- 1 | from asyncio import timeout 2 | 3 | import pytest 4 | 5 | from counterweight._utils import forever 6 | 7 | 8 | async def test_forever() -> None: 9 | # Not the most inspiring test, but it's hard to test that it will *never* return... 10 | with pytest.raises(TimeoutError): 11 | async with timeout(0.01): 12 | await forever() 13 | -------------------------------------------------------------------------------- /docs/styles/index.md: -------------------------------------------------------------------------------- 1 | # Styles 2 | 3 | ## API 4 | 5 | ::: counterweight.styles.Style 6 | 7 | ::: counterweight.styles.Flex 8 | ::: counterweight.styles.Span 9 | ::: counterweight.styles.Margin 10 | ::: counterweight.styles.Border 11 | ::: counterweight.styles.Padding 12 | ::: counterweight.styles.Typography 13 | ::: counterweight.styles.Color 14 | ::: counterweight.styles.CellStyle 15 | -------------------------------------------------------------------------------- /counterweight/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import ClassVar 4 | 5 | from pydantic import BaseModel, ConfigDict 6 | 7 | 8 | class ForbidExtras(BaseModel): 9 | model_config: ClassVar[ConfigDict] = { 10 | "extra": "forbid", 11 | } 12 | 13 | 14 | class FrozenForbidExtras(ForbidExtras): 15 | model_config: ClassVar[ConfigDict] = { 16 | "frozen": True, 17 | } 18 | -------------------------------------------------------------------------------- /counterweight/hooks/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Callable, Coroutine, Generic, TypeVar 4 | 5 | from pydantic import BaseModel 6 | 7 | T = TypeVar("T") 8 | 9 | Getter = Callable[[], T] 10 | Setter = Callable[[Callable[[T], T] | T], None] 11 | Setup = Callable[[], Coroutine[None, None, None]] 12 | Deps = tuple[object, ...] | None 13 | 14 | 15 | class Ref(BaseModel, Generic[T]): 16 | current: T 17 | -------------------------------------------------------------------------------- /tests/styles/test_utilities.py: -------------------------------------------------------------------------------- 1 | from counterweight.styles.utilities import * 2 | 3 | 4 | def test_relative() -> None: 5 | assert relative(x=3, y=5) == Style(layout=Flex(position=Relative(x=3, y=5))) 6 | 7 | 8 | def test_absolute() -> None: 9 | assert absolute(x=3, y=5) == Style(layout=Flex(position=Absolute(x=3, y=5))) 10 | 11 | 12 | def test_fixed() -> None: 13 | assert fixed(x=3, y=5) == Style(layout=Flex(position=Fixed(x=3, y=5))) 14 | -------------------------------------------------------------------------------- /.github/workflows/publish-docs.yml: -------------------------------------------------------------------------------- 1 | name: publish-docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | publish-docs: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Check out repository 13 | uses: actions/checkout@v6.0.0 14 | - name: Install uv 15 | uses: astral-sh/setup-uv@v7.1.6 16 | with: 17 | enable-cache: true 18 | - name: Build and deploy docs 19 | run: uv run mkdocs gh-deploy --clean --strict --verbose --force 20 | -------------------------------------------------------------------------------- /tests/utils/test_maybe_await.py: -------------------------------------------------------------------------------- 1 | from counterweight._utils import maybe_await 2 | 3 | 4 | def foo() -> str: 5 | return "bar" 6 | 7 | 8 | async def test_with_normal() -> None: 9 | assert await maybe_await(foo()) == "bar" 10 | 11 | 12 | async def afoo() -> str: 13 | return "bar" 14 | 15 | 16 | async def test_with_coroutine() -> None: 17 | # mypy can't seem to infer the type of `afoo()`, it thinks it's Awaitable[Never] ? 18 | assert await maybe_await(afoo()) == "bar" # type: ignore[arg-type] 19 | -------------------------------------------------------------------------------- /docs/cookbook/border-healing.md: -------------------------------------------------------------------------------- 1 | # Border Healing 2 | 3 | When border elements for certain border types are adjacent to each other and appear as if they 4 | should "join up", but don't because they belong to different elements, they will be joined up. 5 | 6 | ```python 7 | --8<-- "border_healing.py:example" 8 | ``` 9 | 10 | With border healing **enabled**: 11 | 12 | ![Border Healing Enabled](../assets/border-healing-on.svg) 13 | 14 | With border healing **disabled**: 15 | 16 | ![Border Healing Disabled](../assets/border-healing-off.svg) 17 | -------------------------------------------------------------------------------- /tests/cli/test_help.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | 4 | import pytest 5 | from typer.testing import CliRunner 6 | 7 | from counterweight.cli import cli 8 | from counterweight.constants import PACKAGE_NAME 9 | 10 | 11 | def test_help(runner: CliRunner) -> None: 12 | result = runner.invoke(cli, ("--help",)) 13 | 14 | assert result.exit_code == 0 15 | 16 | 17 | @pytest.mark.slow 18 | def test_help_via_main() -> None: 19 | result = subprocess.run((sys.executable, "-m", PACKAGE_NAME, "--help"), check=False) 20 | 21 | assert result.returncode == 0 22 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | 3 | branch = True 4 | 5 | source = 6 | counterweight/ 7 | tests/ 8 | 9 | [report] 10 | 11 | skip_empty = True 12 | show_missing = True 13 | sort = -cover 14 | 15 | exclude_lines = 16 | def __repr__ 17 | def __str__ 18 | 19 | raise AssertionError 20 | raise NotImplementedError 21 | 22 | if 0: 23 | if False: 24 | if __name__ == .__main__.: 25 | if TYPE_CHECKING: 26 | @overload 27 | assert False 28 | assert_never 29 | 30 | pragma: unreachable 31 | pragma: debugging 32 | pragma: never runs 33 | pragma: untestable 34 | -------------------------------------------------------------------------------- /counterweight/hooks/__init__.py: -------------------------------------------------------------------------------- 1 | from counterweight.hooks.hooks import ( 2 | Hovered, 3 | Mouse, 4 | Rects, 5 | use_effect, 6 | use_hovered, 7 | use_mouse, 8 | use_rects, 9 | use_ref, 10 | use_state, 11 | ) 12 | from counterweight.hooks.types import Deps, Getter, Ref, Setter, Setup 13 | 14 | __all__ = [ 15 | "Deps", 16 | "Getter", 17 | "Hovered", 18 | "Mouse", 19 | "Rects", 20 | "Ref", 21 | "Setter", 22 | "Setup", 23 | "use_effect", 24 | "use_hovered", 25 | "use_mouse", 26 | "use_rects", 27 | "use_ref", 28 | "use_state", 29 | ] 30 | -------------------------------------------------------------------------------- /docs/input-handling/controls.md: -------------------------------------------------------------------------------- 1 | # Controls 2 | 3 | **Controls** are objects that can be returned from event handlers (e.g., `on_key`) 4 | to instruct Counterweight to take some action during the render loop, 5 | such as gracefully quitting the application or playing the 6 | [terminal "bell" sound](https://en.wikipedia.org/wiki/Bell_character). 7 | 8 | ## API 9 | 10 | ::: counterweight.controls.AnyControl 11 | 12 | ::: counterweight.controls.Quit 13 | ::: counterweight.controls.Bell 14 | ::: counterweight.controls.Screenshot 15 | ::: counterweight.controls.Suspend 16 | ::: counterweight.controls.ToggleBorderHealing 17 | -------------------------------------------------------------------------------- /docs/hooks/use_mouse.md: -------------------------------------------------------------------------------- 1 | # `use_mouse` 2 | 3 | ## API 4 | 5 | ::: counterweight.hooks.use_mouse 6 | ::: counterweight.hooks.Mouse 7 | 8 | !!! tip "`use_mouse` vs. `on_mouse`" 9 | 10 | `use_mouse` and `on_mouse` provide similar functionality, but `use_mouse` is a hook and `on_mouse` is an event handler. 11 | `use_mouse` is more efficient when a component depends only on the *current* state of the mouse 12 | (e.g., the current position, or whether a button is currently pressed), 13 | while `on_mouse` is more convenient when a component needs to respond to *changes* in the mouse state, 14 | (e.g., a button release ([`MouseUp`][counterweight.events.MouseUp]). 15 | -------------------------------------------------------------------------------- /counterweight/styles/__init__.py: -------------------------------------------------------------------------------- 1 | from counterweight.styles.styles import ( 2 | Absolute, 3 | Border, 4 | BorderEdge, 5 | BorderKind, 6 | CellStyle, 7 | Color, 8 | Content, 9 | Fixed, 10 | Flex, 11 | Inset, 12 | Margin, 13 | Padding, 14 | Relative, 15 | Span, 16 | Style, 17 | Typography, 18 | ) 19 | 20 | __all__ = [ 21 | "Absolute", 22 | "Border", 23 | "BorderEdge", 24 | "BorderKind", 25 | "CellStyle", 26 | "Color", 27 | "Content", 28 | "Fixed", 29 | "Flex", 30 | "Inset", 31 | "Margin", 32 | "Padding", 33 | "Relative", 34 | "Span", 35 | "Style", 36 | "Typography", 37 | ] 38 | -------------------------------------------------------------------------------- /tests/cli/test_version.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | 4 | import pytest 5 | from typer.testing import CliRunner 6 | 7 | from counterweight.cli import cli 8 | from counterweight.constants import PACKAGE_NAME, __version__ 9 | 10 | 11 | def test_version(runner: CliRunner) -> None: 12 | result = runner.invoke(cli, ("version",)) 13 | 14 | assert result.exit_code == 0 15 | assert __version__ in result.stdout 16 | 17 | 18 | @pytest.mark.slow 19 | def test_version_via_main() -> None: 20 | result = subprocess.run((sys.executable, "-m", PACKAGE_NAME, "version"), check=False, capture_output=True) 21 | 22 | assert result.returncode == 0 23 | assert __version__ in result.stdout.decode() 24 | -------------------------------------------------------------------------------- /tests/utils/test_drain_queue.py: -------------------------------------------------------------------------------- 1 | from asyncio import Queue, timeout 2 | 3 | import pytest 4 | 5 | from counterweight._utils import drain_queue 6 | 7 | 8 | @pytest.fixture 9 | def queue() -> Queue[int]: 10 | return Queue() 11 | 12 | 13 | async def test_non_empty_queue(queue: Queue[int]) -> None: 14 | for i in range(2): 15 | await queue.put(i) 16 | 17 | assert await drain_queue(queue) == [0, 1] 18 | 19 | 20 | async def test_empty_queue_hangs(queue: Queue[int]) -> None: 21 | try: 22 | async with timeout(0.05): # pragma: unreachable 23 | await drain_queue(queue) 24 | assert False, "drain_queue() should have hung" 25 | except TimeoutError: 26 | pass 27 | -------------------------------------------------------------------------------- /.github/workflows/publish-package.yml: -------------------------------------------------------------------------------- 1 | name: publish-package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | pypi: 9 | runs-on: ubuntu-latest 10 | environment: 11 | name: pypi 12 | url: https://pypi.org/p/${{ github.event.repository.name }} 13 | permissions: 14 | contents: read # add default back in 15 | id-token: write 16 | steps: 17 | - name: Check out repository 18 | uses: actions/checkout@v6.0.0 19 | - name: Install uv 20 | uses: astral-sh/setup-uv@v7.1.6 21 | with: 22 | enable-cache: true 23 | - name: Build the package 24 | run: uv build -vvv 25 | - name: Publish package distributions to PyPI 26 | uses: pypa/gh-action-pypi-publish@v1.13.0 27 | -------------------------------------------------------------------------------- /tests/utils/test_halve_integer.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from hypothesis import given 3 | from hypothesis.strategies import integers 4 | 5 | from counterweight._utils import halve_integer 6 | 7 | 8 | @pytest.mark.parametrize( 9 | ("x", "result"), 10 | ( 11 | (0, (0, 0)), 12 | (1, (1, 0)), # leftover favors the left result 13 | (2, (1, 1)), 14 | ), 15 | ) 16 | def test_examples(x: int, result: tuple[int, int]) -> None: 17 | assert halve_integer(x) == result 18 | 19 | 20 | @given( 21 | x=integers( 22 | min_value=0, 23 | max_value=10_000, 24 | ), 25 | ) 26 | def test_properties(x: int) -> None: 27 | result = halve_integer(x) 28 | 29 | assert len(result) == 2 30 | assert sum(result) == x 31 | -------------------------------------------------------------------------------- /counterweight/_context_vars.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from asyncio import Queue 4 | from collections.abc import Callable 5 | from contextvars import ContextVar 6 | from typing import TYPE_CHECKING 7 | from weakref import WeakSet 8 | 9 | if TYPE_CHECKING: 10 | from counterweight.events import AnyEvent 11 | from counterweight.hooks import Mouse 12 | from counterweight.hooks.impls import Hooks 13 | 14 | current_event_queue: ContextVar[Queue[AnyEvent]] = ContextVar("current_event_queue") 15 | current_use_mouse_listeners: ContextVar[WeakSet[Callable[[Mouse], None]]] = ContextVar("current_use_mouse_listeners") 16 | current_hook_idx: ContextVar[int] = ContextVar("current_hook_idx") 17 | current_hook_state: ContextVar[Hooks] = ContextVar("current_hook_state") 18 | -------------------------------------------------------------------------------- /docs/hooks/use_rects.md: -------------------------------------------------------------------------------- 1 | # `use_rects` 2 | 3 | ## API 4 | 5 | ::: counterweight.hooks.use_rects 6 | ::: counterweight.hooks.Rects 7 | 8 | !!! tip "Use `use_hovered` for detecting hover state" 9 | 10 | `use_rects` is a low-level hook. 11 | For the common use case of detecting whether the mouse is hovering over an element, 12 | use the higher-level [`use_hovered`](./use_hovered.md) hook instead. 13 | 14 | !!! warning "This is not a stateful hook" 15 | 16 | `use_rects` is not a stateful hook: it does not use `use_state` under the hood. 17 | That means that if the dimensions of the component's top-level element change 18 | in a way that is not connected to some other state change 19 | (e.g., if a sibling component changes size), 20 | using this hook will not cause the component to re-render. 21 | -------------------------------------------------------------------------------- /tests/inputs/test_on_key.py: -------------------------------------------------------------------------------- 1 | from counterweight import app 2 | from counterweight.components import component 3 | from counterweight.controls import Quit 4 | from counterweight.elements import Div 5 | from counterweight.events import KeyPressed 6 | from counterweight.styles import Span, Style 7 | 8 | 9 | async def test_on_key() -> None: 10 | recorder = [] 11 | 12 | @component 13 | def root() -> Div: 14 | return Div( 15 | on_key=lambda event: recorder.append(event), 16 | style=Style(span=Span(width=10, height=10)), 17 | ) 18 | 19 | await app( 20 | root, 21 | headless=True, 22 | autopilot=( 23 | KeyPressed(key="f"), 24 | Quit(), 25 | ), 26 | ) 27 | 28 | assert recorder == [ 29 | KeyPressed(key="f"), 30 | ] 31 | -------------------------------------------------------------------------------- /tests/output/test_sgr_from_cell_style.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from counterweight.output import sgr_from_cell_style 4 | from counterweight.styles import CellStyle 5 | 6 | 7 | @pytest.mark.parametrize( 8 | ("style", "expected"), 9 | ( 10 | (CellStyle(), "\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m"), 11 | (CellStyle(bold=True), "\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m\x1b[1m"), 12 | (CellStyle(dim=True), "\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m\x1b[2m"), 13 | (CellStyle(italic=True), "\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m\x1b[3m"), 14 | (CellStyle(underline=True), "\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m\x1b[4m"), 15 | (CellStyle(strikethrough=True), "\x1b[38;2;255;255;255m\x1b[48;2;0;0;0m\x1b[9m"), 16 | ), 17 | ) 18 | def test_examples(style: CellStyle, expected: str) -> None: 19 | assert sgr_from_cell_style(style) == expected 20 | -------------------------------------------------------------------------------- /tests/output/test_move_to.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from hypothesis import given 3 | from hypothesis.strategies import integers 4 | 5 | from counterweight.geometry import Position 6 | from counterweight.output import move_to 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ("pos", "expected"), 11 | ( 12 | (Position(0, 0), "\x1b[1;1f"), 13 | (Position(1, 0), "\x1b[1;2f"), 14 | (Position(0, 1), "\x1b[2;1f"), 15 | (Position(1, 1), "\x1b[2;2f"), 16 | ), 17 | ) 18 | def test_examples(pos: Position, expected: str) -> None: 19 | assert move_to(pos) == expected 20 | 21 | 22 | @given(x=integers(min_value=0, max_value=1000), y=integers(min_value=0, max_value=1000)) 23 | def test_properties(x: int, y: int) -> None: 24 | m = move_to(position=Position(x, y)) 25 | 26 | assert m.startswith("\x1b[") 27 | assert m.endswith("f") 28 | assert m.count(";") == 1 29 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env just --justfile 2 | 3 | alias t := test 4 | alias w := watch 5 | alias wt := watch-test 6 | alias d := docs-serve 7 | alias db := docs-build 8 | alias ds := docs-screenshots 9 | alias p := pre-commit 10 | 11 | install: 12 | uv sync --extra dev 13 | 14 | test: 15 | uv run mypy 16 | uv run pytest -vv --failed-first --cov --durations=10 17 | 18 | watch CMD: 19 | uv run watchfiles '{{CMD}}' counterweight/ tests/ docs/ examples/ 20 | 21 | watch-test: (watch "just test") 22 | 23 | docs-serve: 24 | uv run mkdocs serve 25 | 26 | docs-build: 27 | uv run mkdocs build --strict 28 | 29 | docs-screenshots: 30 | uv run docs/examples/generate-screenshots.sh 31 | 32 | pre-commit: 33 | git add -u 34 | uv run pre-commit 35 | git add -u 36 | 37 | profile FILE DURATION: 38 | austin --output profile.austin --exposure {{DURATION}} python {{FILE}} 39 | austin2speedscope profile.austin profile.ss 40 | reset 41 | -------------------------------------------------------------------------------- /tests/hooks/test_use_state.py: -------------------------------------------------------------------------------- 1 | from counterweight import app 2 | from counterweight.components import component 3 | from counterweight.controls import Quit 4 | from counterweight.elements import Div 5 | from counterweight.events import KeyPressed 6 | from counterweight.hooks import Setter, use_state 7 | 8 | 9 | async def test_use_state() -> None: 10 | recorder = [] 11 | 12 | def track_state_changes() -> tuple[int, Setter[int]]: 13 | state, set_state = use_state(0) 14 | recorder.append(state) 15 | return state, set_state 16 | 17 | @component 18 | def root() -> Div: 19 | state, set_state = track_state_changes() 20 | 21 | def on_key(event: KeyPressed) -> None: 22 | set_state(state + 1) 23 | 24 | return Div(on_key=on_key) 25 | 26 | await app( 27 | root, 28 | headless=True, 29 | autopilot=( 30 | KeyPressed(key="f"), 31 | Quit(), 32 | ), 33 | ) 34 | 35 | assert recorder == [0, 1] 36 | -------------------------------------------------------------------------------- /docs/hooks/use_effect.md: -------------------------------------------------------------------------------- 1 | # `use_effect` 2 | 3 | ## API 4 | 5 | ::: counterweight.hooks.use_effect 6 | ::: counterweight.hooks.Setup 7 | ::: counterweight.hooks.Deps 8 | 9 | ## Effect Cancellation 10 | 11 | Effects are canceled by the framework when one of the following conditions is met: 12 | 13 | - The component that created the effect is unmounted. 14 | - The effect's dependencies change and the effect's `setup` function is going to be re-run. 15 | 16 | !!! note "Effect cancellation is synchronous" 17 | 18 | Note that the effect is *synchronously cancelled* 19 | (i.e., the [`Task`][asyncio.Task] that represents the effect is cancelled and then `await`ed; 20 | see [this discussion](https://discuss.python.org/t/asyncio-cancel-a-cancellation-utility-as-a-coroutine-this-time-with-feeling/26304/5)) 21 | before the next render cycle starts. 22 | Assuming that you do not mess with the cancellation yourself from inside the effect setup function, 23 | the effect will definitely stop running before the next frame is rendered. 24 | -------------------------------------------------------------------------------- /synth.yaml: -------------------------------------------------------------------------------- 1 | flows: 2 | default: 3 | nodes: 4 | codegen: 5 | target: 6 | commands: | 7 | codegen/generate_utilities.py 8 | triggers: 9 | - watch: ["codegen/"] 10 | screens: 11 | target: 12 | commands: | 13 | docs/examples/generate-screenshots.sh 14 | triggers: 15 | - code-changes 16 | tests: 17 | target: tests 18 | triggers: 19 | - code-changes 20 | types: 21 | target: types 22 | triggers: 23 | - code-changes 24 | docs: 25 | target: docs 26 | triggers: 27 | - delay: 1 28 | 29 | targets: 30 | tests: 31 | commands: | 32 | pytest -vv --cov 33 | 34 | types: 35 | commands: | 36 | mypy 37 | 38 | docs: 39 | commands: | 40 | mkdocs serve --strict 41 | 42 | triggers: 43 | code-changes: 44 | watch: 45 | - counterweight/ 46 | - tests/ 47 | - docs/examples/ 48 | - pyproject.toml 49 | - .coveragerc 50 | -------------------------------------------------------------------------------- /tests/controls/test_screenshot.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from xml.etree.ElementTree import Element, ElementTree, SubElement 3 | 4 | import pytest 5 | 6 | from counterweight.controls import Screenshot 7 | 8 | 9 | @pytest.fixture 10 | def svg() -> ElementTree: 11 | root = Element("parent") 12 | SubElement(root, "child") 13 | return ElementTree(element=root) 14 | 15 | 16 | @pytest.mark.parametrize( 17 | "path_parts", 18 | ( 19 | ("screenshot.svg",), 20 | ("subdir", "screenshot.svg"), 21 | ), 22 | ) 23 | def test_to_file(tmp_path: Path, path_parts: tuple[str], svg: ElementTree) -> None: 24 | full_path = tmp_path.joinpath(*path_parts) 25 | screenshot = Screenshot.to_file(full_path) 26 | 27 | screenshot.handler(svg) 28 | 29 | assert full_path.stat().st_size > 0 30 | 31 | 32 | def test_indent(tmp_path: Path, svg: ElementTree) -> None: 33 | path = tmp_path / "screenshot.svg" 34 | screenshot = Screenshot.to_file(path, indent=4) 35 | 36 | screenshot.handler(svg) 37 | 38 | assert "\n " in path.read_text() 39 | -------------------------------------------------------------------------------- /tests/utils/test_unordered_range.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from hypothesis import given 3 | from hypothesis.strategies import integers 4 | 5 | from counterweight._utils import unordered_range 6 | 7 | 8 | @pytest.mark.parametrize( 9 | ("a", "b", "expected"), 10 | ( 11 | (0, 0, (0,)), 12 | (0, 1, (0, 1)), 13 | (0, 2, (0, 1, 2)), 14 | (0, 3, (0, 1, 2, 3)), 15 | (1, 1, (1,)), 16 | (1, 2, (1, 2)), 17 | (2, 1, (2, 1)), 18 | (1, -1, (1, 0, -1)), 19 | ), 20 | ) 21 | def test_examples(a: int, b: int, expected: tuple[int, ...]) -> None: 22 | assert tuple(unordered_range(a, b)) == expected 23 | 24 | 25 | @pytest.mark.slow 26 | @given( 27 | a=integers(min_value=-10_000, max_value=10_000), 28 | b=integers(min_value=-10_000, max_value=10_000), 29 | ) 30 | def test_properties(a: int, b: int) -> None: 31 | result = tuple(unordered_range(a, b)) 32 | 33 | assert a in result 34 | assert b in result 35 | 36 | assert result[0] <= result[-1] if a <= b else result[0] >= result[-1] 37 | 38 | assert len(result) == abs(a - b) + 1 39 | -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | from counterweight.app import app 2 | from counterweight.components import component 3 | from counterweight.controls import Quit, Screenshot, Suspend, ToggleBorderHealing 4 | from counterweight.elements import Div 5 | from counterweight.events import KeyPressed, MouseDown, MouseMoved, MouseUp, TerminalResized 6 | from counterweight.geometry import Position 7 | 8 | 9 | async def test_headless_autopilot_events_with_empty_app() -> None: 10 | @component 11 | def root() -> Div: 12 | return Div() 13 | 14 | await app( 15 | root, 16 | headless=True, 17 | autopilot=( 18 | KeyPressed(key="f"), 19 | MouseMoved(absolute=Position(0, 0), button=None), 20 | MouseDown(absolute=Position(0, 0), button=1), 21 | MouseMoved(absolute=Position(0, 1), button=1), 22 | MouseUp(absolute=Position(0, 2), button=1), 23 | TerminalResized(), 24 | Screenshot(handler=lambda _: None), 25 | Suspend(handler=lambda: None), 26 | ToggleBorderHealing(), 27 | Quit(), 28 | ), 29 | ) 30 | -------------------------------------------------------------------------------- /docs/examples/box_model.py: -------------------------------------------------------------------------------- 1 | # --8<-- [start:example] 2 | from counterweight.app import app 3 | from counterweight.components import component 4 | from counterweight.controls import Quit, Screenshot 5 | from counterweight.elements import Div 6 | from counterweight.styles.utilities import * 7 | 8 | 9 | @component 10 | def root() -> Div: 11 | return Div( 12 | style=content_green_500 13 | | padding_orange_500 14 | | pad_x_2 15 | | pad_y_1 16 | | border_lightrounded 17 | | border_bg_blue_500 18 | | margin_red_500 19 | | margin_x_2 20 | | margin_y_1 21 | ) 22 | 23 | 24 | # --8<-- [end:example] 25 | 26 | if __name__ == "__main__": 27 | import asyncio 28 | from pathlib import Path 29 | 30 | THIS_DIR = Path(__file__).parent 31 | 32 | asyncio.run( 33 | app( 34 | root, 35 | headless=True, 36 | dimensions=(30, 10), 37 | autopilot=[ 38 | Screenshot.to_file(THIS_DIR.parent / "assets" / "box-model.svg", indent=1), 39 | Quit(), 40 | ], 41 | ) 42 | ) 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Josh Karpel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /counterweight/components.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, replace 4 | from functools import wraps 5 | from typing import Callable, ParamSpec 6 | 7 | from counterweight.elements import AnyElement 8 | 9 | P = ParamSpec("P") 10 | 11 | 12 | def component(func: Callable[P, AnyElement]) -> Callable[P, Component]: 13 | """ 14 | A decorator that marks a function as a component. 15 | """ 16 | 17 | @wraps(func) 18 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> Component: 19 | return Component(func=func, args=args, kwargs=kwargs) 20 | 21 | return wrapper 22 | 23 | 24 | @dataclass(frozen=True, slots=True) 25 | class Component: 26 | """ 27 | The result of calling a component function. 28 | These should not be instantiated directly; 29 | instead, use the `@component` decorator on a function 30 | and call it normally. 31 | """ 32 | 33 | func: Callable[..., AnyElement] 34 | args: tuple[object, ...] 35 | kwargs: dict[str, object] 36 | key: str | int | None = None 37 | 38 | def with_key(self, key: str | int | None) -> Component: 39 | return replace(self, key=key) 40 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Counterweight 2 | 3 | Counterweight is an experimental text user interface (TUI) framework for Python, 4 | inspired by [React](https://react.dev/) and [Tailwind CSS](https://tailwindcss.com/). 5 | 6 | A TUI application built with Counterweight is a tree of declarative 7 | [**components**](components/index.md), 8 | each of which represents some piece of the UI by bundling together 9 | a visual [**element**](elements/index.md) along with its 10 | [**state**](hooks/use_state.md) and how that state should change due to 11 | [**events**](input-handling/events.md) like user input. 12 | 13 | As an application author, 14 | you define the components and their relationships as a tree of Python functions. 15 | You use [**hooks**](hooks/index.md) to manage state and side effects, 16 | and [**styles**](styles/index.md) to change how the elements look. 17 | 18 | Counterweight takes this declarative representation of the UI and **renders** it to the terminal, 19 | updating the UI when state changes in response to user input or side effects 20 | (by calling your function tree). 21 | 22 | ## Installation 23 | 24 | Counterweight is available [on PyPI](https://pypi.org/project/counterweight/): 25 | 26 | ```bash 27 | pip install counterweight 28 | ``` 29 | -------------------------------------------------------------------------------- /docs/examples/fixed_positioning.py: -------------------------------------------------------------------------------- 1 | # --8<-- [start:example] 2 | from counterweight.app import app 3 | from counterweight.components import component 4 | from counterweight.controls import Quit, Screenshot 5 | from counterweight.elements import Div, Text 6 | from counterweight.styles.utilities import * 7 | 8 | extra_style = border_heavy | pad_1 | margin_1 | margin_red_600 9 | 10 | 11 | @component 12 | def root() -> Div: 13 | return Div( 14 | style=row, 15 | children=[ 16 | Text( 17 | style=fixed(x=x, y=y) | extra_style, 18 | content=f"fixed(x={x}, y={y})", 19 | ) 20 | for x, y in ( 21 | (0, 0), 22 | (10, 10), 23 | (30, 20), 24 | (15, 25), 25 | (33, 3), 26 | ) 27 | ], 28 | ) 29 | 30 | 31 | # --8<-- [end:example] 32 | 33 | if __name__ == "__main__": 34 | import asyncio 35 | from pathlib import Path 36 | 37 | THIS_DIR = Path(__file__).parent 38 | 39 | asyncio.run( 40 | app( 41 | root, 42 | headless=True, 43 | dimensions=(60, 30), 44 | autopilot=[ 45 | Screenshot.to_file(THIS_DIR.parent / "assets" / "fixed-positioning.svg", indent=1), 46 | Quit(), 47 | ], 48 | ) 49 | ) 50 | -------------------------------------------------------------------------------- /tests/elements/test_text.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Sequence 2 | 3 | import pytest 4 | 5 | from counterweight.elements import CellPaint, Chunk, Text 6 | from counterweight.styles import Style 7 | 8 | 9 | def test_texts_have_no_children() -> None: 10 | assert Text(content="foo").children == () 11 | 12 | 13 | @pytest.mark.parametrize( 14 | ("content", "style", "expected"), 15 | ( 16 | ( 17 | "", 18 | Style(), 19 | (), 20 | ), 21 | ( 22 | " ", 23 | Style(), 24 | (CellPaint(char=" "),), 25 | ), 26 | ( 27 | "f", 28 | Style(), 29 | (CellPaint(char="f"),), 30 | ), 31 | ( 32 | "foo", 33 | Style(), 34 | (CellPaint(char="f"), CellPaint(char="o"), CellPaint(char="o")), 35 | ), 36 | ( 37 | (Chunk(content="f"), Chunk(content="o"), Chunk(content="o")), 38 | Style(), 39 | (CellPaint(char="f"), CellPaint(char="o"), CellPaint(char="o")), 40 | ), 41 | ), 42 | ) 43 | def test_text_cells( 44 | content: str | Sequence[Chunk], 45 | style: Style, 46 | expected: tuple[CellPaint], 47 | ) -> None: 48 | assert tuple(Text(content=content, style=style).cells) == expected 49 | 50 | 51 | def test_chunk_space() -> None: 52 | assert Chunk.space() == Chunk(content=" ") 53 | 54 | 55 | def test_chunk_newline() -> None: 56 | assert Chunk.newline() == Chunk(content="\n") 57 | -------------------------------------------------------------------------------- /docs/examples/z.py: -------------------------------------------------------------------------------- 1 | # --8<-- [start:example] 2 | from counterweight.app import app 3 | from counterweight.components import component 4 | from counterweight.controls import Quit, Screenshot 5 | from counterweight.elements import Div, Text 6 | from counterweight.styles.utilities import * 7 | 8 | 9 | @component 10 | def root() -> Div: 11 | return Div( 12 | style=col, 13 | children=[ 14 | Text( 15 | style=z(1) | absolute(6, 6) | border_lightrounded | margin_1 | margin_purple_600, 16 | content="z = +1", 17 | ), 18 | Text( 19 | style=z(0) | absolute(4, 3) | border_lightrounded | margin_1 | margin_teal_600, 20 | content="z = 0", 21 | ), 22 | Text( 23 | style=z(-1) | absolute(0, 0) | border_lightrounded | margin_1 | margin_red_600, 24 | content="z = -1", 25 | ), 26 | Text( 27 | style=z(2) | absolute(13, 3) | border_lightrounded | margin_1 | margin_amber_600, 28 | content="z = +2", 29 | ), 30 | ], 31 | ) 32 | 33 | 34 | # --8<-- [end:example] 35 | 36 | if __name__ == "__main__": 37 | import asyncio 38 | from pathlib import Path 39 | 40 | THIS_DIR = Path(__file__).parent 41 | 42 | asyncio.run( 43 | app( 44 | root, 45 | headless=True, 46 | dimensions=(30, 15), 47 | autopilot=[ 48 | Screenshot.to_file(THIS_DIR.parent / "assets" / "z.svg", indent=1), 49 | Quit(), 50 | ], 51 | ) 52 | ) 53 | -------------------------------------------------------------------------------- /.github/workflows/quality-check.yml: -------------------------------------------------------------------------------- 1 | name: quality-check 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | test-code: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | platform: [ubuntu-latest, macos-latest] 15 | python-version: ["3.12"] 16 | defaults: 17 | run: 18 | shell: bash 19 | runs-on: ${{ matrix.platform }} 20 | timeout-minutes: 15 21 | env: 22 | PLATFORM: ${{ matrix.platform }} 23 | PYTHON_VERSION: ${{ matrix.python-version }} 24 | PYTHONUTF8: 1 # https://peps.python.org/pep-0540/ 25 | COLORTERM: truecolor 26 | PIP_DISABLE_PIP_VERSION_CHECK: 1 27 | steps: 28 | - name: Check out repository 29 | uses: actions/checkout@v6.0.0 30 | - name: Install uv 31 | uses: astral-sh/setup-uv@v7.1.6 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | enable-cache: true 35 | - name: Run pre-commit checks 36 | run: uv run pre-commit run --all-files --show-diff-on-failure --color=always 37 | - name: Make sure we can build the package 38 | run: uv build 39 | - name: Test types 40 | run: uv run mypy 41 | - name: Test code 42 | run: uv run pytest -v --cov --cov-report=xml --durations=20 43 | - name: Test docs 44 | run: uv run mkdocs build --clean --strict --verbose 45 | - name: Upload coverage 46 | uses: codecov/codecov-action@v5.5.1 47 | env: 48 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 49 | with: 50 | env_vars: PLATFORM,PYTHON_VERSION 51 | fail_ci_if_error: false 52 | -------------------------------------------------------------------------------- /tests/utils/test_cancel.py: -------------------------------------------------------------------------------- 1 | from asyncio import CancelledError, create_task, current_task, sleep 2 | 3 | import pytest 4 | 5 | from counterweight._utils import cancel, forever 6 | 7 | 8 | async def test_cancel_happy_path() -> None: 9 | await cancel(create_task(forever())) 10 | 11 | 12 | async def test_cancel_when_cancel_is_itself_cancelled() -> None: 13 | async def inner() -> None: 14 | try: 15 | await forever() 16 | except CancelledError: 17 | ct = current_task() 18 | if ct: 19 | ct.uncancel() 20 | await forever() 21 | 22 | # We need sleeps below to make sure that the tasks actually progress to the awaits inside them, 23 | # instead of being cancelled before they even start running 24 | 25 | inner_task = create_task(inner()) 26 | await sleep(0.01) 27 | 28 | cancel_task = create_task(cancel(inner_task)) 29 | await sleep(0.01) 30 | 31 | cancel_task.cancel() 32 | await sleep(0.01) 33 | 34 | with pytest.raises(CancelledError): 35 | await cancel_task 36 | 37 | 38 | async def test_cancel_with_task_that_returns_after_cancellation() -> None: 39 | async def t() -> None: 40 | try: 41 | await forever() 42 | except CancelledError: 43 | return 44 | 45 | task = create_task(t()) 46 | 47 | # Let the task progress to the await forever(), 48 | # otherwise it gets cancelled before it even starts running 49 | await sleep(0.01) 50 | 51 | with pytest.raises(RuntimeError): 52 | await cancel(task) 53 | 54 | 55 | async def test_cancel_with_task_that_has_already_finished() -> None: 56 | async def t() -> None: 57 | return 58 | 59 | task = create_task(t()) 60 | 61 | await task # run the task to completion 62 | 63 | await cancel(task) 64 | -------------------------------------------------------------------------------- /counterweight/logging.py: -------------------------------------------------------------------------------- 1 | from logging import NOTSET 2 | from pathlib import Path 3 | from shutil import get_terminal_size 4 | from tempfile import gettempdir 5 | from time import sleep 6 | 7 | import structlog 8 | from structlog import WriteLoggerFactory 9 | 10 | from counterweight.constants import PACKAGE_NAME 11 | 12 | DEVLOG_FILE = Path(gettempdir()) / f"{PACKAGE_NAME}.log" 13 | 14 | 15 | def configure_logging() -> None: 16 | DEVLOG_FILE.write_text("") # truncate file and make sure it exists 17 | 18 | structlog.configure( 19 | processors=[ 20 | structlog.contextvars.merge_contextvars, 21 | structlog.processors.add_log_level, 22 | structlog.processors.StackInfoRenderer(), 23 | structlog.dev.set_exc_info, 24 | structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S.%f", utc=False), 25 | structlog.dev.ConsoleRenderer(), 26 | ], 27 | wrapper_class=structlog.make_filtering_bound_logger(NOTSET), 28 | context_class=dict, 29 | logger_factory=WriteLoggerFactory(DEVLOG_FILE.open(mode="w")), 30 | cache_logger_on_first_use=False, 31 | ) 32 | 33 | 34 | def tail_devlog() -> None: 35 | while True: 36 | DEVLOG_FILE.touch(exist_ok=True) 37 | with DEVLOG_FILE.open(mode="r") as f: 38 | w, _ = get_terminal_size() 39 | while True: 40 | line = f.readline() 41 | if line: 42 | print(line, end="") 43 | else: 44 | if f.tell() > DEVLOG_FILE.stat().st_size: 45 | # the file is shorter than our current position, so it was truncated 46 | print(" DevLog Rotated ".center(w, "━")) 47 | break 48 | else: 49 | sleep(0.01) 50 | -------------------------------------------------------------------------------- /tests/inputs/test_on_mouse.py: -------------------------------------------------------------------------------- 1 | from counterweight import app 2 | from counterweight.components import component 3 | from counterweight.controls import Quit 4 | from counterweight.elements import Div 5 | from counterweight.events import MouseDown, MouseEvent, MouseMoved, MouseScrolledDown, MouseScrolledUp, MouseUp 6 | from counterweight.geometry import Position 7 | from counterweight.styles import Border, BorderKind, Span, Style 8 | 9 | 10 | async def test_on_mouse_only_captures_events_in_border_rect_with_history() -> None: 11 | recorder = [] 12 | 13 | @component 14 | def root() -> Div: 15 | return Div( 16 | on_mouse=lambda event: recorder.append(event), 17 | style=Style(span=Span(width=1, height=1), border=Border(kind=BorderKind.Light)), 18 | ) 19 | 20 | events: list[tuple[MouseEvent, bool]] = [ 21 | (MouseMoved(absolute=Position(1, 1), button=None), True), # in content rect 22 | (MouseMoved(absolute=Position(2, 0), button=None), True), # on border 23 | (MouseMoved(absolute=Position(3, 0), button=None), True), # outside, but included because previous was inside 24 | (MouseMoved(absolute=Position(3, 0), button=None), False), # outside border, not included 25 | (MouseMoved(absolute=Position(1, 0), button=None), True), # back inside 26 | (MouseDown(absolute=Position(1, 0), button=1), True), # back inside 27 | (MouseMoved(absolute=Position(1, 1), button=1), True), 28 | (MouseUp(absolute=Position(1, 1), button=1), True), 29 | (MouseScrolledUp(absolute=Position(1, 1)), True), 30 | (MouseScrolledDown(absolute=Position(1, 1)), True), 31 | ] 32 | 33 | await app( 34 | root, 35 | headless=True, 36 | autopilot=( 37 | *(event for event, _ in events), 38 | Quit(), 39 | ), 40 | ) 41 | 42 | assert recorder == [event for event, tf in events if tf] 43 | -------------------------------------------------------------------------------- /docs/under-the-hood/index.md: -------------------------------------------------------------------------------- 1 | # Under the Hood 2 | 3 | This section is for those who want to know more about how Counterweight's internals work. 4 | 5 | ## Inspirations 6 | 7 | Counterweight is inspired by a variety of existing frameworks and libraries: 8 | 9 | - [React](https://react.dev/) - state and side effect management via hooks, component tree, 10 | [declarative/immediate-mode UI](https://en.wikipedia.org/wiki/Immediate_mode_(computer_graphics)) 11 | - [Tailwind CSS](https://tailwindcss.com/) - utility styles on top of a granular CSS-like framework 12 | - [Textual](https://textual.textualize.io/) - rendering to the terminal without going through something like 13 | [curses](https://docs.python.org/3/library/curses.html#module-curses), CSS-like styles 14 | 15 | ## Data Flow 16 | 17 | ```mermaid 18 | flowchart TB 19 | 20 | en[Entrypoint] 21 | r[Render] 22 | l[Layout] 23 | p[Paint] 24 | 25 | subgraph Output 26 | ph[Paint History] 27 | dp[Overlay & Diff Paint] 28 | d[Apply Paint] 29 | t[Terminal Output] 30 | end 31 | 32 | subgraph Input 33 | i[Keyboard/Mouse Input] 34 | vp[Parse VT Commands] 35 | ev[Populate Event Stream] 36 | eh[Call Event Handlers] 37 | end 38 | 39 | subgraph Effects 40 | efm[Mount/Unmount Effects] 41 | eff[Mounted Effects] 42 | end 43 | 44 | en -- Initial Render --> r 45 | r -- Shadow Tree --> l 46 | l -- Layout Tree --> p 47 | p -- Paint --> dp 48 | 49 | ph -- Previous Paint --> dp 50 | dp -- Store Current Paint --> ph 51 | dp -- Diffed Paint --> d 52 | 53 | d -- VT Commands --> t 54 | 55 | i -- VT Commands --> vp 56 | vp -- Keys/Mouse Position --> ev 57 | ev -- Events --> eh 58 | l -- Handler Tree --> eh 59 | p -- Event Targets --> eh 60 | 61 | eh -- Set State --> r 62 | 63 | l -- Mounted Components --> efm 64 | efm --> eff 65 | eff -- Set State --> r 66 | ``` 67 | -------------------------------------------------------------------------------- /docs/assets/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --class-color: #00b8d4; 3 | --class-header-color: #00b8d41a; 4 | --function-color: #448aff; 5 | --function-header-color: #448aff1a; 6 | } 7 | 8 | article > .doc { 9 | border-style: solid; 10 | border-width: 0.05rem; 11 | border-radius: 0.2rem; 12 | padding: 0.6rem 0.6rem; 13 | box-shadow: var(--md-shadow-z1); 14 | } 15 | 16 | article > .doc + .doc { 17 | margin-top: 1rem; 18 | } 19 | 20 | .doc-object-name { 21 | font-family: monospace; 22 | } 23 | 24 | h3.doc { 25 | margin: -0.6rem; 26 | padding: 0.6rem; 27 | } 28 | 29 | article > .doc.doc-class { 30 | border-color: var(--class-color); 31 | } 32 | 33 | .doc-class > h3.doc { 34 | background-color: var(--class-header-color); 35 | } 36 | 37 | article > .doc.doc-function { 38 | border-color: var(--function-color); 39 | } 40 | 41 | .doc-function > h3.doc { 42 | background-color: var(--function-header-color); 43 | } 44 | 45 | /* Indentation. */ 46 | div.doc-contents:not(.first) { 47 | padding-left: 25px; 48 | border-left: .05rem solid var(--md-typeset-table-color); 49 | } 50 | 51 | /* Mark external links as such. */ 52 | a.autorefs-external::after { 53 | /* https://primer.style/octicons/arrow-up-right-24 */ 54 | background-image: url('data:image/svg+xml,'); 55 | content: ' '; 56 | 57 | display: inline-block; 58 | position: relative; 59 | top: 0.1em; 60 | margin-left: 0.2em; 61 | margin-right: 0.1em; 62 | 63 | height: 1em; 64 | width: 1em; 65 | border-radius: 100%; 66 | background-color: var(--md-typeset-a-color); 67 | } 68 | a.autorefs-external:hover::after { 69 | background-color: var(--md-accent-fg-color); 70 | } 71 | 72 | /* Styles for SVG screenshots */ 73 | 74 | .md-typeset img { 75 | width: 100%; 76 | height: 100%; 77 | } 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI](https://img.shields.io/pypi/v/counterweight)](https://pypi.org/project/counterweight) 2 | [![PyPI - License](https://img.shields.io/pypi/l/counterweight)](https://pypi.org/project/counterweight) 3 | [![Docs](https://img.shields.io/badge/docs-exist-brightgreen)](https://www.counterweight.dev) 4 | 5 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/JoshKarpel/counterweight/main.svg)](https://results.pre-commit.ci/latest/github/JoshKarpel/counterweight/main) 6 | [![codecov](https://codecov.io/gh/JoshKarpel/counterweight/branch/main/graph/badge.svg?token=2sjP4V0AfY)](https://codecov.io/gh/JoshKarpel/counterweight) 7 | 8 | [![GitHub issues](https://img.shields.io/github/issues/JoshKarpel/counterweight)](https://github.com/JoshKarpel/counterweight/issues) 9 | [![GitHub pull requests](https://img.shields.io/github/issues-pr/JoshKarpel/counterweight)](https://github.com/JoshKarpel/counterweight/pulls) 10 | 11 | # Counterweight 12 | 13 | Counterweight is an experimental text user interface (TUI) framework for Python, 14 | inspired by [React](https://react.dev/) and [Tailwind CSS](https://tailwindcss.com/). 15 | 16 | A TUI application built with Counterweight is a tree of declarative **components**, 17 | each of which represents some piece of the UI by bundling together 18 | a visual **element** along with its **state** and how that state should change due to **events** like **user input**. 19 | 20 | As an application author, 21 | you define the components and their relationships as a tree of Python functions. 22 | You use **hooks** to manage state and side effects, 23 | and **styles** to change how the elements look. 24 | 25 | Counterweight takes this declarative representation of the UI and **renders** it to the terminal, 26 | updating the UI when state changes in response to user input or side effects 27 | (by calling your function tree). 28 | 29 | ## Installation 30 | 31 | Counterweight is available [on PyPI](https://pypi.org/project/counterweight/): 32 | 33 | ```bash 34 | pip install counterweight 35 | ``` 36 | -------------------------------------------------------------------------------- /examples/borders.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from itertools import combinations, cycle 3 | 4 | from more_itertools import flatten 5 | from structlog import get_logger 6 | 7 | from counterweight.app import app 8 | from counterweight.components import component 9 | from counterweight.elements import Div, Text 10 | from counterweight.events import KeyPressed 11 | from counterweight.hooks import use_ref, use_state 12 | from counterweight.styles.utilities import * 13 | 14 | logger = get_logger() 15 | 16 | 17 | @component 18 | def root() -> Div: 19 | border_kind_cycle_ref = use_ref(cycle(BorderKind)) 20 | border_edge_cycle_ref = use_ref(cycle(reversed(list(flatten(combinations(BorderEdge, r) for r in range(1, 5)))))) 21 | 22 | def advance_border() -> BorderKind: 23 | return next(border_kind_cycle_ref.current) 24 | 25 | def advance_edges() -> frozenset[BorderEdge]: 26 | return frozenset(next(border_edge_cycle_ref.current)) 27 | 28 | border_kind, set_border_kind = use_state(advance_border) 29 | border_edges, set_border_edges = use_state(advance_edges) 30 | 31 | def on_key(event: KeyPressed) -> None: 32 | match event.key: 33 | case "k": 34 | set_border_kind(advance_border()) 35 | case "e": 36 | set_border_edges(advance_edges()) 37 | 38 | logger.debug("Rendering", border_kind=border_kind, border_edges=border_edges) 39 | 40 | return Div( 41 | style=col | align_children_center | justify_children_space_evenly, 42 | children=[ 43 | Div( 44 | style=border_heavy, 45 | children=[ 46 | Text( 47 | content=f"Border Edge Selection Demo\n{border_kind}\n{', '.join(be.name for be in border_edges)}", 48 | style=Style(border=Border(kind=border_kind, edges=border_edges)) 49 | | text_justify_center 50 | | text_bg_amber_800, 51 | ) 52 | ], 53 | ) 54 | ], 55 | on_key=on_key, 56 | ) 57 | 58 | 59 | if __name__ == "__main__": 60 | asyncio.run(app(root)) 61 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v6.0.0 5 | hooks: 6 | - id: check-added-large-files 7 | - id: check-ast 8 | - id: check-builtin-literals 9 | - id: check-case-conflict 10 | - id: check-docstring-first 11 | - id: check-merge-conflict 12 | - id: check-toml 13 | - id: check-json 14 | - id: debug-statements 15 | - id: end-of-file-fixer 16 | exclude_types: [svg] 17 | - id: forbid-new-submodules 18 | - id: mixed-line-ending 19 | - id: trailing-whitespace 20 | 21 | - repo: https://github.com/pre-commit/pygrep-hooks 22 | rev: v1.10.0 23 | hooks: 24 | - id: python-check-mock-methods 25 | - id: python-no-eval 26 | - id: python-no-log-warn 27 | - id: python-use-type-annotations 28 | - id: python-check-blanket-type-ignore 29 | 30 | - repo: https://github.com/python-jsonschema/check-jsonschema 31 | rev: 0.36.0 32 | hooks: 33 | - id: check-github-workflows 34 | - id: check-github-actions 35 | - id: check-dependabot 36 | 37 | - repo: local 38 | hooks: 39 | - id: generate-utilities 40 | name: generate utilities 41 | language: unsupported 42 | entry: uv run codegen/generate_utilities.py 43 | always_run: true 44 | pass_filenames: false 45 | - id: generate-screenshots 46 | name: generate docs screenshots 47 | language: unsupported 48 | entry: uv run docs/examples/generate-screenshots.sh 49 | always_run: true 50 | pass_filenames: false 51 | 52 | - repo: https://github.com/astral-sh/ruff-pre-commit 53 | rev: 'v0.14.9' 54 | hooks: 55 | - id: ruff-check 56 | args: [ --fix, --exit-non-zero-on-fix ] 57 | - id: ruff-format 58 | 59 | # TODO: figure out how to get this and snippets to play nicely 60 | # - repo: https://github.com/adamchainz/blacken-docs 61 | # rev: 1.16.0 62 | # hooks: 63 | # - id: blacken-docs 64 | # additional_dependencies: 65 | # - black==23.3.0 66 | 67 | ci: 68 | skip: 69 | - generate-utilities 70 | - generate-screenshots 71 | -------------------------------------------------------------------------------- /tests/utils/test_partition_int.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from hypothesis import assume, given 3 | from hypothesis.strategies import integers, lists 4 | 5 | from counterweight._utils import partition_int 6 | 7 | 8 | @pytest.mark.parametrize( 9 | ("total", "weights", "expected"), 10 | ( 11 | (0, (1,), [0]), 12 | (0, (1, 1), [0, 0]), 13 | (1, (1,), [1]), 14 | (3, (1,), [3]), 15 | (3, (3,), [3]), 16 | (2, (1, 1), [1, 1]), 17 | (3, (2, 1), [2, 1]), 18 | (3, (1, 2), [1, 2]), 19 | (4, (1, 2, 1), [1, 2, 1]), 20 | (5, (2, 1), [3, 2]), 21 | (5, (1, 2), [2, 3]), 22 | (3, (1, 1), [2, 1]), 23 | (100, (1, 1, 1), [33, 34, 33]), 24 | (101, (1, 1, 1), [34, 33, 34]), 25 | (102, (1, 1, 1), [34, 34, 34]), 26 | (103, (1, 1, 1), [34, 35, 34]), 27 | (104, (1, 1, 1), [35, 34, 35]), 28 | (105, (1, 1, 1), [35, 35, 35]), 29 | (201, (50, 100, 50), [50, 101, 50]), 30 | (201, (50, 50, 100), [50, 50, 101]), 31 | (201, (100, 50, 50), [100, 51, 50]), 32 | ), 33 | ) 34 | def test_examples(total: int, weights: tuple[int], expected: list[int]) -> None: 35 | assert partition_int(total, weights) == expected 36 | 37 | 38 | @pytest.mark.parametrize( 39 | ("total", "weights", "exc"), 40 | ( 41 | (5, (-1,), ValueError), 42 | (5, (-1, 1), ValueError), 43 | (5, (0,), ValueError), 44 | ), 45 | ) 46 | def test_errors(total: int, weights: tuple[int], exc: type[Exception]) -> None: 47 | with pytest.raises(exc): 48 | partition_int(total, weights) 49 | 50 | 51 | @pytest.mark.slow 52 | @given( 53 | total=integers( 54 | min_value=0, 55 | # The algorithm is inaccurate once you start hitting float precision problems, 56 | # but we only care about ints that could plausibly represent screen sizes in at most pixels 57 | # but more likely terminal cells, which are even coarser. 58 | max_value=10_000, 59 | ), 60 | weights=lists(integers(min_value=0), min_size=1), 61 | ) 62 | def test_properties(total: int, weights: list[int]) -> None: 63 | assume(sum(weights) > 0) 64 | 65 | result = partition_int(total, tuple(weights)) 66 | 67 | assert sum(result) == total 68 | 69 | assert len(result) == len(weights) 70 | -------------------------------------------------------------------------------- /docs/styles/utilities.md: -------------------------------------------------------------------------------- 1 | # Utilities 2 | 3 | To avoid the tedium of writing out styles explicitly over and over, 4 | Counterweight provides a variety of [Tailwind-inspired](https://tailwindcss.com/) "utility styles". 5 | These utilities are pre-defined styles that can be combined to form more complex styles. 6 | 7 | The utilities can be imported from the `counterweight.styles.utilities` module. 8 | We recommend a `*` import for ease of use: 9 | 10 | ```python 11 | from counterweight.styles.utilities import * 12 | ``` 13 | 14 | Each utility is a pre-defined `Style` specifying just a small set of properties. 15 | For example, `border_rose_200` is defined as: 16 | ```python 17 | from counterweight.styles import Style, Border, Color, CellStyle 18 | 19 | border_rose_200 = Style( 20 | border=Border(style=CellStyle(foreground=Color.from_hex("#fecdd3"))) 21 | ) 22 | ``` 23 | 24 | Since they are normal `Style`s, they can be combined to form more complex styles using the `|` operator. 25 | Here we make a new `Style` for a `rose_200`-colored heavy border on the top and bottom sides: 26 | 27 | ```python 28 | from counterweight.styles.utilities import * 29 | 30 | border_heavy_top_bottom_rose_200 = border_heavy | border_rose_200 | border_top_bottom 31 | ``` 32 | 33 | Actually giving a name to the new style is optional. 34 | We can also use the expression `border_heavy | border_rose_200 | border_top_bottom` directly in our component: 35 | 36 | ```python 37 | from counterweight.styles.utilities import * 38 | from counterweight.components import component 39 | from counterweight.elements import Text 40 | 41 | 42 | @component 43 | def my_component() -> Text: 44 | return Text( 45 | content="Hello, world!", 46 | style=border_heavy | border_rose_200 | border_top_bottom, 47 | ) 48 | ``` 49 | 50 | !!! tip "Performance Considerations" 51 | 52 | If you have an expression like `border_heavy | border_rose_200 | border_top_bottom` in your component, 53 | it will be evaluated every time the component is rendered. 54 | Merging styles with `|` does take some time, though it is aggressively cached inside the framework. 55 | If you find that this is causing performance issues, 56 | you should create the style just once, 57 | ideally outside your component (i.e., at module scope), 58 | so that it runs only once. 59 | -------------------------------------------------------------------------------- /counterweight/output.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, TextIO 4 | 5 | from structlog import get_logger 6 | 7 | from counterweight.geometry import Position 8 | from counterweight.paint import Paint 9 | from counterweight.styles.styles import CellStyle 10 | 11 | if TYPE_CHECKING: 12 | pass 13 | # https://www.xfree86.org/current/ctlseqs.html 14 | # https://invisible-island.net/xterm/ctlseqs/ctlseqs.pdf 15 | 16 | CURSOR_ON = "\x1b[?25h" 17 | CURSOR_OFF = "\x1b[?25l" 18 | 19 | ALT_SCREEN_ON = "\x1b[?1049h" 20 | ALT_SCREEN_OFF = "\x1b[?1049l" 21 | 22 | # 1003 = any event 23 | # 1006 = sgr format 24 | SET_ANY_EVENT_MOUSE_SGR_FORMAT = "\x1b[?1003h\x1b[?1006h" 25 | UNSET_ANY_EVENT_MOUSE_SGR_FORMAT = "\x1b[?1003l\x1b[?1006l" 26 | 27 | CLEAR_SCREEN = "\x1b[2J" 28 | 29 | BELL = "\x07" 30 | 31 | logger = get_logger() 32 | 33 | 34 | def start_output_control(stream: TextIO) -> None: # pragma: untestable 35 | stream.write(ALT_SCREEN_ON) 36 | stream.write(CURSOR_OFF) 37 | stream.write(CLEAR_SCREEN) 38 | 39 | stream.flush() 40 | 41 | 42 | def stop_output_control(stream: TextIO) -> None: # pragma: untestable 43 | stream.write(ALT_SCREEN_OFF) 44 | stream.write(CURSOR_ON) 45 | 46 | stream.flush() 47 | 48 | 49 | def start_mouse_tracking(stream: TextIO) -> None: # pragma: untestable 50 | stream.write(SET_ANY_EVENT_MOUSE_SGR_FORMAT) 51 | 52 | stream.flush() 53 | 54 | 55 | def stop_mouse_tracking(stream: TextIO) -> None: # pragma: untestable 56 | stream.write(UNSET_ANY_EVENT_MOUSE_SGR_FORMAT) 57 | 58 | stream.flush() 59 | 60 | 61 | def move_to(position: Position) -> str: 62 | return f"\x1b[{position.y + 1};{position.x + 1}f" 63 | 64 | 65 | def sgr_from_cell_style(style: CellStyle) -> str: 66 | fg_r, fg_g, fg_b = style.foreground 67 | bg_r, bg_g, bg_b = style.background 68 | 69 | sgr = f"\x1b[38;2;{fg_r};{fg_g};{fg_b}m\x1b[48;2;{bg_r};{bg_g};{bg_b}m" 70 | 71 | if style.bold: 72 | sgr += "\x1b[1m" 73 | 74 | if style.dim: 75 | sgr += "\x1b[2m" 76 | 77 | if style.italic: 78 | sgr += "\x1b[3m" 79 | 80 | if style.underline: 81 | sgr += "\x1b[4m" 82 | 83 | if style.strikethrough: 84 | sgr += "\x1b[9m" 85 | 86 | return sgr 87 | 88 | 89 | def paint_to_instructions(paint: Paint) -> str: 90 | return "".join(f"{move_to(pos)}{sgr_from_cell_style(cell.style)}{cell.char}\x1b[0m" for pos, cell in paint.items()) 91 | -------------------------------------------------------------------------------- /counterweight/elements.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from functools import cached_property 5 | from typing import Callable, Literal, Sequence, Union 6 | 7 | from pydantic import Field 8 | 9 | from counterweight.controls import AnyControl 10 | from counterweight.events import KeyPressed, MouseEvent 11 | from counterweight.styles import CellStyle, Style 12 | from counterweight.types import FrozenForbidExtras 13 | 14 | 15 | class Div(FrozenForbidExtras): 16 | type: Literal["div"] = "div" 17 | style: Style = Field(default=Style()) 18 | children: Sequence[Component | AnyElement] = Field(default=()) 19 | on_key: Callable[[KeyPressed], AnyControl | None] | None = None 20 | on_mouse: Callable[[MouseEvent], AnyControl | None] | None = None 21 | 22 | 23 | class Chunk(FrozenForbidExtras): 24 | content: str 25 | style: CellStyle = Field(default_factory=CellStyle) 26 | 27 | @cached_property 28 | def cells(self) -> tuple[CellPaint, ...]: 29 | return tuple(CellPaint(char=char, style=self.style) for char in self.content) 30 | 31 | @classmethod 32 | def space(cls) -> Chunk: 33 | return SPACE 34 | 35 | @classmethod 36 | def newline(cls) -> Chunk: 37 | return NEWLINE 38 | 39 | 40 | SPACE = Chunk(content=" ") 41 | NEWLINE = Chunk(content="\n") 42 | 43 | 44 | class Text(FrozenForbidExtras): 45 | type: Literal["text"] = "text" 46 | content: str | Sequence[Chunk] 47 | style: Style = Field(default=Style()) 48 | on_key: Callable[[KeyPressed], AnyControl | None] | None = None 49 | on_mouse: Callable[[MouseEvent], AnyControl | None] | None = None 50 | 51 | @property 52 | def children(self) -> Sequence[Component | AnyElement]: 53 | return () 54 | 55 | @cached_property 56 | def cells(self) -> tuple[CellPaint, ...]: 57 | if isinstance(self.content, str): 58 | return tuple(CellPaint(char=char, style=self.style.typography.style) for char in self.content) 59 | else: 60 | return sum((chunk.cells for chunk in self.content), ()) 61 | 62 | 63 | AnyElement = Union[ 64 | Div, 65 | Text, 66 | ] 67 | 68 | from counterweight.components import Component # noqa: E402, deferred to avoid circular import 69 | 70 | Div.model_rebuild() 71 | Text.model_rebuild() 72 | 73 | 74 | @dataclass(slots=True) 75 | class CellPaint: 76 | char: str 77 | style: CellStyle = field(default=CellStyle()) 78 | -------------------------------------------------------------------------------- /docs/examples/border_titles.py: -------------------------------------------------------------------------------- 1 | # --8<-- [start:example] 2 | from counterweight.app import app 3 | from counterweight.components import component 4 | from counterweight.controls import Quit, Screenshot 5 | from counterweight.elements import Div, Text 6 | from counterweight.styles.utilities import * 7 | 8 | 9 | @component 10 | def root() -> Div: 11 | return Div( 12 | style=col, 13 | children=[ 14 | Div( 15 | style=row | align_self_stretch | justify_children_center | align_children_center | border_lightrounded, 16 | children=[ 17 | Text( 18 | style=inset_top_left | absolute(x=1, y=-1), 19 | content=" Top-Left Title ", 20 | ), 21 | Text( 22 | style=inset_top_center | absolute(x=0, y=-1), 23 | content=" Top-Center Title ", 24 | ), 25 | Text( 26 | style=inset_top_right | absolute(x=-1, y=-1), 27 | content=" Top-Right Title ", 28 | ), 29 | Text( 30 | style=inset_bottom_left | absolute(x=1, y=1), 31 | content=" Bottom-Left Title ", 32 | ), 33 | Text( 34 | style=inset_bottom_center | absolute(x=0, y=1), 35 | content=" Bottom-Center Title ", 36 | ), 37 | Text( 38 | style=inset_bottom_right | absolute(x=-1, y=1), 39 | content=" Bottom-Right Title ", 40 | ), 41 | Text( 42 | style=weight_none, 43 | content="Lorem ipsum dolor sit amet, consectetur adipiscing elit.", 44 | ), 45 | ], 46 | ) 47 | ], 48 | ) 49 | 50 | 51 | # --8<-- [end:example] 52 | 53 | if __name__ == "__main__": 54 | import asyncio 55 | from pathlib import Path 56 | 57 | THIS_DIR = Path(__file__).parent 58 | 59 | asyncio.run( 60 | app( 61 | root, 62 | headless=True, 63 | dimensions=(70, 5), 64 | autopilot=[ 65 | Screenshot.to_file(THIS_DIR.parent / "assets" / "border-titles.svg", indent=1), 66 | Quit(), 67 | ], 68 | ) 69 | ) 70 | -------------------------------------------------------------------------------- /counterweight/events.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Union 5 | 6 | from counterweight.geometry import Position 7 | 8 | 9 | @dataclass(frozen=True, slots=True) 10 | class _Event: 11 | pass 12 | 13 | 14 | @dataclass(frozen=True, slots=True) 15 | class TerminalResized(_Event): 16 | pass 17 | 18 | 19 | @dataclass(frozen=True, slots=True) 20 | class KeyPressed(_Event): 21 | key: str 22 | 23 | 24 | @dataclass(frozen=True, slots=True) 25 | class MouseMoved(_Event): 26 | absolute: Position 27 | """The absolute position on the screen that the mouse moved to.""" 28 | 29 | button: int | None 30 | """The button that was held during the motion, or `None` if no button was pressed.""" 31 | 32 | 33 | @dataclass(frozen=True, slots=True) 34 | class MouseDown(_Event): 35 | absolute: Position 36 | """The absolute position on the screen that the mouse moved to.""" 37 | 38 | button: int 39 | """The mouse button that was pressed during the motion.""" 40 | 41 | 42 | @dataclass(frozen=True, slots=True) 43 | class MouseUp(_Event): 44 | absolute: Position 45 | """The absolute position on the screen that the mouse moved to.""" 46 | 47 | button: int 48 | """The mouse button that was released during the motion.""" 49 | 50 | 51 | @dataclass(frozen=True, slots=True) 52 | class MouseScrolledDown(_Event): 53 | absolute: Position 54 | """The absolute position on the screen that the mouse moved to.""" 55 | 56 | direction: int = 1 57 | """The direction that the mouse was scrolled as an integer offset; `-1` for up, `+1` for down.""" 58 | 59 | 60 | @dataclass(frozen=True, slots=True) 61 | class MouseScrolledUp(_Event): 62 | absolute: Position 63 | """The absolute position on the screen that the mouse moved to.""" 64 | 65 | direction: int = -1 66 | """The direction that the mouse was scrolled as an integer offset; `-1` for up, `+1` for down.""" 67 | 68 | 69 | @dataclass(frozen=True, slots=True) 70 | class StateSet(_Event): 71 | pass 72 | 73 | 74 | @dataclass(frozen=True, slots=True) 75 | class Dummy(_Event): 76 | pass 77 | 78 | 79 | MouseEvent = Union[ 80 | MouseMoved, 81 | MouseDown, 82 | MouseUp, 83 | MouseScrolledDown, 84 | MouseScrolledUp, 85 | ] 86 | 87 | AnyEvent = Union[ 88 | TerminalResized, 89 | KeyPressed, 90 | StateSet, 91 | MouseMoved, 92 | MouseDown, 93 | MouseUp, 94 | MouseScrolledDown, 95 | MouseScrolledUp, 96 | Dummy, 97 | ] 98 | -------------------------------------------------------------------------------- /examples/border_healing.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from itertools import combinations, product 3 | from random import randint 4 | 5 | from more_itertools import flatten 6 | from structlog import get_logger 7 | 8 | from counterweight.app import app 9 | from counterweight.components import component 10 | from counterweight.controls import AnyControl, ToggleBorderHealing 11 | from counterweight.elements import Div, Text 12 | from counterweight.events import KeyPressed 13 | from counterweight.hooks import use_state 14 | from counterweight.keys import Key 15 | from counterweight.styles.utilities import * 16 | from examples.canvas import clamp 17 | 18 | logger = get_logger() 19 | 20 | E = list(product(reversed(list(flatten(combinations(BorderEdge, r) for r in range(1, 5)))), repeat=4)) 21 | 22 | 23 | @component 24 | def root() -> Div: 25 | border_edge_idx, set_border_edge_idx = use_state(0) 26 | 27 | (top_left, top_right, bottom_left, bottom_right) = E[border_edge_idx] 28 | 29 | def on_key(event: KeyPressed) -> AnyControl | None: 30 | match event.key: 31 | case Key.Right: 32 | set_border_edge_idx(lambda i: clamp(0, i + 1, len(E) - 1)) 33 | case Key.Left: 34 | set_border_edge_idx(lambda i: clamp(0, i - 1, len(E) - 1)) 35 | case "r": 36 | set_border_edge_idx(randint(0, len(E) - 1)) 37 | case Key.Space: 38 | return ToggleBorderHealing() 39 | 40 | return None 41 | 42 | return Div( 43 | style=col | align_children_center | justify_children_center, 44 | children=[ 45 | Div( 46 | style=align_children_end, 47 | children=[ 48 | box(e=frozenset(top_left)), 49 | box(e=frozenset(top_right)), 50 | ], 51 | ), 52 | Div( 53 | style=align_children_start, 54 | children=[ 55 | box(e=frozenset(bottom_left)), 56 | box(e=frozenset(bottom_right)), 57 | ], 58 | ), 59 | ], 60 | on_key=on_key, 61 | ) 62 | 63 | 64 | @component 65 | def box(e: frozenset[BorderEdge]) -> Text: 66 | return Text( 67 | content=f"Border Join Demo\n{', '.join(be.name for be in e)}", 68 | style=Style(border=Border(edges=e)) | text_justify_center | text_bg_amber_800, 69 | ) 70 | 71 | 72 | if __name__ == "__main__": 73 | asyncio.run(app(root)) 74 | # print(len(E)) 75 | # print(E[0]) 76 | -------------------------------------------------------------------------------- /docs/examples/absolute_positioning.py: -------------------------------------------------------------------------------- 1 | # --8<-- [start:example] 2 | from counterweight.app import app 3 | from counterweight.components import component 4 | from counterweight.controls import Quit, Screenshot 5 | from counterweight.elements import Div, Text 6 | from counterweight.styles.utilities import * 7 | 8 | extra_style = border_light | pad_1 | margin_1 9 | 10 | 11 | @component 12 | def root() -> Div: 13 | return Div( 14 | style=col | justify_children_space_around, 15 | children=[ 16 | Div( 17 | style=border_heavy, 18 | children=[ 19 | Text( 20 | style=text_green_600, 21 | content="Lorem ipsum dolor sit amet, consectetur adipiscing elit.", 22 | ) 23 | ] 24 | + [ 25 | Text( 26 | style=absolute(x=x, y=y) | extra_style | margin_red_600, 27 | content=f"absolute(x={x}, y={y})", 28 | ) 29 | for x, y in ( 30 | (0, 0), 31 | (10, -7), 32 | (33, 3), 33 | ) 34 | ], 35 | ), 36 | Div( 37 | style=border_heavy, 38 | children=[ 39 | Text( 40 | style=text_cyan_600, 41 | content="Lorem ipsum dolor sit amet, consectetur adipiscing elit.", 42 | ) 43 | ] 44 | + [ 45 | Text( 46 | style=absolute(x=x, y=y) | extra_style | margin_amber_600, 47 | content=f"absolute(x={x}, y={y})", 48 | ) 49 | for x, y in ( 50 | (0, 0), 51 | (10, -7), 52 | (33, 3), 53 | ) 54 | ], 55 | ), 56 | ], 57 | ) 58 | 59 | 60 | # --8<-- [end:example] 61 | 62 | if __name__ == "__main__": 63 | import asyncio 64 | from pathlib import Path 65 | 66 | THIS_DIR = Path(__file__).parent 67 | 68 | asyncio.run( 69 | app( 70 | root, 71 | headless=True, 72 | dimensions=(60, 30), 73 | autopilot=[ 74 | Screenshot.to_file(THIS_DIR.parent / "assets" / "absolute-positioning.svg", indent=1), 75 | Quit(), 76 | ], 77 | ) 78 | ) 79 | -------------------------------------------------------------------------------- /docs/examples/relative_positioning.py: -------------------------------------------------------------------------------- 1 | # --8<-- [start:example] 2 | from counterweight.app import app 3 | from counterweight.components import component 4 | from counterweight.controls import Quit, Screenshot 5 | from counterweight.elements import Div, Text 6 | from counterweight.styles.utilities import * 7 | 8 | extra_style = pad_1 | margin_1 9 | 10 | 11 | @component 12 | def root() -> Div: 13 | return Div( 14 | style=col | justify_children_space_between, 15 | children=[ 16 | Div( 17 | style=row, 18 | children=[ 19 | Text( 20 | style=relative(x=x, y=y) | extra_style | border_lightrounded | margin_red_600, 21 | content=f"relative(x={x}, y={y})", 22 | ) 23 | for x, y in ( 24 | (0, 0), 25 | (0, 5), 26 | (0, -3), 27 | ) 28 | ], 29 | ), 30 | Div( 31 | style=row, 32 | children=[ 33 | Text( 34 | style=relative(x=x, y=y) | extra_style | border_heavy | margin_amber_600, 35 | content=f"relative(x={x}, y={y})", 36 | ) 37 | for x, y in ( 38 | (0, 0), 39 | (3, 3), 40 | (0, 0), 41 | ) 42 | ], 43 | ), 44 | Div( 45 | style=row, 46 | children=[ 47 | Text( 48 | style=relative(x=x, y=y) | extra_style | border_light | margin_violet_700, 49 | content=f"relative(x={x}, y={y})", 50 | ) 51 | for x, y in ( 52 | (0, 0), 53 | (5, 0), 54 | (0, -5), 55 | ) 56 | ], 57 | ), 58 | ], 59 | ) 60 | 61 | 62 | # --8<-- [end:example] 63 | 64 | if __name__ == "__main__": 65 | import asyncio 66 | from pathlib import Path 67 | 68 | THIS_DIR = Path(__file__).parent 69 | 70 | asyncio.run( 71 | app( 72 | root, 73 | headless=True, 74 | dimensions=(80, 30), 75 | autopilot=[ 76 | Screenshot.to_file(THIS_DIR.parent / "assets" / "relative-positioning.svg", indent=1), 77 | Quit(), 78 | ], 79 | ) 80 | ) 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | .idea/ 132 | .vscode/ 133 | 134 | *.lprof 135 | 136 | /.ruff_cache/ 137 | 138 | *.svg 139 | !/docs/**/*.svg 140 | 141 | *.austin 142 | *.ss 143 | -------------------------------------------------------------------------------- /counterweight/input.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import selectors 5 | import termios 6 | from collections.abc import Callable 7 | from copy import deepcopy 8 | from selectors import DefaultSelector 9 | from threading import Event 10 | from time import perf_counter_ns 11 | from typing import TextIO 12 | 13 | from structlog import get_logger 14 | 15 | from counterweight.events import AnyEvent 16 | from counterweight.keys import vt_inputs 17 | 18 | logger = get_logger() 19 | 20 | 21 | def read_keys( 22 | stream: TextIO, 23 | put_event: Callable[[AnyEvent], None], 24 | allow: Event, 25 | waiting: Event, 26 | ) -> None: 27 | selector = DefaultSelector() 28 | selector.register(stream, selectors.EVENT_READ) 29 | 30 | while True: 31 | if not allow.is_set(): 32 | waiting.set() 33 | allow.wait() 34 | waiting.clear() 35 | 36 | # We're just reading from one file, 37 | # so we can dispense with the ceremony of actually using the results of the select, 38 | # other than knowing that it did return something. 39 | if not selector.select(timeout=1 / 60): 40 | continue 41 | 42 | start_parsing = perf_counter_ns() 43 | bytes = os.read(stream.fileno(), 2**10) 44 | 45 | if not bytes: 46 | continue 47 | 48 | try: 49 | inputs = vt_inputs.parse(bytes) 50 | 51 | for i in inputs: 52 | put_event(i) 53 | 54 | logger.debug( 55 | "Parsed user input", 56 | inputs=inputs, 57 | bytes=bytes, 58 | elapsed_ns=f"{perf_counter_ns() - start_parsing:_}", 59 | ) 60 | except Exception as e: 61 | logger.error( 62 | "Failed to parse input", 63 | error=repr(e), 64 | bytes=bytes, 65 | elapsed_ns=f"{perf_counter_ns() - start_parsing:_}", 66 | ) 67 | 68 | 69 | LFLAG = 3 70 | CC = 6 71 | 72 | TCGetAttr = list[int | list[int | bytes]] 73 | 74 | 75 | def start_input_control(stream: TextIO) -> TCGetAttr: # pragma: untestable 76 | original = termios.tcgetattr(stream) 77 | 78 | modified = deepcopy(original) 79 | 80 | modified[LFLAG] = original[LFLAG] & ~(termios.ECHO | termios.ICANON) 81 | modified[CC][termios.VMIN] = 1 82 | modified[CC][termios.VTIME] = 0 83 | 84 | termios.tcsetattr(stream.fileno(), termios.TCSADRAIN, modified) 85 | 86 | return original 87 | 88 | 89 | def stop_input_control(stream: TextIO, original: TCGetAttr) -> None: # pragma: untestable 90 | termios.tcsetattr(stream.fileno(), termios.TCSADRAIN, original) 91 | -------------------------------------------------------------------------------- /examples/table.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from structlog import get_logger 4 | 5 | from counterweight.app import app 6 | from counterweight.components import component 7 | from counterweight.controls import AnyControl, ToggleBorderHealing 8 | from counterweight.elements import Div, Text 9 | from counterweight.events import KeyPressed 10 | from counterweight.keys import Key 11 | from counterweight.styles.utilities import * 12 | 13 | logger = get_logger() 14 | 15 | 16 | # TODO: what happens if you tweak these (removing align_self_stretch definitely breaks it) 17 | style = align_self_stretch | justify_children_center | align_children_center 18 | bs = border_double 19 | 20 | 21 | @component 22 | def root() -> Div: 23 | def on_key(event: KeyPressed) -> AnyControl | None: 24 | match event.key: 25 | case Key.Space: 26 | return ToggleBorderHealing() 27 | case _: 28 | return None 29 | 30 | return Div( 31 | style=row | style, 32 | on_key=on_key, 33 | children=[ 34 | Div( 35 | style=col | style, 36 | children=[ 37 | box("A1", edge_style=None), 38 | box("A2", edge_style=border_bottom_left_right), 39 | ], 40 | ), 41 | Div( 42 | style=col | style, 43 | children=[ 44 | Div( 45 | style=row | style, 46 | children=[ 47 | box("B1", edge_style=border_top_bottom_right), 48 | box("B2", edge_style=border_top_bottom_right), 49 | ], 50 | ), 51 | Div( 52 | style=row | style, 53 | children=[ 54 | box("C1"), 55 | box("C2"), 56 | box("C3"), 57 | box("C4"), 58 | ], 59 | ), 60 | Div( 61 | style=row | style, 62 | children=[ 63 | box("D1"), 64 | box("D2"), 65 | box("D3"), 66 | ], 67 | ), 68 | ], 69 | ), 70 | ], 71 | ) 72 | 73 | 74 | @component 75 | def box(s: str, edge_style: Style | None = border_bottom_right) -> Div: 76 | return Div( 77 | style=style | bs | edge_style, 78 | children=[Text(style=text_justify_center, content=s)], 79 | ) 80 | 81 | 82 | if __name__ == "__main__": 83 | asyncio.run(app(root)) 84 | -------------------------------------------------------------------------------- /counterweight/border_healing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import lru_cache 4 | 5 | from more_itertools import flatten 6 | from structlog import get_logger 7 | 8 | from counterweight.geometry import Position 9 | from counterweight.paint import BorderHealingHints, P, Paint 10 | from counterweight.styles.styles import JoinedBorderKind, JoinedBorderParts 11 | 12 | logger = get_logger() 13 | 14 | 15 | @lru_cache(maxsize=2**12) 16 | def get_replacement_char( 17 | parts: JoinedBorderParts, 18 | center: str, 19 | left: str | None, 20 | right: str | None, 21 | above: str | None, 22 | below: str | None, 23 | ) -> str | None: 24 | if ( 25 | c := parts.select( 26 | top=center in parts.connects_top or above in parts.connects_bottom, 27 | bottom=center in parts.connects_bottom or below in parts.connects_top, 28 | left=center in parts.connects_left or left in parts.connects_right, 29 | right=center in parts.connects_right or right in parts.connects_left, 30 | ) 31 | ) != center: 32 | return c 33 | else: 34 | return None 35 | 36 | 37 | @lru_cache(maxsize=2**12) 38 | def dither(position: Position) -> tuple[Position, Position, Position, Position]: 39 | return ( 40 | Position.flyweight(position.x - 1, position.y), # left 41 | Position.flyweight(position.x + 1, position.y), # right 42 | Position.flyweight(position.x, position.y - 1), # above 43 | Position.flyweight(position.x, position.y + 1), # below 44 | ) 45 | 46 | 47 | ALL_JOINED_BORDER_KIND_CHARS = set(flatten(k.value for k in JoinedBorderKind)) 48 | 49 | 50 | def heal_borders(paint: Paint, hints: BorderHealingHints) -> Paint: 51 | overlay: Paint = {} 52 | for center_position, parts in hints.items(): 53 | center = paint[center_position] 54 | 55 | if center.char not in ALL_JOINED_BORDER_KIND_CHARS: 56 | # Even if we got a hint, that cell may have been overwritten by another 57 | # element (e.g., putting a title over a border using absolute positioning). 58 | continue 59 | 60 | left, right, above, below = map(paint.get, dither(center_position)) 61 | 62 | # TODO: cell styles and z-levels must match too (i.e., colors) 63 | 64 | if replaced_char := get_replacement_char( 65 | parts=parts, 66 | center=center.char, 67 | left=left.char if left else None, 68 | right=right.char if right else None, 69 | above=above.char if above else None, 70 | below=below.char if below else None, 71 | ): 72 | overlay[center_position] = P(char=replaced_char, style=center.style, z=center.z) 73 | 74 | return overlay 75 | -------------------------------------------------------------------------------- /counterweight/cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from asyncio import Queue, get_running_loop, run 5 | from textwrap import dedent 6 | from threading import Event, Thread 7 | 8 | from typer import Option, Typer 9 | 10 | from counterweight._context_vars import current_event_queue 11 | from counterweight.constants import PACKAGE_NAME, __version__ 12 | from counterweight.events import AnyEvent 13 | from counterweight.input import read_keys, start_input_control, stop_input_control 14 | from counterweight.logging import tail_devlog 15 | from counterweight.output import start_mouse_tracking, stop_mouse_tracking 16 | 17 | cli = Typer( 18 | name=PACKAGE_NAME, 19 | no_args_is_help=True, 20 | help=dedent( 21 | """\ 22 | CLI tools for Counterweight. 23 | """ 24 | ), 25 | ) 26 | 27 | 28 | @cli.command() 29 | def version() -> None: 30 | """ 31 | Display version information. 32 | """ 33 | print(__version__) 34 | 35 | 36 | @cli.command() 37 | def devlog() -> None: 38 | """Tail the developer log file.""" 39 | tail_devlog() 40 | 41 | 42 | @cli.command() 43 | def check_input(mouse: bool = Option(default=False, help="Also capture mouse inputs and show mouse events.")) -> None: 44 | """ 45 | Enter the same input-capture state used during application mode, 46 | and show the results of reading input (e.g., key events). 47 | """ 48 | 49 | run(_check_input(mouse=mouse)) 50 | 51 | 52 | async def _check_input(mouse: bool) -> None: 53 | event_queue: Queue[AnyEvent] = Queue() 54 | current_event_queue.set(event_queue) 55 | 56 | loop = get_running_loop() 57 | 58 | def put_event(event: AnyEvent) -> None: 59 | loop.call_soon_threadsafe(event_queue.put_nowait, event) 60 | 61 | input_stream = sys.stdin 62 | output_stream = sys.stdout 63 | 64 | allow, waiting = Event(), Event() 65 | allow.set() 66 | 67 | key_thread = Thread( 68 | target=read_keys, 69 | args=( 70 | input_stream, 71 | put_event, 72 | allow, 73 | waiting, 74 | ), 75 | daemon=True, 76 | ) 77 | key_thread.start() 78 | 79 | original = start_input_control(stream=input_stream) 80 | if mouse: 81 | start_mouse_tracking(stream=output_stream) 82 | try: 83 | while True: 84 | print("Waiting for input (press ctrl+c to exit)...") 85 | key = await event_queue.get() 86 | print(f"{key!r}") 87 | except KeyboardInterrupt: 88 | print("Exiting...") 89 | finally: 90 | stop_input_control(stream=input_stream, original=original) 91 | if mouse: 92 | stop_mouse_tracking(stream=output_stream) 93 | 94 | 95 | if __name__ == "__main__": 96 | cli() 97 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | Counterweight is an experimental text user interface (TUI) framework for Python, inspired by React and Tailwind CSS. 8 | It builds declarative TUI applications using a component-based architecture with hooks for state management and styles for visual presentation. 9 | 10 | ## Architecture 11 | 12 | The framework follows a React-like architecture: 13 | 14 | - **Components** (counterweight/components.py): Declarative functions decorated with `@component` that return elements and manage state through hooks 15 | - **Elements** (counterweight/elements.py): Core UI primitives like `Div` and `Text`, with style and event handling capabilities 16 | - **Hooks** (counterweight/hooks/): State management (`use_state`), side effects (`use_effect`), and UI interactions (`use_mouse`, `use_hovered`) 17 | - **App** (counterweight/app.py): Main application loop that handles rendering, input, and event processing 18 | - **Styles** (counterweight/styles/): Tailwind-inspired utility classes and styling system 19 | - **Paint/Rendering** (counterweight/paint.py, counterweight/output.py): Terminal rendering engine that converts components to terminal output 20 | 21 | The entry point is typically through `counterweight.app.app()` which starts the main application loop. 22 | 23 | ## Development Commands 24 | 25 | This project uses [Just](https://github.com/casey/just) as the task runner. Key commands: 26 | 27 | ```bash 28 | # Install dependencies 29 | just install 30 | 31 | # Run tests with type checking and coverage 32 | just test 33 | 34 | # Build the documentation 35 | just docs-build 36 | 37 | # Lint and format code 38 | just pre-commit 39 | ``` 40 | 41 | ## Project Structure 42 | 43 | - **counterweight/**: Main framework code 44 | - **hooks/**: React-like hooks for state and side effects 45 | - **styles/**: Styling system with utilities 46 | - **examples/**: Complete example applications (wordle.py, chess.py, etc.) 47 | - **tests/**: Test suites organized by module 48 | - **docs/**: MkDocs documentation with examples and API reference 49 | 50 | ## Key Dependencies 51 | 52 | - **pydantic**: Data validation and settings 53 | - **typer**: CLI interface 54 | - **structlog**: Structured logging 55 | - **parsy**: Parser combinators for terminal input 56 | - **more-itertools**: Extended iteration utilities 57 | 58 | ## Code Style Guidelines 59 | - Line length: 120 60 | - Strict typing: All functions must be fully typed 61 | - Formatting and linting: Run `uv run pre-commit` to enforce 62 | - Use pathlib instead of os.path 63 | - Follow PEP 8 conventions for naming 64 | - Use rich for terminal output 65 | - Pydantic for data modeling 66 | - Use pytest for testing 67 | - No implicit optional types 68 | - Don't write lots of comments; the code and test names should be self-explanatory. 69 | -------------------------------------------------------------------------------- /docs/styles/layout.md: -------------------------------------------------------------------------------- 1 | # Layout 2 | 3 | ## Box Model 4 | 5 | Counterweight's layout system is roughly based on the 6 | [CSS box model](https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/The_box_model). 7 | When you build your application, you represent it as a hierarchy of nested elements. 8 | The layout system constructs a mirrored nested hierarchy of layout boxes, 9 | each of which has a size and position (calculated by the layout system). 10 | Each layout box is composed of four nested **rectangles**: 11 | 12 | - **Content**: The area where the element's "content" is laid out. 13 | What the content actually is depends on the element's type. 14 | For example, a [`Div`'s][counterweight.elements.Div] content is its children, 15 | while a [`Text`'s][counterweight.elements.Text] content is its text. 16 | - **Padding**: The area between the content and the border. 17 | - **Border**: The area between the padding and the margin, 18 | with a border drawn from box-drawing characters. 19 | - **Margin**: The area between the border and the next element. 20 | 21 | The size, background color, and other display properties of each area are controlled via dedicated styles. 22 | The example below shows how the four areas are laid out for a simple `Div` element. 23 | 24 | ```python 25 | --8<-- "box_model.py:example" 26 | ``` 27 | 28 | ![Box Model](../assets/box-model.svg) 29 | 30 | !!! tip "Terminal cells are not square!" 31 | 32 | Unlike in a web browser, where pixel coordinates in the `x` and `y` directions represent the same physical distance, 33 | terminal cell coordinates (which is what Counterweight uses) *are not square*: 34 | they are twice as tall as they are wide. 35 | 36 | Be careful with vertical padding and margin in particular, 37 | as they will appear to be twice as large as horizontal padding and margin, 38 | which can throw off your layout. Adding only horizontal padding/margin is often sufficient. 39 | Note how the example above uses twice as much horizontal padding/margin as vertical padding/margin 40 | in order to achieve a more equal aspect ratio. 41 | 42 | ## Positioning 43 | 44 | ### Relative Positioning 45 | 46 | ::: counterweight.styles.Relative 47 | 48 | ```python 49 | --8<-- "relative_positioning.py:example" 50 | ``` 51 | 52 | ![Relative Positioning](../assets/relative-positioning.svg) 53 | 54 | ### Absolute Positioning 55 | 56 | ::: counterweight.styles.Absolute 57 | 58 | ```python 59 | --8<-- "absolute_positioning.py:example" 60 | ``` 61 | 62 | ![Absolute Positioning](../assets/absolute-positioning.svg) 63 | 64 | #### Controlling Overlapping with `z` 65 | 66 | ```python 67 | --8<-- "z.py:example" 68 | ``` 69 | 70 | ![Z Layers](../assets/z.svg) 71 | 72 | 73 | ```python 74 | --8<-- "absolute_positioning_insets.py:example" 75 | ``` 76 | 77 | ![Absolute Positioning Insets](../assets/absolute-positioning-insets.svg) 78 | 79 | 80 | ### Fixed Positioning 81 | 82 | ::: counterweight.styles.Fixed 83 | 84 | ```python 85 | --8<-- "fixed_positioning.py:example" 86 | ``` 87 | 88 | ![Fixed Positioning](../assets/fixed-positioning.svg) 89 | -------------------------------------------------------------------------------- /docs/examples/absolute_positioning_insets.py: -------------------------------------------------------------------------------- 1 | # --8<-- [start:example] 2 | from counterweight.app import app 3 | from counterweight.components import component 4 | from counterweight.controls import Quit, Screenshot 5 | from counterweight.elements import Div, Text 6 | from counterweight.styles.utilities import * 7 | 8 | 9 | @component 10 | def root() -> Div: 11 | return Div( 12 | style=col | align_self_stretch, 13 | children=[ 14 | Div( 15 | style=row | align_self_stretch | border_heavy, 16 | children=[ 17 | Text( 18 | style=inset_top_left, 19 | content="inset_top_left", 20 | ), 21 | Text( 22 | style=inset_top_left | absolute(x=3, y=3), 23 | content="inset_top_left | absolute(x=3, y=3)", 24 | ), 25 | Text( 26 | style=inset_top_center, 27 | content="inset_top_center", 28 | ), 29 | Text( 30 | style=inset_top_right, 31 | content="inset_top_right", 32 | ), 33 | Text( 34 | style=inset_center_left, 35 | content="inset_center_left", 36 | ), 37 | Text( 38 | style=inset_center_center, 39 | content="inset_center_center", 40 | ), 41 | Text( 42 | style=inset_center_center | absolute(x=-2, y=-4), 43 | content="inset_center_center | absolute(x=-2, y=-4)", 44 | ), 45 | Text( 46 | style=inset_center_right, 47 | content="inset_center_right", 48 | ), 49 | Text( 50 | style=inset_bottom_left, 51 | content="inset_bottom_left", 52 | ), 53 | Text( 54 | style=inset_bottom_center, 55 | content="inset_bottom_center", 56 | ), 57 | Text( 58 | style=inset_bottom_right, 59 | content="inset_bottom_right", 60 | ), 61 | Text( 62 | style=inset_bottom_right | absolute(y=-4), 63 | content="inset_bottom_right | absolute(y=-4)", 64 | ), 65 | ], 66 | ) 67 | ], 68 | ) 69 | 70 | 71 | # --8<-- [end:example] 72 | 73 | if __name__ == "__main__": 74 | import asyncio 75 | from pathlib import Path 76 | 77 | THIS_DIR = Path(__file__).parent 78 | 79 | asyncio.run( 80 | app( 81 | root, 82 | headless=True, 83 | dimensions=(60, 25), 84 | autopilot=[ 85 | Screenshot.to_file(THIS_DIR.parent / "assets" / "absolute-positioning-insets.svg", indent=1), 86 | Quit(), 87 | ], 88 | ) 89 | ) 90 | -------------------------------------------------------------------------------- /examples/stopwatch.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from textwrap import dedent 3 | from time import monotonic 4 | 5 | from structlog import get_logger 6 | 7 | from counterweight.app import app 8 | from counterweight.components import component 9 | from counterweight.elements import Div, Text 10 | from counterweight.events import KeyPressed 11 | from counterweight.hooks import use_effect, use_state 12 | from counterweight.keys import Key 13 | from counterweight.styles.utilities import * 14 | 15 | logger = get_logger() 16 | 17 | 18 | @component 19 | def root() -> Div: 20 | num_stopwatches = 3 21 | 22 | selected_stopwatch, set_selected_stopwatch = use_state(0) 23 | 24 | def on_key(event: KeyPressed) -> None: 25 | match event.key: 26 | case Key.Tab: 27 | set_selected_stopwatch(lambda s: (s + 1) % num_stopwatches) 28 | case Key.BackTab: 29 | set_selected_stopwatch(lambda s: (s - 1) % num_stopwatches) 30 | 31 | return Div( 32 | style=col | justify_children_space_between | align_children_center, 33 | children=[ 34 | Text( 35 | style=text_amber_600, 36 | content="Stopwatch", 37 | ), 38 | Div( 39 | style=row | align_self_stretch | align_children_center | justify_children_space_evenly, 40 | on_key=on_key, 41 | children=[stopwatch(selected=selected_stopwatch == n) for n in range(num_stopwatches)], 42 | ), 43 | Text( 44 | style=border_slate_400 | text_slate_200 | border_lightrounded | pad_x_2 | pad_y_1, 45 | content=dedent( 46 | """\ 47 | - / to select next/previous stopwatch 48 | - to start/stop selected stopwatch 49 | - to reset selected stopwatch 50 | """ 51 | ), 52 | ), 53 | ], 54 | ) 55 | 56 | 57 | @component 58 | def stopwatch(selected: bool) -> Text: 59 | running, set_running = use_state(False) 60 | elapsed_time, set_elapsed_time = use_state(0.0) 61 | 62 | def on_key(event: KeyPressed) -> None: 63 | if not selected: 64 | return 65 | 66 | match event.key: 67 | case Key.Space: 68 | set_running(not running) 69 | case Key.Backspace: 70 | set_running(False) 71 | set_elapsed_time(0) 72 | 73 | async def tick() -> None: 74 | if running: 75 | previous = monotonic() 76 | while True: 77 | now = monotonic() 78 | set_elapsed_time(lambda e: e + (now - previous)) 79 | previous = now 80 | 81 | await asyncio.sleep(0.01) 82 | 83 | use_effect(tick, deps=(running,)) 84 | 85 | return Text( 86 | content=f"{elapsed_time:.6f}", 87 | style=( 88 | (border_emerald_600 if selected else border_emerald_300) 89 | if running 90 | else (border_rose_500 if selected else border_rose_400) 91 | ) 92 | | (border_heavy if selected else border_double) 93 | | pad_x_2 94 | | pad_y_1, 95 | on_key=on_key, 96 | ) 97 | 98 | 99 | if __name__ == "__main__": 100 | asyncio.run(app(root)) 101 | -------------------------------------------------------------------------------- /docs/examples/border_healing.py: -------------------------------------------------------------------------------- 1 | # --8<-- [start:example] 2 | from counterweight.app import app 3 | from counterweight.components import component 4 | from counterweight.controls import AnyControl, Quit, Screenshot, ToggleBorderHealing 5 | from counterweight.elements import Div, Text 6 | from counterweight.events import KeyPressed 7 | from counterweight.keys import Key 8 | from counterweight.styles.utilities import * 9 | 10 | common_style = align_self_stretch | justify_children_center | align_children_center 11 | border_kind = border_double 12 | 13 | 14 | @component 15 | def root() -> Div: 16 | def on_key(event: KeyPressed) -> AnyControl | None: 17 | match event.key: 18 | case Key.Space: 19 | return ToggleBorderHealing() 20 | case _: 21 | return None 22 | 23 | return Div( 24 | style=row | common_style, 25 | on_key=on_key, 26 | children=[ 27 | Div( 28 | style=col | common_style, 29 | children=[ 30 | box("A1", edge_style=None), 31 | box("A2", edge_style=border_bottom_left_right), 32 | ], 33 | ), 34 | Div( 35 | style=col | common_style, 36 | children=[ 37 | Div( 38 | style=row | common_style, 39 | children=[ 40 | box("B1", edge_style=border_top_bottom_right), 41 | box("B2", edge_style=border_top_bottom_right), 42 | ], 43 | ), 44 | Div( 45 | style=row | common_style, 46 | children=[ 47 | box("C1"), 48 | box("C2"), 49 | box("C3"), 50 | box("C4"), 51 | ], 52 | ), 53 | Div( 54 | style=row | common_style, 55 | children=[ 56 | box("D1"), 57 | box("D2"), 58 | box("D3"), 59 | ], 60 | ), 61 | ], 62 | ), 63 | ], 64 | ) 65 | 66 | 67 | @component 68 | def box(s: str, edge_style: Style | None = border_bottom_right) -> Div: 69 | return Div( 70 | style=common_style | border_kind | edge_style, 71 | children=[ 72 | Text( 73 | style=text_justify_center | (text_cyan_500 if edge_style == border_bottom_right else text_amber_500), 74 | content=s, 75 | ) 76 | ], 77 | ) 78 | 79 | 80 | # --8<-- [end:example] 81 | 82 | if __name__ == "__main__": 83 | import asyncio 84 | from pathlib import Path 85 | 86 | THIS_DIR = Path(__file__).parent 87 | 88 | asyncio.run( 89 | app( 90 | root, 91 | headless=True, 92 | dimensions=(60, 20), 93 | autopilot=[ 94 | Screenshot.to_file(THIS_DIR.parent / "assets" / "border-healing-on.svg", indent=1), 95 | KeyPressed(key=Key.Space), 96 | Screenshot.to_file(THIS_DIR.parent / "assets" / "border-healing-off.svg", indent=1), 97 | Quit(), 98 | ], 99 | ) 100 | ) 101 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Counterweight 2 | 3 | site_url: https://www.counterweight.dev 4 | repo_url: https://github.com/JoshKarpel/counterweight 5 | edit_uri: edit/main/docs/ 6 | 7 | extra_css: 8 | - assets/style.css 9 | 10 | watch: 11 | - counterweight/ 12 | 13 | theme: 14 | name: material 15 | favicon: assets/favicon.png 16 | icon: 17 | logo: fontawesome/solid/terminal 18 | palette: 19 | - scheme: default 20 | toggle: 21 | icon: material/brightness-7 22 | name: Switch to dark mode 23 | media: "(prefers-color-scheme: light)" 24 | - scheme: slate 25 | toggle: 26 | icon: material/brightness-4 27 | name: Switch to light mode 28 | media: "(prefers-color-scheme: dark)" 29 | features: 30 | - navigation.instant 31 | - navigation.tracking 32 | - navigation.sections 33 | - navigation.indexes 34 | - toc.follow 35 | - content.code.annotate 36 | - content.code.copy 37 | 38 | plugins: 39 | - tags 40 | - search 41 | - autorefs: 42 | resolve_closest: true 43 | - mkdocstrings: 44 | handlers: 45 | python: 46 | options: 47 | show_root_heading: true 48 | heading_level: 3 49 | docstring_section_style: spacy 50 | merge_init_into_class: true 51 | show_if_no_docstring: true 52 | show_source: false 53 | show_bases: false 54 | show_symbol_type_heading: true 55 | show_signature_annotations: true 56 | signature_crossrefs: true 57 | separate_signature: true 58 | unwrap_annotated: true 59 | members_order: source 60 | inventories: 61 | - url: https://docs.python.org/3/objects.inv 62 | domains: [std, py] 63 | 64 | markdown_extensions: 65 | - admonition 66 | - pymdownx.details 67 | - pymdownx.highlight: 68 | anchor_linenums: true 69 | - pymdownx.inlinehilite 70 | - pymdownx.snippets: 71 | base_path: ['docs/examples/'] 72 | check_paths: true 73 | - pymdownx.superfences 74 | - pymdownx.tabbed: 75 | alternate_style: true 76 | - attr_list 77 | - def_list 78 | - md_in_html 79 | - pymdownx.tasklist: 80 | custom_checkbox: true 81 | - tables 82 | - pymdownx.superfences: 83 | custom_fences: 84 | - name: mermaid 85 | class: mermaid 86 | format: !!python/name:pymdownx.superfences.fence_code_format 87 | 88 | extra: 89 | social: 90 | - icon: fontawesome/brands/github 91 | link: https://github.com/JoshKarpel/counterweight 92 | name: Counterweight on GitHub 93 | 94 | nav: 95 | - Home: index.md 96 | - changelog.md 97 | - Components: 98 | - components/index.md 99 | - Elements: 100 | - elements/index.md 101 | - elements/div.md 102 | - elements/text.md 103 | - Hooks: 104 | - hooks/index.md 105 | - hooks/use_state.md 106 | - hooks/use_effect.md 107 | - hooks/use_ref.md 108 | - hooks/use_mouse.md 109 | - hooks/use_rects.md 110 | - hooks/use_hovered.md 111 | - Input Handling: 112 | - input-handling/index.md 113 | - input-handling/events.md 114 | - input-handling/controls.md 115 | - Styles: 116 | - styles/index.md 117 | - styles/layout.md 118 | - styles/utilities.md 119 | - Cookbook: 120 | - cookbook/border-titles.md 121 | - cookbook/border-healing.md 122 | - Under the Hood: 123 | - under-the-hood/index.md 124 | -------------------------------------------------------------------------------- /examples/suspend.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import sys 4 | from asyncio import run 5 | from functools import partial 6 | from pathlib import Path 7 | 8 | from counterweight.app import app 9 | from counterweight.components import component 10 | from counterweight.controls import AnyControl, Suspend 11 | from counterweight.elements import Div, Text 12 | from counterweight.events import KeyPressed 13 | from counterweight.hooks import use_state 14 | from counterweight.keys import Key 15 | from counterweight.styles.utilities import * 16 | 17 | 18 | @component 19 | def root() -> Div: 20 | return Div( 21 | style=col | align_children_center | justify_children_center, 22 | children=[ 23 | Text(content="Suspend Demo", style=text_amber_600 | weight_none), 24 | picker(), 25 | ], 26 | ) 27 | 28 | 29 | def clamp(min_: int, val: int, max_: int) -> int: 30 | return max(min_, min(val, max_)) 31 | 32 | 33 | def open_editor(file: Path) -> None: 34 | subprocess.run( 35 | [os.environ.get("EDITOR", "vim"), str(file)], 36 | stdin=sys.stdin, 37 | stdout=sys.stdout, 38 | stderr=sys.stderr, 39 | check=False, 40 | ) 41 | 42 | 43 | @component 44 | def picker() -> Div: 45 | glob, set_glob = use_state("**/*.py") 46 | selected_file_idx, set_selected_file_idx = use_state(0) 47 | 48 | files = sorted(Path.cwd().rglob(glob)) 49 | 50 | def on_key(event: KeyPressed) -> AnyControl | None: 51 | match event.key: 52 | case Key.Enter: 53 | return Suspend(handler=partial(open_editor, files[selected_file_idx])) 54 | case Key.Down: 55 | set_selected_file_idx(lambda i: clamp(0, i + 1, len(files) - 1)) 56 | case Key.Up: 57 | set_selected_file_idx(lambda i: clamp(0, i - 1, len(files) - 1)) 58 | case Key.Backspace: 59 | new_glob = glob[:-1] 60 | set_selected_file_idx(0) 61 | set_glob(new_glob) 62 | case c if c.isprintable() and len(c) == 1: 63 | new_glob = glob + c 64 | set_selected_file_idx(0) 65 | set_glob(new_glob) 66 | 67 | return None 68 | 69 | return Div( 70 | style=col | align_self_stretch | justify_children_center | border_lightrounded, 71 | on_key=on_key, 72 | children=[ 73 | glob_input(glob=glob), 74 | file_list(files=files, selected_idx=selected_file_idx), 75 | ], 76 | ) 77 | 78 | 79 | @component 80 | def glob_input(glob: str) -> Div: 81 | return Div( 82 | style=row | align_self_stretch | border_bottom | weight_none, 83 | children=[ 84 | Text(style=weight_none | border_right | pad_x_1, content="glob"), 85 | Text(style=weight_none | pad_x_1, content=glob), 86 | ], 87 | ) 88 | 89 | 90 | @component 91 | def file_list(files: list[Path], selected_idx: int) -> Div: 92 | start_idx = max(0, selected_idx - 5) 93 | return Div( 94 | style=col | justify_children_start | pad_x_1, 95 | children=[ 96 | Text( 97 | style=weight_none | (text_cyan_700 if idx == selected_idx else None), 98 | content=str(file.relative_to(Path.cwd())), 99 | ) 100 | for idx, file in enumerate(files[start_idx : start_idx + 10], start=start_idx) 101 | ], 102 | ) 103 | 104 | 105 | if __name__ == "__main__": 106 | run(app(root)) 107 | -------------------------------------------------------------------------------- /counterweight/geometry.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Iterator 4 | from dataclasses import dataclass, field 5 | from functools import lru_cache 6 | from itertools import product 7 | from typing import NamedTuple 8 | 9 | from counterweight._utils import unordered_range 10 | 11 | 12 | class Position(NamedTuple): 13 | x: int 14 | y: int 15 | 16 | @classmethod 17 | @lru_cache(maxsize=2**14) 18 | def flyweight(cls, x: int, y: int) -> Position: 19 | return cls(x, y) 20 | 21 | def __add__(self, other: Position) -> Position: # type: ignore[override] 22 | return Position.flyweight(x=self.x + other.x, y=self.y + other.y) 23 | 24 | def __sub__(self, other: Position) -> Position: 25 | return Position.flyweight(x=self.x - other.x, y=self.y - other.y) 26 | 27 | def fill_to(self, other: Position) -> Iterator[Position]: 28 | return ( 29 | Position.flyweight(x=x, y=y) 30 | for x, y in product( 31 | unordered_range(self.x, other.x), 32 | unordered_range(self.y, other.y), 33 | ) 34 | ) 35 | 36 | 37 | @dataclass(slots=True) 38 | class Rect: 39 | x: int = field(default=0) 40 | y: int = field(default=0) 41 | width: int = field(default=0) 42 | height: int = field(default=0) 43 | 44 | def expand_by(self, edge: Edge) -> Rect: 45 | return Rect( 46 | x=self.x - edge.left, 47 | y=self.y - edge.top, 48 | width=self.width + edge.left + edge.right, 49 | height=self.height + edge.top + edge.bottom, 50 | ) 51 | 52 | def x_range(self) -> range: 53 | return range(self.x, self.x + self.width) 54 | 55 | def y_range(self) -> range: 56 | return range(self.y, self.y + self.height) 57 | 58 | def xy_range(self) -> Iterator[tuple[int, int]]: 59 | return product(self.x_range(), self.y_range()) 60 | 61 | @property 62 | def left(self) -> int: 63 | return self.x 64 | 65 | @property 66 | def right(self) -> int: 67 | return self.x + self.width - 1 68 | 69 | @property 70 | def top(self) -> int: 71 | return self.y 72 | 73 | @property 74 | def bottom(self) -> int: 75 | return self.y + self.height - 1 76 | 77 | def left_edge(self) -> tuple[Position, ...]: 78 | left = self.left 79 | return tuple(Position.flyweight(left, y) for y in self.y_range()) 80 | 81 | def right_edge(self) -> tuple[Position, ...]: 82 | right = self.right 83 | return tuple(Position.flyweight(right, y) for y in self.y_range()) 84 | 85 | def top_edge(self) -> tuple[Position, ...]: 86 | top = self.top 87 | return tuple(Position.flyweight(x, top) for x in self.x_range()) 88 | 89 | def bottom_edge(self) -> tuple[Position, ...]: 90 | bottom = self.bottom 91 | return tuple(Position.flyweight(x, bottom) for x in self.x_range()) 92 | 93 | def top_left(self) -> Position: 94 | return Position.flyweight(x=self.left, y=self.top) 95 | 96 | def __contains__(self, item: object) -> bool: 97 | if isinstance(item, Position): 98 | return item.x in self.x_range() and item.y in self.y_range() 99 | else: 100 | return False 101 | 102 | 103 | @dataclass(slots=True) 104 | class Edge: 105 | left: int = field(default=0) 106 | right: int = field(default=0) 107 | top: int = field(default=0) 108 | bottom: int = field(default=0) 109 | -------------------------------------------------------------------------------- /counterweight/controls.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Awaitable, Callable 4 | from dataclasses import dataclass 5 | from pathlib import Path 6 | from typing import Union 7 | from xml.etree.ElementTree import ElementTree 8 | from xml.etree.ElementTree import indent as indent_svg 9 | 10 | 11 | @dataclass(frozen=True, slots=True) 12 | class _Control: 13 | pass 14 | 15 | 16 | @dataclass(frozen=True, slots=True) 17 | class Quit(_Control): 18 | """ 19 | Cause the application to quit. 20 | 21 | The quit occurs at the beginning of the next render cycle, 22 | so all other events that are due to be processed in the current cycle 23 | will be processed before the application exits. 24 | """ 25 | 26 | 27 | @dataclass(frozen=True, slots=True) 28 | class Bell(_Control): 29 | """ 30 | Cause the terminal to emit a bell sound. 31 | 32 | The bell occurs at the beginning of the next render cycle, 33 | so all other events that are due to be processed in the current cycle 34 | will be processed before the sound is played. 35 | """ 36 | 37 | 38 | @dataclass(frozen=True, slots=True) 39 | class Screenshot(_Control): 40 | """ 41 | Take a "screenshot" of the rendered UI, 42 | using the given `handler` callback function. 43 | The screenshot is passed to the `handler` as an 44 | [`ElementTree`][xml.etree.ElementTree.ElementTree] 45 | containing an SVG representation of the UI. 46 | 47 | The screenshot is taken at the beginning of the next render cycle, 48 | so all other events that are due to be processed in the current cycle 49 | will be processed before the screenshot is taken 50 | (but the screenshot will still be of the UI from *before* the next render occurs!). 51 | """ 52 | 53 | handler: Callable[[ElementTree], Awaitable[None] | None] 54 | 55 | @classmethod 56 | def to_file(cls, path: Path, indent: int | None = None) -> Screenshot: 57 | """ 58 | A convenience method for producing a `Screenshot` 59 | that writes the resulting SVG to the given `path`. 60 | 61 | Parameters: 62 | path: The path to write the SVG to. 63 | Parent directories will be created if they do not exist. 64 | indent: The number of spaces to indent the SVG by (for readability). 65 | If `None`, the SVG will not be indented. 66 | """ 67 | 68 | def handler(et: ElementTree) -> None: 69 | if indent: 70 | indent_svg(et, space=" " * indent) 71 | 72 | path.parent.mkdir(parents=True, exist_ok=True) 73 | 74 | with path.open("w") as f: 75 | et.write(f, encoding="unicode") 76 | 77 | return cls(handler=handler) 78 | 79 | 80 | @dataclass(frozen=True, slots=True) 81 | class Suspend(_Control): 82 | """ 83 | Suspend the application while the handler function is running. 84 | 85 | The application will be suspended (and then resumed) at the beginning of the next render cycle, 86 | so all other events that are due to be processed in the current cycle 87 | will be processed before the application is suspended. 88 | """ 89 | 90 | handler: Callable[[], Awaitable[None] | None] 91 | 92 | 93 | @dataclass(frozen=True, slots=True) 94 | class ToggleBorderHealing(_Control): 95 | """ 96 | Toggle whether border healing occurs. 97 | """ 98 | 99 | 100 | AnyControl = Union[ 101 | Quit, 102 | Bell, 103 | Screenshot, 104 | Suspend, 105 | ToggleBorderHealing, 106 | ] 107 | -------------------------------------------------------------------------------- /docs/input-handling/events.md: -------------------------------------------------------------------------------- 1 | # Events 2 | 3 | ## Handling Keyboard Events 4 | 5 | Each time a key is pressed, 6 | Counterweight calls the `on_key` event handler 7 | of every element with a `KeyPressed` event that holds 8 | information about which key was pressed. 9 | 10 | ::: counterweight.events.KeyPressed 11 | 12 | ## Handling Mouse Events 13 | 14 | Each time the state of the mouse changes, 15 | Counterweight emits a _single_ mouse event, 16 | one of `MouseMoved`, `MouseDown`, `MouseUp`, `MouseScrolledDown`, or `MouseScrolledUp`. 17 | 18 | ::: counterweight.events.MouseEvent 19 | 20 | ::: counterweight.events.MouseMoved 21 | ::: counterweight.events.MouseDown 22 | ::: counterweight.events.MouseUp 23 | 24 | ::: counterweight.events.MouseScrolledDown 25 | ::: counterweight.events.MouseScrolledUp 26 | 27 | For example, consider the following series of mouse actions and corresponding events: 28 | 29 | | Action | Event | 30 | |----------------------------------------------------------------------------------|--------------------------------------------------------| 31 | | Mouse starts at position `(0, 0)` | No event emitted | 32 | | Mouse moves to position `(1, 0)` | `MouseMoved(position=Position(x=1, y=0), button=None)` | 33 | | Mouse button `1` is pressed | `MouseDown(position=Position(x=1, y=0), button=1)` | 34 | | Mouse moves to position `(1, 1)` | `MouseMoved(position=Position(x=1, y=1), button=1)` | 35 | | Mouse button `1` is released | `MouseUp(position=Position(x=1, y=1), button=1)` | 36 | | Mouse button `3` is pressed | `MouseDown(position=Position(x=1, y=1), button=3)` | 37 | | Mouse moves to position `(2, 2)` and mouse button `3` is released simultaneously | `MouseUp(position=Position(x=2, y=2), button=3)` | 38 | 39 | !!! tip "Mouse Button Identifiers" 40 | 41 | Mouse buttons are identified by numbers instead of names (e.g., "left", "middle", "right") because 42 | the button numbers are the same regardless of whether the mouse is configured for left-handed or right-handed use. 43 | 44 | | Number | Button for Right-Handed Mouse | Button for Left-Handed Mouse | 45 | |--------|-------------------------------|------------------------------| 46 | | `1` | Left | Right | 47 | | `2` | Middle | Middle | 48 | | `3` | Right | Left | 49 | 50 | You should always use the numbers instead of names to refer to mouse buttons to ensure that your application works 51 | as expected for both left-handed and right-handed users. 52 | 53 | Counterweight calls the 54 | `on_mouse` event handler of each element whose border rectangle 55 | contains the new mouse position or the previous mouse position 56 | with the relevant mouse event object. 57 | 58 | !!! tip "`use_mouse` vs. `on_mouse`" 59 | 60 | `use_mouse` and `on_mouse` provide similar functionality, but `use_mouse` is a hook and `on_mouse` is an event handler. 61 | `use_mouse` is more efficient when a component depends only on the *current* state of the mouse 62 | (e.g., the current position, or whether a button is currently pressed), 63 | while `on_mouse` is more convenient when a component needs to respond to *changes* in the mouse state, 64 | (e.g., a button release [`MouseUp`][counterweight.events.MouseUp]). 65 | -------------------------------------------------------------------------------- /counterweight/_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from asyncio import CancelledError, Queue, QueueEmpty, Task, current_task, get_event_loop 4 | from functools import lru_cache 5 | from inspect import isawaitable 6 | from math import ceil, floor 7 | from typing import Awaitable, List, TypeVar 8 | 9 | T = TypeVar("T") 10 | K = TypeVar("K") 11 | V = TypeVar("V") 12 | 13 | 14 | async def drain_queue(queue: Queue[T]) -> List[T]: 15 | items = [await queue.get()] 16 | 17 | while True: 18 | try: 19 | items.append(queue.get_nowait()) 20 | except QueueEmpty: 21 | break 22 | 23 | return items 24 | 25 | 26 | @lru_cache(maxsize=2**10) 27 | def halve_integer(x: int) -> tuple[int, int]: 28 | """Halve an integer, accounting for odd integers by making the first "half" larger by one than the second "half".""" 29 | half = x / 2 30 | return ceil(half), floor(half) 31 | 32 | 33 | @lru_cache(maxsize=2**12) 34 | def partition_int(total: int, weights: tuple[int, ...]) -> list[int]: 35 | """Partition an integer into a list of integers, with each integer in the list corresponding to the weight at the same index in the weights list.""" 36 | # https://stackoverflow.com/questions/62914824/c-sharp-split-integer-in-parts-given-part-weights-algorithm 37 | 38 | if any(w < 0 for w in weights): 39 | raise ValueError("Weights must be non-negative") 40 | 41 | if total == 0: # optimization 42 | return [0] * len(weights) 43 | 44 | total_weight = sum(weights) 45 | 46 | if not total_weight > 0: 47 | raise ValueError("Total weight must be positive") 48 | 49 | partition = [] 50 | accumulated_diff = 0.0 51 | for w in weights: 52 | exact = total * (w / total_weight) 53 | rounded = round(exact) 54 | accumulated_diff += exact - rounded 55 | 56 | if accumulated_diff > 0.5: 57 | rounded += 1 58 | accumulated_diff -= 1 59 | elif accumulated_diff < -0.5: 60 | rounded -= 1 61 | accumulated_diff += 1 62 | 63 | partition.append(rounded) 64 | 65 | return partition 66 | 67 | 68 | R = TypeVar("R") 69 | 70 | 71 | async def maybe_await(val: Awaitable[R] | R) -> R: 72 | if isawaitable(val): 73 | return await val 74 | else: 75 | return val 76 | 77 | 78 | async def forever() -> None: 79 | await get_event_loop().create_future() # This waits forever since the future will never resolve on its own 80 | 81 | 82 | async def cancel(task: Task[T]) -> None: 83 | # Based on https://discuss.python.org/t/asyncio-cancel-a-cancellation-utility-as-a-coroutine-this-time-with-feeling/26304/2 84 | if task.done(): 85 | # If the task has already completed, there's nothing to cancel. 86 | # This can happen if, for example, an effect aborts itself by returning, 87 | # and then we try to cancel it when reconciling effects. 88 | return 89 | 90 | task.cancel() 91 | 92 | try: 93 | await task 94 | except CancelledError: 95 | ct = current_task() 96 | if ct and ct.cancelling() == 0: 97 | # The CancelledError is from the task we cancelled, so this is the normal flow 98 | return 99 | else: 100 | # cancel() is itself being cancelled, propagate the CancelledError 101 | raise 102 | else: 103 | raise RuntimeError("Cancelled task did not end with an exception") 104 | 105 | 106 | def unordered_range(a: int, b: int) -> range: 107 | """ 108 | A range from a to b (inclusive), regardless of the order of a and b. 109 | 110 | https://stackoverflow.com/a/38036694 111 | """ 112 | step = -1 if b < a else 1 113 | return range(a, b + step, step) 114 | -------------------------------------------------------------------------------- /counterweight/hooks/impls.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from asyncio import Task 4 | from collections.abc import Callable, Iterator 5 | from dataclasses import dataclass, field 6 | from typing import TypeVar 7 | 8 | from counterweight._context_vars import current_event_queue, current_hook_idx 9 | from counterweight.events import StateSet 10 | from counterweight.hooks.types import Deps, Getter, Ref, Setter, Setup 11 | from counterweight.layout import LayoutBoxDimensions 12 | 13 | 14 | @dataclass(slots=True) 15 | class UseState: 16 | value: object 17 | 18 | 19 | @dataclass(slots=True) 20 | class UseRef: 21 | ref: Ref[object] 22 | 23 | 24 | @dataclass(slots=True) 25 | class UseEffect: 26 | setup: Setup 27 | deps: Deps 28 | new_deps: Deps 29 | task: Task[None] | None = None 30 | 31 | 32 | T = TypeVar("T") 33 | 34 | 35 | class InconsistentHookExecution(Exception): 36 | pass 37 | 38 | 39 | @dataclass(slots=True) 40 | class Hooks: 41 | data: list[UseState | UseRef | UseEffect] = field(default_factory=list) 42 | dims: LayoutBoxDimensions = field(default_factory=LayoutBoxDimensions) 43 | 44 | @property 45 | def effects(self) -> Iterator[UseEffect]: 46 | return (hook for hook in self.data if isinstance(hook, UseEffect)) 47 | 48 | def use_state(self, initial_value: Getter[T] | T) -> tuple[T, Setter[T]]: 49 | try: 50 | hook = self.data[current_hook_idx.get()] 51 | if not isinstance(hook, UseState): 52 | raise InconsistentHookExecution( 53 | f"Expected a {UseState.__name__} hook, but got a {type(hook).__name__} hook instead." 54 | ) 55 | except IndexError: 56 | hook = UseState(value=initial_value() if callable(initial_value) else initial_value) 57 | self.data.append(hook) 58 | 59 | def set_state(value: T | Callable[[T], T]) -> None: 60 | if callable(value): 61 | value = value(hook.value) # type: ignore[arg-type] 62 | 63 | if hook.value != value: # avoid unnecessary updates 64 | hook.value = value 65 | current_event_queue.get().put_nowait(StateSet()) 66 | 67 | current_hook_idx.set(current_hook_idx.get() + 1) 68 | 69 | return hook.value, set_state # type: ignore[return-value] 70 | 71 | def use_ref(self, initial_value: Getter[T] | T) -> Ref[T]: 72 | try: 73 | hook = self.data[current_hook_idx.get()] 74 | if not isinstance(hook, UseRef): 75 | raise InconsistentHookExecution( 76 | f"Expected a {UseRef.__name__} hook, but got a {type(hook).__name__} hook instead." 77 | ) 78 | except IndexError: 79 | hook = UseRef(ref=Ref[object](current=initial_value() if callable(initial_value) else initial_value)) 80 | self.data.append(hook) 81 | 82 | current_hook_idx.set(current_hook_idx.get() + 1) 83 | 84 | return hook.ref # type: ignore[return-value] 85 | 86 | def use_effect(self, setup: Setup, deps: Deps) -> None: 87 | try: 88 | hook = self.data[current_hook_idx.get()] 89 | if not isinstance(hook, UseEffect): 90 | raise InconsistentHookExecution( 91 | f"Expected a {UseEffect.__name__} hook, but got a {type(hook).__name__} hook instead." 92 | ) 93 | except IndexError: 94 | hook = UseEffect( 95 | setup=setup, 96 | deps=(object(),), # these deps will never equal anything else 97 | new_deps=deps, 98 | ) 99 | self.data.append(hook) 100 | 101 | hook.setup = setup # we must capture the new setup function to update its closure 102 | hook.new_deps = deps # ... but the decision about whether to actually rerun it will be made based on its deps 103 | 104 | current_hook_idx.set(current_hook_idx.get() + 1) 105 | 106 | return None 107 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "counterweight" 7 | version = "0.0.9" 8 | description = "An experimental TUI framework for Python, inspired by React and Tailwind" 9 | readme = "README.md" 10 | license = "MIT" 11 | authors = [ 12 | { name = "JoshKarpel", email = "josh.karpel@gmail.com" } 13 | ] 14 | keywords = [ 15 | "tui", 16 | "terminal", 17 | ] 18 | classifiers = [ 19 | "Development Status :: 3 - Alpha", 20 | "Environment :: Console", 21 | "Intended Audience :: Developers", 22 | "Intended Audience :: Education", 23 | "License :: OSI Approved :: MIT License", 24 | "Operating System :: MacOS", 25 | "Operating System :: Unix", 26 | "Programming Language :: Python :: 3", 27 | "Programming Language :: Python :: 3 :: Only", 28 | "Programming Language :: Python :: 3.11", 29 | "Programming Language :: Python :: 3.12", 30 | "Topic :: Artistic Software", 31 | "Topic :: Multimedia :: Graphics :: Presentation", 32 | "Typing :: Typed", 33 | ] 34 | requires-python = ">=3.12" 35 | dependencies = [ 36 | "typer>=0.9", 37 | "pydantic>=2", 38 | "structlog>=23.1", 39 | "parsy>=2.1", 40 | "more-itertools>=9.1", 41 | "cachetools>=5.3", 42 | ] 43 | 44 | [dependency-groups] 45 | dev = [ 46 | "pre-commit>=4.4", 47 | "black==24.3.0", 48 | "watchfiles>=0.19", 49 | "pytest<8", 50 | "pytest-cov>=3", 51 | "pytest-xdist>=3", 52 | "pytest-asyncio>=0.20", 53 | "pytest-mock>=3", 54 | "hypothesis>=6.80", 55 | "mypy>=1", 56 | "types-cachetools>=5.3", 57 | "mkdocs>=1.4", 58 | "mkdocs-material>=9", 59 | "mkdocstrings[python]>=0.19.0", 60 | "line-profiler>=4.1", 61 | "austin-dist>=3.6", 62 | "austin-python>=1.7", 63 | ] 64 | 65 | [project.scripts] 66 | counterweight = "counterweight.cli:cli" 67 | 68 | [project.urls] 69 | Homepage = "https://github.com/JoshKarpel/counterweight" 70 | Repository = "https://github.com/JoshKarpel/counterweight" 71 | Documentation = "https://www.counterweight.dev" 72 | "Bug Tracker" = "https://github.com/JoshKarpel/counterweight/issues" 73 | 74 | [tool.hatch.build.targets.wheel] 75 | include = ["counterweight", "py.typed"] 76 | 77 | [tool.black] 78 | line-length = 120 79 | include = "\\.pyi?$" 80 | 81 | [tool.pytest.ini_options] 82 | addopts = ["--strict-markers", "-Werror"] 83 | testpaths = ["tests", "counterweight", "docs"] 84 | 85 | markers = ["slow"] 86 | 87 | asyncio_mode = "auto" 88 | 89 | [tool.mypy] 90 | pretty = true 91 | show_error_codes = true 92 | 93 | files = ["."] 94 | 95 | check_untyped_defs = true 96 | disallow_incomplete_defs = true 97 | disallow_untyped_defs = true 98 | no_implicit_optional = true 99 | disallow_any_generics = true 100 | 101 | warn_unused_configs = true 102 | warn_unused_ignores = true 103 | warn_no_return = true 104 | warn_unreachable = true 105 | warn_redundant_casts = true 106 | 107 | ignore_missing_imports = true 108 | 109 | [tool.ruff] 110 | line-length = 120 111 | 112 | [tool.ruff.lint] 113 | select = [ 114 | "I", # https://beta.ruff.rs/docs/rules/#isort-i 115 | "F", # https://beta.ruff.rs/docs/rules/#pyflakes-f 116 | "E", # https://beta.ruff.rs/docs/rules/#error-e 117 | "W", # https://beta.ruff.rs/docs/rules/#warning-w 118 | "T20", # https://beta.ruff.rs/docs/rules/#flake8-print-t20 119 | "PIE", # https://beta.ruff.rs/docs/rules/#flake8-pie-pie 120 | "PLC", # https://beta.ruff.rs/docs/rules/#convention-plc 121 | "PLE", # https://beta.ruff.rs/docs/rules/#error-ple 122 | "PLW", # https://beta.ruff.rs/docs/rules/#warning-plw 123 | "PTH", # https://beta.ruff.rs/docs/rules/#flake8-use-pathlib-pth 124 | "PGH", # https://beta.ruff.rs/docs/rules/#pygrep-hooks-pgh 125 | "RUF", # https://beta.ruff.rs/docs/rules/#ruff-specific-rules-ruf 126 | ] 127 | 128 | ignore = [ 129 | "E501", # line length exceeds limit 130 | "E741", # ambiguous variable name 131 | "T201", # print 132 | "T203", # pprint 133 | "F403", # star imports, used for utilities 134 | "F405", # star imports, used for utilities 135 | ] 136 | -------------------------------------------------------------------------------- /examples/canvas.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import random 3 | from asyncio import sleep 4 | from functools import lru_cache 5 | from itertools import product 6 | 7 | from more_itertools import grouper 8 | from structlog import get_logger 9 | 10 | from counterweight.app import app 11 | from counterweight.components import component 12 | from counterweight.elements import Chunk, Div, Text 13 | from counterweight.hooks import use_effect, use_state 14 | from counterweight.styles.styles import COLORS_BY_NAME 15 | from counterweight.styles.utilities import * 16 | 17 | logger = get_logger() 18 | 19 | BLACK = Color.from_name("black") 20 | 21 | 22 | def canvas( 23 | width: int, 24 | height: int, 25 | cells: dict[tuple[int, int], Color], 26 | ) -> list[Chunk]: 27 | c: list[Chunk] = [] 28 | for y_top, y_bot in grouper(range(height), 2): 29 | c.extend( 30 | Chunk( 31 | content="▀", 32 | style=CellStyle( 33 | foreground=cells.get((x, y_top), BLACK), 34 | background=cells.get((x, y_bot), BLACK), 35 | ), 36 | ) 37 | for x in range(width) 38 | ) 39 | c.append(Chunk.newline()) 40 | return c[:-1] # strip off last newline 41 | 42 | 43 | @lru_cache(maxsize=2**12) 44 | def clamp(min_: int, val: int, max_: int) -> int: 45 | return max(min_, min(val, max_)) 46 | 47 | 48 | @component 49 | def root() -> Div: 50 | return Div( 51 | style=col | align_children_center | justify_children_space_evenly, 52 | children=[ 53 | header(), 54 | Div( 55 | style=col | align_children_center | justify_children_center, 56 | children=[ 57 | Div( 58 | style=row | align_children_center | justify_children_space_evenly, 59 | children=[ 60 | random_walkers(), 61 | random_walkers(), 62 | ], 63 | ), 64 | Div( 65 | style=row | align_children_center | justify_children_space_evenly, 66 | children=[ 67 | random_walkers(), 68 | random_walkers(), 69 | ], 70 | ), 71 | ], 72 | ), 73 | ], 74 | ) 75 | 76 | 77 | @component 78 | def header() -> Text: 79 | return Text( 80 | content=[ 81 | Chunk( 82 | content="Canvas", 83 | style=CellStyle(foreground=amber_600), 84 | ), 85 | Chunk.space(), 86 | Chunk( 87 | content="Demo", 88 | style=CellStyle(foreground=cyan_600), 89 | ), 90 | Chunk.newline(), 91 | Chunk( 92 | content="App", 93 | style=CellStyle(foreground=pink_600), 94 | ), 95 | ], 96 | style=text_justify_center, 97 | ) 98 | 99 | 100 | moves = [(x, y) for x, y in product((-1, 0, 1), repeat=2) if (x, y) != (0, 0)] 101 | 102 | w, h = 30, 30 103 | n = 30 104 | 105 | 106 | @component 107 | def random_walkers() -> Text: 108 | colors, _set_colors = use_state(random.sample(list(COLORS_BY_NAME.values()), k=n)) 109 | walkers, set_walkers = use_state([(random.randrange(w), random.randrange(h)) for _ in range(len(colors))]) 110 | 111 | def update_movers(m: list[tuple[int, int]]) -> list[tuple[int, int]]: 112 | new = [] 113 | for x, y in m: 114 | dx, dy = random.choice(moves) 115 | new.append( 116 | ( 117 | clamp(0, x + dx, w - 1), 118 | clamp(0, y + dy, h - 1), 119 | ) 120 | ) 121 | return new 122 | 123 | async def tick() -> None: 124 | while True: 125 | await sleep(0.5) 126 | set_walkers(update_movers) 127 | 128 | use_effect(tick, deps=()) 129 | 130 | return Text( 131 | content=canvas( 132 | width=w, 133 | height=h, 134 | cells=dict(zip(walkers, colors)), 135 | ), 136 | style=border_heavy | border_slate_400, 137 | ) 138 | 139 | 140 | if __name__ == "__main__": 141 | asyncio.run(app(root)) 142 | -------------------------------------------------------------------------------- /tests/styles/test_merging.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from counterweight.styles import Border, Style 4 | from counterweight.styles.styles import Absolute, BorderKind, CellStyle, Color, Flex, Relative, Span 5 | from counterweight.styles.utilities import absolute, border_heavy, relative 6 | 7 | 8 | @pytest.mark.parametrize( 9 | ("left", "right", "expected"), 10 | ( 11 | (Style(), Style(), Style()), 12 | (Style(span=Span(width=5)), Style(), Style(span=Span(width=5))), 13 | ( 14 | Style(border=Border(style=CellStyle(bold=True))), 15 | Style(), 16 | Style(border=Border(style=CellStyle(bold=True))), 17 | ), 18 | ( 19 | Style(), 20 | Style(border=Border(style=CellStyle(bold=True))), 21 | Style(border=Border(style=CellStyle(bold=True))), 22 | ), 23 | ( 24 | Style(border=Border(style=CellStyle(bold=False))), 25 | Style(border=Border(style=CellStyle(bold=True))), 26 | Style(border=Border(style=CellStyle(bold=True))), 27 | ), 28 | ( 29 | Style(border=Border(style=CellStyle(bold=True))), 30 | Style(border=Border(style=CellStyle(bold=False))), 31 | Style(border=Border(style=CellStyle(bold=False))), 32 | ), 33 | ( 34 | Style(), 35 | Style(border=Border(style=CellStyle(foreground=Color.from_name("green")))), 36 | Style(border=Border(style=CellStyle(foreground=Color.from_name("green")))), 37 | ), 38 | ( 39 | Style(border=Border(kind=BorderKind.LightRounded)), 40 | Style(border=Border(style=CellStyle(foreground=Color.from_name("green")))), 41 | Style(border=Border(kind=BorderKind.LightRounded, style=CellStyle(foreground=Color.from_name("green")))), 42 | ), 43 | ( 44 | Style(border=Border(style=CellStyle(foreground=Color.from_name("green")))), 45 | Style(border=Border(kind=BorderKind.LightRounded)), 46 | Style(border=Border(kind=BorderKind.LightRounded, style=CellStyle(foreground=Color.from_name("green")))), 47 | ), 48 | ( 49 | Style(), 50 | Style(span=Span(width=5)), 51 | Style(span=Span(width=5)), 52 | ), 53 | ( 54 | Style(span=Span(width=5)), 55 | Style(), 56 | Style(span=Span(width=5)), 57 | ), 58 | ( 59 | Style(layout=Flex(direction="row")), 60 | Style(layout=Flex(direction="column")), 61 | Style(layout=Flex(direction="column")), 62 | ), 63 | ( 64 | Style(layout=Flex(direction="column")), 65 | Style(layout=Flex(direction="row")), 66 | Style(layout=Flex(direction="row")), 67 | ), 68 | ( 69 | Style(layout=Flex(direction="row")), 70 | Style(layout=Flex(weight=None)), 71 | Style(layout=Flex(direction="row", weight=None)), 72 | ), 73 | ( 74 | Style(layout=Flex(direction="column", weight=5)), 75 | Style(layout=Flex(direction="row")), 76 | Style(layout=Flex(direction="row", weight=5)), 77 | ), 78 | ( 79 | relative(x=3, y=5), 80 | border_heavy, 81 | Style(layout=Flex(position=Relative(x=3, y=5)), border=Border(kind=BorderKind.Heavy)), 82 | ), 83 | ( 84 | border_heavy, 85 | relative(x=3, y=5), 86 | Style(layout=Flex(position=Relative(x=3, y=5)), border=Border(kind=BorderKind.Heavy)), 87 | ), 88 | ( 89 | absolute(x=3, y=5), 90 | border_heavy, 91 | Style(layout=Flex(position=Absolute(x=3, y=5)), border=Border(kind=BorderKind.Heavy)), 92 | ), 93 | ( 94 | border_heavy, 95 | absolute(x=3, y=5), 96 | Style(layout=Flex(position=Absolute(x=3, y=5)), border=Border(kind=BorderKind.Heavy)), 97 | ), 98 | ), 99 | ) 100 | def test_style_merging(left: Style, right: Style, expected: Style) -> None: 101 | print(f"{left.mergeable_dump()=}") 102 | print() 103 | print(f"{right.mergeable_dump()=}") 104 | print() 105 | print(f"{(left|right).mergeable_dump()=}") 106 | print() 107 | print(f"{expected.mergeable_dump()=}") 108 | assert (left | right).model_dump() == expected.model_dump() 109 | -------------------------------------------------------------------------------- /counterweight/shadow.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Iterator 4 | from dataclasses import dataclass, field 5 | from itertools import zip_longest 6 | 7 | from structlog import get_logger 8 | 9 | from counterweight._context_vars import current_hook_idx, current_hook_state 10 | from counterweight.components import Component 11 | from counterweight.elements import AnyElement 12 | from counterweight.hooks.impls import Hooks 13 | 14 | logger = get_logger() 15 | 16 | 17 | @dataclass(slots=True) 18 | class ShadowNode: 19 | component: Component | None 20 | element: AnyElement 21 | hooks: Hooks 22 | children: list[ShadowNode] = field(default_factory=list) 23 | 24 | def walk(self) -> Iterator[ShadowNode]: 25 | yield self 26 | for child in self.children: 27 | if isinstance(child, ShadowNode): 28 | yield from child.walk() 29 | 30 | 31 | def update_shadow(next: Component | AnyElement, previous: ShadowNode | None) -> ShadowNode: 32 | match next, previous: 33 | case Component( 34 | func=next_func, 35 | args=next_args, 36 | kwargs=next_kwargs, 37 | key=next_key, 38 | ) as next_component, ShadowNode( 39 | component=previous_component, 40 | children=previous_children, 41 | hooks=previous_hooks, 42 | ) if ( 43 | previous_component is not None 44 | and next_func == previous_component.func 45 | and next_key == previous_component.key 46 | ): 47 | reset_current_hook_idx = current_hook_idx.set(0) 48 | reset_current_hook_state = current_hook_state.set(previous_hooks) 49 | 50 | element = next_component.func(*next_args, **next_kwargs) 51 | 52 | children = [] 53 | for new_child, previous_child in zip_longest(element.children, previous_children): 54 | if new_child is None: 55 | continue 56 | children.append(update_shadow(new_child, previous_child)) 57 | 58 | new = ShadowNode( 59 | component=next_component, 60 | element=element, 61 | children=children, 62 | hooks=previous_hooks, # the hooks are mutable and carry through renders 63 | ) 64 | 65 | current_hook_idx.reset(reset_current_hook_idx) 66 | current_hook_state.reset(reset_current_hook_state) 67 | 68 | # logger.debug( 69 | # "Updated shadow node", 70 | # type="component", 71 | # id=id, 72 | # generation=new.generation, 73 | # ) 74 | case Component(func=next_func, args=next_args, kwargs=next_kwargs) as next_component, _: 75 | reset_current_hook_idx = current_hook_idx.set(0) 76 | 77 | hook_state = Hooks() 78 | reset_current_hook_state = current_hook_state.set(hook_state) 79 | 80 | element = next_func(*next_args, **next_kwargs) 81 | 82 | children = [update_shadow(child, None) for child in element.children] 83 | 84 | new = ShadowNode( 85 | component=next_component, 86 | element=element, 87 | children=children, 88 | hooks=hook_state, 89 | ) 90 | 91 | current_hook_idx.reset(reset_current_hook_idx) 92 | current_hook_state.reset(reset_current_hook_state) 93 | case element, ShadowNode( 94 | children=previous_children, 95 | hooks=previous_hooks, 96 | ): 97 | children = [] 98 | for new_child, previous_child in zip_longest(element.children, previous_children): 99 | if new_child is None: 100 | continue 101 | children.append(update_shadow(new_child, previous_child)) 102 | 103 | new = ShadowNode( 104 | component=None, 105 | element=element, 106 | children=children, 107 | hooks=previous_hooks, # the hooks are mutable and carry through renders 108 | ) 109 | case element, None: 110 | new = ShadowNode( 111 | component=None, 112 | element=element, 113 | children=[update_shadow(child, None) for child in element.children], 114 | hooks=Hooks(), 115 | ) 116 | case _: 117 | raise Exception("Unreachable!") 118 | 119 | return new 120 | -------------------------------------------------------------------------------- /examples/end_to_end.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from datetime import datetime 3 | from itertools import cycle 4 | 5 | from structlog import get_logger 6 | 7 | from counterweight.app import app 8 | from counterweight.components import component 9 | from counterweight.elements import Div, Text 10 | from counterweight.events import KeyPressed 11 | from counterweight.hooks import Setter, use_effect, use_ref, use_state 12 | from counterweight.keys import Key 13 | from counterweight.styles import Border, BorderKind, Style 14 | from counterweight.styles.styles import Flex, Padding 15 | from counterweight.styles.utilities import ( 16 | border_amber_700, 17 | border_lime_700, 18 | border_rose_500, 19 | border_sky_700, 20 | border_teal_600, 21 | text_rose_500, 22 | text_teal_600, 23 | ) 24 | 25 | logger = get_logger() 26 | 27 | 28 | @component 29 | def toggle() -> Div: 30 | border_cycle_ref = use_ref(cycle(BorderKind)) 31 | 32 | def advance_border() -> BorderKind: 33 | return next(border_cycle_ref.current) 34 | 35 | border, set_border = use_state(advance_border) 36 | 37 | border_color_ref = use_ref(cycle([border_lime_700, border_amber_700, border_sky_700])) 38 | 39 | def advance_border_color() -> Style: 40 | return next(border_color_ref.current) 41 | 42 | border_color, set_border_color = use_state(advance_border_color) 43 | 44 | toggled, set_toggled = use_state(False) 45 | 46 | def on_key(event: KeyPressed) -> None: 47 | match event.key: 48 | case Key.Tab: 49 | set_toggled(not toggled) 50 | case Key.F1: 51 | set_border(advance_border()) 52 | case Key.F2: 53 | set_border_color(advance_border_color()) 54 | 55 | return Div( 56 | children=[ 57 | # TODO: why does putting this here break the layout? it's above the outer div... 58 | # Paragraph( 59 | # content="End-to-End Demo", 60 | # style=Style( 61 | # span=Span(width="auto"), 62 | # border=Border(kind=BorderKind.LightRounded), 63 | # ), 64 | # ), 65 | Div( 66 | children=[ 67 | Text( 68 | content="End-to-End Demo", 69 | style=border_color 70 | | Style( 71 | border=Border(kind=border), 72 | padding=Padding(top=1, bottom=1, left=1, right=1), 73 | ), 74 | ), 75 | time() if toggled else textpad(), 76 | ], 77 | style=Style( 78 | layout=Flex(direction="row"), 79 | border=Border(kind=BorderKind.LightRounded), 80 | ), 81 | ), 82 | ], 83 | style=Style( 84 | layout=Flex( 85 | direction="column", 86 | # TODO: without align_children="stretch", the children don't grow in width, even though they have text in them... 87 | # maybe I'm applying auto width too late? 88 | align_children="stretch", 89 | ), 90 | ), 91 | on_key=on_key, 92 | ) 93 | 94 | 95 | @component 96 | def time() -> Text: 97 | now, set_now = use_state(datetime.now()) 98 | 99 | async def tick() -> None: 100 | while True: 101 | await asyncio.sleep(0.01) 102 | set_now(datetime.now()) 103 | 104 | use_effect(tick, deps=()) 105 | 106 | return Text( 107 | content=f"{now:%Y-%m-%d %H:%M:%S}", 108 | style=text_rose_500 109 | | border_teal_600 110 | | Style( 111 | border=Border(kind=BorderKind.LightRounded), 112 | padding=Padding(top=1, bottom=1, left=1, right=1), 113 | ), 114 | ) 115 | 116 | 117 | @component 118 | def textpad() -> Text: 119 | buffer: list[str] 120 | set_buffer: Setter[list[str]] 121 | buffer, set_buffer = use_state([]) 122 | 123 | def on_key(event: KeyPressed) -> None: 124 | match event.key: 125 | case Key.Backspace: 126 | set_buffer(buffer[:-1]) 127 | case _ if event.key.isprintable() and len(event.key) == 1: # TODO: gross 128 | s = [*buffer, event.key] 129 | set_buffer(s) 130 | 131 | content = "".join(buffer) or "..." 132 | 133 | return Text( 134 | content=content, 135 | style=text_teal_600 136 | | border_rose_500 137 | | Style( 138 | border=Border(kind=BorderKind.LightRounded), 139 | padding=Padding(top=1, bottom=1, left=1, right=1), 140 | ), 141 | on_key=on_key, 142 | ) 143 | 144 | 145 | if __name__ == "__main__": 146 | asyncio.run(app(toggle)) 147 | -------------------------------------------------------------------------------- /examples/chess.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from enum import Enum, StrEnum 3 | from pathlib import Path 4 | from typing import NamedTuple 5 | 6 | from structlog import get_logger 7 | 8 | from counterweight.app import app 9 | from counterweight.components import component 10 | from counterweight.controls import AnyControl, Screenshot 11 | from counterweight.elements import Div, Text 12 | from counterweight.events import KeyPressed 13 | from counterweight.hooks import use_state 14 | from counterweight.keys import Key 15 | from counterweight.styles.utilities import * 16 | 17 | logger = get_logger() 18 | 19 | 20 | class Player(StrEnum): 21 | White = "white" 22 | Black = "black" 23 | 24 | 25 | class WhiteBlack(NamedTuple): 26 | white: str 27 | black: str 28 | 29 | 30 | class PieceCharacter(Enum): 31 | Pawn = WhiteBlack(white="♙", black="♙") 32 | Knight = WhiteBlack(white="♞", black="♞") 33 | Bishop = WhiteBlack(white="♝", black="♝") 34 | Rook = WhiteBlack(white="♜", black="♜") 35 | Queen = WhiteBlack(white="♛", black="♛") 36 | King = WhiteBlack(white="♚", black="♚") 37 | 38 | def char(self, player: Player) -> str: 39 | return self.value.white if player is Player.White else self.value.black 40 | 41 | 42 | @component 43 | def root() -> Div: 44 | def on_key(event: KeyPressed) -> AnyControl | None: 45 | if event.key == Key.Enter: 46 | return Screenshot.to_file(Path("chess.svg")) 47 | else: 48 | return None 49 | 50 | return Div( 51 | style=row | align_self_stretch | align_children_center, 52 | children=[board()], 53 | on_key=on_key, 54 | ) 55 | 56 | 57 | @component 58 | def board() -> Div: 59 | positions, _set_positions = use_state( 60 | { 61 | (0, 0): (Player.White, PieceCharacter.Rook), 62 | (0, 1): (Player.White, PieceCharacter.Knight), 63 | (0, 2): (Player.White, PieceCharacter.Bishop), 64 | (0, 3): (Player.White, PieceCharacter.Queen), 65 | (0, 4): (Player.White, PieceCharacter.King), 66 | (0, 5): (Player.White, PieceCharacter.Bishop), 67 | (0, 6): (Player.White, PieceCharacter.Knight), 68 | (0, 7): (Player.White, PieceCharacter.Rook), 69 | (1, 0): (Player.White, PieceCharacter.Pawn), 70 | (1, 1): (Player.White, PieceCharacter.Pawn), 71 | (1, 2): (Player.White, PieceCharacter.Pawn), 72 | (1, 3): (Player.White, PieceCharacter.Pawn), 73 | (1, 4): (Player.White, PieceCharacter.Pawn), 74 | (1, 5): (Player.White, PieceCharacter.Pawn), 75 | (1, 6): (Player.White, PieceCharacter.Pawn), 76 | (1, 7): (Player.White, PieceCharacter.Pawn), 77 | (7, 0): (Player.Black, PieceCharacter.Rook), 78 | (7, 1): (Player.Black, PieceCharacter.Knight), 79 | (7, 2): (Player.Black, PieceCharacter.Bishop), 80 | (7, 3): (Player.Black, PieceCharacter.Queen), 81 | (7, 4): (Player.Black, PieceCharacter.King), 82 | (7, 5): (Player.Black, PieceCharacter.Bishop), 83 | (7, 6): (Player.Black, PieceCharacter.Knight), 84 | (7, 7): (Player.Black, PieceCharacter.Rook), 85 | (6, 0): (Player.Black, PieceCharacter.Pawn), 86 | (6, 1): (Player.Black, PieceCharacter.Pawn), 87 | (6, 2): (Player.Black, PieceCharacter.Pawn), 88 | (6, 3): (Player.Black, PieceCharacter.Pawn), 89 | (6, 4): (Player.Black, PieceCharacter.Pawn), 90 | (6, 5): (Player.Black, PieceCharacter.Pawn), 91 | (6, 6): (Player.Black, PieceCharacter.Pawn), 92 | (6, 7): (Player.Black, PieceCharacter.Pawn), 93 | }, 94 | ) 95 | 96 | b = text_bg_amber_600 97 | w = text_bg_amber_900 98 | rows: list[list[Text]] = [] 99 | for r in range(8): 100 | rows.append([]) 101 | for c in range(8): 102 | player_piece = positions.get((r, c)) 103 | s = b if (r + c) % 2 == 0 else w 104 | if not player_piece: 105 | rows[-1].append( 106 | Text( 107 | style=s, 108 | content=" ", 109 | ) 110 | ) 111 | else: 112 | player, piece = player_piece 113 | rows[-1].append( 114 | Text( 115 | style=s | (text_white if player is Player.White else text_black), 116 | content=f" {piece.char(player)}", 117 | ) 118 | ) 119 | 120 | return Div( 121 | style=col | justify_children_center | align_children_center, 122 | children=[ 123 | Div( 124 | style=row | weight_none | justify_children_center | align_children_center, 125 | children=r, 126 | ) 127 | for r in rows 128 | ], 129 | ) 130 | 131 | 132 | if __name__ == "__main__": 133 | asyncio.run(app(root)) 134 | -------------------------------------------------------------------------------- /examples/mouse.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from functools import lru_cache 3 | 4 | from more_itertools import intersperse 5 | from structlog import get_logger 6 | 7 | from counterweight.app import app 8 | from counterweight.components import component 9 | from counterweight.elements import Chunk, Div, Text 10 | from counterweight.events import MouseDown, MouseEvent, MouseMoved, MouseUp 11 | from counterweight.geometry import Position 12 | from counterweight.hooks import use_hovered, use_mouse, use_rects, use_state 13 | from counterweight.styles import Span 14 | from counterweight.styles.utilities import * 15 | 16 | logger = get_logger() 17 | 18 | 19 | BLACK = Color.from_name("black") 20 | 21 | 22 | @lru_cache(maxsize=2**10) 23 | def canvas_chunk(color: Color) -> Chunk: 24 | return Chunk(content="█", style=CellStyle(foreground=color)) 25 | 26 | 27 | def canvas( 28 | width: int, 29 | height: int, 30 | cells: dict[Position, Color], 31 | ) -> list[Chunk]: 32 | return list( 33 | intersperse( 34 | Chunk.newline(), 35 | (canvas_chunk(cells.get(Position.flyweight(x, y), BLACK)) for y in range(height) for x in range(width)), 36 | n=width, 37 | ) 38 | ) 39 | 40 | 41 | @component 42 | def root() -> Div: 43 | return Div( 44 | style=col | align_children_center | justify_children_space_evenly, 45 | children=[ 46 | header(), 47 | Div( 48 | style=row | gap_children_1, 49 | children=[ 50 | tracking_box(), 51 | last_clicked_box(), 52 | last_dragged_box(), 53 | ], 54 | ), 55 | Div( 56 | style=row | gap_children_1, 57 | children=[ 58 | drag_text_box(), 59 | ], 60 | ), 61 | ], 62 | ) 63 | 64 | 65 | @component 66 | def header() -> Text: 67 | return Text( 68 | content="Mouse Tracking Demo", 69 | style=text_justify_center | text_amber_600, 70 | ) 71 | 72 | 73 | canvas_style = border_light | weight_none 74 | hover_style = border_heavy | border_amber_600 75 | 76 | 77 | @component 78 | def tracking_box() -> Text: 79 | mouse = use_mouse() 80 | rects = use_rects() 81 | hovered = use_hovered() 82 | 83 | return Text( 84 | style=canvas_style | (hover_style if hovered.border else None), 85 | content=canvas( 86 | 20, 87 | 10, 88 | ({mouse.absolute - rects.content.top_left(): Color.from_name("red")} if hovered.content else {}), 89 | ), 90 | ) 91 | 92 | 93 | @component 94 | def last_clicked_box() -> Text: 95 | rects = use_rects() 96 | hovered = use_hovered() 97 | 98 | clicked, set_clicked = use_state(Position.flyweight(0, 0)) 99 | 100 | def on_mouse(event: MouseEvent) -> None: 101 | match event: 102 | case MouseUp(absolute=p, button=1): 103 | set_clicked(p - rects.content.top_left()) 104 | 105 | return Text( 106 | on_mouse=on_mouse, 107 | style=canvas_style | (hover_style if hovered.border else None), 108 | content=canvas( 109 | 20, 110 | 10, 111 | { 112 | clicked: Color.from_name("green"), 113 | }, 114 | ), 115 | ) 116 | 117 | 118 | @component 119 | def last_dragged_box() -> Text: 120 | rects = use_rects() 121 | hovered = use_hovered() 122 | 123 | start, set_start = use_state(Position.flyweight(0, 0)) 124 | end, set_end = use_state(Position.flyweight(0, 0)) 125 | 126 | def on_mouse(event: MouseEvent) -> None: 127 | match event: 128 | case MouseDown(absolute=a, button=1): 129 | set_start(a - rects.content.top_left()) 130 | set_end(a - rects.content.top_left()) 131 | case MouseUp(absolute=a, button=1): 132 | set_end(a - rects.content.top_left()) 133 | case MouseMoved(absolute=a, button=1): 134 | set_end(a - rects.content.top_left()) 135 | 136 | return Text( 137 | on_mouse=on_mouse, 138 | style=canvas_style | (hover_style if hovered.border else None), 139 | content=canvas( 140 | 20, 141 | 10, 142 | {p: Color.from_name("khaki") for p in start.fill_to(end)} if start != end else {}, 143 | ), 144 | ) 145 | 146 | 147 | @component 148 | def drag_text_box() -> Div: 149 | hovered = use_hovered() 150 | 151 | return Div( 152 | style=canvas_style | (hover_style if hovered.border else None) | Style(span=Span(width=20, height=10)), 153 | children=[ 154 | draggable_text("Drag me!", inset_top_left), 155 | draggable_text("No, me!", inset_bottom_right), 156 | ], 157 | ) 158 | 159 | 160 | @component 161 | def draggable_text(content: str, start: Style) -> Text: 162 | mouse = use_mouse() 163 | offset, set_offset = use_state(Position.flyweight(0, 0)) 164 | 165 | # TODO: if you don't go slow, your mouse can easily outrun the render speed 166 | def on_mouse(event: MouseEvent) -> None: 167 | match event: 168 | case MouseMoved(absolute=a, button=1): 169 | set_offset(lambda o: o + (a - mouse.absolute)) 170 | 171 | return Text( 172 | on_mouse=on_mouse, 173 | style=start | absolute(x=offset.x, y=offset.y), 174 | content=content, 175 | ) 176 | 177 | 178 | if __name__ == "__main__": 179 | asyncio.run(app(root)) 180 | -------------------------------------------------------------------------------- /counterweight/hooks/hooks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import TypeVar, overload 5 | 6 | from structlog import get_logger 7 | 8 | from counterweight._context_vars import current_hook_state, current_use_mouse_listeners 9 | from counterweight._utils import forever 10 | from counterweight.geometry import Position, Rect 11 | from counterweight.hooks.types import Deps, Getter, Ref, Setter, Setup 12 | 13 | logger = get_logger() 14 | 15 | T = TypeVar("T") 16 | 17 | 18 | @overload 19 | def use_state(initial_value: Getter[T]) -> tuple[T, Setter[T]]: ... 20 | 21 | 22 | @overload 23 | def use_state(initial_value: T) -> tuple[T, Setter[T]]: ... 24 | 25 | 26 | def use_state(initial_value: Getter[T] | T) -> tuple[T, Setter[T]]: 27 | """ 28 | Parameters: 29 | initial_value: The initial value of the state. 30 | It can either be the initial value itself, or a zero-argument function that returns the initial value. 31 | 32 | Returns: 33 | The current value of the state (i.e., for the current render cycle). 34 | 35 | A function that can be called to update the value of the state (e.g., in an event handler). 36 | It can either be called with the new value of the state, 37 | or a function that takes the current value of the state and returns the new value of the state. 38 | If the value is not equal to the current of the state, 39 | Counterweight will trigger a render cycle. 40 | """ 41 | return current_hook_state.get().use_state(initial_value) 42 | 43 | 44 | def use_ref(initial_value: Getter[T] | T) -> Ref[T]: 45 | """ 46 | Parameters: 47 | initial_value: the initial value of the ref. 48 | It can either be the initial value itself, or a zero-argument function that returns the initial value. 49 | 50 | Returns: 51 | A [`Ref`][counterweight.hooks.Ref] that holds a reference to the given value. 52 | """ 53 | return current_hook_state.get().use_ref(initial_value) 54 | 55 | 56 | def use_effect(setup: Setup, deps: Deps = None) -> None: 57 | """ 58 | Parameters: 59 | setup: The setup function that will be called when the component first mounts 60 | or if its dependencies have changed (see below). 61 | 62 | deps: The dependencies of the effect. 63 | If any of the dependencies change, the previous invocation of the `setup` function will be cancelled 64 | and the `setup` function will be run again. 65 | If `None`, the `setup` function will be run on every render. 66 | """ 67 | return current_hook_state.get().use_effect(setup, deps) 68 | 69 | 70 | @dataclass(frozen=True, slots=True) 71 | class Rects: 72 | content: Rect 73 | padding: Rect 74 | border: Rect 75 | margin: Rect 76 | 77 | 78 | def use_rects() -> Rects: 79 | """ 80 | Returns: 81 | A recording describing the rectangular areas of the 82 | `content`, `padding`, `border`, and `margin` of 83 | the calling component's top-level element 84 | *on the previous render cycle*. 85 | In the initial render, the returned rectangles will all be positioned 86 | at the top-left corner of the screen with `0` width and height. 87 | """ 88 | dims = current_hook_state.get().dims 89 | 90 | p, b, m = dims.padding_border_margin_rects() 91 | 92 | return Rects( 93 | content=dims.content, 94 | padding=p, 95 | border=b, 96 | margin=m, 97 | ) 98 | 99 | 100 | @dataclass(frozen=True, slots=True) 101 | class Mouse: 102 | absolute: Position 103 | """The absolute position of the mouse on the screen (i.e., the top-left corner of the screen is `Position(x=0, y=0)`).""" 104 | 105 | motion: Position 106 | """The difference in the `absolute` position of the mouse since the last render cycle.""" 107 | 108 | button: int | None 109 | """The button that is currently pressed, or `None` if no button is pressed.""" 110 | 111 | 112 | _INITIAL_MOUSE = Mouse(absolute=Position.flyweight(-1, -1), motion=Position.flyweight(0, 0), button=None) 113 | 114 | 115 | def use_mouse() -> Mouse: 116 | """ 117 | Returns: 118 | A record describing the current state of the mouse. 119 | """ 120 | # Why bother making this a state hook when we could instead store the mouse state directly in a context var? 121 | # Triggering a state change is a way to signal to the render loop that a component cares about the mouse state 122 | # without needing to invent a new mechanism dedicated to mouse events, 123 | # and thus makes them work a lot more like key events. 124 | mouse, set_mouse = use_state(_INITIAL_MOUSE) 125 | 126 | async def setup() -> None: 127 | use_mouse_listeners = current_use_mouse_listeners.get() 128 | 129 | use_mouse_listeners.add(set_mouse) 130 | 131 | try: 132 | await forever() 133 | finally: 134 | use_mouse_listeners.remove(set_mouse) 135 | 136 | use_effect(setup=setup, deps=()) 137 | 138 | return mouse 139 | 140 | 141 | @dataclass(frozen=True, slots=True) 142 | class Hovered: 143 | content: bool 144 | padding: bool 145 | border: bool 146 | margin: bool 147 | 148 | 149 | def use_hovered() -> Hovered: 150 | """ 151 | Returns: 152 | A record describing which of the calling component's top-level element's 153 | `content`, `padding`, `border`, and `margin` rectangles the mouse is currently inside. 154 | """ 155 | mouse = use_mouse() 156 | rects = use_rects() 157 | 158 | return Hovered( 159 | content=mouse.absolute in rects.content, 160 | padding=mouse.absolute in rects.padding, 161 | border=mouse.absolute in rects.border, 162 | margin=mouse.absolute in rects.margin, 163 | ) 164 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Next 4 | 5 | ### Removed 6 | 7 | - [#165](https://github.com/JoshKarpel/counterweight/pull/165) 8 | Dropped support for Python `3.11`. 9 | 10 | ### Added 11 | 12 | - [#123](https://github.com/JoshKarpel/counterweight/pull/123) 13 | Add styling for content area background color. 14 | 15 | ### Fixed 16 | 17 | - [#125](https://github.com/JoshKarpel/counterweight/pull/125) 18 | Mouse wheel scroll events are now captured correctly 19 | (they were previously reported as mouse presses). 20 | The `check-input` CLI subcommand is also fixed 21 | (was broken by [#86](https://github.com/JoshKarpel/counterweight/pull/86)). 22 | 23 | ## `0.0.9` 24 | 25 | Released `2024-02-27` 26 | 27 | ### Fixed 28 | 29 | - [#121](https://github.com/JoshKarpel/counterweight/pull/121) 30 | A change in effect reconciliation introduced in `0.0.8` caused a regression in the behavior of `use_effect`, 31 | where if the `setup` function `return`ed (i.e., stopped itself), 32 | Counterweight would crash when trying to cancel the effect. 33 | This now works again. 34 | 35 | ## `0.0.8` 36 | 37 | Released `2024-02-17` 38 | 39 | ### Changed 40 | 41 | - [#110](https://github.com/JoshKarpel/counterweight/pull/110) 42 | Calling a `use_state` setter with a `value` equal to the current value of the state 43 | no longer emits a `SetState` event, to avoid triggering unnecessary render cycles. 44 | - [#111](https://github.com/JoshKarpel/counterweight/pull/111) 45 | Border healing is now more efficient, especially when there are many non-border characters in the UI. 46 | - [#112](https://github.com/JoshKarpel/counterweight/pull/112) 47 | Major, backwards-incompatible changes to how Counterweight handles mouse interactions. 48 | The `on_mouse_down` and `on_mouse_up` event handlers have been removed; 49 | use the new combined `on_mouse` event handler instead, which receives all mouse events 50 | ([`MouseMoved`][counterweight.events.MouseMoved], 51 | [`MouseDown`][counterweight.events.MouseDown], and 52 | [`MouseUp`][counterweight.events.MouseUp]). 53 | The `on_hover` style attribute on elements has been removed; 54 | use the new 55 | [`use_mouse`](hooks/use_mouse.md), 56 | [`use_rects`](hooks/use_rects.md), 57 | and [`use_hovered`](hooks/use_hovered.md) 58 | hooks instead, and calculate the desired style in your component. 59 | The goal of these changes is to provide more flexibility and control over mouse interactions 60 | to application authors while minimizing the work that Counterweight needs to do while rendering, 61 | at the cost of more complex application code for simple cases like detecting hover state. 62 | 63 | ## `0.0.7` 64 | 65 | Released `2024-02-02` 66 | 67 | ### Changed 68 | 69 | - [#105](https://github.com/JoshKarpel/counterweight/pull/105) 70 | `Screenshot` and `Suspend` handlers can now be `async` functions. 71 | 72 | ## `0.0.6` 73 | 74 | Released `2024-01-28` 75 | 76 | ### Added 77 | 78 | - [#92](https://github.com/JoshKarpel/counterweight/pull/92) 79 | Added an `inset` attribute to [`Absolute`][counterweight.styles.Absolute] that chooses which corner 80 | of the parent's context box to use as the origin for the absolute positioning. 81 | - [#98](https://github.com/JoshKarpel/counterweight/pull/98) 82 | Added a `z` attribute to [`Flex`][counterweight.styles.Flex] that controls the stacking order of elements. 83 | 84 | ### Changed 85 | 86 | - [#101](https://github.com/JoshKarpel/counterweight/pull/101) 87 | The `initial_value` of [`use_ref`][counterweight.hooks.use_ref] may now be a `Callable[[], T]` instead of just a `T`. 88 | 89 | ### Removed 90 | 91 | - [#96](https://github.com/JoshKarpel/counterweight/pull/96) 92 | `Chunk`s can no longer be created with `CellPaint`s directly. 93 | 94 | ## `0.0.5` 95 | 96 | Released `2024-01-06` 97 | 98 | ### Added 99 | 100 | - [#86](https://github.com/JoshKarpel/counterweight/pull/86) 101 | Added a `Suspend` control which suspends the Counterweight application while a user-supplied callback function runs. 102 | Counterweight will stop controlling the terminal while the callback runs, and will resume when the callback returns. 103 | This can be used to run a subprocess that also wants control of the terminal (e.g., a text editor). 104 | - [#88](https://github.com/JoshKarpel/counterweight/pull/88) 105 | [#90](https://github.com/JoshKarpel/counterweight/pull/90) 106 | Implemented [`Absolute`][counterweight.styles.Absolute] and [`Fixed`][counterweight.styles.Fixed] 107 | positioning, which allow for precise placement of elements outside the normal layout flow. 108 | 109 | ## `0.0.4` 110 | 111 | Released `2023-12-31` 112 | 113 | ### Fixed 114 | 115 | - [#83](https://github.com/JoshKarpel/counterweight/pull/83) 116 | Fixed virtual terminal escape code parsing for mouse tracking when the moues coordinates are large (>94 or so). 117 | Mouse tracking should now work for any terminal size. 118 | Various `Key` members that aren't currently parseable have been removed to avoid confusion. 119 | 120 | ## `0.0.3` 121 | 122 | Released `2023-12-30` 123 | 124 | ### Changed 125 | 126 | - [#71](https://github.com/JoshKarpel/counterweight/pull/71) 127 | Controls are now a union over different `@dataclass`es (instead of an `Enum`) to allow for more flexibility. 128 | The `Screenshot` control now takes a callback function which will be called with the SVG screenshot as an XML tree. 129 | - [#81](https://github.com/JoshKarpel/counterweight/pull/81) 130 | The minimum Python version has been raised to `3.11.2` due to a [bug in CPython `3.11.1`](https://github.com/python/cpython/issues/100098) 131 | 132 | ## `0.0.2` 133 | 134 | Released `2023-12-26` 135 | 136 | ### Added 137 | 138 | - [#65](https://github.com/JoshKarpel/counterweight/pull/65) 139 | Added ability to take an SVG "screenshot" by returning `Control.Screenshot` from an event handler. 140 | The screenshot will be saved to the current working directory as `screenshot.svg`; more options will be added in the future. 141 | - [#66](https://github.com/JoshKarpel/counterweight/pull/66) 142 | Added "border healing": when border elements for certain border types are adjacent to each other and appear as if they 143 | should "join up", but don't because they belong to different elements, they will now be joined up. 144 | 145 | ### Changed 146 | 147 | - Various namespaces moved around to make more sense (especially in documentation) 148 | and separate concerns better as part of [#68](https://github.com/JoshKarpel/counterweight/pull/68). 149 | 150 | ## `0.0.1` 151 | 152 | Released `2023-12-19` 153 | -------------------------------------------------------------------------------- /docs/assets/box-model.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | -------------------------------------------------------------------------------- /tests/test_vt_parsing.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from counterweight.events import ( 4 | AnyEvent, 5 | KeyPressed, 6 | MouseDown, 7 | MouseMoved, 8 | MouseScrolledDown, 9 | MouseScrolledUp, 10 | MouseUp, 11 | ) 12 | from counterweight.geometry import Position 13 | from counterweight.keys import Key, vt_inputs 14 | 15 | 16 | @pytest.mark.parametrize( 17 | "buffer, expected", 18 | [ 19 | # single characters, raw 20 | (b"f", [KeyPressed(key="f")]), 21 | (b"foo", [KeyPressed(key="f"), KeyPressed(key="o"), KeyPressed(key="o")]), 22 | # single bytes, transformed 23 | (b"\x1b", [KeyPressed(key=Key.Escape)]), 24 | (b"\t", [KeyPressed(key=Key.Tab)]), 25 | (b"\n", [KeyPressed(key=Key.Enter)]), 26 | (b" ", [KeyPressed(key=Key.Space)]), 27 | (b"\x00", [KeyPressed(key=Key.ControlSpace)]), 28 | (b"\x01", [KeyPressed(key=Key.ControlA)]), 29 | (b"\x02", [KeyPressed(key=Key.ControlB)]), 30 | (b"\x03", [KeyPressed(key=Key.ControlC)]), 31 | (b"\x04", [KeyPressed(key=Key.ControlD)]), 32 | (b"\x05", [KeyPressed(key=Key.ControlE)]), 33 | (b"\x06", [KeyPressed(key=Key.ControlF)]), 34 | (b"\x07", [KeyPressed(key=Key.ControlG)]), 35 | (b"\x08", [KeyPressed(key=Key.Backspace)]), 36 | (b"\x0b", [KeyPressed(key=Key.ControlK)]), 37 | (b"\x0c", [KeyPressed(key=Key.ControlL)]), 38 | (b"\x0e", [KeyPressed(key=Key.ControlN)]), 39 | (b"\x0f", [KeyPressed(key=Key.ControlO)]), 40 | (b"\x10", [KeyPressed(key=Key.ControlP)]), 41 | (b"\x11", [KeyPressed(key=Key.ControlQ)]), 42 | (b"\x12", [KeyPressed(key=Key.ControlR)]), 43 | (b"\x13", [KeyPressed(key=Key.ControlS)]), 44 | (b"\x14", [KeyPressed(key=Key.ControlT)]), 45 | (b"\x15", [KeyPressed(key=Key.ControlU)]), 46 | (b"\x16", [KeyPressed(key=Key.ControlV)]), 47 | (b"\x17", [KeyPressed(key=Key.ControlW)]), 48 | (b"\x18", [KeyPressed(key=Key.ControlX)]), 49 | (b"\x19", [KeyPressed(key=Key.ControlY)]), 50 | (b"\x1a", [KeyPressed(key=Key.ControlZ)]), 51 | (b"\x7f", [KeyPressed(key=Key.Backspace)]), 52 | # f1 to f4 53 | (b"\x1bOP", [KeyPressed(key=Key.F1)]), 54 | (b"\x1bOQ", [KeyPressed(key=Key.F2)]), 55 | (b"\x1bOR", [KeyPressed(key=Key.F3)]), 56 | (b"\x1bOS", [KeyPressed(key=Key.F4)]), 57 | # shift-tab 58 | (b"\x1b[Z", [KeyPressed(key=Key.BackTab)]), 59 | # CSI lookups 60 | (b"\x1b[A", [KeyPressed(key=Key.Up)]), 61 | (b"\x1b[B", [KeyPressed(key=Key.Down)]), 62 | (b"\x1b[C", [KeyPressed(key=Key.Right)]), 63 | (b"\x1b[D", [KeyPressed(key=Key.Left)]), 64 | (b"\x1b[2~", [KeyPressed(key=Key.Insert)]), 65 | (b"\x1b[3~", [KeyPressed(key=Key.Delete)]), 66 | (b"\x1b[F", [KeyPressed(key=Key.End)]), 67 | (b"\x1b[11~", [KeyPressed(key=Key.F1)]), 68 | (b"\x1b[12~", [KeyPressed(key=Key.F2)]), 69 | (b"\x1b[13~", [KeyPressed(key=Key.F3)]), 70 | (b"\x1b[14~", [KeyPressed(key=Key.F4)]), 71 | (b"\x1b[17~", [KeyPressed(key=Key.F6)]), 72 | (b"\x1b[18~", [KeyPressed(key=Key.F7)]), 73 | (b"\x1b[19~", [KeyPressed(key=Key.F8)]), 74 | (b"\x1b[20~", [KeyPressed(key=Key.F9)]), 75 | (b"\x1b[21~", [KeyPressed(key=Key.F10)]), 76 | (b"\x1b[23~", [KeyPressed(key=Key.F11)]), 77 | (b"\x1b[24~", [KeyPressed(key=Key.F12)]), 78 | (b"\x1b[25~", [KeyPressed(key=Key.F13)]), 79 | (b"\x1b[26~", [KeyPressed(key=Key.F14)]), 80 | (b"\x1b[28~", [KeyPressed(key=Key.F15)]), 81 | (b"\x1b[29~", [KeyPressed(key=Key.F16)]), 82 | (b"\x1b[31~", [KeyPressed(key=Key.F17)]), 83 | (b"\x1b[32~", [KeyPressed(key=Key.F18)]), 84 | (b"\x1b[33~", [KeyPressed(key=Key.F19)]), 85 | (b"\x1b[34~", [KeyPressed(key=Key.F20)]), 86 | (b"\x1b[1;2A", [KeyPressed(key=Key.ShiftUp)]), 87 | (b"\x1b[1;2B", [KeyPressed(key=Key.ShiftDown)]), 88 | (b"\x1b[1;2C", [KeyPressed(key=Key.ShiftRight)]), 89 | (b"\x1b[1;2D", [KeyPressed(key=Key.ShiftLeft)]), 90 | (b"\x1b[1;5A", [KeyPressed(key=Key.ControlUp)]), 91 | (b"\x1b[1;5B", [KeyPressed(key=Key.ControlDown)]), 92 | (b"\x1b[1;5C", [KeyPressed(key=Key.ControlRight)]), 93 | (b"\x1b[1;5D", [KeyPressed(key=Key.ControlLeft)]), 94 | (b"\x1b[1;6A", [KeyPressed(key=Key.ControlShiftUp)]), 95 | (b"\x1b[1;6B", [KeyPressed(key=Key.ControlShiftDown)]), 96 | (b"\x1b[1;6C", [KeyPressed(key=Key.ControlShiftRight)]), 97 | (b"\x1b[3;3~", [KeyPressed(key=Key.AltDelete)]), 98 | (b"\x1b[3;5~", [KeyPressed(key=Key.ControlDelete)]), 99 | (b"\x1b[3;6~", [KeyPressed(key=Key.ControlShiftInsert)]), 100 | # Mouse events 101 | (b"\x1b[<35;1;1m", [MouseMoved(absolute=Position(x=0, y=0), button=None)]), 102 | (b"\x1b[<35;2;1m", [MouseMoved(absolute=Position(x=1, y=0), button=None)]), 103 | (b"\x1b[<32;2;1m", [MouseMoved(absolute=Position(x=1, y=0), button=1)]), 104 | (b"\x1b[<33;2;1m", [MouseMoved(absolute=Position(x=1, y=0), button=2)]), 105 | (b"\x1b[<34;2;1m", [MouseMoved(absolute=Position(x=1, y=0), button=3)]), 106 | (b"\x1b[<35;1;2m", [MouseMoved(absolute=Position(x=0, y=1), button=None)]), 107 | (b"\x1b[<35;2;2m", [MouseMoved(absolute=Position(x=1, y=1), button=None)]), 108 | (b"\x1b[<35;95;1m", [MouseMoved(absolute=Position(x=94, y=0), button=None)]), 109 | (b"\x1b[<35;1;95m", [MouseMoved(absolute=Position(x=0, y=94), button=None)]), 110 | (b"\x1b[<35;95;95m", [MouseMoved(absolute=Position(x=94, y=94), button=None)]), 111 | (b"\x1b[<35;500;500m", [MouseMoved(absolute=Position(x=499, y=499), button=None)]), 112 | (b"\x1b[<35;1000;1000m", [MouseMoved(absolute=Position(x=999, y=999), button=None)]), 113 | (b"\x1b[<0;1;1M", [MouseDown(absolute=Position(x=0, y=0), button=1)]), 114 | (b"\x1b[<0;1;1m", [MouseUp(absolute=Position(x=0, y=0), button=1)]), 115 | (b"\x1b[<1;1;1M", [MouseDown(absolute=Position(x=0, y=0), button=2)]), 116 | (b"\x1b[<1;1;1m", [MouseUp(absolute=Position(x=0, y=0), button=2)]), 117 | (b"\x1b[<2;1;1M", [MouseDown(absolute=Position(x=0, y=0), button=3)]), 118 | (b"\x1b[<2;1;1m", [MouseUp(absolute=Position(x=0, y=0), button=3)]), 119 | # It seems like some systems will use an M even in the mouse up state for motion... 120 | (b"\x1b[<35;2;1M", [MouseMoved(absolute=Position(x=1, y=0), button=None)]), 121 | (b"\x1b[<32;2;1M", [MouseMoved(absolute=Position(x=1, y=0), button=1)]), 122 | (b"\x1b[<33;2;1M", [MouseMoved(absolute=Position(x=1, y=0), button=2)]), 123 | (b"\x1b[<34;2;1M", [MouseMoved(absolute=Position(x=1, y=0), button=3)]), 124 | (b"\x1b[<64;2;1M", [MouseScrolledDown(absolute=Position(x=1, y=0))]), 125 | (b"\x1b[<65;2;1M", [MouseScrolledUp(absolute=Position(x=1, y=0))]), 126 | ], 127 | ) 128 | def test_vt_input_parsing(buffer: bytes, expected: list[AnyEvent]) -> None: 129 | assert vt_inputs.parse(buffer) == expected 130 | -------------------------------------------------------------------------------- /docs/assets/border-titles.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | T 20 | o 21 | p 22 | - 23 | L 24 | e 25 | f 26 | t 27 | T 28 | i 29 | t 30 | l 31 | e 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | T 41 | o 42 | p 43 | - 44 | C 45 | e 46 | n 47 | t 48 | e 49 | r 50 | T 51 | i 52 | t 53 | l 54 | e 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | T 63 | o 64 | p 65 | - 66 | R 67 | i 68 | g 69 | h 70 | t 71 | T 72 | i 73 | t 74 | l 75 | e 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | L 86 | o 87 | r 88 | e 89 | m 90 | i 91 | p 92 | s 93 | u 94 | m 95 | d 96 | o 97 | l 98 | o 99 | r 100 | s 101 | i 102 | t 103 | a 104 | m 105 | e 106 | t 107 | , 108 | c 109 | o 110 | n 111 | s 112 | e 113 | c 114 | t 115 | e 116 | t 117 | u 118 | r 119 | a 120 | d 121 | i 122 | p 123 | i 124 | s 125 | c 126 | i 127 | n 128 | g 129 | e 130 | l 131 | i 132 | t 133 | . 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | B 144 | o 145 | t 146 | t 147 | o 148 | m 149 | - 150 | L 151 | e 152 | f 153 | t 154 | T 155 | i 156 | t 157 | l 158 | e 159 | 160 | 161 | 162 | B 163 | o 164 | t 165 | t 166 | o 167 | m 168 | - 169 | C 170 | e 171 | n 172 | t 173 | e 174 | r 175 | T 176 | i 177 | t 178 | l 179 | e 180 | 181 | 182 | 183 | B 184 | o 185 | t 186 | t 187 | o 188 | m 189 | - 190 | R 191 | i 192 | g 193 | h 194 | t 195 | T 196 | i 197 | t 198 | l 199 | e 200 | 201 | 202 | 203 | 204 | -------------------------------------------------------------------------------- /docs/assets/z.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | z 85 | = 86 | - 87 | 1 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | z 116 | = 117 | 0 118 | 119 | 120 | z 121 | = 122 | + 123 | 2 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | z 148 | = 149 | + 150 | 1 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | -------------------------------------------------------------------------------- /counterweight/keys.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Generator, Mapping 4 | from enum import Enum 5 | from string import printable 6 | 7 | from parsy import Parser, char_from, generate, match_item 8 | from structlog import get_logger 9 | 10 | from counterweight.events import ( 11 | AnyEvent, 12 | KeyPressed, 13 | MouseDown, 14 | MouseMoved, 15 | MouseScrolledDown, 16 | MouseScrolledUp, 17 | MouseUp, 18 | ) 19 | from counterweight.geometry import Position 20 | 21 | logger = get_logger() 22 | 23 | 24 | class Key(str, Enum): 25 | """ 26 | Derived from https://github.com/Textualize/textual/blob/c966243b059f0352e2a23b9695776838195364a3/src/textual/keys.py 27 | """ 28 | 29 | Escape = "escape" # Also Control-[ 30 | ShiftEscape = "shift+escape" 31 | 32 | ControlA = "ctrl+a" 33 | ControlB = "ctrl+b" 34 | ControlC = "ctrl+c" 35 | ControlD = "ctrl+d" 36 | ControlE = "ctrl+e" 37 | ControlF = "ctrl+f" 38 | ControlG = "ctrl+g" 39 | ControlH = "ctrl+h" 40 | ControlI = "ctrl+i" # Tab 41 | ControlJ = "ctrl+j" # Newline 42 | ControlK = "ctrl+k" 43 | ControlL = "ctrl+l" 44 | ControlM = "ctrl+m" # Carriage return 45 | ControlN = "ctrl+n" 46 | ControlO = "ctrl+o" 47 | ControlP = "ctrl+p" 48 | ControlQ = "ctrl+q" 49 | ControlR = "ctrl+r" 50 | ControlS = "ctrl+s" 51 | ControlT = "ctrl+t" 52 | ControlU = "ctrl+u" 53 | ControlV = "ctrl+v" 54 | ControlW = "ctrl+w" 55 | ControlX = "ctrl+x" 56 | ControlY = "ctrl+y" 57 | ControlZ = "ctrl+z" 58 | 59 | Left = "left" 60 | Right = "right" 61 | Up = "up" 62 | Down = "down" 63 | End = "end" 64 | Insert = "insert" 65 | Delete = "delete" 66 | 67 | ControlLeft = "ctrl+left" 68 | ControlRight = "ctrl+right" 69 | ControlUp = "ctrl+up" 70 | ControlDown = "ctrl+down" 71 | ControlHome = "ctrl+home" 72 | ControlEnd = "ctrl+end" 73 | ControlInsert = "ctrl+insert" 74 | ControlDelete = "ctrl+delete" 75 | ControlPageUp = "ctrl+pageup" 76 | ControlPageDown = "ctrl+pagedown" 77 | 78 | ShiftLeft = "shift+left" 79 | ShiftRight = "shift+right" 80 | ShiftUp = "shift+up" 81 | ShiftDown = "shift+down" 82 | ShiftHome = "shift+home" 83 | ShiftEnd = "shift+end" 84 | ShiftInsert = "shift+insert" 85 | ShiftDelete = "shift+delete" 86 | ShiftPageUp = "shift+pageup" 87 | ShiftPageDown = "shift+pagedown" 88 | 89 | ControlShiftLeft = "ctrl+shift+left" 90 | ControlShiftRight = "ctrl+shift+right" 91 | ControlShiftUp = "ctrl+shift+up" 92 | ControlShiftDown = "ctrl+shift+down" 93 | ControlShiftHome = "ctrl+shift+home" 94 | ControlShiftEnd = "ctrl+shift+end" 95 | ControlShiftInsert = "ctrl+shift+insert" 96 | ControlShiftDelete = "ctrl+shift+delete" 97 | ControlShiftPageUp = "ctrl+shift+pageup" 98 | ControlShiftPageDown = "ctrl+shift+pagedown" 99 | 100 | AltDelete = "alt+delete" 101 | 102 | BackTab = "shift+tab" # shift + tab 103 | 104 | F1 = "f1" 105 | F2 = "f2" 106 | F3 = "f3" 107 | F4 = "f4" 108 | F5 = "f5" 109 | F6 = "f6" 110 | F7 = "f7" 111 | F8 = "f8" 112 | F9 = "f9" 113 | F10 = "f10" 114 | F11 = "f11" 115 | F12 = "f12" 116 | F13 = "f13" 117 | F14 = "f14" 118 | F15 = "f15" 119 | F16 = "f16" 120 | F17 = "f17" 121 | F18 = "f18" 122 | F19 = "f19" 123 | F20 = "f20" 124 | F21 = "f21" 125 | F22 = "f22" 126 | F23 = "f23" 127 | F24 = "f24" 128 | 129 | ControlF1 = "ctrl+f1" 130 | ControlF2 = "ctrl+f2" 131 | ControlF3 = "ctrl+f3" 132 | ControlF4 = "ctrl+f4" 133 | ControlF5 = "ctrl+f5" 134 | ControlF6 = "ctrl+f6" 135 | ControlF7 = "ctrl+f7" 136 | ControlF8 = "ctrl+f8" 137 | ControlF9 = "ctrl+f9" 138 | ControlF10 = "ctrl+f10" 139 | ControlF11 = "ctrl+f11" 140 | ControlF12 = "ctrl+f12" 141 | ControlF13 = "ctrl+f13" 142 | ControlF14 = "ctrl+f14" 143 | ControlF15 = "ctrl+f15" 144 | ControlF16 = "ctrl+f16" 145 | ControlF17 = "ctrl+f17" 146 | ControlF18 = "ctrl+f18" 147 | ControlF19 = "ctrl+f19" 148 | ControlF20 = "ctrl+f20" 149 | ControlF21 = "ctrl+f21" 150 | ControlF22 = "ctrl+f22" 151 | ControlF23 = "ctrl+f23" 152 | ControlF24 = "ctrl+f24" 153 | 154 | ControlSpace = "ctrl-space" 155 | Tab = "tab" 156 | Space = "space" 157 | Enter = "enter" 158 | Backspace = "backspace" 159 | 160 | def __repr__(self) -> str: 161 | return str(self) 162 | 163 | 164 | SINGLE_CHAR_TRANSFORMS: Mapping[bytes, Key] = { 165 | b"\x1b": Key.Escape, 166 | b"\t": Key.Tab, 167 | b"\n": Key.Enter, 168 | b" ": Key.Space, 169 | b"\x00": Key.ControlSpace, 170 | b"\x01": Key.ControlA, 171 | b"\x02": Key.ControlB, 172 | b"\x03": Key.ControlC, 173 | b"\x04": Key.ControlD, 174 | b"\x05": Key.ControlE, 175 | b"\x06": Key.ControlF, 176 | b"\x07": Key.ControlG, 177 | b"\x08": Key.Backspace, 178 | b"\x0b": Key.ControlK, 179 | b"\x0c": Key.ControlL, 180 | b"\x0e": Key.ControlN, 181 | b"\x0f": Key.ControlO, 182 | b"\x10": Key.ControlP, 183 | b"\x11": Key.ControlQ, 184 | b"\x12": Key.ControlR, 185 | b"\x13": Key.ControlS, 186 | b"\x14": Key.ControlT, 187 | b"\x15": Key.ControlU, 188 | b"\x16": Key.ControlV, 189 | b"\x17": Key.ControlW, 190 | b"\x18": Key.ControlX, 191 | b"\x19": Key.ControlY, 192 | b"\x1a": Key.ControlZ, 193 | b"\x7f": Key.Backspace, 194 | } 195 | 196 | single_transformable_char = char_from(b"".join((printable.encode("utf-8"), *SINGLE_CHAR_TRANSFORMS.keys()))) 197 | decimal_digits = char_from(b"0123456789").many().map(b"".join) 198 | 199 | 200 | @generate 201 | def single_char() -> Generator[Parser, bytes, AnyEvent]: 202 | c = yield single_transformable_char 203 | 204 | return KeyPressed(key=SINGLE_CHAR_TRANSFORMS.get(c) or c.decode("utf-8")) 205 | 206 | 207 | esc = match_item(b"\x1b") 208 | left_bracket = match_item(b"[") 209 | 210 | 211 | @generate 212 | def escape_sequence() -> Generator[Parser, AnyEvent, AnyEvent]: 213 | keys = yield esc >> (f1to4 | (left_bracket >> (mouse | two_params | zero_or_one_params))) 214 | 215 | return keys 216 | 217 | 218 | F1TO4 = { 219 | b"P": Key.F1, 220 | b"Q": Key.F2, 221 | b"R": Key.F3, 222 | b"S": Key.F4, 223 | } 224 | 225 | O_PQRS = match_item(b"O") >> char_from(b"PQRS") 226 | 227 | 228 | @generate 229 | def f1to4() -> Generator[Parser, bytes, AnyEvent]: 230 | final = yield O_PQRS 231 | 232 | return KeyPressed(key=F1TO4[final]) 233 | 234 | 235 | left_angle = match_item(b"<") 236 | mM = char_from(b"mM") 237 | 238 | 239 | @generate 240 | def mouse() -> Generator[Parser, bytes, AnyEvent]: 241 | # https://www.xfree86.org/current/ctlseqs.html 242 | # https://invisible-island.net/xterm/ctlseqs/ctlseqs.pdf 243 | yield left_angle 244 | 245 | buttons_ = yield decimal_digits 246 | yield semicolon 247 | x_ = yield decimal_digits 248 | yield semicolon 249 | y_ = yield decimal_digits 250 | m = yield mM 251 | 252 | button_info = int(buttons_) 253 | x = int(x_) - 1 254 | y = int(y_) - 1 255 | 256 | pos = Position.flyweight(x=x, y=y) 257 | moving = button_info & 32 258 | button = (button_info & 0b11) + 1 259 | 260 | if button_info == 65: 261 | return MouseScrolledUp(absolute=pos) 262 | elif button_info == 64: 263 | return MouseScrolledDown(absolute=pos) 264 | elif moving: 265 | return MouseMoved(absolute=pos, button=button if button != 4 else None) # raw 3 is released, becomes 4 above 266 | elif m == b"m": 267 | return MouseUp(absolute=pos, button=button) 268 | else: # m == b"M" 269 | return MouseDown(absolute=pos, button=button) 270 | 271 | 272 | CSI_LOOKUP: Mapping[tuple[bytes, ...], str] = { 273 | # 0 params 274 | (b"", b"A"): Key.Up, 275 | (b"", b"B"): Key.Down, 276 | (b"", b"C"): Key.Right, 277 | (b"", b"D"): Key.Left, 278 | (b"", b"F"): Key.End, 279 | (b"", b"Z"): Key.BackTab, 280 | # 1 param 281 | (b"2", b"~"): Key.Insert, 282 | (b"3", b"~"): Key.Delete, 283 | (b"11", b"~"): Key.F1, 284 | (b"12", b"~"): Key.F2, 285 | (b"13", b"~"): Key.F3, 286 | (b"14", b"~"): Key.F4, 287 | (b"15", b"~"): Key.F5, 288 | # skip 16 289 | (b"17", b"~"): Key.F6, 290 | (b"18", b"~"): Key.F7, 291 | (b"19", b"~"): Key.F8, 292 | (b"20", b"~"): Key.F9, 293 | (b"21", b"~"): Key.F10, 294 | # skip 22 295 | (b"23", b"~"): Key.F11, 296 | (b"24", b"~"): Key.F12, 297 | (b"25", b"~"): Key.F13, 298 | (b"26", b"~"): Key.F14, 299 | # skip 27 300 | (b"28", b"~"): Key.F15, 301 | (b"29", b"~"): Key.F16, 302 | # skip 30 303 | (b"31", b"~"): Key.F17, 304 | (b"32", b"~"): Key.F18, 305 | (b"33", b"~"): Key.F19, 306 | (b"34", b"~"): Key.F20, 307 | # 2 params 308 | (b"1", b"2", b"A"): Key.ShiftUp, 309 | (b"1", b"2", b"B"): Key.ShiftDown, 310 | (b"1", b"2", b"C"): Key.ShiftRight, 311 | (b"1", b"2", b"D"): Key.ShiftLeft, 312 | (b"1", b"5", b"A"): Key.ControlUp, 313 | (b"1", b"5", b"B"): Key.ControlDown, 314 | (b"1", b"5", b"C"): Key.ControlRight, 315 | (b"1", b"5", b"D"): Key.ControlLeft, 316 | (b"1", b"6", b"A"): Key.ControlShiftUp, 317 | (b"1", b"6", b"B"): Key.ControlShiftDown, 318 | (b"1", b"6", b"C"): Key.ControlShiftRight, 319 | (b"1", b"6", b"D"): Key.ControlShiftLeft, 320 | (b"3", b"3", b"~"): Key.AltDelete, 321 | (b"3", b"5", b"~"): Key.ControlDelete, 322 | (b"3", b"6", b"~"): Key.ControlShiftInsert, 323 | } 324 | 325 | final_csi_char = char_from(b"".join(sorted(set(key[-1] for key in CSI_LOOKUP)))) 326 | semicolon = match_item(b";") 327 | 328 | 329 | @generate 330 | def two_params() -> Generator[Parser, bytes, AnyEvent]: 331 | p1 = yield decimal_digits 332 | yield semicolon 333 | p2 = yield decimal_digits 334 | e = yield final_csi_char 335 | 336 | return KeyPressed(key=CSI_LOOKUP[(p1, p2, e)]) 337 | 338 | 339 | @generate 340 | def zero_or_one_params() -> Generator[Parser, bytes, AnyEvent]: 341 | p1 = yield decimal_digits # zero params => b"" 342 | e = yield final_csi_char 343 | 344 | return KeyPressed(key=CSI_LOOKUP[(p1, e)]) 345 | 346 | 347 | @generate 348 | def vt_inputs() -> Generator[Parser, list[AnyEvent], list[AnyEvent]]: 349 | commands = yield (escape_sequence | single_char).many() 350 | return commands 351 | --------------------------------------------------------------------------------