bittty 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ # WTFPL + Warranty
2
+
3
+ Licensed under the WTFPL with one additional clause:
4
+
5
+ 1. Don't blame me.
6
+
7
+ Do whatever the fuck you want, just don't blame me.
bittty-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: bittty
3
+ Version: 0.0.1
4
+ Summary: a pure python tty
5
+ Author-email: Gareth Davidson <gaz@bitplane.net>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE.md
9
+ Requires-Dist: pre-commit ; extra == "dev"
10
+ Requires-Dist: pytest ; extra == "dev"
11
+ Requires-Dist: coverage ; extra == "dev"
12
+ Requires-Dist: pytest-cov ; extra == "dev"
13
+ Requires-Dist: pytest-asyncio ; extra == "dev"
14
+ Requires-Dist: build ; extra == "dev"
15
+ Requires-Dist: twine ; extra == "dev"
16
+ Requires-Dist: ruff ; extra == "dev"
17
+ Provides-Extra: dev
18
+
19
+ # bittty
20
+
21
+ A pure Python terminal emulator.
22
+
23
+ Currently buggy and a bit slow, but it's still somewhat usable.
24
+
25
+ ## Demo
26
+
27
+ Run the standalone demo:
28
+
29
+ ```bash
30
+ python ./demo/terminal.py
31
+ ```
32
+
33
+ Or use the textual demo to see it in a TUI:
34
+
35
+ ```bash
36
+ uvx textual-tty
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ There's 3 main classes:
42
+
43
+ 1. `Terminal`, a standalone terminal that doesn't need Textual.
44
+ 2. `TextualTerminal`, a tty widget subclass.
45
+ 3. `TerminalApp`, a terminal emulator in a window.
46
+
47
+ Read the demo code for more info.
48
+
49
+ ## Links
50
+
51
+ * [🏠 home](https://bitplane.net/dev/python/bittty)
52
+ * [🐍 pypi](https://pypi.org/project/bittty)
53
+ * [🐱 github](https://github.com/bitplane/bittty)
54
+
55
+ ## License
56
+
57
+ WTFPL with one additional clause
58
+
59
+ 1. Don't blame me
60
+
61
+ Do wtf you want, but don't blame me when it rips a hole in your trousers.
62
+
63
+ ## todo / ideas
64
+
65
+ - [ ] split pty out into a cross platform package
66
+ - [x] break terminal project out from Textual deps
67
+ - [x] write a minimal demo that doesn't need textual
68
+ - [ ] gui
69
+ - [ ] make `framebuffer.py`
70
+ - [ ] choose a backend
71
+ - [ ] performance improvements
72
+ - [ ] parse with regex over large buffer sizes
73
+ - [ ] scrollback buffer
74
+ - [ ] implement `logloglog` for scrollback with wrapping
75
+ - [ ] bugs
76
+ - [ ] blank background to end of line
77
+ - [ ] corruption in stream - debug it
78
+ - [ ] scroll region: scroll up in `vim` corrupts outside scroll region
79
+ - [ ] reduce redundancy redundancy of repeated repeated code code
80
+ - [ ] code code of of redundancy redundancy
81
+ - [ ] add terminal visuals
82
+ - [ ] bell flash effect
83
+ - [ ] Support themes
84
+
bittty-0.0.1/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # bittty
2
+
3
+ A pure Python terminal emulator.
4
+
5
+ Currently buggy and a bit slow, but it's still somewhat usable.
6
+
7
+ ## Demo
8
+
9
+ Run the standalone demo:
10
+
11
+ ```bash
12
+ python ./demo/terminal.py
13
+ ```
14
+
15
+ Or use the textual demo to see it in a TUI:
16
+
17
+ ```bash
18
+ uvx textual-tty
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ There's 3 main classes:
24
+
25
+ 1. `Terminal`, a standalone terminal that doesn't need Textual.
26
+ 2. `TextualTerminal`, a tty widget subclass.
27
+ 3. `TerminalApp`, a terminal emulator in a window.
28
+
29
+ Read the demo code for more info.
30
+
31
+ ## Links
32
+
33
+ * [🏠 home](https://bitplane.net/dev/python/bittty)
34
+ * [🐍 pypi](https://pypi.org/project/bittty)
35
+ * [🐱 github](https://github.com/bitplane/bittty)
36
+
37
+ ## License
38
+
39
+ WTFPL with one additional clause
40
+
41
+ 1. Don't blame me
42
+
43
+ Do wtf you want, but don't blame me when it rips a hole in your trousers.
44
+
45
+ ## todo / ideas
46
+
47
+ - [ ] split pty out into a cross platform package
48
+ - [x] break terminal project out from Textual deps
49
+ - [x] write a minimal demo that doesn't need textual
50
+ - [ ] gui
51
+ - [ ] make `framebuffer.py`
52
+ - [ ] choose a backend
53
+ - [ ] performance improvements
54
+ - [ ] parse with regex over large buffer sizes
55
+ - [ ] scrollback buffer
56
+ - [ ] implement `logloglog` for scrollback with wrapping
57
+ - [ ] bugs
58
+ - [ ] blank background to end of line
59
+ - [ ] corruption in stream - debug it
60
+ - [ ] scroll region: scroll up in `vim` corrupts outside scroll region
61
+ - [ ] reduce redundancy redundancy of repeated repeated code code
62
+ - [ ] code code of of redundancy redundancy
63
+ - [ ] add terminal visuals
64
+ - [ ] bell flash effect
65
+ - [ ] Support themes
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "bittty"
3
+ description = "a pure python tty"
4
+ version = "0.0.1"
5
+ authors = [
6
+ { name = "Gareth Davidson", email = "gaz@bitplane.net" }
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.10"
10
+
11
+ dependencies = [
12
+
13
+ ]
14
+
15
+ [project.optional-dependencies]
16
+ dev = [
17
+ "pre-commit",
18
+ "pytest",
19
+ "coverage",
20
+ "pytest-cov",
21
+ "pytest-asyncio",
22
+ "build",
23
+ "twine",
24
+ "ruff"
25
+ ]
26
+
27
+ [build-system]
28
+ build-backend = "flit_core.buildapi"
29
+ requires = ["flit_core >=3.2,<4"]
30
+
31
+ [tool.ruff]
32
+ line-length = 120
33
+ target-version = "py310"
34
+
35
+ [tool.ruff.format]
36
+ docstring-code-format = true
@@ -0,0 +1,34 @@
1
+ """
2
+ bittty: A fast, pure Python terminal emulator library.
3
+
4
+ bittty (bitplane-tty) is a high-performance terminal emulator engine
5
+ that provides comprehensive ANSI sequence parsing and terminal state management.
6
+ """
7
+
8
+ from .terminal import Terminal
9
+ from .buffer import Buffer
10
+ from .parser import Parser
11
+ from .color import (
12
+ get_color_code,
13
+ get_rgb_code,
14
+ get_style_code,
15
+ get_combined_code,
16
+ reset_code,
17
+ get_cursor_code,
18
+ get_clear_line_code,
19
+ )
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ __all__ = [
24
+ "Terminal",
25
+ "Buffer",
26
+ "Parser",
27
+ "get_color_code",
28
+ "get_rgb_code",
29
+ "get_style_code",
30
+ "get_combined_code",
31
+ "reset_code",
32
+ "get_cursor_code",
33
+ "get_clear_line_code",
34
+ ]
@@ -0,0 +1,272 @@
1
+ """
2
+ Terminal Buffer: Grid-based terminal content storage.
3
+
4
+ This module provides the Buffer class that manages terminal screen content
5
+ as a 2D grid of (ansi_code, character) tuples.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import List, Tuple
11
+
12
+ from . import constants
13
+ from .color import get_cursor_code, reset_code
14
+
15
+
16
+ # Type alias for a cell: (ANSI code, character)
17
+ Cell = Tuple[str, str]
18
+
19
+
20
+ class Buffer:
21
+ """
22
+ A buffer that stores terminal content as a 2D grid.
23
+
24
+ Each cell contains a tuple of (ansi_code, character) where:
25
+ - ansi_code: ANSI escape sequence for styling (empty string for no styling)
26
+ - character: The actual character to display
27
+ """
28
+
29
+ def __init__(self, width: int, height: int) -> None:
30
+ """Initialize buffer with given dimensions."""
31
+ self.width = width
32
+ self.height = height
33
+ # Initialize grid with empty cells
34
+ self.grid: List[List[Cell]] = [[("", " ") for _ in range(width)] for _ in range(height)]
35
+
36
+ def get_content(self) -> List[List[Cell]]:
37
+ """Get buffer content as a 2D grid."""
38
+ return [row[:] for row in self.grid]
39
+
40
+ def get_cell(self, x: int, y: int) -> Cell:
41
+ """Get cell at position."""
42
+ if 0 <= y < self.height and 0 <= x < self.width:
43
+ return self.grid[y][x]
44
+ return ("", " ")
45
+
46
+ def set_cell(self, x: int, y: int, char: str, ansi_code: str = "") -> None:
47
+ """Set a single cell at position."""
48
+ if 0 <= y < self.height and 0 <= x < self.width:
49
+ self.grid[y][x] = (ansi_code, char)
50
+
51
+ def set(self, x: int, y: int, text: str, ansi_code: str = "") -> None:
52
+ """Set text at position, overwriting existing content."""
53
+ if not (0 <= y < self.height):
54
+ return
55
+
56
+ for i, char in enumerate(text):
57
+ if x + i >= self.width:
58
+ break
59
+ self.grid[y][x + i] = (ansi_code, char)
60
+
61
+ def insert(self, x: int, y: int, text: str, ansi_code: str = "") -> None:
62
+ """Insert text at position, shifting existing content right."""
63
+ if not (0 <= y < self.height) or x >= self.width:
64
+ return
65
+
66
+ # Get the current row
67
+ row = self.grid[y]
68
+
69
+ # Create new cells for the inserted text
70
+ new_cells = [(ansi_code, char) for char in text]
71
+
72
+ # Insert at position
73
+ if x < len(row):
74
+ # Split row and insert
75
+ new_row = row[:x] + new_cells + row[x:]
76
+ # Truncate to width
77
+ self.grid[y] = new_row[: self.width]
78
+ else:
79
+ # Pad with spaces if needed
80
+ padding_needed = x - len(row)
81
+ if padding_needed > 0:
82
+ row.extend([("", " ")] * padding_needed)
83
+ row.extend(new_cells)
84
+ # Truncate to width
85
+ self.grid[y] = row[: self.width]
86
+
87
+ def delete(self, x: int, y: int, count: int = 1) -> None:
88
+ """Delete characters at position."""
89
+ if not (0 <= y < self.height) or x >= self.width:
90
+ return
91
+
92
+ row = self.grid[y]
93
+
94
+ # Delete characters and shift left
95
+ if x < len(row):
96
+ end_pos = min(x + count, len(row))
97
+ new_row = row[:x] + row[end_pos:]
98
+ # Pad with spaces to maintain width
99
+ while len(new_row) < self.width:
100
+ new_row.append(("", " "))
101
+ self.grid[y] = new_row
102
+
103
+ def clear_region(self, x1: int, y1: int, x2: int, y2: int, ansi_code: str = "") -> None:
104
+ """Clear a rectangular region."""
105
+ for y in range(max(0, y1), min(self.height, y2 + 1)):
106
+ for x in range(max(0, x1), min(self.width, x2 + 1)):
107
+ self.grid[y][x] = (ansi_code, " ")
108
+
109
+ def clear_line(
110
+ self, y: int, mode: int = constants.ERASE_FROM_CURSOR_TO_END, cursor_x: int = 0, ansi_code: str = ""
111
+ ) -> None:
112
+ """Clear line content."""
113
+ if not (0 <= y < self.height):
114
+ return
115
+
116
+ if mode == constants.ERASE_FROM_CURSOR_TO_END:
117
+ # Clear from cursor to end of line
118
+ for x in range(cursor_x, self.width):
119
+ self.grid[y][x] = (ansi_code, " ")
120
+ elif mode == constants.ERASE_FROM_START_TO_CURSOR:
121
+ # Clear from start to cursor
122
+ for x in range(0, min(cursor_x + 1, self.width)):
123
+ self.grid[y][x] = (ansi_code, " ")
124
+ elif mode == constants.ERASE_ALL:
125
+ # Clear entire line
126
+ self.grid[y] = [(ansi_code, " ") for _ in range(self.width)]
127
+
128
+ def scroll_up(self, count: int) -> None:
129
+ """Scroll content up, removing top lines and adding blank lines at bottom."""
130
+ for _ in range(count):
131
+ self.grid.pop(0)
132
+ self.grid.append([("", " ") for _ in range(self.width)])
133
+
134
+ def scroll_down(self, count: int) -> None:
135
+ """Scroll content down, removing bottom lines and adding blank lines at top."""
136
+ for _ in range(count):
137
+ self.grid.pop()
138
+ self.grid.insert(0, [("", " ") for _ in range(self.width)])
139
+
140
+ def resize(self, width: int, height: int) -> None:
141
+ """Resize buffer to new dimensions."""
142
+ # Adjust number of rows
143
+ if len(self.grid) < height:
144
+ # Add new rows
145
+ for _ in range(height - len(self.grid)):
146
+ self.grid.append([("", " ") for _ in range(width)])
147
+ elif len(self.grid) > height:
148
+ # Remove excess rows
149
+ self.grid = self.grid[:height]
150
+
151
+ # Adjust width of each row
152
+ for y in range(len(self.grid)):
153
+ row = self.grid[y]
154
+ if len(row) < width:
155
+ # Extend row
156
+ row.extend([("", " ")] * (width - len(row)))
157
+ elif len(row) > width:
158
+ # Truncate row
159
+ self.grid[y] = row[:width]
160
+
161
+ # Update dimensions
162
+ self.width = width
163
+ self.height = height
164
+
165
+ def get_line_text(self, y: int) -> str:
166
+ """Get plain text content of a line (for debugging/testing)."""
167
+ if 0 <= y < self.height:
168
+ return "".join(cell[1] for cell in self.grid[y])
169
+ return ""
170
+
171
+ def get_line(
172
+ self,
173
+ y: int,
174
+ width: int = None,
175
+ cursor_x: int = -1,
176
+ cursor_y: int = -1,
177
+ show_cursor: bool = False,
178
+ mouse_x: int = -1,
179
+ mouse_y: int = -1,
180
+ show_mouse: bool = False,
181
+ ) -> str:
182
+ """Get full ANSI sequence for a line (like tmux capture-pane)."""
183
+ if not (0 <= y < self.height):
184
+ return ""
185
+
186
+ # Use buffer width if not specified
187
+ if width is None:
188
+ width = self.width
189
+
190
+ parts = []
191
+ row = self.grid[y]
192
+
193
+ # Process each cell up to specified width
194
+ for x in range(min(len(row), width)):
195
+ ansi_code, char = row[x]
196
+
197
+ # Handle mouse cursor (convert to 0-based, as original code does mouse_x - 1)
198
+ if show_mouse and x == (mouse_x - 1) and y == (mouse_y - 1):
199
+ char = "↖"
200
+
201
+ # Handle text cursor position
202
+ if show_cursor and x == cursor_x and y == cursor_y:
203
+ # Add cursor style
204
+ parts.append(ansi_code)
205
+ parts.append(get_cursor_code())
206
+ parts.append(char)
207
+ parts.append("\033[27m") # Turn off reverse video only
208
+ else:
209
+ # Normal cell
210
+ parts.append(ansi_code)
211
+ parts.append(char)
212
+
213
+ # Pad to width if needed
214
+ current_width = min(len(row), width)
215
+ if current_width < width:
216
+ # Reset all attributes for padding (including background)
217
+ parts.append(reset_code())
218
+ parts.append(" " * (width - current_width))
219
+
220
+ # Always end with a reset to prevent bleeding to next line
221
+ parts.append(reset_code())
222
+
223
+ return "".join(parts)
224
+
225
+ def get_line_tuple(
226
+ self,
227
+ y: int,
228
+ width: int = None,
229
+ cursor_x: int = -1,
230
+ cursor_y: int = -1,
231
+ show_cursor: bool = False,
232
+ mouse_x: int = -1,
233
+ mouse_y: int = -1,
234
+ show_mouse: bool = False,
235
+ ) -> tuple:
236
+ """Get line as hashable tuple for caching: (ansi_code, char, ansi_code, char, ...)"""
237
+ if not (0 <= y < self.height):
238
+ return tuple()
239
+
240
+ # Use buffer width if not specified
241
+ if width is None:
242
+ width = self.width
243
+
244
+ parts = []
245
+ row = self.grid[y]
246
+
247
+ # Process each cell up to specified width
248
+ for x in range(min(len(row), width)):
249
+ ansi_code, char = row[x]
250
+
251
+ # Handle mouse cursor (convert to 0-based, as original code does mouse_x - 1)
252
+ if show_mouse and x == (mouse_x - 1) and y == (mouse_y - 1):
253
+ char = "↖"
254
+
255
+ # Handle text cursor position
256
+ if show_cursor and x == cursor_x and y == cursor_y:
257
+ # Add cursor style
258
+ parts.extend(("ansi", ansi_code, "cursor", get_cursor_code(), "char", char, "cursor_end", "\033[27m"))
259
+ else:
260
+ # Normal cell
261
+ parts.extend(("ansi", ansi_code, "char", char))
262
+
263
+ # Pad to width if needed
264
+ current_width = min(len(row), width)
265
+ if current_width < width:
266
+ # Reset all attributes for padding (including background)
267
+ parts.extend(("reset", reset_code(), "pad", " " * (width - current_width)))
268
+
269
+ # Always end with a reset to prevent bleeding to next line
270
+ parts.extend(("final_reset", reset_code()))
271
+
272
+ return tuple(parts)