├── 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 | 
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 | 
13 |
14 | With border healing **disabled**:
15 |
16 | 
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 | [](https://pypi.org/project/counterweight)
2 | [](https://pypi.org/project/counterweight)
3 | [](https://www.counterweight.dev)
4 |
5 | [](https://results.pre-commit.ci/latest/github/JoshKarpel/counterweight/main)
6 | [](https://codecov.io/gh/JoshKarpel/counterweight)
7 |
8 | [](https://github.com/JoshKarpel/counterweight/issues)
9 | [](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 | 
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 | 
53 |
54 | ### Absolute Positioning
55 |
56 | ::: counterweight.styles.Absolute
57 |
58 | ```python
59 | --8<-- "absolute_positioning.py:example"
60 | ```
61 |
62 | 
63 |
64 | #### Controlling Overlapping with `z`
65 |
66 | ```python
67 | --8<-- "z.py:example"
68 | ```
69 |
70 | 
71 |
72 |
73 | ```python
74 | --8<-- "absolute_positioning_insets.py:example"
75 | ```
76 |
77 | 
78 |
79 |
80 | ### Fixed Positioning
81 |
82 | ::: counterweight.styles.Fixed
83 |
84 | ```python
85 | --8<-- "fixed_positioning.py:example"
86 | ```
87 |
88 | 
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/docs/assets/z.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------