diff --git a/.gitignore b/.gitignore index e60d27dc..b7fb03ec 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,3 @@ .vscode/ build/ dist/ -# generated by setup.py -_char_widths.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dc7d2519..211f6da1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,3 +13,8 @@ repos: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format + - repo: https://github.com/MarcoGorelli/cython-lint + rev: v0.16.2 + hooks: + - id: cython-lint + - id: double-quote-cython-strings diff --git a/docs/conf.py b/docs/conf.py index c155a6a0..898ba68f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,7 +22,11 @@ "inherited-members": True, "ignore-module-all": True, } -autodoc_mock_imports = ["batgrl._char_widths"] + +# FIXME: May need to mock other some other pyx files... +# FIXME: Not sure if `batgrl.char_width` needs to be mocked +autodoc_mock_imports = ["batgrl.char_width"] + html_theme = "pydata_sphinx_theme" html_sidebars = {"**": ["search-field", "sidebar-nav-bs"]} html_theme_options = { diff --git a/examples/advanced/cloth.py b/examples/advanced/cloth.py index 7ac0447f..c526126d 100644 --- a/examples/advanced/cloth.py +++ b/examples/advanced/cloth.py @@ -5,7 +5,7 @@ import numpy as np from batgrl.app import App from batgrl.colors import AWHITE, AColor -from batgrl.gadgets.graphics import Graphics, Size +from batgrl.gadgets.graphics import Graphics, Size, scale_geometry from batgrl.gadgets.slider import Slider from batgrl.gadgets.text import Text from numpy.typing import NDArray @@ -77,10 +77,10 @@ def make_mesh(size: Size) -> tuple[list[Node], list[Link]]: class Cloth(Graphics): def __init__(self, mesh_size: Size, scale=5, mesh_color: AColor = AWHITE, **kwargs): - super().__init__(**kwargs) self.nodes, self.links = make_mesh(mesh_size) self.scale = scale self.mesh_color = mesh_color + super().__init__(**kwargs) self.on_size() def on_size(self): @@ -109,7 +109,9 @@ def step(self): def on_mouse(self, mouse_event): if mouse_event.button != "left": return False - mouse_pos = np.array(self.to_local(mouse_event.pos)) + mouse_pos = np.array( + scale_geometry(self._blitter, self.to_local(mouse_event.pos)) + ) for node in self.nodes: force_direction = self.scale_pos(node.position) - mouse_pos magnitude = np.linalg.norm(force_direction) diff --git a/examples/advanced/doom_fire.py b/examples/advanced/doom_fire.py index 5eb309fb..f9f26c6e 100644 --- a/examples/advanced/doom_fire.py +++ b/examples/advanced/doom_fire.py @@ -6,7 +6,7 @@ from batgrl.app import App from batgrl.colors import Color from batgrl.gadgets.gadget import Gadget, clamp -from batgrl.gadgets.graphics import Graphics +from batgrl.gadgets.graphics import Graphics, scale_geometry from batgrl.gadgets.slider import Slider from batgrl.gadgets.text import Text, new_cell @@ -50,7 +50,8 @@ [223, 223, 159, 255], [239, 239, 199, 255], [255, 255, 255, 255], - ] + ], + dtype=np.uint8, ) MAX_STRENGTH = len(FIRE_PALETTE) - 1 @@ -61,12 +62,9 @@ class DoomFire(Graphics): def __init__(self, fire_strength=MAX_STRENGTH, **kwargs): + self._fire_strength = fire_strength super().__init__(**kwargs) - h, w = self.size - self._fire_values = np.zeros((2 * h, w), dtype=int) - self.fire_strength = fire_strength - def on_add(self): super().on_add() self._step_forever_task = asyncio.create_task(self._step_forever()) @@ -82,17 +80,19 @@ def fire_strength(self): @fire_strength.setter def fire_strength(self, fire_strength): self._fire_strength = clamp(fire_strength, 0, MAX_STRENGTH) + _, w = scale_geometry(self._blitter, self._size) np.clip( - self._fire_strength + np.random.randint(-3, 4, self.width), + self._fire_strength + np.random.randint(-3, 4, w), 0, MAX_STRENGTH, out=self._fire_values[-1], ) def on_size(self): - h, w = self._size - self._fire_values = np.zeros((2 * h, w), dtype=int) + self._fire_values = np.zeros( + scale_geometry(self._blitter, self._size), dtype=int + ) self.fire_strength = self.fire_strength # Trigger `fire_strength.setter` self.texture = FIRE_PALETTE[self._fire_values] diff --git a/examples/advanced/exploding_logo.py b/examples/advanced/exploding_logo.py index ae2f2904..1363b6d4 100644 --- a/examples/advanced/exploding_logo.py +++ b/examples/advanced/exploding_logo.py @@ -14,8 +14,9 @@ from batgrl.app import App from batgrl.colors import Color from batgrl.figfont import FIGFont -from batgrl.gadgets.pane import Cell, Pane -from batgrl.gadgets.text_field import TextParticleField, particle_data_from_canvas +from batgrl.gadgets.pane import Cell, Pane, Point, Size +from batgrl.gadgets.text_field import TextParticleField +from batgrl.geometry.easings import in_exp def make_logo(): @@ -28,7 +29,7 @@ def make_logo(): LOGO = make_logo() -HEIGHT, WIDTH = LOGO.shape +LOGO_SIZE = Size(*LOGO.shape) POWER = 2 MAX_PARTICLE_SPEED = 10 @@ -36,38 +37,35 @@ def make_logo(): NCOLORS = 100 YELLOW = Color.from_hex("c4a219") -BLUE = Color.from_hex("070c25") +BLUE = Color.from_hex("123456") COLOR_CHANGE_SPEED = 5 -PERCENTS = tuple(np.linspace(0, 1, 30)) +PERCENTS = [in_exp(p) for p in np.linspace(0, 1, 30)] class PokeParticleField(TextParticleField): - def __init__(self, **kwargs): - super().__init__(**kwargs) - self._old_middle = 0, 0 - - def on_add(self): - super().on_add() - self._reset_task = asyncio.create_task(asyncio.sleep(0)) # dummy task - self._update_task = asyncio.create_task(self.update()) + _origin = Point(0, 0) + _reset_task: asyncio.Task | None = None + _update_task: asyncio.Task | None = None def on_remove(self): super().on_remove() - self._reset_task.cancel() - self._update_task.cancel() + if self._reset_task is not None: + self._reset_task.cancel() + if self._update_task is not None: + self._update_task.cancel() def on_size(self): super().on_size() - oh, ow = self._old_middle - h = self.height // 2 - w = self.width // 2 - nh, nw = self._old_middle = h - HEIGHT // 2, w - WIDTH // 2 - - real_positions = self.particle_properties["real_positions"] - real_positions += nh - oh, nw - ow - self.particle_properties["original_positions"] += nh - oh, nw - ow - self.particle_positions[:] = real_positions.astype(int) + if self._reset_task is not None: + self._reset_task.cancel() + if self._update_task is not None: + self._update_task.cancel() + old_origin = self._origin + self._origin = self.center - LOGO_SIZE.center + dif = old_origin - self._origin + self.particle_properties["original_positions"] -= dif + self.particle_positions -= dif def on_mouse(self, mouse_event): if mouse_event.button == "left" and self.collides_point(mouse_event.pos): @@ -81,17 +79,19 @@ def on_mouse(self, mouse_event): POWER * relative_distances / distances_sq[:, None] ) - if self._update_task.done(): + if self._reset_task is not None: self._reset_task.cancel() + if self._update_task is None or self._update_task.done(): self._update_task = asyncio.create_task(self.update()) def on_key(self, key_event): - if key_event.key == "r" and self._reset_task.done(): + if key_event.key == "r" and ( + self._reset_task is None or self._reset_task.done() + ): self._reset_task = asyncio.create_task(self.reset()) async def update(self): positions = self.particle_positions - real_positions = self.particle_properties["real_positions"] velocities = self.particle_properties["velocities"] while True: @@ -102,9 +102,8 @@ async def update(self): speed_mask = speeds > MAX_PARTICLE_SPEED velocities[speed_mask] *= MAX_PARTICLE_SPEED / speeds[:, None][speed_mask] - real_positions += velocities + positions += velocities velocities *= FRICTION - positions[:] = real_positions.astype(int) # Boundary conditions ys, xs = positions.T @@ -130,21 +129,16 @@ async def update(self): await asyncio.sleep(0) async def reset(self): - self._update_task.cancel() + if self._update_task is not None: + self._update_task.cancel() self.particle_properties["velocities"][:] = 0 - pos = self.particle_positions start = pos.copy() end = self.particle_properties["original_positions"] - real = self.particle_properties["real_positions"] - for percent in PERCENTS: - percent_left = 1 - percent - - real[:] = percent_left * start + percent * end - pos[:] = real.astype(int) - + pos[:] = (1 - percent) * start + percent * end await asyncio.sleep(0.03) + pos[:] = end class ExplodingLogoApp(App): @@ -152,21 +146,15 @@ async def on_start(self): cell_arr = np.zeros_like(LOGO, dtype=Cell) cell_arr["char"] = LOGO cell_arr["fg_color"] = YELLOW - positions, cells = particle_data_from_canvas(cell_arr) - - props = dict( - original_positions=positions.copy(), - real_positions=positions.astype(float), - velocities=np.zeros((len(positions), 2), dtype=float), - ) field = PokeParticleField( - size_hint={"height_hint": 1.0, "width_hint": 1.0}, - particle_positions=positions, - particle_cells=cells, - particle_properties=props, - is_transparent=True, + size_hint={"height_hint": 1.0, "width_hint": 1.0}, is_transparent=True ) + field.particles_from_cells(cell_arr) + field.particle_properties = { + "original_positions": field.particle_positions.copy(), + "velocities": np.zeros((field.nparticles, 2), dtype=float), + } # This background to show off field transparency. bg = Pane( diff --git a/examples/advanced/exploding_logo_redux.py b/examples/advanced/exploding_logo_redux.py index 7f0513f7..aa94a7eb 100644 --- a/examples/advanced/exploding_logo_redux.py +++ b/examples/advanced/exploding_logo_redux.py @@ -12,59 +12,55 @@ import numpy as np from batgrl.app import App -from batgrl.gadgets.graphic_field import ( - GraphicParticleField, - particle_data_from_texture, -) -from batgrl.gadgets.image import Image, Size +from batgrl.gadgets.graphic_field import Blitter, GraphicParticleField +from batgrl.gadgets.graphics import scale_geometry +from batgrl.gadgets.image import Image, Point, Size +from batgrl.gadgets.slider import Slider +from batgrl.gadgets.toggle_button import ToggleButton +from batgrl.geometry.easings import out_bounce from batgrl.texture_tools import read_texture, resize_texture -LOGO_SIZE = Size(36, 36) +LOGO_SIZE = Size(18, 36) POWER = 2 MAX_PARTICLE_SPEED = 10 FRICTION = 0.99 -PERCENTS = tuple(np.linspace(0, 1, 30)) +PERCENTS = [out_bounce(p) for p in np.linspace(0, 1, 30)] ASSETS = Path(__file__).parent.parent / "assets" PATH_TO_BACKGROUND = ASSETS / "background.png" PATH_TO_LOGO_FULL = ASSETS / "python_discord_logo.png" class PokeParticleField(GraphicParticleField): - def __init__(self, **kwargs): - super().__init__(**kwargs) - self._old_middle = 0, 0 - - def on_add(self): - super().on_add() - self._reset_task = asyncio.create_task(asyncio.sleep(0)) # dummy task - self._update_task = asyncio.create_task(self.update()) + _origin = Point(0, 0) + _reset_task: asyncio.Task | None = None + _update_task: asyncio.Task | None = None def on_remove(self): super().on_remove() - self._reset_task.cancel() - self._update_task.cancel() + if self._reset_task is not None: + self._reset_task.cancel() + if self._update_task is not None: + self._update_task.cancel() def on_size(self): + if self._reset_task is not None: + self._reset_task.cancel() + if self._update_task is not None: + self._update_task.cancel() super().on_size() - oh, ow = self._old_middle - h = self.height - w = self.width // 2 - nh = h - LOGO_SIZE.height // 2 - nw = w - LOGO_SIZE.width // 2 - self._old_middle = nh, nw - - self.particle_properties["original_positions"] += nh - oh, nw - ow - real_positions = self.particle_properties["real_positions"] - real_positions += nh - oh, nw - ow - self.particle_positions[:] = real_positions.astype(int) + old_origin = self._origin + h, w = self._size + th, tw = LOGO_SIZE # scale_geometry(self._blitter, LOGO_SIZE) + self._origin = Point((h - th) // 2, (w - tw) // 2) + dif = old_origin - self._origin + self.particle_properties["original_positions"] -= dif + self.particle_positions -= dif def on_mouse(self, mouse_event): if mouse_event.button == "left" and self.collides_point(mouse_event.pos): y, x = self.to_local(mouse_event.pos) - y *= 2 relative_distances = self.particle_positions - (y, x) - distances_sq = (relative_distances**2).sum(axis=1) distances_sq[distances_sq == 0] = 1 @@ -72,17 +68,19 @@ def on_mouse(self, mouse_event): POWER * relative_distances / distances_sq[:, None] ) - if self._update_task.done(): + if self._reset_task is not None: self._reset_task.cancel() + if self._update_task is None or self._update_task.done(): self._update_task = asyncio.create_task(self.update()) def on_key(self, key_event): - if key_event.key == "r" and self._reset_task.done(): + if key_event.key == "r" and ( + self._reset_task is None or self._reset_task.done() + ): self._reset_task = asyncio.create_task(self.reset()) async def update(self): positions = self.particle_positions - real_positions = self.particle_properties["real_positions"] velocities = self.particle_properties["velocities"] while True: @@ -93,17 +91,14 @@ async def update(self): speed_mask = speeds > MAX_PARTICLE_SPEED velocities[speed_mask] *= MAX_PARTICLE_SPEED / speeds[:, None][speed_mask] - real_positions += velocities + positions += velocities velocities *= FRICTION - positions[:] = real_positions.astype(int) # Boundary conditions ys, xs = positions.T vys, vxs = velocities.T h, w = self.size - h *= 2 - top = ys < 0 left = xs < 0 bottom = ys >= h @@ -122,21 +117,16 @@ async def update(self): await asyncio.sleep(0) async def reset(self): - self._update_task.cancel() + if self._update_task is not None: + self._update_task.cancel() self.particle_properties["velocities"][:] = 0 - pos = self.particle_positions start = pos.copy() end = self.particle_properties["original_positions"] - real = self.particle_properties["real_positions"] - for percent in PERCENTS: - percent_left = 1 - percent - - real[:] = percent_left * start + percent * end - pos[:] = real.astype(int) - + pos[:] = (1 - percent) * start + percent * end await asyncio.sleep(0.03) + pos[:] = end class ExplodingLogoApp(App): @@ -144,25 +134,65 @@ async def on_start(self): background = Image( path=PATH_TO_BACKGROUND, size_hint={"height_hint": 1.0, "width_hint": 1.0} ) - texture = resize_texture(read_texture(PATH_TO_LOGO_FULL), LOGO_SIZE) - positions, colors = particle_data_from_texture(texture) - - props = dict( - original_positions=positions.copy(), - real_positions=positions.astype(float), - velocities=np.zeros((len(positions), 2), dtype=float), - ) - field = PokeParticleField( size_hint={"height_hint": 1.0, "width_hint": 1.0}, - particle_positions=positions, - particle_colors=colors, - particle_properties=props, alpha=0.7, - is_transparent=True, + blitter="sixel", + is_transparent=False, + ) + + def toggle_cb(blitter): + def cb(toggle): + if toggle == "on": + field._origin = Point(0, 0) + field.blitter = blitter + texture = resize_texture( + read_texture(PATH_TO_LOGO_FULL), + scale_geometry(field.blitter, LOGO_SIZE), + ) + field.particles_from_texture(texture) + field.particle_properties = { + "original_positions": field.particle_positions.copy(), + "velocities": np.zeros((field.nparticles, 2), float), + } + field.on_size() + + return cb + + buttons = [ + ToggleButton( + label=blitter, + callback=toggle_cb(blitter), + pos=(i, 0), + size=(1, 15), + group=0, + ) + for i, blitter in enumerate(Blitter.__args__) + ] + buttons[0].callback("on") + + def toggle_trans(toggle): + field.is_transparent = toggle == "on" + + trans_button = ToggleButton( + label="Transparent", callback=toggle_trans, size=(1, 15), pos=(4, 0) + ) + + def on_slide(p): + field.alpha = p + + bg_color = ToggleButton.color_theme.button_normal.bg + slider = Slider( + min=0.0, + max=1.0, + start_value=0.7, + callback=on_slide, + size=(1, 15), + pos=(5, 0), + bg_color=bg_color, ) - self.add_gadgets(background, field) + self.add_gadgets(background, field, *buttons, trans_button, slider) if __name__ == "__main__": diff --git a/examples/advanced/game_of_life.py b/examples/advanced/game_of_life.py index 0c9b80a5..182db699 100644 --- a/examples/advanced/game_of_life.py +++ b/examples/advanced/game_of_life.py @@ -8,7 +8,7 @@ import numpy as np from batgrl.app import run_gadget_as_app -from batgrl.gadgets.graphics import Graphics +from batgrl.gadgets.graphics import Graphics, scale_geometry from cv2 import BORDER_CONSTANT, filter2D KERNEL = np.array( @@ -18,7 +18,7 @@ [1, 1, 1], ] ) -UPDATE_SPEED = 0.1 +UPDATE_SPEED = 1 / 60 class Life(Graphics): @@ -35,13 +35,10 @@ def on_size(self): self._reset() def _reset(self): - h, w = self._size - - self.universe = np.random.randint(0, 2, (h * 2, w), dtype=bool) - - self.texture[..., :3] = np.random.randint(0, 256, (h * 2, w, 3)) - self.texture[..., 3] = 255 - self.texture[~self.universe, :3] = 0 + h, w = scale_geometry(self._blitter, self._size) + self.universe = np.random.randint(0, 2, (h, w), dtype=bool) + self.texture[..., :3] = np.random.randint(0, 256, (h, w, 3)) + self.texture[~self.universe] = 0 async def _update(self): while True: @@ -56,6 +53,8 @@ async def _update(self): new_colors = filter2D(rgb, -1, KERNEL / 3) rgb[~still_alive] = 0 rgb[new_borns] = new_colors[new_borns] + self.texture[..., 3] = 0 + self.texture[..., 3][self.universe] = 255 await asyncio.sleep(UPDATE_SPEED) @@ -66,9 +65,7 @@ def on_key(self, key_event): def on_mouse(self, mouse_event): if mouse_event.button != "no_button" and self.collides_point(mouse_event.pos): - h, w = self.to_local(mouse_event.pos) - h *= 2 - + h, w = scale_geometry(self._blitter, mouse_event.pos) self.universe[h - 1 : h + 3, w - 1 : w + 2] = 1 self.texture[h - 1 : h + 3, w - 1 : w + 2, :3] = np.random.randint( 0, 256, 3 @@ -77,5 +74,6 @@ def on_mouse(self, mouse_event): if __name__ == "__main__": run_gadget_as_app( - Life(size_hint={"height_hint": 1.0, "width_hint": 1.0}), title="Game of Life" + Life(size_hint={"height_hint": 1.0, "width_hint": 1.0}, blitter="braille"), + title="Game of Life", ) diff --git a/examples/advanced/hack/hack/__main__.py b/examples/advanced/hack/hack/__main__.py index b5c223bc..7a41de76 100644 --- a/examples/advanced/hack/hack/__main__.py +++ b/examples/advanced/hack/hack/__main__.py @@ -53,9 +53,14 @@ async def on_start(self): modal.memory = memory terminal = Image( - path=TERMINAL, size=(36, 63), pos_hint={"y_hint": 0.5, "x_hint": 0.5} + path=TERMINAL, + size=(36, 63), + pos_hint={"y_hint": 0.5, "x_hint": 0.5}, + blitter="sixel", + ) + container = Pane( + size=(22, 53), pos=(5, 5), bg_color=DARK_GREEN, is_transparent=False ) - container = Pane(size=(22, 53), pos=(5, 5), bg_color=DARK_GREEN) crt = BOLDCRT(size=(22, 53), is_transparent=True) terminal.add_gadget(container) diff --git a/examples/advanced/hack/hack/effects.py b/examples/advanced/hack/hack/effects.py index 425b6fb6..64c2b42e 100644 --- a/examples/advanced/hack/hack/effects.py +++ b/examples/advanced/hack/hack/effects.py @@ -6,13 +6,12 @@ class Darken(Gadget): """Darken view.""" - def _render(self, canvas): - super()._render(canvas) - root_pos = self.root._pos + def _render(self, cells, graphics, kind): + super()._render(cells, graphics, kind) for pos, size in self._region.rects(): - s = rect_slice(pos - root_pos, size) - canvas["fg_color"][s] >>= 1 - canvas["bg_color"][s] >>= 1 + s = rect_slice(pos, size) + cells["fg_color"][s] >>= 1 + cells["bg_color"][s] >>= 1 class BOLDCRT(Gadget): @@ -23,7 +22,7 @@ def on_add(self): self._i = 0 self.pct = np.ones((*self.size, 1), float) - def _render(self, canvas): + def _render(self, cells, graphics, kind): py, px = self.absolute_pos h, w = self.size size = h * w @@ -33,13 +32,13 @@ def _render(self, canvas): y, x = divmod(self._i, w) dst = slice(py, py + h), slice(px, px + w) - canvas[dst]["bold"] = True + cells[dst]["bold"] = True self.pct[y, x] = 1 - canvas["fg_color"][dst] = (canvas["fg_color"][dst] * self.pct).astype( + cells["fg_color"][dst] = (cells["fg_color"][dst] * self.pct).astype( np.uint8 ) - canvas["bg_color"][dst] = (canvas["bg_color"][dst] * self.pct).astype( + cells["bg_color"][dst] = (cells["bg_color"][dst] * self.pct).astype( np.uint8 ) self._i += 1 diff --git a/examples/advanced/isotiles.py b/examples/advanced/isotiles.py index f16ec353..94adb658 100644 --- a/examples/advanced/isotiles.py +++ b/examples/advanced/isotiles.py @@ -1,11 +1,11 @@ import asyncio +from math import ceil from pathlib import Path from time import perf_counter import batgrl.colors as colors from batgrl.app import App -from batgrl.colors import AWHITE -from batgrl.gadgets.graphics import Graphics, Point, Size +from batgrl.gadgets.graphics import Graphics, Point, Size, scale_geometry from batgrl.gadgets.scroll_view import ScrollView from batgrl.terminal.events import MouseEvent from batgrl.texture_tools import composite, read_texture @@ -42,11 +42,15 @@ class WorldGadget(Graphics): - def __init__(self): + def __init__(self, blitter): wh, ww = WORLD_SIZE th, tw = TILE_SIZE + h, w = scale_geometry(blitter, Size(1, 1)) - super().__init__(size=(wh * th // 2, ww * tw), default_color=AWHITE) + super().__init__( + size=(ceil(wh * th / h), ceil(ww * tw / w)), + blitter=blitter, + ) self.tile_map = [[0 for _ in range(ww)] for _ in range(wh)] self.selected_tile = -1, -1 self.paint_world() @@ -117,9 +121,7 @@ def on_mouse(self, mouse_event: MouseEvent) -> bool | None: return True - y, x = self.to_local(mouse_event.pos) - y *= 2 - + y, x = scale_geometry(self._blitter, self.to_local(mouse_event.pos)) tile_y, tile_offset_y = divmod(y, TILE_SIZE.height) tile_x, tile_offset_x = divmod(x, TILE_SIZE.width) @@ -150,7 +152,7 @@ async def on_start(self): show_horizontal_bar=False, mouse_button="middle", ) - sv.view = WorldGadget() + sv.view = WorldGadget(blitter="half") self.add_gadget(sv) diff --git a/examples/advanced/labyrinth.py b/examples/advanced/labyrinth.py index a5a7c1d6..2adb6a8d 100644 --- a/examples/advanced/labyrinth.py +++ b/examples/advanced/labyrinth.py @@ -12,7 +12,7 @@ import numpy as np from batgrl.app import run_gadget_as_app from batgrl.colors import AWHITE, AColor, gradient -from batgrl.gadgets.graphics import Graphics +from batgrl.gadgets.graphics import Graphics, scale_geometry GREEN = AColor.from_hex("0bbf23") BLUE = AColor.from_hex("0b38bf") @@ -30,27 +30,43 @@ def _path_yx(a, b): class Labyrinth(Graphics): + _new_level_task = None + _player_task = None + _reconfigure_task = None + def on_add(self): - self._new_level_task = asyncio.create_task(asyncio.sleep(0)) # dummy task - self._player_task = asyncio.create_task(self._update_player()) - self._reconfigure_task = asyncio.create_task(self._step_reconfigure()) - self.on_size() super().on_add() + if self._player_task is not None: + self._player_task.cancel() + if self._reconfigure_task is not None: + self._reconfigure_task.cancel() + if self._new_level_task is not None: + self._new_level_task.cancel() + self.new_level() def on_remove(self): super().on_remove() - self._player_task.cancel() - self._new_level_task.cancel() - self._reconfigure_task.cancel() + if self._new_level_task is not None: + self._new_level_task.cancel() + if self._player_task is not None: + self._player_task.cancel() + if self._reconfigure_task is not None: + self._reconfigure_task.cancel() def on_size(self): - h, w = self._size - self.texture = np.zeros((2 * h, w, 4), dtype=np.uint8) + h, w = scale_geometry(self._blitter, self._size) + self.texture = np.zeros((h, w, 4), dtype=np.uint8) + if self.root is None: + return # Creating new level is intensive. To prevent lock-up when resizing terminal, # defer creation for a small amount of time. - self._suspend_tasks = True - self._new_level_task.cancel() + if self._player_task is not None: + self._player_task.cancel() + if self._reconfigure_task is not None: + self._reconfigure_task.cancel() + if self._new_level_task is not None: + self._new_level_task.cancel() self._new_level_task = asyncio.create_task(self._new_level_soon()) async def _new_level_soon(self): @@ -59,11 +75,14 @@ async def _new_level_soon(self): def new_level(self): self.player = 1, 0 - self._suspend_tasks = False - - h, w = self._size - h *= 2 + if self._player_task is not None: + self._player_task.cancel() + self._player_task = asyncio.create_task(self._update_player()) + if self._reconfigure_task is not None: + self._reconfigure_task.cancel() + self._reconfigure_task = asyncio.create_task(self._step_reconfigure()) + h, w = scale_geometry(self._blitter, self._size) self.grid_graph = gg = nx.grid_graph(((w - 1) // 2, (h - 1) // 2)) for e in gg.edges: gg.edges[e]["weight"] = random() @@ -89,14 +108,12 @@ def new_level(self): async def _update_player(self): while True: - if not self._suspend_tasks: - self.texture[self.player] = next(PLAYER_GRADIENT) + self.texture[self.player] = next(PLAYER_GRADIENT) await asyncio.sleep(0.03) async def _step_reconfigure(self): while True: - if not self._suspend_tasks: - self._reconfigure_maze() + self._reconfigure_maze() await asyncio.sleep(1) def _reconfigure_maze(self): diff --git a/examples/advanced/minesweeper/minesweeper/minefield.py b/examples/advanced/minesweeper/minesweeper/minefield.py index 37a0859d..78794bdf 100644 --- a/examples/advanced/minesweeper/minesweeper/minefield.py +++ b/examples/advanced/minesweeper/minesweeper/minefield.py @@ -3,6 +3,7 @@ import cv2 import numpy as np from batgrl.geometry import rect_slice +from batgrl.text_tools import Cell from .colors import BORDER, FLAG_COLOR, HIDDEN_SQUARE from .grid import Grid @@ -213,11 +214,10 @@ def _game_over(self, win: bool): self._is_gameover = True self.parent.game_over(win=win) - def _render(self, canvas): - root_pos = self.root._pos + def _render(self, cell, graphics, kind): abs_pos = self.absolute_pos for pos, size in self._region.rects(): - dst = rect_slice(pos - root_pos, size) + dst = rect_slice(pos, size) src = rect_slice(pos - abs_pos, size) visible = self.hidden[src] != 0 - canvas[dst][visible] = self.canvas[src][visible] + cell.view(Cell)[dst][visible] = self.canvas[src][visible] diff --git a/examples/advanced/navier_stokes.py b/examples/advanced/navier_stokes.py index 219d73c3..6b57eadf 100644 --- a/examples/advanced/navier_stokes.py +++ b/examples/advanced/navier_stokes.py @@ -11,7 +11,7 @@ import numpy as np from batgrl.app import run_gadget_as_app from batgrl.colors import AColor -from batgrl.gadgets.graphics import Graphics +from batgrl.gadgets.graphics import Graphics, scale_geometry from cv2 import filter2D # Kernels @@ -62,11 +62,10 @@ def on_remove(self): self._update_task.cancel() def on_size(self): - h, w = self._size + h, w = scale_geometry(self._blitter, self._size) + size_with_border = h + 4, w + 4 - size_with_border = 2 * h + 4, w + 4 - - self.texture = np.zeros((2 * h, w, 4), dtype=int) + self.texture = np.zeros((h, w, 4), dtype=np.uint8) self.pressure = np.zeros(size_with_border, dtype=float) self.momentum = np.zeros(size_with_border, dtype=float) @@ -75,12 +74,8 @@ def on_mouse(self, mouse_event): return False if mouse_event.button != "no_button": - y, x = self.to_local(mouse_event.pos) - - Y = 2 * y + 2 - - self.pressure[Y : Y + 2, x + 2 : x + 4] += 2.0 - + y, x = scale_geometry(self._blitter, self.to_local(mouse_event.pos)) + self.pressure[y + 2 : y + 4, x + 2 : x + 4] += 2.0 return True def on_key(self, key_event): @@ -107,7 +102,7 @@ async def _update(self): # Note the alpha channel is affected by `pressure` as well. self.texture[:] = ( sigmoid(self.pressure[2:-2, 2:-2, None]) * WATER_COLOR - ).astype(int) + ).astype(np.uint8) await asyncio.sleep(0) diff --git a/examples/advanced/rigid_body_physics.py b/examples/advanced/rigid_body_physics.py index a63a2f30..96aad706 100644 --- a/examples/advanced/rigid_body_physics.py +++ b/examples/advanced/rigid_body_physics.py @@ -14,7 +14,7 @@ import pymunk from batgrl.app import App from batgrl.colors import AWHITE, AColor -from batgrl.gadgets.graphics import Graphics +from batgrl.gadgets.graphics import Graphics, scale_geometry from batgrl.gadgets.image import Image from batgrl.texture_tools import composite, read_texture, resize_texture from pymunk.vec2d import Vec2d @@ -72,7 +72,8 @@ async def _run_simulation(self): def _to_texture_coords(self, point: Vec2d) -> tuple[int, int]: x, y = point - return round(x), 2 * self.height - round(y) - 1 + h, _ = scale_geometry(self._blitter, self._size) + return round(x), h - round(y) - 1 def _draw_space(self): self.clear() diff --git a/examples/advanced/rubiks/README.md b/examples/advanced/rubiks/README.md index cd1903ba..c90b1fa9 100644 --- a/examples/advanced/rubiks/README.md +++ b/examples/advanced/rubiks/README.md @@ -1,9 +1,9 @@ -# 3D Rubik's Cube. +# 3-D Rubik's Cube Shameless port of [Robust Reindeer's Codejam 8 Project](https://github.com/bjoseru/pdcj8-robust-reindeer) to `batgrl`. `python -m rubiks` to run. -Controls --------- +## Controls + `r` to rotate selected plane counter-clockwise `R` to rotate selected plane clockwise diff --git a/examples/advanced/rubiks/rubiks/__main__.py b/examples/advanced/rubiks/rubiks/__main__.py index 85d36f8b..0ccc665d 100644 --- a/examples/advanced/rubiks/rubiks/__main__.py +++ b/examples/advanced/rubiks/rubiks/__main__.py @@ -19,4 +19,4 @@ async def on_start(self): if __name__ == "__main__": - RubiksApp(title="Rubiks 3D").run() + RubiksApp(title="Rubiks 3-D").run() diff --git a/examples/advanced/rubiks/rubiks/rubiks_cube.py b/examples/advanced/rubiks/rubiks/rubiks_cube.py index 4118ecff..d3e38ad2 100644 --- a/examples/advanced/rubiks/rubiks/rubiks_cube.py +++ b/examples/advanced/rubiks/rubiks/rubiks_cube.py @@ -174,7 +174,7 @@ def grab_update(self, mouse_event): beta = np.pi * mouse_event.dx / self.width self.camera.rotate_y(beta) - def _render(self, canvas): + def _render(self, cells, graphics, kind): texture = self.texture texture[:] = 0 @@ -185,4 +185,4 @@ def _render(self, canvas): for cube in cubes: cam.render_cube(cube, texture, self.aspect_ratio) - super()._render(canvas) + super()._render(cells, graphics, kind) diff --git a/examples/advanced/sandbox/sandbox/element_buttons.py b/examples/advanced/sandbox/sandbox/element_buttons.py index bb92fa72..da08311a 100644 --- a/examples/advanced/sandbox/sandbox/element_buttons.py +++ b/examples/advanced/sandbox/sandbox/element_buttons.py @@ -13,7 +13,8 @@ class ElementButton(ButtonBehavior, Text): def __init__(self, pos, element): self.element = element - self.down_color = lerp_colors(WHITE, element.COLOR, 0.5) + self.hover_color = lerp_colors(element.COLOR, WHITE, 0.25) + self.down_color = lerp_colors(element.COLOR, WHITE, 0.5) super().__init__( size=(2, 4), pos=pos, @@ -27,6 +28,9 @@ def update_down(self): def update_normal(self): self.canvas["bg_color"] = self.default_bg_color + def update_hover(self): + self.canvas["bg_color"] = self.hover_color + def on_release(self): element = self.element sandbox = self.parent.parent diff --git a/examples/advanced/sandbox/sandbox/particles.py b/examples/advanced/sandbox/sandbox/particles.py index af84d540..2879e2a7 100644 --- a/examples/advanced/sandbox/sandbox/particles.py +++ b/examples/advanced/sandbox/sandbox/particles.py @@ -120,17 +120,7 @@ def neighbors(self): world = self.world h, w = world.shape y, x = self.pos - deltas = ( - (-1, -1), - (-1, 0), - (-1, 1), - (0, -1), - (0, 1), - (1, -1), - (1, 0), - (1, 1), - ) - + deltas = ((-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)) for dy, dx in deltas: if 0 <= y + dy < h and 0 <= x + dx < w: yield world[y + dy, x + dx] diff --git a/examples/advanced/sandbox/sandbox/sandbox.py b/examples/advanced/sandbox/sandbox/sandbox.py index 02a0b1d0..9fe2f927 100644 --- a/examples/advanced/sandbox/sandbox/sandbox.py +++ b/examples/advanced/sandbox/sandbox/sandbox.py @@ -2,7 +2,7 @@ import numpy as np from batgrl.colors import ABLACK -from batgrl.gadgets.graphics import Graphics, Size +from batgrl.gadgets.graphics import Graphics, Size, scale_geometry from batgrl.gadgets.text import Text, new_cell from .element_buttons import MENU_BACKGROUND_COLOR, ButtonContainer @@ -20,15 +20,19 @@ class Sandbox(Graphics): def __init__(self, size: Size): super().__init__( - size=size, pos_hint={"y_hint": 0.5, "x_hint": 0.5}, default_color=ABLACK + size=size, + pos_hint={"y_hint": 0.5, "x_hint": 0.5}, + default_color=ABLACK, + blitter="full", ) def on_add(self): super().on_add() # Build array of particles -- Initially all Air - self.world = world = np.full((2 * self.height, self.width), None, dtype=object) - for y in range(2 * self.height): - for x in range(self.width): + h, w = scale_geometry(self._blitter, self._size) + self.world = world = np.full((h, w), None, dtype=object) + for y in range(h): + for x in range(w): world[y, x] = Air(world, (y, x)) self.display = Text( @@ -47,10 +51,10 @@ def on_remove(self): for particle in self.world.flatten(): particle.sleep() - def _render(self, canvas): + def _render(self, cells, graphics, kind): # Color of each particle in `self.world` is written into color array. self.texture[..., :3] = np.dstack(particles_to_colors(self.world)) - super()._render(canvas) + super()._render(cells, graphics, kind) def on_mouse(self, mouse_event): if mouse_event.button != "left" or not self.collides_point(mouse_event.pos): @@ -58,9 +62,9 @@ def on_mouse(self, mouse_event): world = self.world particle_type = self.particle_type - y, x = self.to_local(mouse_event.pos) - - world[2 * y, x].replace(particle_type) - world[2 * y + 1, x].replace(particle_type) - + y, x = scale_geometry(self._blitter, self.to_local(mouse_event.pos)) + h, w = scale_geometry(self._blitter, Size(1, 1)) + for i in range(h): + for j in range(w): + world[y + i, x + j].replace(particle_type) return True diff --git a/examples/advanced/slime.py b/examples/advanced/slime.py index 9ef3f57b..4cc601bd 100644 --- a/examples/advanced/slime.py +++ b/examples/advanced/slime.py @@ -5,9 +5,7 @@ import cv2 import numpy as np from batgrl.app import App -from batgrl.colors import DEFAULT_PRIMARY_BG -from batgrl.gadgets.graphics import Graphics -from batgrl.gadgets.pane import Pane +from batgrl.gadgets.graphics import Graphics, scale_geometry SENSE_SIZE = 7 SENSE_ANGLE = np.pi / 4 @@ -15,7 +13,7 @@ MOVE_SPEED = 1.3 DISSIPATE = 0.01 BLUR = 0.4 -NAGENTS = 200 +NAGENTS = 3000 rng = np.random.default_rng() @@ -39,8 +37,7 @@ def on_size(self): self._simulation_task = asyncio.create_task(self._simulate()) async def _simulate(self): - h, w = self.size - h *= 2 + h, w = scale_geometry(self._blitter, self.size) def as_pos(coords): ys, xs = np.clip(coords.astype(int), ((0,), (0,)), ((h - 1,), (w - 1,))) @@ -109,12 +106,10 @@ def as_pos(coords): class SlimeApp(App): async def on_start(self): - background = Pane( - size_hint={"height_hint": 1.0, "width_hint": 1.0}, - bg_color=DEFAULT_PRIMARY_BG, + slime = Slime( + size_hint={"height_hint": 1.0, "width_hint": 1.0}, blitter="braille" ) - slime = Slime(size_hint={"height_hint": 1.0, "width_hint": 1.0}) - self.add_gadgets(background, slime) + self.add_gadgets(slime) if __name__ == "__main__": diff --git a/examples/advanced/snake.py b/examples/advanced/snake.py index 29f8c8bc..de518047 100644 --- a/examples/advanced/snake.py +++ b/examples/advanced/snake.py @@ -127,7 +127,9 @@ async def on_start(self): kwargs = dict( size=(HEIGHT // 2, WIDTH), pos_hint={"y_hint": 0.5, "x_hint": 0.5} ) - background = Video(source=SPINNER, alpha=0.5, is_transparent=True, **kwargs) + background = Video( + source=SPINNER, alpha=0.5, is_transparent=True, blitter="sixel", **kwargs + ) background.play() snake = Snake(**kwargs) diff --git a/examples/advanced/sph/sph/__main__.py b/examples/advanced/sph/sph/__main__.py index 3ae6f02b..13b46ad5 100644 --- a/examples/advanced/sph/sph/__main__.py +++ b/examples/advanced/sph/sph/__main__.py @@ -2,22 +2,23 @@ import numpy as np from batgrl.app import App -from batgrl.colors import Color -from batgrl.gadgets.graphics import Graphics +from batgrl.colors import AColor, Color +from batgrl.gadgets.graphics import Graphics, scale_geometry from batgrl.gadgets.slider import Slider from batgrl.gadgets.text import Text from .solver import SPHSolver -WATER_COLOR = Color.from_hex("1e1ea8") +WATER_COLOR = AColor.from_hex("1e1ea8ff") FILL_COLOR = Color.from_hex("2fa399") class SPH(Graphics): - def __init__(self, nparticles, is_transparent=False, **kwargs): - super().__init__(is_transparent=is_transparent, **kwargs) - y, x = self.size - self.sph_solver = SPHSolver(nparticles, (2 * y, x)) + def __init__(self, nparticles, **kwargs): + super().__init__(**kwargs) + self.sph_solver = SPHSolver( + nparticles, scale_geometry(self._blitter, self.size) + ) def on_add(self): super().on_add() @@ -40,34 +41,31 @@ def on_mouse(self, mouse_event): return False # Apply a force from click to every particle in the solver. - my, mx = self.to_local(mouse_event.pos) - - relative_positions = self.sph_solver.state[:, :2] - (2 * my, mx) - + my, mx = scale_geometry(self._blitter, self.to_local(mouse_event.pos)) + relative_positions = self.sph_solver.state[:, :2] - (my, mx) self.sph_solver.state[:, 2:4] += ( 1e2 * relative_positions / np.linalg.norm(relative_positions, axis=-1, keepdims=True) ) - return True async def _update(self): while True: + h, w = scale_geometry(self._blitter, self.size) solver = self.sph_solver solver.step() - positions = solver.state[:, :2] ys, xs = positions.astype(int).T - xs = xs + (self.width - solver.WIDTH) // 2 # Center the particles. + xs = xs + (w - solver.WIDTH) // 2 # Center the particles. # Some solver configurations are unstable. Clip positions to prevent errors. - ys = np.clip(ys, 0, 2 * self.height - 1) - xs = np.clip(xs, 0, self.width - 1) + ys = np.clip(ys, 0, h - 1) + xs = np.clip(xs, 0, w - 1) self.clear() - self.texture[ys, xs, :3] = WATER_COLOR + self.texture[ys, xs] = WATER_COLOR await asyncio.sleep(0) @@ -90,7 +88,7 @@ async def on_start(self): container = Text(size=(height, width), pos_hint={"y_hint": 0.5, "x_hint": 0.5}) fluid = SPH( - nparticles=225, + nparticles=300, pos=(sliders_height, 0), size=(height - sliders_height, width), ) @@ -118,7 +116,7 @@ def update(value): callback=create_callback(caption, attr, y, x), size=(1, width // 2), fill_color=FILL_COLOR, - slider_color=WATER_COLOR, + slider_color=WATER_COLOR[:3], ) container.add_gadget(slider) self.add_gadget(container) diff --git a/examples/advanced/stable_fluid.py b/examples/advanced/stable_fluid.py index ffe8a2de..bab1b055 100644 --- a/examples/advanced/stable_fluid.py +++ b/examples/advanced/stable_fluid.py @@ -11,7 +11,7 @@ import numpy as np from batgrl.app import App from batgrl.colors import ABLACK, DEFAULT_PRIMARY_BG, rainbow_gradient -from batgrl.gadgets.graphics import Graphics +from batgrl.gadgets.graphics import Graphics, scale_geometry from batgrl.terminal.events import MouseEvent from scipy.ndimage import convolve, map_coordinates @@ -54,9 +54,7 @@ def on_remove(self): self._update_task.cancel() def on_size(self): - h, w = self._size - h *= 2 - + h, w = scale_geometry(self._blitter, self._size) self.texture = np.full((h, w, 4), self.default_color, dtype=np.uint8) self.dye = np.zeros((4, h, w)) self.indices = np.indices((h, w)) @@ -69,9 +67,7 @@ def on_mouse(self, mouse_event: MouseEvent): ): return False - y, x = self.to_local(mouse_event.pos) - y *= 2 - + y, x = scale_geometry(self._blitter, self.to_local(mouse_event.pos)) ys, xs = self.indices ry = ys - y rx = xs - x diff --git a/examples/advanced/tetris/tetris/matrix.py b/examples/advanced/tetris/tetris/matrix.py index 41ae349d..665e42c0 100644 --- a/examples/advanced/tetris/tetris/matrix.py +++ b/examples/advanced/tetris/tetris/matrix.py @@ -35,13 +35,12 @@ async def glow(self): await asyncio.sleep(sleep) - def _render(self, canvas): - super()._render(canvas) + def _render(self, cells, graphics, kind): + super()._render(cells, graphics, kind) glow = self._glow - root_pos = self.root._pos abs_pos = self.absolute_pos for pos, size in self._region.rects(): - dst_y, dst_x = rect_slice(pos - root_pos, size) + dst_y, dst_x = rect_slice(pos, size) src_y, src_x = rect_slice(pos - abs_pos, size) visible = ( @@ -52,11 +51,11 @@ def _render(self, canvas): ] == 255 ) - fg = canvas["fg_color"][dst_y, dst_x] + fg = cells["fg_color"][dst_y, dst_x] fg[visible[::2]] = (fg[visible[::2]] * (1 - glow) + glow * 255).astype( np.uint8 ) - bg = canvas["bg_color"][dst_y, dst_x] + bg = cells["bg_color"][dst_y, dst_x] bg[visible[1::2]] = (bg[visible[1::2]] * (1 - glow) + glow * 255).astype( np.uint8 ) diff --git a/examples/advanced/tetris/tetris/tetrominoes.py b/examples/advanced/tetris/tetris/tetrominoes.py index 5443ceb2..7807acae 100644 --- a/examples/advanced/tetris/tetris/tetrominoes.py +++ b/examples/advanced/tetris/tetris/tetrominoes.py @@ -38,7 +38,7 @@ def __init__(self, base, color, kicks): np.kron(shape, np.ones((2, 2), int))[..., None], 4, axis=-1 ) texture *= (*color, 255) - self.textures[orientation] = texture + self.textures[orientation] = texture.astype(np.uint8) self.kicks = kicks diff --git a/examples/basic/box_and_braille_image.py b/examples/basic/box_and_braille_image.py deleted file mode 100644 index d2fc6fa5..00000000 --- a/examples/basic/box_and_braille_image.py +++ /dev/null @@ -1,25 +0,0 @@ -from pathlib import Path - -from batgrl.app import App -from batgrl.gadgets.box_image import BoxImage -from batgrl.gadgets.braille_image import BrailleImage - -ASSETS = Path(__file__).parent.parent / "assets" -PATH_TO_IMAGE = ASSETS / "loudypixelsky.png" - - -class ImageApp(App): - async def on_start(self): - box_image = BoxImage( - path=PATH_TO_IMAGE, size_hint={"height_hint": 1.0, "width_hint": 0.5} - ) - braille_image = BrailleImage( - path=PATH_TO_IMAGE, - size_hint={"height_hint": 1.0, "width_hint": 0.5}, - pos_hint={"x_hint": 0.5, "anchor": "top-left"}, - ) - self.add_gadgets(box_image, braille_image) - - -if __name__ == "__main__": - ImageApp(title="Box and Braille Image Example").run() diff --git a/examples/basic/file_chooser.py b/examples/basic/file_chooser.py index 87dd2fe5..412b6472 100644 --- a/examples/basic/file_chooser.py +++ b/examples/basic/file_chooser.py @@ -10,7 +10,7 @@ class FileApp(App): async def on_start(self): - label = Text(size=(1, 50), pos=(0, 26)) + label = Text(size=(1, 50), pos=(0, 26), is_transparent=True) def select_callback(path): label.add_str(f"{f'{path.name} selected!':<50}"[:50]) diff --git a/examples/basic/image.py b/examples/basic/image.py index d1ea1c9e..253c7d5e 100644 --- a/examples/basic/image.py +++ b/examples/basic/image.py @@ -1,6 +1,7 @@ from pathlib import Path from batgrl.app import App +from batgrl.gadgets.behaviors.movable import Movable from batgrl.gadgets.image import Image ASSETS = Path(__file__).parent.parent / "assets" @@ -9,21 +10,31 @@ PATH_TO_BACKGROUND = ASSETS / "background.png" +class MovableImage(Movable, Image): + pass + + class ImageApp(App): async def on_start(self): background = Image( - size_hint={"height_hint": 1.0, "width_hint": 1.0}, path=PATH_TO_BACKGROUND + size_hint={"height_hint": 1.0, "width_hint": 1.0}, + path=PATH_TO_BACKGROUND, + is_transparent=False, ) - logo_flat = Image( - size_hint={"height_hint": 0.5, "width_hint": 0.5}, path=PATH_TO_LOGO_FLAT + logo_flat = MovableImage( + size_hint={"height_hint": 0.5, "width_hint": 0.5}, + path=PATH_TO_LOGO_FLAT, + ptf_on_grab=True, ) - logo_full = Image( + logo_full = MovableImage( size_hint={"height_hint": 0.5, "width_hint": 0.5}, pos_hint={"y_hint": 0.5, "x_hint": 0.5}, path=PATH_TO_LOGO_FULL, + blitter="sixel", alpha=0.8, + ptf_on_grab=True, ) self.add_gadgets(background, logo_flat, logo_full) diff --git a/examples/basic/line_plot.py b/examples/basic/line_plot.py index b6abfe49..ae363cc7 100644 --- a/examples/basic/line_plot.py +++ b/examples/basic/line_plot.py @@ -12,7 +12,7 @@ class PlotApp(App): async def on_start(self): - BUTTON_WIDTH = 15 + BUTTON_WIDTH = 17 plot = LinePlot( xs=[XS, XS, XS], @@ -21,32 +21,46 @@ async def on_start(self): y_label="Y Values", legend_labels=["Before", "During", "After"], size_hint={"height_hint": 1.0, "width_hint": 1.0}, - mode="box", + blitter="braille", ) - def set_box_mode(toggle_state): + def set_braille_mode(toggle_state): if toggle_state == "on": - plot.mode = "box" + plot.blitter = "braille" - def set_braille_mode(toggle_state): + def set_half_mode(toggle_state): if toggle_state == "on": - plot.mode = "braille" + plot.blitter = "half" + + def set_sixel_mode(toggle_state): + if toggle_state == "on": + plot.blitter = "sixel" - box_button = ToggleButton( - size=(1, BUTTON_WIDTH), label="Box Mode", callback=set_box_mode, group=0 - ) braille_button = ToggleButton( size=(1, BUTTON_WIDTH), - pos=(1, 0), - label="Braille Mode", + label="Braille Blitter", callback=set_braille_mode, group=0, ) + half_button = ToggleButton( + size=(1, BUTTON_WIDTH), + pos=(1, 0), + label="Half Blitter", + callback=set_half_mode, + group=0, + ) + sixel_button = ToggleButton( + size=(1, BUTTON_WIDTH), + pos=(2, 0), + label="Sixel Blitter", + callback=set_sixel_mode, + group=0, + ) container = Gadget( - size=(2, BUTTON_WIDTH), pos_hint={"x_hint": 1.0, "anchor": "top-right"} + size=(3, BUTTON_WIDTH), pos_hint={"x_hint": 1.0, "anchor": "top-right"} ) - container.add_gadgets(box_button, braille_button) + container.add_gadgets(braille_button, half_button, sixel_button) self.add_gadgets(plot, container) diff --git a/examples/basic/markdown.py b/examples/basic/markdown.py index d0dadd6e..948cb2be 100644 --- a/examples/basic/markdown.py +++ b/examples/basic/markdown.py @@ -78,7 +78,9 @@ class MarkdownApp(App): async def on_start(self): markdown = Markdown( - markdown=MARKDOWN_TEXT, size_hint={"height_hint": 1.0, "width_hint": 1.0} + markdown=MARKDOWN_TEXT, + size_hint={"height_hint": 1.0, "width_hint": 1.0}, + blitter="sixel", ) self.add_gadget(markdown) diff --git a/examples/basic/menu.py b/examples/basic/menu.py index 036d748f..9ceb4742 100644 --- a/examples/basic/menu.py +++ b/examples/basic/menu.py @@ -7,7 +7,7 @@ class MenuApp(App): async def on_start(self): - label = Text(size=(1, 50)) + label = Text(size=(1, 50), is_transparent=True) def add_label(text): def inner(): diff --git a/examples/basic/progress_bar.py b/examples/basic/progress_bar.py index d3d4e9ee..06c2344c 100644 --- a/examples/basic/progress_bar.py +++ b/examples/basic/progress_bar.py @@ -10,11 +10,10 @@ class ProgressBarApp(App): async def on_start(self): - label_a = Text( - default_cell=new_cell( - fg_color=DEFAULT_PRIMARY_FG, bg_color=DEFAULT_PRIMARY_BG - ) + default_cell = new_cell( + fg_color=DEFAULT_PRIMARY_FG, bg_color=DEFAULT_PRIMARY_BG ) + label_a = Text(default_cell=default_cell) horizontal_a = ProgressBar(pos=(0, 10), size=(1, 50)) label_b = TextAnimation( @@ -22,8 +21,7 @@ async def on_start(self): frame_durations=1 / 12, size=(1, 10), pos=(2, 0), - animation_fg_color=DEFAULT_PRIMARY_FG, - animation_bg_color=DEFAULT_PRIMARY_BG, + default_cell=default_cell, ) label_b.play() diff --git a/examples/basic/spinners.py b/examples/basic/spinners.py index 0ba55d85..31eb46be 100644 --- a/examples/basic/spinners.py +++ b/examples/basic/spinners.py @@ -26,23 +26,21 @@ async def on_start(self): orientation="tb-lr", is_transparent=True, ) + default_cell = new_cell( + fg_color=DEFAULT_PRIMARY_FG, bg_color=DEFAULT_PRIMARY_BG + ) for name, frames in SPINNERS.items(): label = Text( pos_hint={"y_hint": 0.5, "anchor": "left"}, - default_cell=new_cell( - fg_color=DEFAULT_PRIMARY_FG, bg_color=DEFAULT_PRIMARY_BG - ), + default_cell=default_cell, ) label.set_text(f"{name}: ") animation = TextAnimation( - pos=(0, label.right), - frames=frames, - animation_fg_color=DEFAULT_PRIMARY_FG, - animation_bg_color=DEFAULT_PRIMARY_BG, + pos=(0, label.right), frames=frames, default_cell=default_cell ) - animation.size = animation.frames[0].size + animation.size = animation.min_animation_size animation.play() container = Gadget( diff --git a/examples/basic/tile.py b/examples/basic/tiled.py similarity index 84% rename from examples/basic/tile.py rename to examples/basic/tiled.py index 502cb0f8..6c5826cf 100644 --- a/examples/basic/tile.py +++ b/examples/basic/tiled.py @@ -3,7 +3,7 @@ from batgrl.app import App from batgrl.gadgets.image import Image -from batgrl.gadgets.tiled_image import TiledImage +from batgrl.gadgets.tiled import Tiled ASSETS = Path(__file__).parent.parent / "assets" LOGO_PATH = ASSETS / "python_discord_logo.png" @@ -15,7 +15,7 @@ async def on_start(self): tile_1 = Image(size=(10, 25), path=LOGO_PATH) tile_2 = Image(size=(9, 19), path=LOGO_FLAT) - tiled_image = TiledImage(size=(25, 50), tile=tile_1) + tiled_image = Tiled(size=(25, 50), tile=tile_1) self.add_gadget(tiled_image) diff --git a/examples/basic/video_in_terminal.py b/examples/basic/video_in_terminal.py index e598d7b6..c60c08ef 100644 --- a/examples/basic/video_in_terminal.py +++ b/examples/basic/video_in_terminal.py @@ -8,8 +8,6 @@ from pathlib import Path from batgrl.app import App -from batgrl.colors import DEFAULT_PRIMARY_BG, DEFAULT_PRIMARY_FG -from batgrl.gadgets.braille_video import BrailleVideo from batgrl.gadgets.video import Video ASSETS = Path(__file__).parent.parent / "assets" @@ -18,21 +16,19 @@ class VideoApp(App): async def on_start(self): - video = Video( - source=SPINNER, size_hint={"height_hint": 1.0, "width_hint": 0.5} + half_video = Video( + source=SPINNER, + size_hint={"height_hint": 1.0, "width_hint": 0.5}, ) # Try `source=0` to capture a webcam. - braille_video = BrailleVideo( + sixel_video = Video( source=SPINNER, - fg_color=DEFAULT_PRIMARY_FG, - bg_color=DEFAULT_PRIMARY_BG, size_hint={"height_hint": 1.0, "width_hint": 0.5}, pos_hint={"x_hint": 0.5, "anchor": "top-left"}, - gray_threshold=155, - enable_shading=True, + blitter="sixel", ) - self.add_gadgets(video, braille_video) - video.play() - braille_video.play() + self.add_gadgets(half_video, sixel_video) + half_video.play() + sixel_video.play() if __name__ == "__main__": diff --git a/examples/basic/windows.py b/examples/basic/windows.py index 85a80c9c..084aab2d 100644 --- a/examples/basic/windows.py +++ b/examples/basic/windows.py @@ -28,7 +28,11 @@ async def on_start(self): window_kwargs = dict(size=(25, 50), alpha=0.8) animation = Video( - source=SPINNER, interpolation="nearest", is_transparent=True, alpha=0.7 + source=SPINNER, + interpolation="nearest", + is_transparent=True, + alpha=0.5, + blitter="sixel", ) window_1 = Window(title=SPINNER.name, **window_kwargs) window_1.view = animation diff --git a/examples/pyproject.toml b/examples/pyproject.toml index 00c4ed32..a5b912e3 100644 --- a/examples/pyproject.toml +++ b/examples/pyproject.toml @@ -1,6 +1,6 @@ [tool.ruff] extend = "../pyproject.toml" -extend-ignore = [ +lint.extend-ignore = [ "D100", # Missing doc string in public module. "D101", # Missing doc string in public class. "D102", # Missing doc string in public method. diff --git a/pyproject.toml b/pyproject.toml index 937a53a9..0e26ad35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,3 +48,6 @@ convention = "numpy" [tool.ruff.lint.pycodestyle] max-doc-length=88 + +[tool.cython-lint] +max-line-length = 88 diff --git a/setup.py b/setup.py index 9ece16c5..d17751e3 100644 --- a/setup.py +++ b/setup.py @@ -2,25 +2,52 @@ import sys +import numpy as np from Cython.Build import cythonize from setuptools import setup from setuptools.command.build_py import build_py from wcwidth import wcwidth -_CHAR_WIDTHS_DOC = '''\ +_CWIDTH_HEADER = """\ +#ifndef _WIN32 + #include +#endif +#include "cwidth.h" + + +int cwidth(uint32_t wc) { + // Character widths as generated by wcwidth. + // Each item in `CHAR_WIDTHS` represents an interval of ords with common width, + // i.e., `{0u, 31u, 0u}` means ords 0 through 31 (inclusive) have width 0. + // Intervals with width 1 are omitted so if an ord doesn't belong to any interval + // we can assume it has width 1. + static const uint32_t CHAR_WIDTHS[][3] = { """ -Character widths as generated by wcwidth. +_CWIDTH_FOOTER = """\ + }; + static const size_t CHAR_WIDTHS_LEN = sizeof(CHAR_WIDTHS) / sizeof(int[3]); -Each tuple in `CHAR_WIDTHS` represents an interval of ords with common width, i.e., -`(0, 31, 0)` means ords 0 through 31 (inclusive) have width 0. Intervals with width 1 -are omitted so if an ord doesn't belong to any interval we can assume it has width 1. + size_t lo = 0, hi = CHAR_WIDTHS_LEN, mid; + while(lo < hi) { + mid = (lo + hi) / 2; + if(wc < CHAR_WIDTHS[mid][0]) hi = mid; + else if(wc > CHAR_WIDTHS[mid][1]) lo = mid + 1; + else return CHAR_WIDTHS[mid][2]; + } + return 1; +} """ -''' +def _create_cwidth(): + """ + Build ``cwidth.c``. -def _create_char_widths(): - """Build ``_char_widths.py``.""" + This function builds the table used in ``cwidth.c`` needed to determine displayed + width of a character in the terminal. The widths are taken directly from `wcwidth`, + but the ``cwidth`` function is a couple of magnitude times faster than + ``wcwidth.wcwidth``. + """ groups = [] start = 0 group_width = 1 @@ -33,26 +60,34 @@ def _create_char_widths(): start = codepoint if group_width != 1: - groups.append((start, codepoint - 1, group_width)) + groups.append((start, codepoint, group_width)) - with open("src/batgrl/_char_widths.py", "w") as file: - file.write(_CHAR_WIDTHS_DOC) - file.write("CHAR_WIDTHS = (\n") + with open("src/batgrl/cwidth.c", "w") as file: + file.write(_CWIDTH_HEADER) for group in groups: - file.write(f" {group},\n") - file.write(")\n") + file.write(" {{{}u, {}u, {}u}},\n".format(*group)) + file.write(_CWIDTH_FOOTER) -class build_py_with_char_widths(build_py): - """Generate ``_char_widths.py`` on build.""" +class build_py_with_cwidth(build_py): + """Generate ``cwidth.c`` on build.""" def run(self): - """Generate ``_char_widths.py`` on build.""" + """Generate ``cwidth.c`` on build.""" super().run() - _create_char_widths() + _create_cwidth() setup( - ext_modules=cythonize(["src/batgrl/geometry/regions.pyx"]), - cmdclass={"build_py": build_py_with_char_widths}, + ext_modules=cythonize( + [ + "src/batgrl/_fbuf.pyx", + "src/batgrl/_rendering.pyx", + "src/batgrl/_sixel.pyx", + "src/batgrl/char_width.pyx", + "src/batgrl/geometry/regions.pyx", + ] + ), + include_dirs=[np.get_include()], + cmdclass={"build_py": build_py_with_cwidth}, ) diff --git a/src/batgrl/__init__.py b/src/batgrl/__init__.py index dbc7e76e..f42a84dc 100644 --- a/src/batgrl/__init__.py +++ b/src/batgrl/__init__.py @@ -1,3 +1,3 @@ """batgrl, the badass terminal graphics library.""" -__version__ = "0.39.4" +__version__ = "0.40.0" diff --git a/src/batgrl/_fbuf.h b/src/batgrl/_fbuf.h new file mode 100644 index 00000000..ebfcfe96 --- /dev/null +++ b/src/batgrl/_fbuf.h @@ -0,0 +1,161 @@ +// This is nearly verbatim notcurses' fbuf.h, but in cython. +// notcurses' fbuf.h: +// https://github.com/dankamongmen/notcurses/blob/master/src/lib/fbuf.h +// +// notcurses is copyright 2019-2025 Nick Black et al and is licensed under the Apache +// License, Version 2.0: +// http://www.apache.org/licenses/LICENSE-2.0 +// + +#include +#include +#ifdef _WIN32 + #include + typedef SSIZE_T ssize_t; +#else + #include +#endif + + +typedef struct fbuf { + uint64_t size; + uint64_t len; + char *buf; +} fbuf; + + +static inline ssize_t fbuf_init(fbuf *f){ + f->size = 0x200000ul; + f->len = 0; + f->buf = (char*)malloc(f->size); + if(f->buf == NULL){ + return -1; + } + return 0; +} + + +static inline void fbuf_free(fbuf *f){ + f->size = 0; + f->len = 0; + if(f->buf != NULL){ + free(f->buf); + f->buf = NULL; + } +} + + +static inline ssize_t fbuf_grow(fbuf *f, size_t n){ + if(f->len + n <= f->size){ + return 0; + } + while(f->len + n > f->size){ + f->size *= 2; + } + void *tmp = realloc(f->buf, f->size); + if(tmp == NULL){ + return -1; + } + f->buf = (char*)tmp; + return 0; +} + + +static inline ssize_t fbuf_putn(fbuf *f, const char *s, size_t len){ + if(fbuf_grow(f, len)){ + return -1; + } + memcpy(f->buf + f->len, s, len); + f->len += len; + return 0; +} + + +static inline ssize_t fbuf_puts(fbuf *f, const char *s){ + size_t slen = strlen(s); + return fbuf_putn(f, s, slen); +} + + +static inline ssize_t fbuf_printf(fbuf *f, const char *fmt, ...){ + size_t unused = f->size - f->len; + if(unused < BUFSIZ){ + if(fbuf_grow(f, BUFSIZ)){ + return -1; + } + } + va_list va; + va_start(va, fmt); + size_t wrote = (size_t)vsnprintf(f->buf + f->len, unused, fmt, va); + va_end(va); + f->len += wrote; + return 0; +} + + +static inline ssize_t fbuf_putucs4(fbuf *f, uint32_t wc){ + // Put PY_UCS4 as utf8. + // https://github.com/JeffBezanson/cutef8/blob/master/utf8.c + if(fbuf_grow(f, 4)){ + return -1; + } + if(wc < 0x80){ + f->buf[f->len++] = (char)wc; + return 0; + } + if(wc < 0x800){ + f->buf[f->len++] = (wc>>6) | 0xC0; + f->buf[f->len++] = (wc & 0x3F) | 0x80; + return 0; + } + if(wc < 0x10000){ + f->buf[f->len++] = (wc>>12) | 0xE0; + f->buf[f->len++] = ((wc>>6) & 0x3F) | 0x80; + f->buf[f->len++] = (wc & 0x3F) | 0x80; + return 0; + }if(wc < 0x110000) { + f->buf[f->len++] = (wc>>18) | 0xF0; + f->buf[f->len++] = ((wc>>12) & 0x3F) | 0x80; + f->buf[f->len++] = ((wc>>6) & 0x3F) | 0x80; + f->buf[f->len++] = (wc & 0x3F) | 0x80; + return 0; + } + return -1; +} + + +#ifdef _WIN32 +static inline ssize_t fbuf_flush(fbuf *f){ + DWORD wrote = 0, write_len; + size_t written = 0; + while(writtenlen){ + if(f->len - written > MAXDWORD){ + write_len = MAXDWORD; + } else { + write_len = f->len - written; + } + if (!WriteConsoleA( // ! Any reason to use WriteFile instead? + GetStdHandle(STD_OUTPUT_HANDLE), f->buf + written, write_len, &wrote, NULL) + ){ + return -1; + } + written += wrote; + } + f->len = 0; + return 0; +} +#else +static inline ssize_t fbuf_flush(fbuf *f){ + size_t written = 0; + ssize_t wrote = 0; + while(written < f->len){ + wrote = write(1, f->buf + written, f->len - written); + if (wrote < 0){ + return -1; + } + written += wrote; + } + f->len = 0; + return 0; +} +#endif diff --git a/src/batgrl/_fbuf.pxd b/src/batgrl/_fbuf.pxd new file mode 100644 index 00000000..5252db06 --- /dev/null +++ b/src/batgrl/_fbuf.pxd @@ -0,0 +1,23 @@ +"""A growable string buffer.""" + +cdef extern from "_fbuf.h": + ctypedef unsigned long uint32_t + ctypedef unsigned long long uint64_t + + struct fbuf: + uint64_t size, len + char *buf + + ssize_t write(ssize_t, const void*, size_t) + ssize_t fbuf_init(fbuf *f) + void fbuf_free(fbuf *f) + ssize_t fbuf_grow(fbuf *f, size_t n) + ssize_t fbuf_putn(fbuf *f, const char *s, size_t len) + ssize_t fbuf_puts(fbuf *f, const char *s) + ssize_t fbuf_printf(fbuf *f, const char *fmt, ...) + ssize_t fbuf_putucs4(fbuf *f, uint32_t wc) + ssize_t fbuf_flush(fbuf *f) + + +cdef class FBufWrapper: + cdef fbuf f diff --git a/src/batgrl/_fbuf.pyi b/src/batgrl/_fbuf.pyi new file mode 100644 index 00000000..a878c14e --- /dev/null +++ b/src/batgrl/_fbuf.pyi @@ -0,0 +1,21 @@ +""" +A growable string buffer. + +This wrapper around ``fbuf`` should be removed shortly after sixel branch is merged and +Vt100Terminal can be rewritten in cython to handle `fbuf` directly. +""" + +class FBufWrapper: + """A growable string buffer.""" + + def __len__(self) -> int: + """Length of string buffer.""" + + def __bool__(self) -> bool: + """Whether string buffer is nonempty.""" + + def write(self, s: bytes) -> None: + """Write bytes to string buffer.""" + + def flush(self) -> None: + """Flush bytes to stdout.""" diff --git a/src/batgrl/_fbuf.pyx b/src/batgrl/_fbuf.pyx new file mode 100644 index 00000000..dbdeeecd --- /dev/null +++ b/src/batgrl/_fbuf.pyx @@ -0,0 +1,23 @@ +from ._fbuf cimport fbuf_flush, fbuf_free, fbuf_init, fbuf_putn + + +cdef class FBufWrapper: + def __init__(self) -> None: + if fbuf_init(&self.f): + raise MemoryError + + def __dealloc__(self) -> None: + fbuf_free(&self.f) + + def __len__(self) -> int: + return self.f.len + + def __bool__(self) -> bool: + return self.f.len > 0 + + def write(self, s: bytes) -> None: + if fbuf_putn(&self.f, s, len(s)): + raise MemoryError + + def flush(self) -> None: + fbuf_flush(&self.f) diff --git a/src/batgrl/_rendering.pyx b/src/batgrl/_rendering.pyx new file mode 100644 index 00000000..5a73e04b --- /dev/null +++ b/src/batgrl/_rendering.pyx @@ -0,0 +1,1836 @@ +# distutils: language = c +# distutils: sources = src/batgrl/cwidth.c + +from libc.math cimport round +from libc.stdlib cimport malloc, free +from libc.string cimport memset + +cimport cython + +from ._fbuf cimport ( + FBufWrapper, + fbuf, + fbuf_flush, + fbuf_grow, + fbuf_printf, + fbuf_putn, + fbuf_putucs4, +) +from ._sixel cimport OctTree, sixel +from .geometry.regions cimport CRegion, Region, bounding_rect, contains + +ctypedef unsigned char uint8 +cdef uint8 GLYPH = 0, SIXEL = 1, MIXED = 2 +cdef unsigned int[8] BRAILLE_ENUM = [1, 8, 2, 16, 4, 32, 64, 128] + +cdef extern from "cwidth.h": + int cwidth(Py_UCS4) + + +cdef struct RegionIterator: + CRegion *cregion + size_t i, j + int y1, y2, y, x1, x2, x + bint done + + +cdef void init_iter(RegionIterator *it, CRegion *cregion): + if cregion.len == 0: + it.done = 1 + else: + it.cregion = cregion + it.i = 0 + it.j = 0 + it.y1 = cregion.bands[0].y1 + it.y2 = cregion.bands[0].y2 + it.x1 = cregion.bands[0].walls[0] + it.x2 = cregion.bands[0].walls[1] + it.y = it.y1 + it.x = it.x1 + it.done = 0 + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void next_(RegionIterator *it): + if it.done: + return + it.x += 1 + if it.x < it.x2: + return + it.y += 1 + if it.y < it.y2: + it.x = it.x1 + return + it.j += 2 + if it.j < it.cregion.bands[it.i].len: + it.y = it.y1 + it.x1 = it.cregion.bands[it.i].walls[it.j] + it.x2 = it.cregion.bands[it.i].walls[it.j + 1] + it.x = it.x1 + return + it.i += 1 + if it.i < it.cregion.len: + it.j = 0 + it.y1 = it.cregion.bands[it.i].y1 + it.y2 = it.cregion.bands[it.i].y2 + it.y = it.y1 + it.x1 = it.cregion.bands[it.i].walls[it.j] + it.x2 = it.cregion.bands[it.i].walls[it.j + 1] + it.x = it.x1 + return + it.done = 1 + + +cdef packed struct Cell: + Py_UCS4 char_ + uint8 bold + uint8 italic + uint8 underline + uint8 strikethrough + uint8 overline + uint8 reverse + uint8[3] fg_color + uint8[3] bg_color + + +cdef inline bint rgb_eq(uint8 *a, uint8 *b): + return (a[0] == b[0]) & (a[1] == b[1]) & (a[2] == b[2]) + + +cdef inline bint rgba_eq(uint8 *a, uint8 *b): + return (a[0] == b[0]) & (a[1] == b[1]) & (a[2] == b[2]) & (a[3] == b[3]) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef inline bint all_eq(uint8[:, :, ::1] a, uint8[:, :, ::1] b): + cdef size_t h = a.shape[0], w = a.shape[1], y, x + for y in range(h): + for x in range(w): + if not rgba_eq(&a[y, x, 0], &b[y, x, 0]): + return 0 + return 1 + + +cdef inline bint cell_eq(Cell *a, Cell *b): + return ( + (a.char_ == b.char_) + & (a.bold == b.bold) + & (a.italic == b.italic) + & (a.underline == b.underline) + & (a.strikethrough == b.strikethrough) + & (a.overline == b.overline) + & (a.reverse == b.reverse) + & (rgb_eq(&a.fg_color[0], &b.fg_color[0])) + & (rgb_eq(&a.bg_color[0], &b.bg_color[0])) + ) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef inline size_t graphics_geom_height(Cell[:, ::1] cells, uint8[:, :, ::1] graphics): + return graphics.shape[0] // cells.shape[0] + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef inline size_t graphics_geom_width(Cell[:, ::1] cells, uint8[:, :, ::1] graphics): + return graphics.shape[1] // cells.shape[1] + + +cdef inline void composite(uint8 *dst, uint8 *src, double alpha): + cdef double b = dst[0] + dst[0] = ((src[0] - b) * alpha + b) + b = dst[1] + dst[1] = ((src[1] - b) * alpha + b) + b = dst[2] + dst[2] = ((src[2] - b) * alpha + b) + + +cdef inline bint composite_sixels_on_glyph( + uint8 *bg, uint8 *rgba, uint8 *graphics, double alpha +): + if rgba[3]: + graphics[0] = bg[0] + graphics[1] = bg[1] + graphics[2] = bg[2] + graphics[3] = 1 + composite(graphics, rgba, alpha * rgba[3] / 255) + return 0 + return 1 + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef inline double average_graphics(uint8 *bg, uint8 [:, :, ::1] graphics): + cdef: + size_t h = graphics.shape[0] + size_t w = graphics.shape[1] + size_t y, x + unsigned long r = 0, g = 0, b = 0, n = 0 + + for y in range(h): + for x in range(w): + if graphics[y, x, 3]: + n += 1 + r += graphics[y, x, 0] + g += graphics[y, x, 1] + b += graphics[y, x, 2] + if not n: + return 0 + bg[0] = (r // n) + bg[1] = (g // n) + bg[2] = (b // n) + return n / (h * w) + + +cdef inline void lerp_rgb(uint8 *src, uint8 *dst, double p): + cdef double negp = 1 - p + dst[0] = (src[0] * p + dst[0] * negp) + dst[1] = (src[1] * p + dst[1] * negp) + dst[2] = (src[2] * p + dst[2] * negp) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef inline double average_quant( + uint8 *fg, + bint *pixels, + uint8[:, :, ::1] texture, + size_t y, + size_t x, + size_t h, + size_t w, +): + # Average the colors in a rect in texture and return the average alpha. + + cdef: + size_t i, j, k = 0, nfg = 0 + double a, average_alpha = 0 + double[3] quant_fg + + memset(quant_fg, 0, sizeof(double) * 3) + + for i in range(y, y + h): + for j in range(x, x + w): + if texture[i, j, 3]: + pixels[k] = 1 + a = texture[i, j, 3] / 255 + average_alpha += a + nfg += 1 + quant_fg[0] += texture[i, j, 0] * a + quant_fg[1] += texture[i, j, 1] * a + quant_fg[2] += texture[i, j, 2] * a + else: + pixels[k] = 0 + k += 1 + + if nfg: + quant_fg[0] /= nfg + quant_fg[1] /= nfg + quant_fg[2] /= nfg + fg[0] = quant_fg[0] + fg[1] = quant_fg[1] + fg[2] = quant_fg[2] + return average_alpha / nfg + return average_alpha + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void opaque_pane_render( + Cell[:, ::1] cells, uint8 *bg_color, CRegion *cregion +): + cdef: + RegionIterator it + Cell *cell + + init_iter(&it, cregion) + while not it.done: + cell = &cells[it.y, it.x] + cell.char_ = u" " + cell.bold = False + cell.italic = False + cell.underline = False + cell.strikethrough = False + cell.overline = False + cell.reverse = False + cell.bg_color[0] = bg_color[0] + cell.bg_color[1] = bg_color[1] + cell.bg_color[2] = bg_color[2] + next_(&it) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void trans_pane_render( + Cell[:, ::1] cells, + uint8[:, :, ::1] graphics, + uint8[:, ::1] kind, + uint8 *bg_color, + double alpha, + CRegion *cregion, +): + cdef: + RegionIterator it + size_t h = graphics_geom_height(cells, graphics) + size_t w = graphics_geom_width(cells, graphics) + size_t oy, ox, gy, gx + Cell *dst + + init_iter(&it, cregion) + while not it.done: + dst = &cells[it.y, it.x] + if kind[it.y, it.x] != SIXEL: + composite(&dst.fg_color[0], bg_color, alpha) + composite(&dst.bg_color[0], bg_color, alpha) + if kind[it.y, it.x] != GLYPH: + oy = it.y * h + ox = it.x * w + for gy in range(h): + for gx in range(w): + if graphics[oy + gy, ox + gx, 3]: + composite(&graphics[oy + gy, ox + gx, 0], bg_color, alpha) + next_(&it) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cpdef void pane_render( + Cell[:, ::1] cells, + uint8[:, :, ::1] graphics, + uint8[:, ::1] kind, + bint is_transparent, + Region region, + tuple[int, int, int] bg_color, + double alpha, +): + cdef: + CRegion *cregion = ®ion.cregion + uint8[3] bg + + bg[0] = bg_color[0] + bg[1] = bg_color[1] + bg[2] = bg_color[2] + + if is_transparent: + trans_pane_render(cells, graphics, kind, &bg[0], alpha, cregion) + else: + opaque_pane_render(cells, &bg[0], cregion) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void opaque_text_render( + Cell[:, ::1] cells, + int abs_y, + int abs_x, + Cell[:, ::1] self_canvas, + CRegion *cregion +): + cdef RegionIterator it + + init_iter(&it, cregion) + while not it.done: + cells[it.y, it.x] = self_canvas[it.y - abs_y, it.x - abs_x] + next_(&it) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void trans_text_render( + Cell[:, ::1] cells, + uint8[:, :, ::1] graphics, + uint8[:, ::1] kind, + int abs_y, + int abs_x, + Cell[:, ::1] self_canvas, + double alpha, + CRegion *cregion, +): + cdef: + RegionIterator it + size_t h = graphics_geom_height(cells, graphics) + size_t w = graphics_geom_width(cells, graphics) + size_t oy, ox, gy, gx + uint8[3] rgb + double p + Cell *dst + Cell *src + + init_iter(&it, cregion) + while not it.done: + src = &self_canvas[it.y - abs_y, it.x - abs_x] + dst = &cells[it.y, it.x] + # FIXME: Consider all whitespace? + if src.char_ == u" " or src.char_ == u"⠀": + if kind[it.y, it.x] != SIXEL: + composite(&dst.fg_color[0], &src.bg_color[0], alpha) + composite(&dst.bg_color[0], &src.bg_color[0], alpha) + if kind[it.y, it.x] != GLYPH: + oy = it.y * h + ox = it.x * w + for gy in range(h): + for gx in range(w): + if graphics[oy + gy, ox + gx, 3]: + composite( + &graphics[oy + gy, ox + gx, 0], &src.bg_color[0], alpha + ) + else: + dst.char_ = src.char_ + dst.bold = src.bold + dst.italic = src.italic + dst.underline = src.underline + dst.strikethrough = src.strikethrough + dst.overline = src.overline + dst.reverse = src.reverse + dst.fg_color = src.fg_color + if kind[it.y, it.x] == SIXEL: + oy = it.y * h + ox = it.x * w + average_graphics(&dst.bg_color[0], graphics[oy:oy + h, ox:ox + w]) + elif kind[it.y, it.x] == MIXED: + oy = it.y * h + ox = it.x * w + p = average_graphics(&rgb[0], graphics[oy: oy + h, ox:ox + w]) + lerp_rgb(&rgb[0], &dst.bg_color[0], p) + kind[it.y, it.x] = GLYPH + composite(&dst.bg_color[0], &src.bg_color[0], alpha) + next_(&it) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cpdef void text_render( + Cell[:, ::1] cells, + uint8[:, :, ::1] graphics, + uint8[:, ::1] kind, + tuple[int, int] abs_pos, + bint is_transparent, + Cell[:, ::1] self_canvas, + double alpha, + Region region, +): + cdef: + int abs_y = abs_pos[0], abs_x = abs_pos[1] + CRegion *cregion = ®ion.cregion + + if is_transparent: + trans_text_render( + cells, graphics, kind, abs_y, abs_x, self_canvas, alpha, cregion + ) + else: + opaque_text_render(cells, abs_y, abs_x, self_canvas, cregion) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void opaque_full_graphics_render( + Cell[:, ::1] cells, + int abs_y, + int abs_x, + uint8[:, :, ::1] self_texture, + CRegion *cregion, +): + cdef: + RegionIterator it + Cell *cell + uint8 *bg_color + + init_iter(&it, cregion) + while not it.done: + cell = &cells[it.y, it.x] + cell.char_ = u" " + cell.bold = False + cell.italic = False + cell.underline = False + cell.strikethrough = False + cell.overline = False + cell.reverse = False + bg_color = &self_texture[it.y - abs_y, it.x - abs_x, 0] + cell.bg_color[0] = bg_color[0] + cell.bg_color[1] = bg_color[1] + cell.bg_color[2] = bg_color[2] + next_(&it) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void trans_full_graphics_render( + Cell[:, ::1] cells, + uint8[:, :, ::1] graphics, + uint8[:, ::1] kind, + int abs_y, + int abs_x, + uint8[:, :, ::1] self_texture, + double alpha, + CRegion *cregion, +): + cdef: + RegionIterator it + size_t h = graphics_geom_height(cells, graphics) + size_t w = graphics_geom_width(cells, graphics) + size_t oy, ox, gy, gx + Cell *dst + uint8 *bg_color + double a + + init_iter(&it, cregion) + while not it.done: + dst = &cells[it.y, it.x] + bg_color = &self_texture[it.y - abs_y, it.x - abs_x, 0] + a = alpha * bg_color[3] / 255 + if kind[it.y, it.x] != SIXEL: + composite(&dst.fg_color[0], bg_color, a) + composite(&dst.bg_color[0], bg_color, a) + if kind[it.y, it.x] != GLYPH: + oy = it.y * h + ox = it.x * w + for gy in range(h): + for gx in range(w): + if graphics[oy + gy, ox + gx, 3]: + composite(&graphics[oy + gy, ox + gx, 0], bg_color, a) + next_(&it) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void opaque_half_graphics_render( + Cell[:, ::1] cells, + int abs_y, + int abs_x, + uint8[:, :, ::1] self_texture, + CRegion *cregion, +): + cdef: + RegionIterator it + int src_y, src_x + Cell *dst + + init_iter(&it, cregion) + while not it.done: + dst = &cells[it.y, it.x] + dst.char_ = u"▀" + dst.bold = False + dst.italic = False + dst.underline = False + dst.strikethrough = False + dst.overline = False + dst.reverse = False + src_y = 2 * (it.y - abs_y) + src_x = it.x - abs_x + dst.fg_color[0] = self_texture[src_y, src_x, 0] + dst.fg_color[1] = self_texture[src_y, src_x, 1] + dst.fg_color[2] = self_texture[src_y, src_x, 2] + dst.bg_color[0] = self_texture[src_y + 1, src_x, 0] + dst.bg_color[1] = self_texture[src_y + 1, src_x, 1] + dst.bg_color[2] = self_texture[src_y + 1, src_x, 2] + next_(&it) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void trans_half_graphics_render( + Cell[:, ::1] cells, + uint8[:, :, ::1] graphics, + uint8[:, ::1] kind, + int abs_y, + int abs_x, + uint8[:, :, ::1] self_texture, + double alpha, + CRegion *cregion, +): + cdef: + RegionIterator it + int src_y, src_x + size_t h = graphics_geom_height(cells, graphics) + size_t w = graphics_geom_width(cells, graphics) + size_t oy, ox, gy, gx + Cell *dst + double a_top, a_bot + uint8 *rgba_top + uint8 *rgba_bot + + init_iter(&it, cregion) + while not it.done: + src_y = 2 * (it.y - abs_y) + src_x = it.x - abs_x + dst = &cells[it.y, it.x] + rgba_top = &self_texture[src_y, src_x, 0] + a_top = alpha * rgba_top[3] / 255 + rgba_bot = &self_texture[src_y + 1, src_x, 0] + a_bot = alpha * rgba_bot[3] / 255 + if rgba_eq(rgba_top, rgba_bot): + if kind[it.y, it.x] != SIXEL: + composite(&dst.fg_color[0], rgba_top, a_top) + composite(&dst.bg_color[0], rgba_top, a_top) + if kind[it.y, it.x] != GLYPH: + oy = it.y * h + ox = it.x * w + for gy in range(h): + for gx in range(w): + if graphics[oy + gy, ox + gx, 3]: + composite(&graphics[oy + gy, ox + gx, 0], rgba_top, a_top) + elif kind[it.y, it.x] == GLYPH: + dst.bold = False + dst.italic = False + dst.underline = False + dst.strikethrough = False + dst.overline = False + dst.reverse = False + if dst.char_ != u"▀": + dst.fg_color = dst.bg_color + dst.char_ = u"▀" + composite(&dst.fg_color[0], rgba_top, a_top) + composite(&dst.bg_color[0], rgba_bot, a_bot) + else: + oy = it.y * h + ox = it.x * w + if kind[it.y, it.x] == MIXED: + kind[it.y, it.x] = SIXEL + if dst.char_ != u"▀": + dst.fg_color = dst.bg_color + composite(&dst.fg_color[0], rgba_top, a_top) + composite(&dst.bg_color[0], rgba_bot, a_bot) + for gy in range(h // 2): + for gx in range(w): + if graphics[oy + gy, ox + gx, 3]: + composite(&graphics[oy + gy, ox + gx, 0], rgba_top, a_top) + else: + graphics[oy + gy, ox + gx, 0] = dst.fg_color[0] + graphics[oy + gy, ox + gx, 1] = dst.fg_color[1] + graphics[oy + gy, ox + gx, 2] = dst.fg_color[2] + graphics[oy + gy, ox + gx, 3] = 1 + for gy in range(h // 2, h): + for gx in range(w): + if graphics[oy + gy, ox + gx, 3]: + composite(&graphics[oy + gy, ox + gx, 0], rgba_bot, a_bot) + else: + graphics[oy + gy, ox + gx, 0] = dst.bg_color[0] + graphics[oy + gy, ox + gx, 1] = dst.bg_color[1] + graphics[oy + gy, ox + gx, 2] = dst.bg_color[2] + graphics[oy + gy, ox + gx, 3] = 1 + next_(&it) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void opaque_sixel_graphics_render( + Cell[:, ::1] cells, + uint8[:, :, ::1] graphics, + uint8[:, ::1] kind, + int abs_y, + int abs_x, + uint8[:, :, ::1] self_texture, + CRegion *cregion, +): + cdef: + RegionIterator it + int src_y, src_x + size_t h = graphics_geom_height(cells, graphics) + size_t w = graphics_geom_width(cells, graphics) + size_t oy, ox, gy, gx + + init_iter(&it, cregion) + while not it.done: + oy = it.y * h + ox = it.x * w + src_y = h * (it.y - abs_y) + src_x = w * (it.x - abs_x) + kind[it.y, it.x] = SIXEL + for gy in range(h): + for gx in range(w): + graphics[oy + gy, ox + gx, 0] = self_texture[src_y + gy, src_x + gx, 0] + graphics[oy + gy, ox + gx, 1] = self_texture[src_y + gy, src_x + gx, 1] + graphics[oy + gy, ox + gx, 2] = self_texture[src_y + gy, src_x + gx, 2] + graphics[oy + gy, ox + gx, 3] = 1 + next_(&it) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void trans_sixel_graphics_render( + Cell[:, ::1] cells, + uint8[:, :, ::1] graphics, + uint8[:, ::1] kind, + int abs_y, + int abs_x, + uint8[:, :, ::1] self_texture, + double alpha, + CRegion *cregion, +): + cdef: + RegionIterator it + int src_y, src_x + size_t h = graphics_geom_height(cells, graphics) + size_t w = graphics_geom_width(cells, graphics) + size_t oy, ox, gy, gx + uint8 *rgba + + init_iter(&it, cregion) + while not it.done: + oy = it.y * h + ox = it.x * w + src_y = oy - abs_y * h + src_x = ox - abs_x * w + if kind[it.y, it.x] == SIXEL: + for gy in range(h): + for gx in range(w): + rgba = &self_texture[src_y + gy, src_x + gx, 0] + if rgba[3]: + composite( + &graphics[oy + gy, ox + gx, 0], + rgba, + alpha * rgba[3] / 255, + ) + graphics[oy + gy, ox + gx, 3] = 1 + elif kind[it.y, it.x] == GLYPH: + # ! Special case half-blocks + # ! For other blitters, probably don't special case. + kind[it.y, it.x] = SIXEL + if cells[it.y, it.x].char_ == u"▀": + for gy in range(h // 2): + for gx in range(w): + if composite_sixels_on_glyph( + &cells[it.y, it.x].fg_color[0], + &self_texture[src_y + gy, src_x + gx, 0], + &graphics[oy + gy, ox + gx, 0], + alpha, + ): + kind[it.y, it.x] = MIXED + for gy in range(h // 2, h): + for gx in range(w): + if composite_sixels_on_glyph( + &cells[it.y, it.x].bg_color[0], + &self_texture[src_y + gy, src_x + gx, 0], + &graphics[oy + gy, ox + gx, 0], + alpha, + ): + kind[it.y, it.x] = MIXED + else: + for gy in range(h): + for gx in range(w): + if composite_sixels_on_glyph( + &cells[it.y, it.x].bg_color[0], + &self_texture[src_y + gy, src_x + gx, 0], + &graphics[oy + gy, ox + gx, 0], + alpha, + ): + kind[it.y, it.x] = MIXED + elif kind[it.y, it.x] == MIXED: + kind[it.y, it.x] = SIXEL + for gy in range(h): + for gx in range(w): + if not graphics[oy + gy, ox + gx, 3]: + kind[it.y, it.x] = MIXED + composite_sixels_on_glyph( + &cells[it.y, it.x].bg_color[0], + &self_texture[src_y + gy, src_x + gx, 0], + &graphics[oy + gy, ox + gx, 0], + alpha, + ) + else: + rgba = &self_texture[src_y + gy, src_x + gx, 0] + if rgba[3]: + composite( + &graphics[oy + gy, ox + gx, 0], + rgba, + alpha * rgba[3] / 255, + ) + graphics[oy + gy, ox + gx, 3] = 1 + next_(&it) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void opaque_braille_graphics_render( + Cell[:, ::1] cells, + int abs_y, + int abs_x, + uint8[:, :, ::1] self_texture, + CRegion *cregion, +): + cdef: + RegionIterator it + int src_y, src_x + uint8[3] fg + bint[8] pixels + Cell *cell + uint8 i + double average_alpha + unsigned long char_ + + init_iter(&it, cregion) + while not it.done: + src_y = 4 * (it.y - abs_y) + src_x = 2 * (it.x - abs_x) + average_alpha = average_quant( + &fg[0], &pixels[0], self_texture, src_y, src_x, 4, 2 + ) + if average_alpha: + cell = &cells[it.y, it.x] + char_ = 10240 + for i in range(8): + if pixels[i]: + char_ += BRAILLE_ENUM[i] + cell.char_ = char_ + cell.bold = False + cell.italic = False + cell.underline = False + cell.strikethrough = False + cell.overline = False + cell.reverse = False + cell.fg_color = cell.bg_color + composite(&cell.fg_color[0], &fg[0], average_alpha) + next_(&it) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void trans_braille_graphics_render( + Cell[:, ::1] cells, + uint8[:, :, ::1] graphics, + uint8[:, ::1] kind, + int abs_y, + int abs_x, + uint8[:, :, ::1] self_texture, + double alpha, + CRegion *cregion, +): + cdef: + RegionIterator it + int src_y, src_x + bint[8] pixels + Cell *cell + uint8 i + size_t h = graphics_geom_height(cells, graphics) + size_t w = graphics_geom_width(cells, graphics) + size_t oy, ox + uint8[3] rgb, fg + double p, average_alpha + unsigned long char_ + + init_iter(&it, cregion) + while not it.done: + src_y = 4 * (it.y - abs_y) + src_x = 2 * (it.x - abs_x) + average_alpha = average_quant( + &fg[0], &pixels[0], self_texture, src_y, src_x, 4, 2 + ) * alpha + if not average_alpha: + next_(&it) + continue + cell = &cells[it.y, it.x] + char_ = 10240 + for i in range(8): + if pixels[i]: + char_ += BRAILLE_ENUM[i] + cell.char_ = char_ + cell.bold = False + cell.italic = False + cell.underline = False + cell.strikethrough = False + cell.overline = False + cell.reverse = False + if kind[it.y, it.x] == SIXEL: + oy = it.y * h + ox = it.x * w + average_graphics(&cell.bg_color[0], graphics[oy:oy + h, ox:ox + w]) + kind[it.y, it.x] = GLYPH + elif kind[it.y, it.x] == MIXED: + oy = it.y * h + ox = it.x * w + p = average_graphics(&rgb[0], graphics[oy:oy + h, ox: ox + w]) + lerp_rgb(&rgb[0], &cell.bg_color[0], p) + kind[it.y, it.x] = GLYPH + cell.fg_color = cell.bg_color + composite(&cell.fg_color[0], &fg[0], average_alpha) + next_(&it) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cpdef void graphics_render( + Cell[:, ::1] cells, + uint8[:, :, ::1] graphics, + uint8[:, ::1] kind, + tuple[int, int] abs_pos, + str blitter, + bint is_transparent, + uint8[:, :, ::1] self_texture, + double alpha, + Region region, +): + cdef: + int abs_y = abs_pos[0], abs_x = abs_pos[1] + CRegion *cregion = ®ion.cregion + + if blitter == "full": + if is_transparent: + trans_full_graphics_render( + cells, graphics, kind, abs_y, abs_x, self_texture, alpha, cregion + ) + else: + opaque_full_graphics_render(cells, abs_y, abs_x, self_texture, cregion) + elif blitter == "half": + if is_transparent: + trans_half_graphics_render( + cells, graphics, kind, abs_y, abs_x, self_texture, alpha, cregion + ) + else: + opaque_half_graphics_render(cells, abs_y, abs_x, self_texture, cregion) + elif blitter == "braille": + if is_transparent: + trans_braille_graphics_render( + cells, graphics, kind, abs_y, abs_x, self_texture, alpha, cregion + ) + else: + opaque_braille_graphics_render(cells, abs_y, abs_x, self_texture, cregion) + elif blitter == "sixel": + if is_transparent: + trans_sixel_graphics_render( + cells, graphics, kind, abs_y, abs_x, self_texture, alpha, cregion + ) + else: + opaque_sixel_graphics_render( + cells, graphics, kind, abs_y, abs_x, self_texture, cregion + ) + + +@cython.boundscheck(False) +@cython.wraparound(False) +def cursor_render( + Cell[:, ::1] cells, + bold: bool | None, + italic: bool | None, + underline: bool | None, + strikethrough: bool | None, + overline: bool | None, + reverse: bool | None, + fg_color: tuple[int, int, int] | None, + bg_color: tuple[int, int, int] | None, + region: Region, +) -> None: + cdef: + CRegion *cregion = ®ion.cregion + RegionIterator it + Cell *dst + + init_iter(&it, cregion) + while not it.done: + dst = &cells[it.y, it.x] + if bold is not None: + dst.bold = bold + if italic is not None: + dst.italic = italic + if underline is not None: + dst.underline = underline + if strikethrough is not None: + dst.strikethrough = strikethrough + if overline is not None: + dst.overline = overline + if reverse is not None: + dst.reverse = reverse + if fg_color is not None: + dst.fg_color = fg_color + if bg_color is not None: + dst.bg_color = bg_color + next_(&it) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void opaque_text_field_render( + Cell[:, ::1] cells, + int abs_y, + int abs_x, + double[:, ::1] positions, + Cell[::1] particles, + CRegion *cregion +): + cdef: + size_t nparticles = particles.shape[0], i + int py, px + + for i in range(nparticles): + py = positions[i][0] + abs_y + px = positions[i][1] + abs_x + if contains(cregion, py, px): + cells[py, px] = particles[i] + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void trans_text_field_render( + Cell[:, ::1] cells, + uint8[:, :, ::1] graphics, + uint8[:, ::1] kind, + int abs_y, + int abs_x, + double[:, ::1] positions, + Cell[::1] particles, + double alpha, + CRegion *cregion, +): + cdef: + size_t nparticles = particles.shape[0], i + int py, px + size_t h = graphics_geom_height(cells, graphics) + size_t w = graphics_geom_width(cells, graphics) + size_t oy, ox, gy, gx + uint8[3] rgb + double p + Cell *dst + Cell *src + + for i in range(nparticles): + py = positions[i][0] + abs_y + px = positions[i][1] + abs_x + if not contains(cregion, py, px): + continue + src = &particles[i] + dst = &cells[py, px] + # FIXME: Consider all whitespace? + if src.char_ == u" " or src.char_ == u"⠀": + if kind[py, px] != SIXEL: + composite(&dst.fg_color[0], &src.bg_color[0], alpha) + composite(&dst.bg_color[0], &src.bg_color[0], alpha) + if kind[py, px] != GLYPH: + oy = py * h + ox = px * w + for gy in range(h): + for gx in range(w): + if graphics[oy + gy, ox + gx, 3]: + composite( + &graphics[oy + gy, ox + gx, 0], &src.bg_color[0], alpha + ) + else: + dst.char_ = src.char_ + dst.bold = src.bold + dst.italic = src.italic + dst.underline = src.underline + dst.strikethrough = src.strikethrough + dst.overline = src.overline + dst.reverse = src.reverse + dst.fg_color = src.fg_color + if kind[py, px] == SIXEL: + oy = py * h + ox = px * w + average_graphics(&dst.bg_color[0], graphics[oy:oy + h, ox:ox + w]) + elif kind[py, px] == MIXED: + oy = py * h + ox = px * w + p = average_graphics(&rgb[0], graphics[oy: oy + h, ox:ox + w]) + lerp_rgb(&rgb[0], &dst.bg_color[0], p) + kind[py, px] = GLYPH + composite(dst.bg_color, src.bg_color, alpha) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cpdef void text_field_render( + Cell[:, ::1] cells, + uint8[:, :, ::1] graphics, + uint8[:, ::1] kind, + tuple[int, int] abs_pos, + bint is_transparent, + double[:, ::1] positions, + Cell[::1] particles, + double alpha, + Region region, +): + cdef: + int abs_y = abs_pos[0], abs_x = abs_pos[1] + CRegion *cregion = ®ion.cregion + + if is_transparent: + trans_text_field_render( + cells, graphics, kind, abs_y, abs_x, positions, particles, alpha, cregion + ) + else: + opaque_text_field_render(cells, abs_y, abs_x, positions, particles, cregion) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void opaque_full_graphics_field_render( + Cell[:, ::1] cells, + int abs_y, + int abs_x, + double[:, ::1] positions, + uint8[:, ::1] particles, + CRegion *cregion, +): + cdef: + size_t nparticles = particles.shape[0], i + int py, px + Cell *dst + + for i in range(nparticles): + py = positions[i][0] + abs_y + px = positions[i][1] + abs_x + if not contains(cregion, py, px): + continue + dst = &cells[py, px] + dst.char_ = u" " + dst.bold = False + dst.italic = False + dst.underline = False + dst.strikethrough = False + dst.overline = False + dst.reverse = False + dst.bg_color[0] = particles[i, 0] + dst.bg_color[1] = particles[i, 1] + dst.bg_color[2] = particles[i, 2] + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void trans_full_graphics_field_render( + Cell[:, ::1] cells, + uint8[:, :, ::1] graphics, + uint8[:, ::1] kind, + int abs_y, + int abs_x, + double[:, ::1] positions, + uint8[:, ::1] particles, + double alpha, + CRegion *cregion, +): + cdef: + size_t nparticles = particles.shape[0], i + int py, px + size_t h = graphics_geom_height(cells, graphics) + size_t w = graphics_geom_width(cells, graphics) + size_t oy, ox, gy, gx + Cell *dst + double a + uint8 *src_rgba + + for i in range(nparticles): + py = positions[i][0] + abs_y + px = positions[i][1] + abs_x + if not contains(cregion, py, px): + continue + src_rgba = &particles[i, 0] + a = alpha * src_rgba[3] / 255 + if kind[py, px] != SIXEL: + dst = &cells[py, px] + dst.char_ = u" " + dst.bold = False + dst.italic = False + dst.underline = False + dst.strikethrough = False + dst.overline = False + dst.reverse = False + composite(&dst.bg_color[0], src_rgba, a) + if kind[py, px] != GLYPH: + oy = py * h + ox = px * w + for gy in range(h): + for gx in range(w): + if graphics[oy + gy, ox + gx, 3]: + composite(&graphics[oy + gy, ox + gx, 0], src_rgba, a) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void opaque_half_graphics_field_render( + Cell[:, ::1] cells, + int abs_y, + int abs_x, + double[:, ::1] positions, + uint8[:, ::1] particles, + CRegion *cregion, +): + cdef: + size_t nparticles = particles.shape[0], i + double py, px + int ipy, ipx + Cell *dst + uint8 *dst_rgb + + for i in range(nparticles): + py = positions[i][0] + abs_y + ipy = py + px = positions[i][1] + abs_x + ipx = px + if not contains(cregion, ipy, ipx): + continue + dst = &cells[ipy, ipx] + dst.bold = False + dst.italic = False + dst.underline = False + dst.strikethrough = False + dst.overline = False + dst.reverse = False + if py - ipy < .5: + dst_rgb = &dst.fg_color[0] + else: + dst_rgb = &dst.bg_color[0] + if dst.char_ != u"▀": + dst.fg_color = dst.bg_color + dst.char_ = u"▀" + dst_rgb[0] = particles[i, 0] + dst_rgb[1] = particles[i, 1] + dst_rgb[2] = particles[i, 2] + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void trans_half_graphics_field_render( + Cell[:, ::1] cells, + uint8[:, :, ::1] graphics, + uint8[:, ::1] kind, + int abs_y, + int abs_x, + double[:, ::1] positions, + uint8[:, ::1] particles, + double alpha, + CRegion *cregion, +): + cdef: + size_t nparticles = particles.shape[0], i + double py, px + int ipy, ipx + size_t h = graphics_geom_height(cells, graphics) + size_t w = graphics_geom_width(cells, graphics) + size_t oy, ox, gy, gx, gtop, gbot + Cell *dst + double a + uint8 *src_rgba + uint8 *dst_rgb + + for i in range(nparticles): + py = positions[i][0] + abs_y + ipy = py + px = positions[i][1] + abs_x + ipx = px + if not contains(cregion, ipy, ipx): + continue + src_rgba = &particles[i, 0] + a = alpha * src_rgba[3] / 255 + dst = &cells[ipy, ipx] + if kind[ipy, ipx] == GLYPH: + dst.bold = False + dst.italic = False + dst.underline = False + dst.strikethrough = False + dst.overline = False + dst.reverse = False + if dst.char_ != u"▀": + dst.fg_color = dst.bg_color + dst.char_ = u"▀" + if py - ipy < .5: + composite(&dst.fg_color[0], src_rgba, a) + else: + composite(&dst.bg_color[0], src_rgba, a) + else: + if py - ipy <= .5: + gtop = 0 + gbot = h // 2 + dst_rgb = &dst.fg_color[0] + else: + gtop = h // 2 + gbot = h + dst_rgb = &dst.bg_color[0] + if kind[ipy, ipx] == MIXED: + kind[ipy, ipx] = SIXEL + if dst.char_ != u"▀": + dst.fg_color = dst.bg_color + composite(dst_rgb, src_rgba, a) + oy = ipy * h + ox = ipx * w + for gy in range(gtop, gbot): + for gx in range(w): + if graphics[oy + gy, ox + gx, 3]: + composite(&graphics[oy + gy, ox + gx, 0], src_rgba, a) + else: + graphics[oy + gy, ox + gx, 0] = dst_rgb[0] + graphics[oy + gy, ox + gx, 1] = dst_rgb[1] + graphics[oy + gy, ox + gx, 2] = dst_rgb[2] + graphics[oy + gy, ox + gx, 3] = 1 + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void opaque_sixel_graphics_field_render( + Cell[:, ::1] cells, + uint8[:, :, ::1] graphics, + uint8[:, ::1] kind, + int abs_y, + int abs_x, + double[:, ::1] positions, + uint8[:, ::1] particles, + CRegion *cregion, +): + cdef: + size_t nparticles = particles.shape[0], i + double py, px + int ipy, ipx + size_t h = graphics_geom_height(cells, graphics) + size_t w = graphics_geom_width(cells, graphics) + size_t oy, ox, gy, gx, pgy, pgx + RegionIterator it + + for i in range(nparticles): + py = positions[i][0] + abs_y + ipy = py + px = positions[i][1] + abs_x + ipx = px + if not contains(cregion, ipy, ipx): + continue + oy = ipy * h + ox = ipx * w + pgy = oy + round((py - ipy) * h) + if pgy >= graphics.shape[0]: + continue + pgx = ox + round((px - ipx) * w) + if pgx >= graphics.shape[1]: + continue + graphics[pgy, pgx] = particles[i] + kind[ipy, ipx] = SIXEL + + # For all sixel cells in region, mark graphics as opaque: + init_iter(&it, cregion) + while not it.done: + if kind[it.y, it.x] == SIXEL: + oy = it.y * h + ox = it.x * w + for gy in range(h): + for gx in range(w): + graphics[oy + gy, ox + gx, 3] = 1 + next_(&it) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void trans_sixel_graphics_field_render( + Cell[:, ::1] cells, + uint8[:, :, ::1] graphics, + uint8[:, ::1] kind, + int abs_y, + int abs_x, + double[:, ::1] positions, + uint8[:, ::1] particles, + double alpha, + CRegion *cregion, +): + cdef: + size_t nparticles = particles.shape[0], i + double py, px + int ipy, ipx + size_t h = graphics_geom_height(cells, graphics) + size_t w = graphics_geom_width(cells, graphics) + size_t oy, ox, gy, gx, pgy, pgx + + for i in range(nparticles): + if not particles[i, 3]: + continue + py = positions[i][0] + abs_y + ipy = py + px = positions[i][1] + abs_x + ipx = px + if not contains(cregion, ipy, ipx): + continue + oy = ipy * h + ox = ipx * w + pgy = oy + round((py - ipy) * h) + if pgy >= graphics.shape[0]: + continue + pgx = ox + round((px - ipx) * w) + if pgx >= graphics.shape[1]: + continue + if ( + kind[ipy, ipx] == GLYPH + or kind[ipy, ipx] == MIXED and not graphics[pgy, pgx, 3] + ): + if cells[ipy, ipx].char_ == u"▀" and py - ipy < .5: + graphics[pgy, pgx, 0] = cells[ipy, ipx].fg_color[0] + graphics[pgy, pgx, 1] = cells[ipy, ipx].fg_color[1] + graphics[pgy, pgx, 2] = cells[ipy, ipx].fg_color[2] + else: + graphics[pgy, pgx, 0] = cells[ipy, ipx].bg_color[0] + graphics[pgy, pgx, 1] = cells[ipy, ipx].bg_color[1] + graphics[pgy, pgx, 2] = cells[ipy, ipx].bg_color[2] + composite(&graphics[pgy, pgx, 0], &particles[i, 0], alpha) + graphics[pgy, pgx, 3] = 1 + kind[ipy, ipx] = SIXEL + for gy in range(h): + for gx in range(w): + if not graphics[oy + gy, ox + gx, 3]: + kind[ipy, ipx] = MIXED + break + + +cdef struct BraillePixel: + unsigned long char_ + double[4] total_fg + unsigned int ncolors + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void opaque_braille_graphics_field_render( + Cell[:, ::1] cells, + int abs_y, + int abs_x, + double[:, ::1] positions, + uint8[:, ::1] particles, + CRegion *cregion, +): + cdef: + size_t nparticles = particles.shape[0], i + double py, px + int ipy, ipx, y, x, pgy, pgx + size_t h, w + Cell *dst + + bounding_rect(cregion, &y, &x, &h, &w) + cdef: + BraillePixel *pixels = malloc(sizeof(BraillePixel) * h * w) + BraillePixel *pixel + + if pixels is NULL: + return + memset(pixels, 0, sizeof(BraillePixel) * h * w) + + for i in range(nparticles): + py = positions[i][0] + abs_y + ipy = py + px = positions[i][1] + abs_x + ipx = px + if not contains(cregion, ipy, ipx): + continue + pixel = &pixels[(ipy - y) * w + ipx - x] + # ! Why isn't pixel.char_ == 0 sufficient? + if pixel.char_ < 10240 or pixel.char_ > 10495: + pixel.char_ = 10240 + pgy = ((py - ipy) * 4) + pgx = ((px - ipx) * 2) + pixel.char_ |= BRAILLE_ENUM[pgy * 2 + pgx] + pixel.total_fg[0] += particles[i, 0] + pixel.total_fg[1] += particles[i, 1] + pixel.total_fg[2] += particles[i, 2] + pixel.ncolors += 1 + + for i in range(h * w): + pixel = &pixels[i] + # ! Why does pixel.char_ need to be checked? + if not pixel.ncolors or pixel.char_ < 10240 or pixel.char_ > 10495: + continue + dst = &cells[(i // w) + y, (i % w) + x] + dst.char_ = pixel.char_ + dst.bold = False + dst.italic = False + dst.underline = False + dst.strikethrough = False + dst.overline = False + dst.reverse = False + dst.fg_color[0] = (pixel.total_fg[0] / pixel.ncolors) + dst.fg_color[1] = (pixel.total_fg[1] / pixel.ncolors) + dst.fg_color[2] = (pixel.total_fg[2] / pixel.ncolors) + + free(pixels) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void trans_braille_graphics_field_render( + Cell[:, ::1] cells, + uint8[:, :, ::1] graphics, + uint8[:, ::1] kind, + int abs_y, + int abs_x, + double[:, ::1] positions, + uint8[:, ::1] particles, + double alpha, + CRegion *cregion, +): + cdef: + size_t nparticles = particles.shape[0], i + double py, px + int ipy, ipx, y, x + Cell *dst + size_t h = graphics_geom_height(cells, graphics) + size_t w = graphics_geom_width(cells, graphics) + size_t rh, rw + int pgy, pgx + uint8[3] rgb + + bounding_rect(cregion, &y, &x, &rh, &rw) + cdef: + BraillePixel *pixels = malloc(sizeof(BraillePixel) * rh * rw) + BraillePixel *pixel + + if pixels is NULL: + return + memset(pixels, 0, sizeof(BraillePixel) * rh * rw) + + for i in range(nparticles): + if not particles[i, 3]: + continue + py = positions[i][0] + abs_y + ipy = py + px = positions[i][1] + abs_x + ipx = px + if not contains(cregion, ipy, ipx): + continue + pixel = &pixels[(ipy - y) * rw + ipx - x] + # ! Why isn't pixel.char_ == 0 sufficient? + # ! Assumed if char_ is braille, that background has already been composited. + if pixel.char_ < 10240 or pixel.char_ > 10495: + pixel.char_ = 10240 + if kind[ipy, ipx] == SIXEL: + oy = ipy * h + ox = ipx * w + average_graphics( + &cells[ipy, ipx].bg_color[0], graphics[oy:oy + h, ox:ox + w] + ) + kind[ipy, ipx] = GLYPH + elif kind[ipy, ipx] == MIXED: + oy = ipy * h + ox = ipx * w + p = average_graphics(&rgb[0], graphics[oy:oy + h, ox: ox + w]) + lerp_rgb(&rgb[0], &cells[ipy, ipx].bg_color[0], p) + kind[ipy, ipx] = GLYPH + cells[ipy, ipx].fg_color = cells[ipy, ipx].bg_color + kind[ipy, ipx] = GLYPH + pgy = ((py - ipy) * 4) + pgx = ((px - ipx) * 2) + pixel.char_ |= BRAILLE_ENUM[pgy * 2 + pgx] + pixel.total_fg[0] += particles[i, 0] + pixel.total_fg[1] += particles[i, 1] + pixel.total_fg[2] += particles[i, 2] + pixel.total_fg[3] += particles[i, 3] + pixel.ncolors += 1 + + for i in range(rh * rw): + pixel = &pixels[i] + # ! Why does pixel.char_ need to be checked? + if not pixel.ncolors or pixel.char_ < 10240 or pixel.char_ > 10495: + continue + dst = &cells[(i // rw) + y, (i % rw) + x] + dst.char_ = pixel.char_ + dst.bold = False + dst.italic = False + dst.underline = False + dst.strikethrough = False + dst.overline = False + dst.reverse = False + + rgb[0] = (pixel.total_fg[0] / pixel.ncolors) + rgb[1] = (pixel.total_fg[1] / pixel.ncolors) + rgb[2] = (pixel.total_fg[2] / pixel.ncolors) + composite( + &dst.fg_color[0], &rgb[0], pixel.total_fg[3] / 255 / pixel.ncolors * alpha + ) + free(pixels) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cpdef void graphics_field_render( + Cell[:, ::1] cells, + uint8[:, :, ::1] graphics, + uint8[:, ::1] kind, + tuple[int, int] abs_pos, + str blitter, + bint is_transparent, + double[:, ::1] positions, + uint8[:, ::1] particles, + double alpha, + Region region, +): + cdef: + int abs_y = abs_pos[0], abs_x = abs_pos[1] + CRegion *cregion = ®ion.cregion + + if blitter == "full": + if is_transparent: + trans_full_graphics_field_render( + cells, + graphics, + kind, + abs_y, + abs_x, + positions, + particles, + alpha, + cregion, + ) + else: + opaque_full_graphics_field_render( + cells, abs_y, abs_x, positions, particles, cregion + ) + elif blitter == "half": + if is_transparent: + trans_half_graphics_field_render( + cells, + graphics, + kind, + abs_y, + abs_x, + positions, + particles, + alpha, + cregion, + ) + else: + opaque_half_graphics_field_render( + cells, abs_y, abs_x, positions, particles, cregion + ) + elif blitter == "braille": + if is_transparent: + trans_braille_graphics_field_render( + cells, + graphics, + kind, + abs_y, + abs_x, + positions, + particles, + alpha, + cregion, + ) + else: + opaque_braille_graphics_field_render( + cells, abs_y, abs_x, positions, particles, cregion + ) + elif blitter == "sixel": + if is_transparent: + trans_sixel_graphics_field_render( + cells, + graphics, + kind, + abs_y, + abs_x, + positions, + particles, + alpha, + cregion, + ) + else: + opaque_sixel_graphics_field_render( + cells, graphics, kind, abs_y, abs_x, positions, particles, cregion + ) + + +cdef inline void write_sgr(fbuf *f, uint8 param, bint *first): + if first[0]: + fbuf_printf(f, "\x1b[%d", param) + first[0] = 0 + else: + fbuf_printf(f, ";%d", param) + + +cdef inline void write_rgb(fbuf *f, uint8 fg, uint8 *rgb, bint *first): + if first[0]: + fbuf_printf(f, "\x1b[%d;2;%d;%d;%d", fg, rgb[0], rgb[1], rgb[2]) + first[0] = 0 + else: + fbuf_printf(f, ";%d;2;%d;%d;%d", fg, rgb[0], rgb[1], rgb[2]) + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef inline void normalize_canvas(Cell[:, ::1] cells, int[:, ::1] widths): + cdef size_t h = cells.shape[0], w = cells.shape[1], y, x + + # Try to prevent wide chars from clipping. If there is clipping, + # replace wide chars with whitespace. + + for y in range(h): + for x in range(w): + widths[y, x] = cwidth(cells[y, x].char_) + + for y in range(h): + for x in range(w): + if ( + widths[y, x] == 0 and (x == 0 or widths[y, x - 1] != 2) + or widths[y, x] == 2 and (x == w - 1 or widths[y, x + 1] != 0) + ): + cells[y, x].char_ = u" " + widths[y, x] = 1 + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef inline ssize_t write_glyph( + fbuf *f, + size_t oy, + size_t ox, + size_t y, + size_t x, + ssize_t *cursor_y, + ssize_t *cursor_x, + Cell[:, ::1] canvas, + int[:, ::1] widths, + Cell **last_sgr, +): + if not widths[y, x]: + return 0 + + cdef: + ssize_t abs_y = y + oy, abs_x = x + ox + Cell *cell = &canvas[y, x] + Cell *last = last_sgr[0] + bint first = 1 + + if abs_y == cursor_y[0]: + if abs_x != cursor_x[0]: + # CHA, Cursor Horizontal Absolute + if fbuf_printf(f, "\x1b[%dG", abs_x + 1): + return -1 + cursor_x[0] = abs_x + else: + # CUP, Cursor Position + if fbuf_printf(f, "\x1b[%d;%dH", abs_y + 1, abs_x + 1): + return -1 + cursor_y[0] = abs_y + cursor_x[0] = abs_x + + if fbuf_grow(f, 128): + return -1 + # Build up Select Graphic Rendition (SGR) parameters + if last is NULL or cell.bold != last.bold: + write_sgr(f, 1 if cell.bold else 22, &first) + if last is NULL or cell.italic != last.italic: + write_sgr(f, 3 if cell.italic else 23, &first) + if last is NULL or cell.underline != last.underline: + write_sgr(f, 4 if cell.underline else 24, &first) + if last is NULL or cell.strikethrough != last.strikethrough: + write_sgr(f, 9 if cell.strikethrough else 29, &first) + if last is NULL or cell.overline != last.overline: + write_sgr(f, 53 if cell.overline else 55, &first) + if last is NULL or cell.reverse != last.reverse: + write_sgr(f, 7 if cell.reverse else 27, &first) + if last is NULL or not rgb_eq(&cell.fg_color[0], &last.fg_color[0]): + write_rgb(f, 38, &cell.fg_color[0], &first) + if last is NULL or not rgb_eq(&cell.bg_color[0], &last.bg_color[0]): + write_rgb(f, 48, &cell.bg_color[0], &first) + if not first: + fbuf_putn(f, "m", 1) + fbuf_putucs4(f, cell.char_) + cursor_x[0] += widths[y, x] + last_sgr[0] = cell + return 0 + + +@cython.boundscheck(False) +@cython.wraparound(False) +cpdef void terminal_render( + bint resized, + FBufWrapper fwrap, + OctTree octree, + tuple[int, int] app_pos, + Cell[:, ::1] cells, + Cell[:, ::1] prev_cells, + int[:, ::1] widths, + uint8[:, :, ::1] graphics, + uint8[:, :, ::1] prev_graphics, + uint8[:, :, ::1] sgraphics, + uint8[:, ::1] kind, + uint8[:, ::1] prev_kind, + tuple[int, int] aspect_ratio, +): + normalize_canvas(cells, widths) + + cdef: + fbuf *f = &fwrap.f + size_t h = cells.shape[0], w = cells.shape[1], y, x + size_t cell_h = graphics_geom_height(cells, graphics) + size_t cell_w = graphics_geom_width(cells, graphics) + size_t gy, gx, gh, gw + size_t min_y_sixel = h, min_x_sixel = w, max_y_sixel = 0, max_x_sixel = 0 + size_t oy = app_pos[0], ox = app_pos[1] + ssize_t cursor_y = -1, cursor_x = -1 + Cell *last_sgr = NULL + bint emit_sixel = 0 + unsigned int aspect_h = aspect_ratio[0], aspect_w = aspect_ratio[1] + + if fbuf_putn(f, "\x1b7", 2): # Save cursor + raise MemoryError + + for y in range(h): + for x in range(w): + if kind[y, x]: + if y < min_y_sixel: + min_y_sixel = y + if y > max_y_sixel: + max_y_sixel = y + if x < min_x_sixel: + min_x_sixel = x + if x > max_x_sixel: + max_x_sixel = x + if not emit_sixel: + if resized: + emit_sixel = 1 + elif kind[y, x] != prev_kind[y, x]: + emit_sixel = 1 + elif ( + kind[y, x] == MIXED + and not cell_eq(&cells[y, x], &prev_cells[y, x]) + ): + emit_sixel = 1 + else: + gh = y * cell_h + gw = x * cell_w + emit_sixel = not all_eq( + graphics[gh:gh + cell_h, gw:gw + cell_w], + prev_graphics[gh:gh + cell_h, gw:gw + cell_w], + ) + # Note ALL mixed and ALL sixel cells are re-emitted if any has changed. + if emit_sixel: + for y in range(h): + for x in range(w): + if kind[y, x] == MIXED: + if write_glyph( + f, oy, ox, y, x, &cursor_y, &cursor_x, cells, widths, &last_sgr + ): + raise MemoryError + + gy = min_y_sixel * cell_h + gx = min_x_sixel * cell_w + gh = (max_y_sixel + 1 - min_y_sixel) * cell_h + # If sixel graphics rect reaches last line of terminal, its height must be + # truncated to nearest multiple of 6 to prevent scrolling. + if max_y_sixel + 1 == h: + gh -= gh % 6 + gw = (max_x_sixel + 1 - min_x_sixel) * cell_w + + if fbuf_printf(f, "\x1b[%d;%dH", min_y_sixel + 1 + oy, min_x_sixel + 1 + ox): + raise MemoryError + if sixel( + f, &octree.qs, graphics, sgraphics, aspect_h, aspect_w, gy, gx, gh, gw + ): + raise MemoryError + + cursor_y = -1 + cursor_x = -1 + for y in range(h): + for x in range(w): + if kind[y, x] == GLYPH and ( + resized + or prev_kind[y, x] + or ( + emit_sixel + and min_y_sixel <= y <= max_y_sixel + and min_x_sixel <= x <= max_x_sixel + ) + or not cell_eq(&cells[y, x], &prev_cells[y, x]) + ): + if write_glyph( + f, oy, ox, y, x, &cursor_y, &cursor_x, cells, widths, &last_sgr + ): + raise MemoryError + + if f.len == 2: + f.len = 0 # Only 'Save Cursor' in buffer. Don't flush. + return + + if fbuf_putn(f, "\x1b8", 2): # Restore cursor + raise MemoryError + + fbuf_flush(f) diff --git a/src/batgrl/_sixel.pxd b/src/batgrl/_sixel.pxd new file mode 100644 index 00000000..1625be0c --- /dev/null +++ b/src/batgrl/_sixel.pxd @@ -0,0 +1,37 @@ +from ._fbuf cimport fbuf + +cdef: + struct qnode: + unsigned char[3] srgb + unsigned long pop + unsigned int qlink + unsigned int cidx + + struct onode: + (qnode *)[8] q + + struct qstate: + qnode *qnodes + onode *onodes + unsigned int dynnodes_free + unsigned int dynnodes_total + unsigned onodes_free + unsigned onodes_total + unsigned long ncolors + unsigned char *table + + int sixel( + fbuf *f, + qstate *qs, + unsigned char[:, :, ::1] texture, + unsigned char[:, :, ::1] stexture, + unsigned int aspect_h, + unsigned int aspect_w, + size_t oy, + size_t ox, + size_t h, + size_t w, + ) + + class OctTree: + cdef qstate qs diff --git a/src/batgrl/_sixel.pyx b/src/batgrl/_sixel.pyx new file mode 100644 index 00000000..ead7ce7f --- /dev/null +++ b/src/batgrl/_sixel.pyx @@ -0,0 +1,602 @@ +""" +Sixel Graphics. + +The sixel format involves first creating a palette (generally limited to 256 colors) for +the succeeding band data. Each color in the palette uses the ansi "#i;m;r;g;b" where +``i`` is the color register and ``m`` is the "mode" (``1`` for hsl or ``2`` for rgb). +For mode ``2`` (the only mode batgrl uses), the remaining three parameters ``r``, ``g``, +``b`` are the red, green, and blue color components of the color scaled from 0-100 +inclusive. + +The remaining data for an image is split into 6-pixel high bands. A six-pixel tall +column in that band is called a sixel. For each band, for every color in the band output +"#i" with ``i`` being a color in the palette followed by color data finally ending with +"$" to return to the start of the band (to output a new color) or "-" to move to the +next band. + +For the color data, for each pixel in a sixel with values, ``n``, from 0-5 from top-to- +bottom, if that pixel matches the current color add ``2**n``. The result is a value from +0-63. Add 63 to this result to get a character between "?"-"~". If a character is +repeated, run length encoding, "!rc", may be used instead where ``r`` is the number of +times to repeat ``c``. +""" +# This is nearly verbatim notcurses' sixel.c, but in cython. +# notcurses' sixel.c: +# https://github.com/dankamongmen/notcurses/blob/master/src/lib/sixel.c +# +# notcurses is copyright 2019-2025 Nick Black et al and is licensed under the Apache +# License, Version 2.0: +# http://www.apache.org/licenses/LICENSE-2.0 +# + +from libc.stdio cimport sprintf +from libc.stdlib cimport free, malloc, qsort +from libc.string cimport memset, memcpy +from libc.math cimport round + +cimport cython + +from ._fbuf cimport fbuf, fbuf_printf, fbuf_putn, fbuf_puts +from ._sixel cimport onode, qnode, qstate + +ctypedef unsigned char uint8 +ctypedef unsigned int uint + +cdef: + uint QNODES_COUNT = 1000 + uint MAX_COLORS = 256 + + class OctTree: + def __init__(self) -> None: + if alloc_qstate(&self.qs): + raise MemoryError + + def __dealloc__(self) -> None: + free_qstate(&self.qs) + + struct BandExtender: + size_t length, wrote + int rle + char rep + + struct SixelMap: + size_t nbands + char ***bands + + struct ActiveColor: + unsigned int color + char rep + + +cdef inline uint8 uint8_to_100(uint8 c): + cdef uint8 result = round(c * 100.0 / 255) + if result > 99: + return 99 + return result + + +cdef inline uint qnode_keys(uint8 *srgb, uint8 *skey): + skey[0] = ( + (((srgb[0] % 10) // 5) << 2) + + (((srgb[1] % 10) // 5) << 1) + + ((srgb[2] % 10) // 5) + ) + return srgb[0] // 10 * 100 + srgb[1] // 10 * 10 + srgb[2] // 10 + + +cdef inline bint is_chosen(const qnode *q): + return q.cidx & 0x8000 + + +cdef inline uint make_chosen(uint cidx): + return cidx | 0x8000 + + +cdef inline uint qidx(const qnode *q): + if q.cidx & 0x8000: + return q.cidx ^ 0x8000 + return q.cidx + + +cdef inline int insert_color(qstate *qs, uint8 *srgb): + cdef: + uint8 skey, skeynat + uint key = qnode_keys(srgb, &skey) + qnode *q + onode *o + + q = &qs.qnodes[key] + if q.pop == 0 and q.qlink == 0: + q.srgb[0] = srgb[0] + q.srgb[1] = srgb[1] + q.srgb[2] = srgb[2] + q.pop = 1 + qs.ncolors += 1 + return 0 + + if q.qlink == 0: + qnode_keys(&q.srgb[0], &skeynat) + if skey == skeynat: + q.pop += 1 + return 0 + + if qs.onodes_free == 0 or qs.dynnodes_free == 0: + q.pop += 1 + return 0 + + o = qs.onodes + qs.onodes_total - qs.onodes_free + memset(o, 0, sizeof(onode)) + o.q[skeynat] = &qs.qnodes[QNODES_COUNT + qs.dynnodes_total - qs.dynnodes_free] + qs.dynnodes_free -= 1 + memcpy(o.q[skeynat], q, sizeof(qnode)) + q.qlink = qs.onodes_total - qs.onodes_free + 1 + qs.onodes_free -= 1 + q.pop = 0 + else: + o = qs.onodes + q.qlink - 1 + + if o.q[skey]: + o.q[skey].pop += 1 + return 0 + + if qs.dynnodes_free == 0: + return -1 + + o.q[skey] = &qs.qnodes[QNODES_COUNT + qs.dynnodes_total - qs.dynnodes_free] + qs.dynnodes_free -= 1 + o.q[skey].pop = 1 + o.q[skey].srgb[0] = srgb[0] + o.q[skey].srgb[1] = srgb[1] + o.q[skey].srgb[2] = srgb[2] + o.q[skey].qlink = 0 + o.q[skey].cidx = 0 + qs.ncolors += 1 + return 0 + + +cdef inline int find_color(const qstate *qs, uint8 *srgb): + cdef: + uint8 skey + uint key = qnode_keys(srgb, &skey) + qnode *q = &qs.qnodes[key] + + if q.qlink and q.pop == 0: + if qs.onodes[q.qlink - 1].q[skey]: + q = qs.onodes[q.qlink - 1].q[skey] + else: + return -1 + + return qidx(q) + + +cdef int qnode_cmp(const void* a, const void* b) noexcept nogil: + cdef: + const qnode *qa = a + const qnode *qb = b + if qa.pop < qb.pop: + return -1 + if qa.pop == qb.pop: + return 0 + return 1 + + +cdef qnode *get_active_set(qstate *qs): + cdef: + qnode *active = malloc(sizeof(qnode) * qs.ncolors) + uint target_idx = 0 + uint total = QNODES_COUNT + qs.dynnodes_total - qs.dynnodes_free + uint z, s + onode *o + + if active is NULL: + return active + + for z in range(total): + if target_idx >= qs.ncolors: + break + if qs.qnodes[z].pop: + memcpy(&active[target_idx], &qs.qnodes[z], sizeof(qnode)) + active[target_idx].qlink = z + target_idx += 1 + elif qs.qnodes[z].qlink: + o = &qs.onodes[qs.qnodes[z].qlink - 1] + for s in range(8): + if target_idx >= qs.ncolors: + break + if o.q[s]: + memcpy(&active[target_idx], o.q[s], sizeof(qnode)) + active[target_idx].qlink = o.q[s] - qs.qnodes + target_idx += 1 + qsort(active, qs.ncolors, sizeof(qnode), &qnode_cmp) + return active + + +cdef inline int find_next_lowest_chosen( + const qstate *qs, int z, int i, const qnode **hq +): + cdef: + const qnode *h + const onode *o + + while True: + h = &qs.qnodes[z] + if h.pop == 0 and h.qlink: + o = &qs.onodes[h.qlink - 1] + while i >= 0: + h = o.q[i] + if h and is_chosen(h): + hq[0] = h + return z * 8 + i + i += 1 + if i >= 8: + break + elif is_chosen(h): + hq[0] = h + return z * 8 + z += 1 + if z >= QNODES_COUNT: + return -1 + i = 0 + + +cdef inline void choose( + qstate *qs, + qnode *q, + int z, + int i, + int *hi, + int *lo, + const qnode **hq, + const qnode **lq, +): + cdef int cur + if not is_chosen(q): + if z * 8 > hi[0]: + hi[0] = find_next_lowest_chosen(qs, z, i, hq) + cur = z * 8 + (i if i >= 0 else 4) + if lo[0] == -1: + q.cidx = qidx(hq[0]) + elif hi[0] == -1 or cur - lo[0] < hi[0] - cur: + q.cidx = qidx(lq[0]) + else: + q.cidx = qidx(hq[0]) + else: + lq[0] = q + lo[0] = z * 8 + + +cdef inline int merge_color_table(qstate *qs): + if qs.ncolors == 0: + return 0 + + cdef qnode *qactive = get_active_set(qs) + if qactive is NULL: + return -1 + + cdef int cidx = 0, z + for z in range(qs.ncolors - 1, -1, -1): + if cidx == MAX_COLORS: + break + qs.qnodes[qactive[z].qlink].cidx = make_chosen(cidx) + cidx += 1 + free(qactive) + + if qs.ncolors <= MAX_COLORS: + return 0 + + cdef: + int lo = -1, hi = -1, i + const qnode *lq = NULL + const qnode *hq = NULL + const onode *o + for z in range(QNODES_COUNT): + if qs.qnodes[z].pop == 0: + if qs.qnodes[z].qlink == 0: + continue + o = &qs.onodes[qs.qnodes[z].qlink - 1] + for i in range(8): + if o.q[i]: + choose(qs, o.q[i], z, i, &hi, &lo, &hq, &lq) + else: + choose(qs, &qs.qnodes[z], z, -1, &hi, &lo, &hq, &lq) + qs.ncolors = MAX_COLORS + return 0 + + +cdef inline void load_color_table(const qstate *qs): + cdef: + int total = QNODES_COUNT + qs.dynnodes_total - qs.dynnodes_free + int loaded = 0, z + const qnode *q + + for z in range(total): + if loaded == qs.ncolors: + break + q = &qs.qnodes[z] + if is_chosen(q): + qs.table[3 * qidx(q)] = q.srgb[0] + qs.table[3 * qidx(q) + 1] = q.srgb[1] + qs.table[3 * qidx(q) + 2] = q.srgb[2] + loaded += 1 + + +cdef int alloc_qstate(qstate *qs): + qs.dynnodes_total = MAX_COLORS + qs.dynnodes_free = qs.dynnodes_total + qs.qnodes = malloc((QNODES_COUNT + qs.dynnodes_total) * sizeof(qnode)) + if qs.qnodes is NULL: + return -1 + + qs.onodes_total = qs.dynnodes_total // 8 + qs.onodes_free = qs.onodes_total + qs.onodes = malloc(qs.onodes_total * sizeof(onode)) + if qs.onodes is NULL: + free(qs.qnodes) + return -1 + + qs.table = malloc(3 * MAX_COLORS) + if qs.table is NULL: + free(qs.qnodes) + free(qs.onodes) + return -1 + + memset(qs.qnodes, 0, sizeof(qnode) * QNODES_COUNT) + qs.ncolors = 0 + return 0 + + +cdef void reset_qstate(qstate *qs): + memset(qs.qnodes, 0, sizeof(qnode) * QNODES_COUNT) + qs.dynnodes_free = qs.dynnodes_total + qs.onodes_free = qs.onodes_total + qs.ncolors = 0 + + +cdef void free_qstate(qstate *qs): + if qs is not NULL: + free(qs.qnodes) + free(qs.onodes) + free(qs.table) + + +cdef inline size_t sixel_count(size_t h, size_t w): + return (h + 5) // 6 * w + + +cdef inline size_t sixel_band_count(size_t h): + return sixel_count(h, 1) + + +cdef SixelMap *new_sixel_map(size_t h): + cdef SixelMap *sixel_map = malloc(sizeof(SixelMap)) + if sixel_map is NULL: + return NULL + sixel_map.nbands = sixel_band_count(h) + sixel_map.bands = malloc(sizeof(char**) * sixel_map.nbands) + if sixel_map.bands is NULL: + free(sixel_map) + return NULL + return sixel_map + + +cdef void color_band_free(char **color_band, size_t ncolors): + cdef size_t i + for i in range(ncolors): + if color_band[i] is not NULL: + free(color_band[i]) + free(color_band) + + +cdef void sixel_map_free(SixelMap *sixel_map, uint ncolors): + cdef size_t i + for i in range(sixel_map.nbands): + if sixel_map.bands[i] is not NULL: + color_band_free(sixel_map.bands[i], ncolors) + free(sixel_map.bands) + free(sixel_map) + + +cdef inline void write_rle(char *color, size_t *length, ssize_t rle, char rep): + if rle > 2: + length[0] += sprintf(&color[length[0]], "!%d", rle) + elif rle == 2: + color[length[0]] = rep + length[0] += 1 + if rle > 0: + color[length[0]] = rep + length[0] += 1 + color[length[0]] = 0 + + +cdef inline char *color_band_extend( + char *color_band, BandExtender *extender, size_t w, size_t x +): + if color_band is NULL: + color_band = malloc(w + 1) + if color_band is NULL: + return NULL + write_rle(color_band, &extender.length, extender.rle, extender.rep + 63) + cdef ssize_t clear_len = x - (extender.rle + extender.wrote) + write_rle(color_band, &extender.length, clear_len, 63) + return color_band + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef inline int build_sixel_band( + size_t n, + SixelMap *sixel_map, + qstate *qs, + uint8[:, :, ::1] stexture, + size_t oy, + size_t ox, + size_t h, + size_t w, + size_t *P2, +): + cdef: + BandExtender *extenders = malloc( + sizeof(BandExtender) * qs.ncolors + ) + char **color_bands + + if extenders is NULL: + return -1 + + sixel_map.bands[n] = malloc(sizeof(char*) * qs.ncolors) + color_bands = sixel_map.bands[n] + if color_bands is NULL: + free(extenders) + return -1 + + memset(color_bands, 0, sizeof(char*) * qs.ncolors) + memset(extenders, 0, sizeof(BandExtender) * qs.ncolors) + + cdef: + size_t ystart = n * 6, yend = min(ystart + 6, h), x, y, i + ActiveColor[6] active_colors + unsigned int cidx, nactive_colors, color + BandExtender *extender + + for x in range(w): + nactive_colors = 0 + for y in range(ystart, yend): + if not stexture[oy + y, ox + x, 3]: + P2[0] = 1 + continue + + cidx = find_color(qs, &stexture[oy + y, ox + x, 0]) + if cidx < 0: + return -1 + + for i in range(nactive_colors): + if active_colors[i].color == cidx: + active_colors[i].rep += 1 << (y - ystart) + break + else: + active_colors[nactive_colors].color = cidx + active_colors[nactive_colors].rep = 1 << (y - ystart) + nactive_colors += 1 + + for i in range(nactive_colors): + color = active_colors[i].color + extender = &extenders[color] + + if ( + extender.rep == active_colors[i].rep + and extender.rle + extender.wrote == x + ): + extender.rle += 1 + else: + color_bands[color] = color_band_extend( + color_bands[color], extender, w, x + ) + if color_bands[color] is NULL: + free(extenders) + return -1 + extender.rle = 1 + extender.wrote = x + extender.rep = active_colors[i].rep + + for color in range(qs.ncolors): + extender = &extenders[color] + if extender.rle == 0: + color_bands[color] = NULL + else: + color_bands[color] = color_band_extend( + color_bands[color], extender, w, w - 1 + ) + if color_bands[color] is NULL: + free(extenders) + return -1 + + free(extenders) + return 0 + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef int sixel( + fbuf *f, + qstate *qs, + const uint8[:, :, ::1] texture, + uint8[:, :, ::1] stexture, + unsigned int aspect_h, + unsigned int aspect_w, + size_t oy, + size_t ox, + size_t h, + size_t w, +): + reset_qstate(qs) + + cdef size_t y, x + for y in range(oy, oy + h): + for x in range(ox, ox + w): + if texture[y, x, 3]: + stexture[y, x, 0] = uint8_to_100(texture[y, x, 0]) + stexture[y, x, 1] = uint8_to_100(texture[y, x, 1]) + stexture[y, x, 2] = uint8_to_100(texture[y, x, 2]) + stexture[y, x, 3] = 1 + if insert_color(qs, &stexture[y, x, 0]): + return -1 + else: + stexture[y, x, 3] = 0 + if merge_color_table(qs): + return -1 + + load_color_table(qs) + + cdef: + size_t n, color, P2 = 0 + bint close_previous + SixelMap *sixel_map = new_sixel_map(h) + char **color_bands + uint8 *rgb = qs.table + + if sixel_map is NULL: + return -1 + + for n in range(sixel_map.nbands): + if build_sixel_band(n, sixel_map, qs, stexture, oy, ox, h, w, &P2) < 0: + sixel_map_free(sixel_map, qs.ncolors) + return -1 + + if fbuf_printf(f, "\x1bP;%d;q\"%d;%d;%d;%d", P2, aspect_h, aspect_w, w, h): + sixel_map_free(sixel_map, qs.ncolors) + return -1 + + for color in range(qs.ncolors): + if fbuf_printf(f, "#%d;2;%d;%d;%d", color, rgb[0], rgb[1], rgb[2]): + sixel_map_free(sixel_map, qs.ncolors) + return -1 + rgb += 3 + + for n in range(sixel_map.nbands): + close_previous = 0 + color_bands = sixel_map.bands[n] + for color in range(qs.ncolors): + if color_bands[color] is NULL: + continue + if close_previous: + if fbuf_putn(f, "$", 1): + sixel_map_free(sixel_map, qs.ncolors) + return -1 + else: + close_previous = 1 + if fbuf_printf(f, "#%d", color): + sixel_map_free(sixel_map, qs.ncolors) + return -1 + if fbuf_puts(f, color_bands[color]): + sixel_map_free(sixel_map, qs.ncolors) + return -1 + if fbuf_putn(f, "-", 1): + sixel_map_free(sixel_map, qs.ncolors) + return -1 + + f.len -= 1 # Remove last "-" + sixel_map_free(sixel_map, qs.ncolors) + if fbuf_putn(f, "\x1b\\", 2): + return -1 + + return 0 diff --git a/src/batgrl/app.py b/src/batgrl/app.py index 1abda23d..74e8b723 100644 --- a/src/batgrl/app.py +++ b/src/batgrl/app.py @@ -9,18 +9,19 @@ from time import perf_counter from typing import Any, Final +from ._rendering import terminal_render +from ._sixel import OctTree from .colors import DEFAULT_COLOR_THEME, Color, ColorTheme from .gadgets._root import _Root from .gadgets.behaviors.focusable import Focusable from .gadgets.behaviors.themable import Themable from .gadgets.gadget import Gadget +from .gadgets.graphics import _BLITTER_GEOMETRY, Graphics from .geometry import Point, Size -from .rendering import render_root -from .terminal import Vt100Terminal, app_mode, get_platform_terminal +from .terminal import Vt100Terminal, app_mode, get_platform_terminal, get_sixel_info from .terminal.events import ( ColorReportEvent, - CursorPositionResponseEvent, - DeviceAttributesReportEvent, + CursorPositionReportEvent, Event, FocusEvent, KeyEvent, @@ -47,9 +48,11 @@ class App(ABC): Parameters ---------- fg_color : Color | None, default: None - Foreground color of app. If not given, try to use terminal foreground. + Foreground color of the root gadget. If not given, the app will try to use the + terminal foreground. bg_color : Color | None, default: None - Background color of app. If not given, try to use terminal background. + Background color of the root gadget. If not given, the app will try to use the + terminal background. title : str | None, default: None The terminal's title. inline : bool, default: False @@ -68,9 +71,9 @@ class App(ABC): Attributes ---------- fg_color : Color | None - Foreground color of app. + Foreground color of the root gadget. bg_color : Color - Background color of app. + Background color of the root gadget. title : str | None The terminal's title. inline : bool @@ -85,6 +88,12 @@ class App(ABC): Duration in seconds between consecutive frame renders. redirect_stderr : Path | None Path where stderr is saved. + sixel_support : bool + Whether sixel is supported. + sixel_geometry : Size + Current sixel geometry. + sixel_aspect_ratio : Size + Current sixel aspect ratio. root : _Root | None Root of gadget tree. children : list[Gadget] @@ -92,6 +101,8 @@ class App(ABC): Methods ------- + set_sixel_aspect_ratio(aspect_ratio) + Set sixel aspect ratio. on_start() Coroutine scheduled when app is run. run() @@ -120,9 +131,9 @@ def __init__( self.root: _Root | None = None """Root of gadget tree (only set while app is running).""" self.fg_color = fg_color - """Foreground color of app.""" + """Foreground color of the root gadget.""" self.bg_color = bg_color - """Background color of app.""" + """Background color of the root gadget.""" self.title = title """The terminal's title.""" self._inline = inline @@ -139,15 +150,18 @@ def __init__( """Path where stderr is saved.""" self._terminal: Vt100Terminal | None = None """Platform-specific terminal (only set while app is running).""" + self._app_pos: Point = Point(0, 0) + """Position of app in terminal.""" self._exit_value: Any = None """Value set by ``exit(exit_value)`` and returned by ``run()``.""" - self._sixel_support: bool = False - """Whether terminal has sixel support.""" + self._octree: Final = OctTree() + """Used by renderer to quantize graphics.""" def __repr__(self): + bg_color = self.bg_color if self.bg_color is None else (*self.bg_color,) return ( f"{type(self).__name__}(\n" - f" bg_color={(*self.bg_color,)},\n" + f" bg_color={bg_color},\n" f" title={self.title!r},\n" f" inline={self.inline},\n" f" inline_height={self.inline_height},\n" @@ -182,7 +196,7 @@ def inline(self, inline: bool): else: self._terminal.erase_in_display() self._terminal.enter_alternate_screen() - self.root.pos = Point(0, 0) + self._app_pos = Point(0, 0) self.root.size = self._terminal.get_size() def _scroll_inline(self) -> None: @@ -191,8 +205,8 @@ def _scroll_inline(self) -> None: return height = min(self.inline_height, self._terminal.get_size().height) - self._terminal._out_buffer.append("\x0a" * height) # Feed lines (may scroll). - self._terminal._out_buffer.append(f"\x1b[{height}F") # Move cursor back up. + self._terminal._out_buffer.write(b"\x0a" * height) # Feed lines (may scroll). + self._terminal._out_buffer.write(b"\x1b[%dF" % height) # Move cursor back up. self._terminal.request_cursor_position_report() @property @@ -229,7 +243,7 @@ def color_theme(self, color_theme: ColorTheme): @property def fg_color(self) -> Color | None: """ - Foreground color of app. + Foreground color of the root gadget. If set to ``None``, the terminal foreground color will be queried. If the terminal reports the foreground color, then :attr:``fg_color`` will be updated @@ -252,7 +266,7 @@ def fg_color(self, fg_color: Color | None): @property def bg_color(self) -> Color | None: """ - Background color of app. + Background color of the root gadget. If set to ``None``, the terminal background color will be queried. If the terminal reports the background color, then :attr:``bg_color`` will be updated @@ -269,7 +283,46 @@ def bg_color(self, bg_color: Color | None): if bg_color is None: self._terminal.request_background_color() else: - self.root._cell["bg_color"] = bg_color + self.root.bg_color = bg_color + + @property + def sixel_support(self) -> bool: + """ + Whether sixel is supported. + + Will return ``False`` before app has run. + """ + return Graphics._sixel_support + + @property + def sixel_geometry(self) -> Size: + """Current sixel geometry.""" + return _BLITTER_GEOMETRY["sixel"] + + @property + def sixel_aspect_ratio(self) -> Size: + """Current sixel aspect ratio.""" + return Graphics._sixel_aspect_ratio + + def set_sixel_aspect_ratio(self, aspect_ratio: Size) -> None: + """ + Set sixel aspect ratio. + + Parameters + ---------- + aspect_ratio : Size + The desired aspect ratio. Aspect width must be 1 and aspect height must + divide sixel geometry height. + """ + h, w = aspect_ratio + if w != 1 or _BLITTER_GEOMETRY["sixel"].height % h: + raise ValueError(f"Unsupported aspect ratio: {aspect_ratio}.") + Graphics._sixel_aspect_ratio = Size(h, w) + if self.root is not None: + for gadget in self.root.walk_reverse(): + if hasattr(gadget, "blitter") and gadget.blitter == "sixel": + gadget.on_size() + self.root.on_size() @abstractmethod async def on_start(self): @@ -349,6 +402,8 @@ def determine_nclicks(mouse_event: MouseEvent) -> None: def event_handler(events: list[Event]) -> None: """Handle input events.""" + nonlocal last_size + for event in events: if isinstance(event, KeyEvent): if event == _CTRL_C: @@ -361,13 +416,13 @@ def event_handler(events: list[Event]) -> None: Focusable.focus_previous() elif isinstance(event, MouseEvent): determine_nclicks(event) + event.pos -= self._app_pos root.dispatch_mouse(event) elif isinstance(event, PasteEvent): root.dispatch_paste(event) elif isinstance(event, FocusEvent): root.dispatch_terminal_focus(event) elif isinstance(event, ResizeEvent): - nonlocal last_size if event.size == last_size: # Sometimes spurious resize events can appear such as when a # terminal first enables VT100 processing or when @@ -379,7 +434,7 @@ def event_handler(events: list[Event]) -> None: self._scroll_inline() else: root.size = event.size - elif isinstance(event, CursorPositionResponseEvent): + elif isinstance(event, CursorPositionReportEvent): if self.inline: height, width = last_size root.size = Size( @@ -388,24 +443,45 @@ def event_handler(events: list[Event]) -> None: # Needs to be manually set in case root.size hasn't changed. root._resized = True - - root.pos = event.pos + self._app_pos = event.pos elif isinstance(event, ColorReportEvent): if event.kind == "fg": self.fg_color = event.color else: self.bg_color = event.color - elif isinstance(event, DeviceAttributesReportEvent): - self._sixel_support = 4 in event.device_attributes async def auto_render(): """Render screen every ``render_interval`` seconds.""" while True: - render_root(root, terminal) await asyncio.sleep(self.render_interval) + if terminal.expect_dsr(): + continue + + resized = root._resized + root._render() + terminal_render( + resized, + terminal._out_buffer, + self._octree, + self._app_pos, + root.cells, + root._last_cells, + root._widths, + root.graphics, + root._last_graphics, + root._sgraphics, + root.kind, + root._last_kind, + Graphics._sixel_aspect_ratio, + ) + with app_mode(terminal, event_handler): - terminal.request_device_attributes() + ( + Graphics._sixel_support, + _BLITTER_GEOMETRY["sixel"], + ) = await get_sixel_info(terminal) + root.on_size() # Make cell and graphics arrays. if self.title is not None: terminal.set_title(self.title) @@ -418,7 +494,7 @@ async def auto_render(): if self.bg_color is None: terminal.request_background_color() else: - self.root._cell["bg_color"] = self.bg_color + self.root.bg_color = self.bg_color if self.inline: self._scroll_inline() @@ -480,9 +556,9 @@ def run_gadget_as_app( gadget : Gadget A gadget to run as an app. fg_color : Color | None, default: None - Foreground color of app. + Foreground color of the root gadget. bg_color : Color | None, default: None - Background color of app. + Background color of the root gadget. title : str | None, default: None The terminal's title. inline : bool, default: False diff --git a/src/batgrl/char_width.pyi b/src/batgrl/char_width.pyi new file mode 100644 index 00000000..72d2f96d --- /dev/null +++ b/src/batgrl/char_width.pyi @@ -0,0 +1,36 @@ +"""Functions for measuring column width of characters.""" + +__all__ = ["char_width", "str_width"] + +def char_width(char: str) -> int: + """ + Return the column width of a character. + + If the length of ``char`` is greater than 1, only the column width of the first + character is returned. If the length of ``char`` is 0, ``0`` is returned. + + Parameters + ---------- + char : str + A single character. + + Returns + ------- + int + The character column width. + """ + +def str_width(chars: str) -> int: + """ + Return the total column width of a string. + + Parameters + ---------- + chars : str + A string. + + Returns + ------- + int + The total column width of the string. + """ diff --git a/src/batgrl/char_width.pyx b/src/batgrl/char_width.pyx new file mode 100644 index 00000000..0c5a0308 --- /dev/null +++ b/src/batgrl/char_width.pyx @@ -0,0 +1,30 @@ +# distutils: language = c +# distutils: sources = src/batgrl/cwidth.c + +cdef extern from "cwidth.h": + int cwidth(Py_UCS4) + +cdef extern from "Python.h": + Py_ssize_t PyUnicode_GetLength(object) + void *PyUnicode_DATA(object) + int PyUnicode_KIND(object) + Py_UCS4 PyUnicode_READ(int, void*, Py_ssize_t) + Py_UCS4 PyUnicode_READ_CHAR(object, Py_ssize_t) + + +cpdef int char_width(str char): + if not PyUnicode_GetLength(char): + return 0 + return cwidth(PyUnicode_READ_CHAR(char, 0)) + + +cpdef int str_width(str chars): + cdef: + int width = 0 + Py_ssize_t length = PyUnicode_GetLength(chars), i + const void *chars_buffer = PyUnicode_DATA(chars) + int kind = PyUnicode_KIND(chars) + + for i in range(length): + width += cwidth(PyUnicode_READ(kind, chars_buffer, i)) + return width diff --git a/src/batgrl/cwidth.h b/src/batgrl/cwidth.h new file mode 100644 index 00000000..18e5255f --- /dev/null +++ b/src/batgrl/cwidth.h @@ -0,0 +1,2 @@ +#include +int cwidth(uint32_t); diff --git a/src/batgrl/emojis.py b/src/batgrl/emojis.py index 6d7b7ea6..be44de93 100644 --- a/src/batgrl/emojis.py +++ b/src/batgrl/emojis.py @@ -1,8 +1,6 @@ -""" -Markdown emoji codes. +"""Markdown emoji codes.""" -Source: https://github.com/markdown-templates/markdown-emojis -""" +# Source: https://github.com/markdown-templates/markdown-emojis EMOJIS = { "100": "💯", diff --git a/src/batgrl/figfont.py b/src/batgrl/figfont.py index ff5d3732..f4812d0d 100644 --- a/src/batgrl/figfont.py +++ b/src/batgrl/figfont.py @@ -30,7 +30,7 @@ import numpy as np from numpy.typing import NDArray -from .text_tools import char_width, str_width +from .char_width import char_width, str_width __all__ = ["FullLayout", "FIGFont"] @@ -131,7 +131,7 @@ class FIGFont: from_path(path) Load a FIGFont from a path. render_array(text) - Render text as ascii art into a 2D " NDArray[np.dtype(" NDArray[np.dtype(" Color: + """Background color of the app.""" + return self._bg_color + + @bg_color.setter + def bg_color(self, bg_color: Color): + self._bg_color = self._cell["bg_color"] = bg_color + + @property + def pos(self) -> Point: + return self._pos @property def is_transparent(self) -> Literal[False]: @@ -82,49 +116,41 @@ def app(self) -> App: return self._app def _set_regions(self) -> None: - """Recompute valid regions for all gadgets with invalid regions.""" - if not self._region_valid: - self._clipping_region = Region.from_rect(self.absolute_pos, self.size) - self._region = self._clipping_region + """Recompute all gadget regions.""" + self._region = Region.from_rect(self._pos, self.size) for child in self.walk(): - if not child._region_valid: - child._clipping_region = ( - child.parent._clipping_region - & Region.from_rect(child.absolute_pos, child.size) - if child._is_enabled and child._is_visible - else Region() - ) - - skip_valid_regions = True - for child in self.walk_reverse(): - if skip_valid_regions and child._region_valid: - continue - - if child._region_valid and child._root_region_before == self._region: - skip_valid_regions = True - continue + child._region = ( + child.parent._region & Region.from_rect(child.absolute_pos, child.size) + if child._is_enabled and child._is_visible + else Region() + ) - child._root_region_before = self._region - child._region = self._region & child._clipping_region - if not child._is_transparent: - self._region -= child._region + for child in self.walk_reverse(): + if child._is_enabled and child._is_visible: + child._region &= self._region + if not child._is_transparent: + self._region -= child._region - child._region_valid = True - skip_valid_regions = False + self._regions_valid = True + self._resized = False def _render(self): """Render gadget tree into :attr:``canvas``.""" with self._render_lock: - if not self._all_regions_valid: + if not self._regions_valid: self._set_regions() - self.canvas, self._last_canvas = self._last_canvas, self.canvas - self.canvas[:] = self._cell + self.cells, self._last_cells = self._last_cells, self.cells + self.graphics, self._last_graphics = self._last_graphics, self.graphics + self.kind, self._last_kind = self._last_kind, self.kind + + self.cells[:] = self._cell.view(_Cell) + self.graphics[:] = (*self.bg_color, 0) + self.kind[:] = 0 for child in self.walk(): - if child._is_enabled and child._is_visible: - child._render(self.canvas) + if not child._is_enabled or not child._is_visible: + continue - self._all_regions_valid = True - self._resized = False + child._render(self.cells, self.graphics, self.kind) diff --git a/src/batgrl/gadgets/animation.py b/src/batgrl/gadgets/animation.py index e0c70c1f..5ae529a2 100644 --- a/src/batgrl/gadgets/animation.py +++ b/src/batgrl/gadgets/animation.py @@ -8,37 +8,31 @@ import numpy as np from numpy.typing import NDArray -from .gadget import Cell, Gadget, Point, PosHint, Size, SizeHint, bindable, clamp -from .image import Image, Interpolation +from ..colors import TRANSPARENT, AColor +from ..texture_tools import read_texture, resize_texture +from .graphics import ( + Blitter, + Graphics, + Interpolation, + Point, + PosHint, + Size, + SizeHint, + scale_geometry, +) __all__ = ["Animation", "Interpolation", "Point", "Size"] -def _check_frame_durations( - frames: list[Image], frame_durations: float | Sequence[float] -) -> Sequence[float]: - """ - Raise `ValueError` if `frames` and `frame_durations` are incompatible, - else return a sequence of frame durations. - """ - if not isinstance(frame_durations, Sequence): - return [frame_durations] * len(frames) - - if len(frame_durations) != len(frames): - raise ValueError("number of frames doesn't match number of frame durations") - - return frame_durations - - -class Animation(Gadget): +class Animation(Graphics): r""" An animation gadget. Parameters ---------- path : Path | None, default: None - Path to directory of images for frames in the animation (loaded - in lexographical order of filenames). + Path to directory of images for frames in the animation (loaded in lexographical + order of filenames). frame_durations : float | Sequence[float], default: 1/12 Time each frame is displayed. If a sequence is provided, it's length should be equal to number of frames. @@ -46,10 +40,14 @@ class Animation(Gadget): Whether to restart animation after last frame. reverse : bool, default: False Whether to play animation in reverse. + default_color : AColor, default: AColor(0, 0, 0, 0) + Default texture color. alpha : float, default: 1.0 Transparency of gadget. interpolation : Interpolation, default: "linear" Interpolation used when gadget is resized. + blitter : Blitter, default: "half" + Determines how graphics are rendered. size : Size, default: Size(10, 10) Size of gadget. pos : Point, default: Point(0, 0) @@ -77,10 +75,16 @@ class Animation(Gadget): Whether to animation is restarted after last frame. reverse : bool Whether to animation is played in reverse. + texture : NDArray[np.uint8] + uint8 RGBA color array. + default_color : AColor + Default texture color. alpha : float Transparency of gadget. interpolation : Interpolation Interpolation used when gadget is resized. + blitter : Blitter + Determines how graphics are rendered. size : Size Size of gadget. height : int @@ -138,8 +142,10 @@ class Animation(Gadget): Stop the animation and reset current frame. from_textures(textures, ...) Create an :class:`Animation` from an iterable of uint8 RGBA numpy array. - from_images(images, ...) - Create an :class:`Animation` from an iterable of :class:`Image`. + to_png(path) + Write :attr:`texture` to provided path as a `png` image. + clear() + Fill texture with default color. apply_hints() Apply size and pos hints. to_local(point) @@ -197,8 +203,10 @@ def __init__( frame_durations: float | Sequence[float] = 1 / 12, loop: bool = True, reverse: bool = False, + default_color: AColor = TRANSPARENT, alpha: float = 1.0, interpolation: Interpolation = "linear", + blitter: Blitter = "half", size: Size = Size(10, 10), pos: Point = Point(0, 0), size_hint: SizeHint | None = None, @@ -207,21 +215,36 @@ def __init__( is_visible: bool = True, is_enabled: bool = True, ): - self.frames: list[Image] + self.frames: list[NDArray[np.uint8]] """Frames of the animation.""" + # Set in `on_size` + self._sized_frames: list[NDArray[np.uint8]] + """Resized frames of the animation.""" - if path is not None: + if path is None: + self.frames = [] + else: paths = sorted(path.iterdir(), key=lambda file: file.name) - self.frames = [ - Image(path=path, size=size, is_transparent=is_transparent) - for path in paths - ] - for frame in self.frames: - frame.parent = self + self.frames = [read_texture(path) for path in paths] + + self.frame_durations: Sequence[float] + """Time each frame is displayed.""" + + nframes = len(self.frames) + if isinstance(frame_durations, Sequence): + if len(frame_durations) != nframes: + raise ValueError( + "number of frames doesn't match number of frame durations" + ) + self.frame_durations = frame_durations else: - self.frames = [] + self.frame_durations = [frame_durations] * nframes super().__init__( + default_color=default_color, + alpha=alpha, + interpolation=interpolation, + blitter=blitter, size=size, pos=pos, size_hint=size_hint, @@ -231,51 +254,34 @@ def __init__( is_enabled=is_enabled, ) - self.frame_durations = _check_frame_durations(self.frames, frame_durations) - self.alpha = alpha - self.interpolation = interpolation self.loop = loop self.reverse = reverse self._i = len(self.frames) - 1 if self.reverse else 0 self._animation_task = None - def on_remove(self): + @property + def texture(self) -> NDArray[np.uint8]: + """uint8 RGBA color array.""" + if self._i < len(self.frames): + return self._sized_frames[self._i] + return self._texture + + @texture.setter + def texture(self, texture: NDArray[np.uint8]) -> None: + self._texture = texture + + def on_remove(self) -> None: """Pause animation.""" self.pause() super().on_remove() def on_size(self) -> None: """Update size of all frames on resize.""" - for frame in self.frames: - frame.size = self._size - - def on_transparency(self) -> None: - """Update gadget after transparency is enabled/disabled.""" - for frame in self.frames: - frame.is_transparent = self.is_transparent - - @property - def alpha(self) -> float: - """Transparency of gadget.""" - return self._alpha - - @alpha.setter - @bindable - def alpha(self, alpha: float): - self._alpha = clamp(float(alpha), 0.0, 1.0) - for frame in self.frames: - frame.alpha = alpha - - @property - def interpolation(self) -> Interpolation: - """Interpolation used when gadget is resized.""" - return self._interpolation - - @interpolation.setter - def interpolation(self, interpolation: Interpolation): - self._interpolation = interpolation - for frame in self.frames: - frame.interpolation = interpolation + size = scale_geometry(self._blitter, self._size) + self._texture = np.full((*size, 4), self.default_color, np.uint8) + self._sized_frames = [ + resize_texture(texture, size, self.interpolation) for texture in self.frames + ] async def _play_animation(self): while self.frames: @@ -288,12 +294,11 @@ async def _play_animation(self): self._i -= 1 if self._i < 0: self._i = len(self.frames) - 1 - if not self.loop: return else: self._i += 1 - if self._i == len(self.frames): + if self._i >= len(self.frames): self._i = 0 if not self.loop: @@ -318,24 +323,16 @@ def play(self) -> asyncio.Task: self._animation_task = asyncio.create_task(self._play_animation()) return self._animation_task - def pause(self): + def pause(self) -> None: """Pause animation.""" if self._animation_task is not None: self._animation_task.cancel() - def stop(self): + def stop(self) -> None: """Stop the animation and reset current frame.""" self.pause() self._i = len(self.frames) - 1 if self.reverse else 0 - def _render(self, canvas: NDArray[Cell]): - """Render visible region of gadget.""" - if self.frames: - self.frames[self._i]._region = self._region - self.frames[self._i]._render(canvas) - else: - super()._render(canvas) - @classmethod def from_textures( cls, @@ -344,8 +341,10 @@ def from_textures( frame_durations: float | Sequence[float] = 1 / 12, loop: bool = True, reverse: bool = False, + default_color: AColor = TRANSPARENT, alpha: float = 1.0, interpolation: Interpolation = "linear", + blitter: Blitter = "half", size: Size = Size(10, 10), pos: Point = Point(0, 0), size_hint: SizeHint | None = None, @@ -368,10 +367,14 @@ def from_textures( Whether to restart animation after last frame. reverse : bool, default: False Whether to play animation in reverse. + default_color : AColor, default: AColor(0, 0, 0, 0) + Default texture color. alpha : float, default: 1.0 Transparency of gadget. interpolation : Interpolation, default: "linear" Interpolation used when gadget is resized. + blitter : Blitter, default: "half" + Determines how graphics are rendered. size : Size, default: Size(10, 10) Size of gadget. pos : Point, default: Point(0, 0) @@ -394,113 +397,32 @@ def from_textures( Animation A new animation gadget. """ - animation = cls( - loop=loop, - reverse=reverse, - alpha=alpha, - interpolation=interpolation, - is_transparent=is_transparent, - size=size, - pos=pos, - size_hint=size_hint, - pos_hint=pos_hint, - is_visible=is_visible, - is_enabled=is_enabled, - ) - animation.frames = [ - Image.from_texture( - texture, - size=animation.size, - alpha=animation.alpha, - interpolation=animation.interpolation, - ) - for texture in textures - ] - for frame in animation.frames: - frame.parent = animation - animation.frame_durations = _check_frame_durations( - animation.frames, frame_durations - ) - return animation - - @classmethod - def from_images( - cls, - images: Iterable[Image], - *, - frame_durations: float | Sequence[float] = 1 / 12, - loop: bool = True, - reverse: bool = False, - alpha: float = 1.0, - interpolation: Interpolation = "linear", - size: Size = Size(10, 10), - pos: Point = Point(0, 0), - size_hint: SizeHint | None = None, - pos_hint: PosHint | None = None, - is_transparent: bool = True, - is_visible: bool = True, - is_enabled: bool = True, - ) -> Self: - """ - Create an :class:`Animation` from an iterable of :class:`Image`. - - Parameters - ---------- - images : Iterable[Image] - An iterable of images that will be the frames of the animation. - frame_durations : float | Sequence[float], default: 1/12 - Time each frame is displayed. If a sequence is provided, it's length should - be equal to number of frames. - loop : bool, default: True - Whether to restart animation after last frame. - reverse : bool, default: False - Whether to play animation in reverse. - alpha : float, default: 1.0 - Transparency of gadget. - interpolation : Interpolation, default: "linear" - Interpolation used when gadget is resized. - size : Size, default: Size(10, 10) - Size of gadget. - pos : Point, default: Point(0, 0) - Position of upper-left corner in parent. - size_hint : SizeHint | None, default: None - Size as a proportion of parent's height and width. - pos_hint : PosHint | None, default: None - Position as a proportion of parent's height and width. - is_transparent : bool, default: True - Whether gadget is transparent. - is_visible : bool, default: True - Whether gadget is visible. Gadget will still receive input events if not - visible. - is_enabled : bool, default: True - Whether gadget is enabled. A disabled gadget is not painted and doesn't - receive input events. + frames = list(textures) + nframes = len(frames) + if isinstance(frame_durations, Sequence): + if len(frame_durations) != nframes: + raise ValueError( + "number of frames doesn't match number of frame durations" + ) + else: + frame_durations = [frame_durations] * nframes - Returns - ------- - Animation - A new animation gadget. - """ animation = cls( loop=loop, reverse=reverse, + default_color=default_color, alpha=alpha, interpolation=interpolation, - is_transparent=is_transparent, + blitter=blitter, size=size, pos=pos, size_hint=size_hint, pos_hint=pos_hint, + is_transparent=is_transparent, is_visible=is_visible, is_enabled=is_enabled, ) - animation.frames = list(images) - for image in animation.frames: - image.interpolation = animation.interpolation - image.size = animation.size - image.alpha = animation.alpha - image.parent = animation - animation.frame_durations = _check_frame_durations( - animation.frames, frame_durations - ) + animation.frames = frames + animation.frame_durations = frame_durations + animation.on_size() return animation diff --git a/src/batgrl/gadgets/bar_chart.py b/src/batgrl/gadgets/bar_chart.py index 1368322e..e7de1799 100644 --- a/src/batgrl/gadgets/bar_chart.py +++ b/src/batgrl/gadgets/bar_chart.py @@ -2,8 +2,9 @@ from numbers import Real +from ..char_width import str_width from ..colors import DEFAULT_PRIMARY_BG, DEFAULT_PRIMARY_FG, Color, rainbow_gradient -from ..text_tools import add_text, smooth_vertical_bar, str_width +from ..text_tools import add_text, smooth_vertical_bar from .gadget import Gadget, Point, PosHint, Size, SizeHint, lerp from .pane import Pane from .scroll_view import ScrollView diff --git a/src/batgrl/gadgets/box_image.py b/src/batgrl/gadgets/box_image.py deleted file mode 100644 index 7491452a..00000000 --- a/src/batgrl/gadgets/box_image.py +++ /dev/null @@ -1,261 +0,0 @@ -"""An image painted with box unicode characters.""" - -from pathlib import Path - -import cv2 -import numpy as np - -from ..text_tools import binary_to_box -from ..texture_tools import Interpolation, resize_texture -from .gadget import Gadget, Point, PosHint, Size, SizeHint -from .text import Text - -__all__ = ["BoxImage", "Interpolation", "Point", "Size"] - - -class BoxImage(Gadget): - r""" - An image painted with box unicode characters. - - Parameters - ---------- - path : pathlib.Path - Path to image. - alpha : float, default: 1.0 - Transparency of gadget. - interpolation : Interpolation, default: "linear" - Interpolation used when gadget is resized. - size : Size, default: Size(10, 10) - Size of gadget. - pos : Point, default: Point(0, 0) - Position of upper-left corner in parent. - size_hint : SizeHint | None, default: None - Size as a proportion of parent's height and width. - pos_hint : PosHint | None, default: None - Position as a proportion of parent's height and width. - is_transparent : bool, default: False - Whether gadget is transparent. - is_visible : bool, default: True - Whether gadget is visible. Gadget will still receive input events if not - visible. - is_enabled : bool, default: True - Whether gadget is enabled. A disabled gadget is not painted and doesn't receive - input events. - - Attributes - ---------- - path : pathlib.Path - Path to image. - alpha : float - Transparency of gadget. - interpolation : Interpolation - Interpolation used when gadget is resized. - size : Size - Size of gadget. - height : int - Height of gadget. - rows : int - Alias for :attr:`height`. - width : int - Width of gadget. - columns : int - Alias for :attr:`width`. - pos : Point - Position of upper-left corner. - top : int - y-coordinate of top of gadget. - y : int - y-coordinate of top of gadget. - left : int - x-coordinate of left side of gadget. - x : int - x-coordinate of left side of gadget. - bottom : int - y-coordinate of bottom of gadget. - right : int - x-coordinate of right side of gadget. - center : Point - Position of center of gadget. - absolute_pos : Point - Absolute position on screen. - size_hint : SizeHint - Size as a proportion of parent's height and width. - pos_hint : PosHint - Position as a proportion of parent's height and width. - parent: Gadget | None - Parent gadget. - children : list[Gadget] - Children gadgets. - is_transparent : bool - Whether gadget is transparent. - is_visible : bool - Whether gadget is visible. - is_enabled : bool - Whether gadget is enabled. - root : Gadget | None - If gadget is in gadget tree, return the root gadget. - app : App - The running app. - - Methods - ------- - apply_hints() - Apply size and pos hints. - to_local(point) - Convert point in absolute coordinates to local coordinates. - collides_point(point) - Return true if point collides with visible portion of gadget. - collides_gadget(other) - Return true if other is within gadget's bounding box. - pull_to_front() - Move to end of gadget stack so gadget is drawn last. - walk() - Yield all descendents of this gadget (preorder traversal). - walk_reverse() - Yield all descendents of this gadget (reverse postorder traversal). - ancestors() - Yield all ancestors of this gadget. - add_gadget(gadget) - Add a child gadget. - add_gadgets(\*gadgets) - Add multiple child gadgets. - remove_gadget(gadget) - Remove a child gadget. - prolicide() - Recursively remove all children. - destroy() - Remove this gadget and recursively remove all its children. - bind(prop, callback) - Bind `callback` to a gadget property. - unbind(uid) - Unbind a callback from a gadget property. - tween(...) - Sequentially update gadget properties over time. - on_size() - Update gadget after a resize. - on_transparency() - Update gadget after transparency is enabled/disabled. - on_add() - Update gadget after being added to the gadget tree. - on_remove() - Update gadget after being removed from the gadget tree. - on_key(key_event) - Handle a key press event. - on_mouse(mouse_event) - Handle a mouse event. - on_paste(paste_event) - Handle a paste event. - on_terminal_focus(focus_event) - Handle a focus event. - """ - - def __init__( - self, - *, - path: Path, - alpha: float = 1.0, - interpolation: Interpolation = "linear", - size: Size = Size(10, 10), - pos: Point = Point(0, 0), - size_hint: SizeHint | None = None, - pos_hint: PosHint | None = None, - is_transparent: bool = False, - is_visible: bool = True, - is_enabled: bool = True, - ): - self._image = Text(is_transparent=is_transparent) - super().__init__( - size=size, - pos=pos, - size_hint=size_hint, - pos_hint=pos_hint, - is_transparent=is_transparent, - is_visible=is_visible, - is_enabled=is_enabled, - ) - self.add_gadget(self._image) - self.alpha = alpha - self.interpolation = interpolation - self.path = path - - @property - def alpha(self) -> float: - """Transparency of gadget.""" - return self._image.alpha - - @alpha.setter - def alpha(self, alpha: float): - self._image.alpha = alpha - - @property - def interpolation(self) -> Interpolation: - """Interpolation used when gadget is resized.""" - return self._interpolation - - @interpolation.setter - def interpolation(self, interpolation: Interpolation): - if interpolation not in Interpolation.__args__: - raise TypeError(f"{interpolation} is not a valid interpolation type.") - self._interpolation = interpolation - - @property - def path(self) -> Path | None: - """Path to image.""" - return self._path - - @path.setter - def path(self, path: Path | None): - self._path: Path | None = path - - if path is None: - self._otexture = np.zeros((1, 1, 3), dtype=np.uint8) - else: - self._otexture = cv2.imread(str(path.absolute()), cv2.IMREAD_COLOR) - self._load_texture() - - def on_transparency(self) -> None: - """Update gadget after transparency is enabled/disabled.""" - self._image.is_transparent = self.is_transparent - - def on_size(self): - """Remake canvas.""" - self._image.size = self.size - self._load_texture() - - def _load_texture(self): - h, w = self.size - if h == 0 or w == 0: - return - - canvas = self._image.canvas - img_bgr = resize_texture(self._otexture, (2 * h, 2 * w), self.interpolation) - img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) - img_hls = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HLS) - - rgb_sectioned = np.swapaxes(img_rgb.reshape(h, 2, w, 2, 3), 1, 2) - hls_sectioned = np.swapaxes(img_hls.reshape(h, 2, w, 2, 3), 1, 2) - - # First, find the average lightness of each 2x2 section of the image - # (`average_lightness`). Boxes are placed where the lightness is greater than - # `average_lightness`. The background color will be the average of the colors - # darker than `average_lightness`. The foreground color will be the average of - # the colors lighter than `average_lightness`. - - lightness = hls_sectioned[..., 1] - average_lightness = np.average(lightness, axis=(2, 3)) - where_boxes = lightness > average_lightness[..., None, None] - - canvas["char"] = binary_to_box(where_boxes) - - nboxes = where_boxes.sum(axis=(2, 3)) - nboxes_neg = 4 - nboxes - nboxes[nboxes == 0] = 1 - nboxes_neg[nboxes_neg == 0] = 1 - - foreground = rgb_sectioned.copy() - foreground[~where_boxes] = 0 - canvas["fg_color"] = foreground.sum(axis=(2, 3)) / nboxes[..., None] - - background = rgb_sectioned.copy() - background[where_boxes] = 0 - canvas["bg_color"] = background.sum(axis=(2, 3)) / nboxes_neg[..., None] diff --git a/src/batgrl/gadgets/braille_image.py b/src/batgrl/gadgets/braille_image.py deleted file mode 100644 index f0e8c17e..00000000 --- a/src/batgrl/gadgets/braille_image.py +++ /dev/null @@ -1,261 +0,0 @@ -"""An image painted with braille unicode characters.""" - -from pathlib import Path - -import cv2 -import numpy as np - -from ..text_tools import binary_to_braille -from ..texture_tools import Interpolation, resize_texture -from .gadget import Gadget, Point, PosHint, Size, SizeHint -from .text import Text - -__all__ = ["BrailleImage", "Interpolation", "Point", "Size"] - - -class BrailleImage(Gadget): - r""" - An image painted with braille unicode characters. - - Parameters - ---------- - path : pathlib.Path - Path to image. - alpha : float, default: 1.0 - Transparency of gadget. - interpolation : Interpolation, default: "linear" - Interpolation used when gadget is resized. - size : Size, default: Size(10, 10) - Size of gadget. - pos : Point, default: Point(0, 0) - Position of upper-left corner in parent. - size_hint : SizeHint | None, default: None - Size as a proportion of parent's height and width. - pos_hint : PosHint | None, default: None - Position as a proportion of parent's height and width. - is_transparent : bool, default: False - Whether gadget is transparent. - is_visible : bool, default: True - Whether gadget is visible. Gadget will still receive input events if not - visible. - is_enabled : bool, default: True - Whether gadget is enabled. A disabled gadget is not painted and doesn't receive - input events. - - Attributes - ---------- - path : pathlib.Path - Path to image. - alpha : float - Transparency of gadget. - interpolation : Interpolation - Interpolation used when gadget is resized. - size : Size - Size of gadget. - height : int - Height of gadget. - rows : int - Alias for :attr:`height`. - width : int - Width of gadget. - columns : int - Alias for :attr:`width`. - pos : Point - Position of upper-left corner. - top : int - y-coordinate of top of gadget. - y : int - y-coordinate of top of gadget. - left : int - x-coordinate of left side of gadget. - x : int - x-coordinate of left side of gadget. - bottom : int - y-coordinate of bottom of gadget. - right : int - x-coordinate of right side of gadget. - center : Point - Position of center of gadget. - absolute_pos : Point - Absolute position on screen. - size_hint : SizeHint - Size as a proportion of parent's height and width. - pos_hint : PosHint - Position as a proportion of parent's height and width. - parent: Gadget | None - Parent gadget. - children : list[Gadget] - Children gadgets. - is_transparent : bool - Whether gadget is transparent. - is_visible : bool - Whether gadget is visible. - is_enabled : bool - Whether gadget is enabled. - root : Gadget | None - If gadget is in gadget tree, return the root gadget. - app : App - The running app. - - Methods - ------- - apply_hints() - Apply size and pos hints. - to_local(point) - Convert point in absolute coordinates to local coordinates. - collides_point(point) - Return true if point collides with visible portion of gadget. - collides_gadget(other) - Return true if other is within gadget's bounding box. - pull_to_front() - Move to end of gadget stack so gadget is drawn last. - walk() - Yield all descendents of this gadget (preorder traversal). - walk_reverse() - Yield all descendents of this gadget (reverse postorder traversal). - ancestors() - Yield all ancestors of this gadget. - add_gadget(gadget) - Add a child gadget. - add_gadgets(\*gadgets) - Add multiple child gadgets. - remove_gadget(gadget) - Remove a child gadget. - prolicide() - Recursively remove all children. - destroy() - Remove this gadget and recursively remove all its children. - bind(prop, callback) - Bind `callback` to a gadget property. - unbind(uid) - Unbind a callback from a gadget property. - tween(...) - Sequentially update gadget properties over time. - on_size() - Update gadget after a resize. - on_transparency() - Update gadget after transparency is enabled/disabled. - on_add() - Update gadget after being added to the gadget tree. - on_remove() - Update gadget after being removed from the gadget tree. - on_key(key_event) - Handle a key press event. - on_mouse(mouse_event) - Handle a mouse event. - on_paste(paste_event) - Handle a paste event. - on_terminal_focus(focus_event) - Handle a focus event. - """ - - def __init__( - self, - *, - path: Path, - alpha: float = 1.0, - interpolation: Interpolation = "linear", - size: Size = Size(10, 10), - pos: Point = Point(0, 0), - size_hint: SizeHint | None = None, - pos_hint: PosHint | None = None, - is_transparent: bool = False, - is_visible: bool = True, - is_enabled: bool = True, - ): - self._image = Text(is_transparent=is_transparent) - super().__init__( - size=size, - pos=pos, - size_hint=size_hint, - pos_hint=pos_hint, - is_transparent=is_transparent, - is_visible=is_visible, - is_enabled=is_enabled, - ) - self.add_gadget(self._image) - self.alpha = alpha - self.interpolation = interpolation - self.path = path - - @property - def alpha(self) -> float: - """Transparency of gadget.""" - return self._image.alpha - - @alpha.setter - def alpha(self, alpha: float): - self._image.alpha = alpha - - @property - def interpolation(self) -> Interpolation: - """Interpolation used when gadget is resized.""" - return self._interpolation - - @interpolation.setter - def interpolation(self, interpolation: Interpolation): - if interpolation not in Interpolation.__args__: - raise TypeError(f"{interpolation} is not a valid interpolation type.") - self._interpolation = interpolation - - @property - def path(self) -> Path | None: - """Path to image.""" - return self._path - - @path.setter - def path(self, path: Path | None): - self._path: Path | None = path - - if path is None: - self._otexture = np.zeros((1, 1, 3), dtype=np.uint8) - else: - self._otexture = cv2.imread(str(path.absolute()), cv2.IMREAD_COLOR) - self._load_texture() - - def on_transparency(self) -> None: - """Update gadget after transparency is enabled/disabled.""" - self._image.is_transparent = self.is_transparent - - def on_size(self): - """Resize canvas and colors arrays.""" - self._image.size = self.size - self._load_texture() - - def _load_texture(self): - h, w = self.size - if h == 0 or w == 0: - return - - canvas = self._image.canvas - img_bgr = resize_texture(self._otexture, (4 * h, 2 * w), self.interpolation) - img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) - img_hls = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HLS) - - rgb_sectioned = np.swapaxes(img_rgb.reshape(h, 4, w, 2, 3), 1, 2) - hls_sectioned = np.swapaxes(img_hls.reshape(h, 4, w, 2, 3), 1, 2) - - # First, find the average lightness of each 2x2 section of the image - # (`average_lightness`). Boxes are placed where the lightness is greater than - # `average_lightness`. The background color will be the average of the colors - # darker than `average_lightness`. The foreground color will be the average of - # the colors lighter than `average_lightness`. - - lightness = hls_sectioned[..., 1] - average_lightness = np.average(lightness, axis=(2, 3)) - where_dots = lightness > average_lightness[..., None, None] - - canvas["char"] = binary_to_braille(where_dots) - - ndots = where_dots.sum(axis=(2, 3)) - ndots_neg = 8 - ndots - ndots[ndots == 0] = 1 - ndots_neg[ndots_neg == 0] = 1 - - foreground = rgb_sectioned.copy() - foreground[~where_dots] = 0 - canvas["fg_color"] = foreground.sum(axis=(2, 3)) / ndots[..., None] - - background = rgb_sectioned.copy() - background[where_dots] = 0 - canvas["bg_color"] = background.sum(axis=(2, 3)) / ndots_neg[..., None] diff --git a/src/batgrl/gadgets/braille_video.py b/src/batgrl/gadgets/braille_video.py deleted file mode 100644 index 4d8ee63b..00000000 --- a/src/batgrl/gadgets/braille_video.py +++ /dev/null @@ -1,433 +0,0 @@ -"""A video gadget that renders to braille unicode characters in grayscale.""" - -import asyncio -import atexit -import time -import warnings -from pathlib import Path -from platform import uname - -import cv2 -import numpy as np - -from ..colors import BLACK, WHITE, Color -from ..geometry import lerp -from ..text_tools import binary_to_braille -from ..texture_tools import Interpolation, resize_texture -from .gadget import Gadget, Point, PosHint, Size, SizeHint -from .text import Text - -__all__ = ["BrailleVideo", "Point", "Size"] - -_IS_WSL: bool = uname().system == "Linux" and uname().release.endswith("Microsoft") - - -class BrailleVideo(Gadget): - r""" - A video gadget that renders to braille unicode characters in grayscale. - - Parameters - ---------- - source : pathlib.Path | str | int - A path to video, URL to video stream, or video capturing device (by index). - Trying to open a video capturing device on WSL will issue a warning. - fg_color : Color, default: WHITE - Foreground color of video. - bg_color : Color, default: BLACK - Background color of video. - loop : bool, default: True - Whether to restart video after last frame. - gray_threshold : int, default: 127 - Pixel values over this threshold in the source video will be rendered. - enable_shading : bool, default: False - Whether foreground colors are shaded. - invert_colors : bool, default: False - Invert the colors in the source before rendering. - alpha : float, default: 1.0 - Transparency of gadget. - interpolation : Interpolation, default: "linear" - Interpolation used when gadget is resized. - size : Size, default: Size(10, 10) - Size of gadget. - pos : Point, default: Point(0, 0) - Position of upper-left corner in parent. - size_hint : SizeHint | None, default: None - Size as a proportion of parent's height and width. - pos_hint : PosHint | None, default: None - Position as a proportion of parent's height and width. - is_transparent : bool, default: False - Whether gadget is transparent. - is_visible : bool, default: True - Whether gadget is visible. Gadget will still receive input events if not - visible. - is_enabled : bool, default: True - Whether gadget is enabled. A disabled gadget is not painted and doesn't receive - input events. - - Attributes - ---------- - source : Path | str | int - A path, URL, or capturing device (by index) of the video. - fg_color : Color - Foreground color of video. - bg_color : Color - Background color of video. - loop : bool - Whether to restart video after last frame. - gray_threshold : int - Pixel values over this threshold in the source video will be rendered. - enable_shading : bool - Whether foreground colors are shaded. - invert_colors : bool - Whether colors in the source are inverted before video is rendered. - alpha : float - Transparency of gadget. - interpolation : Interpolation - Interpolation used when gadget is resized. - is_device : bool - Whether video is from a video capturing device. - size : Size - Size of gadget. - height : int - Height of gadget. - rows : int - Alias for :attr:`height`. - width : int - Width of gadget. - columns : int - Alias for :attr:`width`. - pos : Point - Position of upper-left corner. - top : int - y-coordinate of top of gadget. - y : int - y-coordinate of top of gadget. - left : int - x-coordinate of left side of gadget. - x : int - x-coordinate of left side of gadget. - bottom : int - y-coordinate of bottom of gadget. - right : int - x-coordinate of right side of gadget. - center : Point - Position of center of gadget. - absolute_pos : Point - Absolute position on screen. - size_hint : SizeHint - Size as a proportion of parent's height and width. - pos_hint : PosHint - Position as a proportion of parent's height and width. - parent: Gadget | None - Parent gadget. - children : list[Gadget] - Children gadgets. - is_transparent : bool - Whether gadget is transparent. - is_visible : bool - Whether gadget is visible. - is_enabled : bool - Whether gadget is enabled. - root : Gadget | None - If gadget is in gadget tree, return the root gadget. - app : App - The running app. - - Methods - ------- - play() - Play the video. Returns a task. - pause() - Pause the video. - seek() - Seek to certain time (in seconds) in the video. - stop() - Stop the video. - apply_hints() - Apply size and pos hints. - to_local(point) - Convert point in absolute coordinates to local coordinates. - collides_point(point) - Return true if point collides with visible portion of gadget. - collides_gadget(other) - Return true if other is within gadget's bounding box. - pull_to_front() - Move to end of gadget stack so gadget is drawn last. - walk() - Yield all descendents of this gadget (preorder traversal). - walk_reverse() - Yield all descendents of this gadget (reverse postorder traversal). - ancestors() - Yield all ancestors of this gadget. - add_gadget(gadget) - Add a child gadget. - add_gadgets(\*gadgets) - Add multiple child gadgets. - remove_gadget(gadget) - Remove a child gadget. - prolicide() - Recursively remove all children. - destroy() - Remove this gadget and recursively remove all its children. - bind(prop, callback) - Bind `callback` to a gadget property. - unbind(uid) - Unbind a callback from a gadget property. - tween(...) - Sequentially update gadget properties over time. - on_size() - Update gadget after a resize. - on_transparency() - Update gadget after transparency is enabled/disabled. - on_add() - Update gadget after being added to the gadget tree. - on_remove() - Update gadget after being removed from the gadget tree. - on_key(key_event) - Handle a key press event. - on_mouse(mouse_event) - Handle a mouse event. - on_paste(paste_event) - Handle a paste event. - on_terminal_focus(focus_event) - Handle a focus event. - """ - - def __init__( - self, - *, - source: Path | str | int, - fg_color: Color = WHITE, - bg_color: Color = BLACK, - loop: bool = True, - gray_threshold: int = 127, - enable_shading: bool = False, - invert_colors: bool = False, - alpha: float = 1.0, - interpolation: Interpolation = "linear", - size: Size = Size(10, 10), - pos: Point = Point(0, 0), - size_hint: SizeHint | None = None, - pos_hint: PosHint | None = None, - is_transparent: bool = False, - is_visible: bool = True, - is_enabled: bool = True, - ): - self._video = Text(is_transparent=is_transparent) - super().__init__( - size=size, - pos=pos, - size_hint=size_hint, - pos_hint=pos_hint, - is_transparent=is_transparent, - is_visible=is_visible, - is_enabled=is_enabled, - ) - self._current_frame = None - self._resource = None - self._video_task = None - self.add_gadget(self._video) - self.source = source - self.fg_color = fg_color - self.bg_color = bg_color - self.loop = loop - self.gray_threshold = gray_threshold - self.enable_shading = enable_shading - self.invert_colors = invert_colors - self.alpha = alpha - self.interpolation = interpolation - - @property - def source(self) -> Path | str | int: - """A path, URL, or capturing device (by index) of the video.""" - return self._source - - @source.setter - def source(self, source: Path | str | int): - self.pause() - self._release_resource() - if isinstance(source, Path) and not source.exists(): - raise FileNotFoundError(str(source)) - self._source = source - self._load_resource() - - @property - def fg_color(self) -> Color: - """Foreground color of video.""" - return self._video.default_fg_color - - @fg_color.setter - def fg_color(self, fg_color: Color): - self._video.default_fg_color = fg_color - self._paint_frame() - - @property - def bg_color(self) -> Color: - """Background color of video.""" - return self._video.default_bg_color - - @bg_color.setter - def bg_color(self, bg_color: Color): - self._video.default_bg_color = bg_color - self._video.canvas["bg_color"] = bg_color - self._paint_frame() - - @property - def alpha(self) -> float: - """Transparency of gadget.""" - return self._video.alpha - - @alpha.setter - def alpha(self, alpha: float): - self._video.alpha = alpha - - @property - def interpolation(self) -> Interpolation: - """Interpolation used when gadget is resized.""" - return self._interpolation - - @interpolation.setter - def interpolation(self, interpolation: Interpolation): - if interpolation not in Interpolation.__args__: - raise TypeError(f"{interpolation} is not a valid interpolation type.") - self._interpolation = interpolation - - def on_transparency(self) -> None: - """Update gadget after transparency is enabled/disabled.""" - self._video.is_transparent = self.is_transparent - - @property - def is_device(self): - """Return true if source is a video capturing device.""" - return isinstance(self._source, int) - - def _load_resource(self): - source = self.source - - if _IS_WSL and self.is_device: - # Because WSL doesn't support most USB devices (yet?), and trying to open - # one with cv2 will pollute the terminal with cv2 errors, we don't attempt - # to open a device in this case and instead issue a warning. - warnings.warn("device not available on WSL") - self._resource = None - return - - if isinstance(source, Path): - source = str(source.absolute()) - - self._resource = cv2.VideoCapture(source) - atexit.register(self._resource.release) - - def _release_resource(self): - if self._resource is not None: - self._resource.release() - atexit.unregister(self._resource.release) - self._resource = None - self._current_frame = None - self._video.clear() - - def _paint_frame(self): - h, w = self.size - if self._current_frame is None or h == 0 or w == 0: - return - - upscaled = ( - resize_texture(self._current_frame, (4 * h, 2 * w), self.interpolation) - > self.gray_threshold - ) - sectioned = np.swapaxes(upscaled.reshape(h, 4, w, 2), 1, 2) - self._video.canvas["char"] = binary_to_braille(sectioned) - self._color_frame() - - def _color_frame(self): - h, w = self.size - if self._current_frame is None or h == 0 or w == 0: - return - - if self.enable_shading: - normals = ( - resize_texture(self._current_frame, self.size, self.interpolation) / 255 - ) - shades = lerp(self.bg_color, self.fg_color, normals[..., None]) - self._video.canvas["fg_color"] = shades.astype(np.uint8) - else: - self._video.canvas["fg_color"] = self.fg_color - - def _time_delta(self) -> float: - return time.perf_counter() - self._resource.get(cv2.CAP_PROP_POS_MSEC) / 1000 - - async def _play_video(self): - if self._resource is None: - return - - self._start_time = self._time_delta() - - while True: - if not self._resource.grab(): - if self.loop: - self.seek(0) - else: - self._current_frame = None - self._video.clear() - return - - if self.is_device: - seconds_ahead = 0 - elif (seconds_ahead := self._start_time - self._time_delta()) < 0: - continue - - await asyncio.sleep(seconds_ahead) - - _, frame = self._resource.retrieve() - self._current_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) - if self.invert_colors: - self._current_frame = 255 - self._current_frame - - self._paint_frame() - - def on_size(self): - """Resize canvas and colors arrays.""" - self._video.size = self.size - self._paint_frame() - - def on_remove(self): - """Pause video and release resource.""" - super().on_remove() - self.pause() - self._release_resource() - - def pause(self): - """Pause video.""" - if self._video_task is not None: - self._video_task.cancel() - - def play(self) -> asyncio.Task: - """ - Play video. - - Returns - ------- - asyncio.Task - The task that plays the video. - """ - self.pause() - - if self._resource is None: - self._load_resource() - - self._video_task = asyncio.create_task(self._play_video()) - return self._video_task - - def seek(self, time: float): - """If supported, seek to certain time (in seconds) in the video.""" - if self._resource is not None and not self.is_device: - self._resource.set(cv2.CAP_PROP_POS_MSEC, time * 1000) - self._resource.grab() - self._start_time = self._time_delta() - - def stop(self): - """Stop video.""" - self.pause() - self.seek(0) - self._current_frame = None - self._video.clear() diff --git a/src/batgrl/gadgets/color_picker.py b/src/batgrl/gadgets/color_picker.py index e487c1a4..169cc028 100644 --- a/src/batgrl/gadgets/color_picker.py +++ b/src/batgrl/gadgets/color_picker.py @@ -3,8 +3,6 @@ from collections.abc import Callable from itertools import pairwise -import numpy as np - from ..colors import ( ABLACK, ABLUE, @@ -24,7 +22,7 @@ from .behaviors.themable import Themable from .button import Button from .gadget import Gadget, Point, PosHint, Size, SizeHint -from .graphics import Graphics +from .graphics import Graphics, scale_geometry from .pane import Pane from .text import Text, new_cell @@ -35,33 +33,32 @@ class _ShadeSelector(Grabbable, Graphics): def __init__(self, color_swatch, label, **kwargs): - super().__init__(**kwargs) - - self._shade_indicator = Text(size=(1, 1), is_transparent=True, default_cell="○") self._shade_hint = 0.0, 1.0 - self.add_gadget(self._shade_indicator) - + self._shade_indicator = Text(size=(1, 1), is_transparent=True, default_cell="○") + self.hue = ARED self.color_swatch = color_swatch self.label = label + + super().__init__(**kwargs) + self.add_gadget(self._shade_indicator) self.update_hue(ARED) def on_size(self): + super().on_size() h, w = self._size hh, wh = self._shade_hint - self.texture = np.zeros((h * 2, w, 4), dtype=np.uint8) self._shade_indicator.pos = round((h - 1) * hh), round((w - 1) * wh) - self.update_hue(self.hue) def update_hue(self, hue: AColor): self.hue = hue - h, w = self._size + h, w = scale_geometry(self._blitter, self._size) if w == 0: return - left_side = gradient(AWHITE, ABLACK, 2 * h) - right_side = gradient(hue, ABLACK, 2 * h) + left_side = gradient(AWHITE, ABLACK, h) + right_side = gradient(hue, ABLACK, h) for row, left, right in zip(self.texture, left_side, right_side): row[:] = gradient(left, right, w) @@ -69,12 +66,9 @@ def update_hue(self, hue: AColor): self.update_swatch_label() def update_swatch_label(self): - y, x = self._shade_indicator.pos - - r, g, b = self.texture[y * 2, x, :3].tolist() - + y, x = scale_geometry(self._blitter, self._shade_indicator.pos) + r, g, b = self.texture[y, x, :3].tolist() self.color_swatch.bg_color = r, g, b - self.label.add_str(hex(r * 2**16 + g * 2**8 + b)[2:], pos=(1, 1)) self.label.add_str(f"R: {r:>3}", pos=(3, 1)) self.label.add_str(f"G: {g:>3}", pos=(4, 1)) @@ -109,11 +103,11 @@ def __init__(self, shade_selector, **kwargs): self.add_gadget(self._hue_indicator) def on_size(self): - h, w = self._size - - self.texture = np.zeros((h * 2, w, 4), dtype=np.uint8) - + super().on_size() + _, w = self._size d, r = divmod(w, 6) + if d == 1: + return rainbow = [] for i, (a, b) in enumerate(GRAD): diff --git a/src/batgrl/gadgets/console.py b/src/batgrl/gadgets/console.py index bbdfddc5..3ba226b9 100644 --- a/src/batgrl/gadgets/console.py +++ b/src/batgrl/gadgets/console.py @@ -9,7 +9,6 @@ from code import InteractiveInterpreter from contextlib import redirect_stderr, redirect_stdout from io import StringIO -from typing import Self from ..terminal.events import KeyEvent, PasteEvent from .behaviors.focusable import Focusable @@ -130,7 +129,7 @@ class _ConsoleTextbox(Textbox): """A custom textbox that grows and shrinks with its input.""" @property - def console(self) -> Self: + def console(self) -> Console: return self.parent.parent.parent @property @@ -157,7 +156,7 @@ def on_size(self): self.console._container.width = self.right else: self.console._container.width = self.console._min_line_length - # self.cursor = self.cursor + self.cursor = self.cursor def _del_text(self, start: int, end: int): result = super()._del_text(start, end) @@ -249,10 +248,10 @@ def set_text(self, text: str, **kwargs): class _AlmostPane(Pane): - def _render(self, canvas): + def _render(self, canvas, graphics, kind): console: Console = self.parent.parent self._region -= console._input._region - super()._render(canvas) + super()._render(canvas, graphics, kind) class Console(Themable, Focusable, Gadget): @@ -565,8 +564,6 @@ def update_theme(self): self._input._box.default_fg_color = primary.fg self._input._box.canvas["bg_color"] = primary.bg self._input._box.default_bg_color = primary.bg - self._input._cursor.fg_color = primary.bg - self._input._cursor.bg_color = primary.fg def on_add(self): """Add running app and root gadget to console's locals.""" diff --git a/src/batgrl/gadgets/cursor.py b/src/batgrl/gadgets/cursor.py new file mode 100644 index 00000000..e1cf178b --- /dev/null +++ b/src/batgrl/gadgets/cursor.py @@ -0,0 +1,211 @@ +"""A gadget that replaces SGR parameters of cells beneath it.""" + +import numpy as np +from numpy.typing import NDArray + +from .._rendering import cursor_render +from ..colors import Color +from ..geometry import Point, Size +from .gadget import Cell, Gadget, PosHint, SizeHint + + +class Cursor(Gadget): + r""" + A gadget that replaces SGR parameters of cells beneath it. + + Parameters + ---------- + bold : bool | None, default: None + Whether cursor is bold. + italic : bool | None, default: None + Whether cursor is italic. + underline : bool | None, default: None + Whether cursor is underlined. + strikethrough : bool | None, default: None + Whether cursor is strikethrough. + overline : bool | None, default: None + Whether cursor is overlined. + reverse : bool | None, default: True + Whether cursor is reversed. + fg_color : Color | None, default: None + Foreground color of cursor. + bg_color : Color | None, default: None + Background color of cursor. + size : Size, default: Size(1, 1) + Size of gadget. + pos : Point, default: Point(0, 0) + Position of upper-left corner in parent. + size_hint : SizeHint | None, default: None + Size as a proportion of parent's height and width. + pos_hint : PosHint | None, default: None + Position as a proportion of parent's height and width. + is_transparent : bool, default: True + Whether gadget is transparent. + is_visible : bool, default: True + Whether gadget is visible. Gadget will still receive input events if not + visible. + is_enabled : bool, default: True + Whether gadget is enabled. A disabled gadget is not painted and doesn't receive + input events. + + Attributes + ---------- + size : Size + Size of gadget. + height : int + Height of gadget. + rows : int + Alias for :attr:`height`. + width : int + Width of gadget. + columns : int + Alias for :attr:`width`. + pos : Point + Position of upper-left corner. + top : int + y-coordinate of top of gadget. + y : int + y-coordinate of top of gadget. + left : int + x-coordinate of left side of gadget. + x : int + x-coordinate of left side of gadget. + bottom : int + y-coordinate of bottom of gadget. + right : int + x-coordinate of right side of gadget. + center : Point + Position of center of gadget. + absolute_pos : Point + Absolute position on screen. + size_hint : SizeHint + Size as a proportion of parent's height and width. + pos_hint : PosHint + Position as a proportion of parent's height and width. + parent : Gadget | None + Parent gadget. + children : list[Gadget] + Children gadgets. + is_transparent : bool + Whether gadget is transparent. + is_visible : bool + Whether gadget is visible. + is_enabled : bool + Whether gadget is enabled. + root : Gadget | None + If gadget is in gadget tree, return the root gadget. + app : App + The running app. + + Methods + ------- + apply_hints() + Apply size and pos hints. + to_local(point) + Convert point in absolute coordinates to local coordinates. + collides_point(point) + Return true if point collides with visible portion of gadget. + collides_gadget(other) + Return true if other is within gadget's bounding box. + pull_to_front() + Move to end of gadget stack so gadget is drawn last. + walk() + Yield all descendents of this gadget (preorder traversal). + walk_reverse() + Yield all descendents of this gadget (reverse postorder traversal). + ancestors() + Yield all ancestors of this gadget. + add_gadget(gadget) + Add a child gadget. + add_gadgets(\*gadgets) + Add multiple child gadgets. + remove_gadget(gadget) + Remove a child gadget. + prolicide() + Recursively remove all children. + destroy() + Remove this gadget and recursively remove all its children. + bind(prop, callback) + Bind `callback` to a gadget property. + unbind(uid) + Unbind a callback from a gadget property. + tween(...) + Sequentially update gadget properties over time. + on_size() + Update gadget after a resize. + on_transparency() + Update gadget after transparency enabled/disabled. + on_add() + Update gadget after being added to the gadget tree. + on_remove() + Update gadget after being removed from the gadget tree. + on_key(key_event) + Handle a key press event. + on_mouse(mouse_event) + Handle a mouse event. + on_paste(paste_event) + Handle a paste event. + on_terminal_focus(focus_event) + Handle a focus event. + """ + + def __init__( + self, + bold: bool | None = None, + italic: bool | None = None, + underline: bool | None = None, + strikethrough: bool | None = None, + overline: bool | None = None, + reverse: bool | None = True, + fg_color: Color | None = None, + bg_color: Color | None = None, + size: Size = Size(1, 1), + pos: Point = Point(0, 0), + size_hint: SizeHint | None = None, + pos_hint: PosHint | None = None, + is_transparent: bool = True, + is_visible: bool = True, + is_enabled: bool = True, + ): + self.bold: bool | None = bold + "Whether cursor is bold." + self.italic: bool | None = italic + "Whether cursor is italic." + self.underline: bool | None = underline + "Whether cursor is underlined." + self.strikethrough: bool | None = strikethrough + "Whether cursor is strikethrough." + self.overline: bool | None = overline + "Whether cursor is overlined." + self.reverse: bool | None = reverse + "Whether cursor is reversed." + self.fg_color: Color | None = fg_color + """Foreground color of cursor.""" + self.bg_color: Color | None = bg_color + """Background color of cursor.""" + super().__init__( + size=size, + pos=pos, + size_hint=size_hint, + pos_hint=pos_hint, + is_transparent=is_transparent, + is_visible=is_visible, + is_enabled=is_enabled, + ) + + def _render( + self, cells: NDArray[Cell], graphics: NDArray[np.uint8], kind: NDArray[np.uint8] + ) -> None: + """Render visible region of gadget.""" + cursor_render( + cells, + self.bold, + self.italic, + self.underline, + self.strikethrough, + self.overline, + self.reverse, + self.fg_color, + self.bg_color, + self._region, + ) diff --git a/src/batgrl/gadgets/data_table.py b/src/batgrl/gadgets/data_table.py index 4af1665c..23cdc5a3 100644 --- a/src/batgrl/gadgets/data_table.py +++ b/src/batgrl/gadgets/data_table.py @@ -269,10 +269,10 @@ def on_release(self): class _FauxPane(Pane): - def _render(self, canvas): + def _render(self, canvas, graphics, kind): data_table: DataTable = self.parent.parent self._region -= data_table._table._region - super()._render(canvas) + super()._render(canvas, graphics, kind) class DataTable(Themable, Gadget): diff --git a/src/batgrl/gadgets/file_chooser.py b/src/batgrl/gadgets/file_chooser.py index 8e32cf60..4e085576 100644 --- a/src/batgrl/gadgets/file_chooser.py +++ b/src/batgrl/gadgets/file_chooser.py @@ -4,7 +4,7 @@ from collections.abc import Callable from pathlib import Path -from ..text_tools import str_width +from ..char_width import str_width from .gadget import Gadget, Point, PosHint, Size, SizeHint from .scroll_view import ScrollView from .tree_view import TreeView, TreeViewNode diff --git a/src/batgrl/gadgets/gadget.py b/src/batgrl/gadgets/gadget.py index 38c5923b..54c3a87b 100644 --- a/src/batgrl/gadgets/gadget.py +++ b/src/batgrl/gadgets/gadget.py @@ -9,7 +9,7 @@ from numbers import Real from time import perf_counter from types import MappingProxyType -from typing import Final, Literal, Self, TypedDict +from typing import TYPE_CHECKING, Final, Literal, TypedDict from weakref import WeakKeyDictionary import numpy as np @@ -19,6 +19,9 @@ from ..terminal.events import FocusEvent, KeyEvent, MouseEvent, PasteEvent from ..text_tools import Cell, new_cell +if TYPE_CHECKING: + from ._root import _Root + __all__ = [ "Anchor", "Cell", @@ -85,8 +88,7 @@ class _GadgetList(MutableSequence): """ A sequence of sibling gadgets. - Gadget regions are invalidated if gadgets later in the sequence are added or - removed. + Gadget regions are invalidated when ``_GadgetList`` is mutated. """ def __init__(self) -> None: @@ -102,14 +104,12 @@ def __setitem__(self, *_) -> None: raise NotImplementedError("_GadgetList.__setitem__ not implemented.") def __delitem__(self, index: int) -> None: + self._gadgets[index]._invalidate_regions() del self._gadgets[index] - for i in range(index - 1, -1, -1): - self[i]._invalidate_region() def insert(self, index: int, gadget: Gadget) -> None: + gadget._invalidate_regions() self._gadgets.insert(index, gadget) - for i in range(index, -1, -1): - self[i]._invalidate_region() def __iter__(self) -> Iterator[Gadget]: return iter(self._gadgets) @@ -426,19 +426,12 @@ def __init__( """Whether gadget is visible.""" self._is_enabled = is_enabled """Whether gadget is enabled.""" - self._region_valid: bool = False - """Whether current region is valid.""" - self._clipping_region: Region = Region() - """Initial region clipped by ancestor regions.""" - self._root_region_before: Region = Region() - """The root's region before gadget's region is removed from it.""" self._region: Region = Region() """The visible portion of the gadget on the screen.""" def __repr__(self): return ( f"{type(self).__name__}(size={self.size}, pos={self.pos}, " - f"size_hint={self._size_hint}, pos_hint={self._pos_hint}, " f"is_transparent={self.is_transparent}, is_visible={self.is_visible}, " f"is_enabled={self.is_enabled})" ) @@ -462,7 +455,7 @@ def size(self, size: Size): else: with self.root._render_lock: self._size = size - self._invalidate_region() + self._invalidate_regions() self._apply_pos_hints() for child in self.children: @@ -509,7 +502,7 @@ def pos(self, pos: Point): else: with self.root._render_lock: self._pos = pos - self._invalidate_region() + self._invalidate_regions() @property def top(self) -> int: @@ -602,7 +595,7 @@ def is_transparent(self, is_transparent: bool): if is_transparent != self._is_transparent: self._is_transparent = is_transparent if self.root is not None: - self._invalidate_region() + self._invalidate_regions() self.on_transparency() @property @@ -619,7 +612,7 @@ def is_visible(self, is_visible: bool): if is_visible != self._is_visible: self._is_visible = is_visible if self.root is not None: - self._invalidate_region() + self._invalidate_regions() @property def is_enabled(self) -> bool: @@ -635,10 +628,10 @@ def is_enabled(self, is_enabled: bool): if is_enabled != self._is_enabled: self._is_enabled = is_enabled if self.root is not None: - self._invalidate_region() + self._invalidate_regions() @property - def root(self) -> Self | None: + def root(self) -> _Root | None: """Return the root gadget if connected to gadget tree.""" return self.parent and self.parent.root @@ -647,7 +640,9 @@ def app(self): """The running app.""" return self.root.app - def _render(self, canvas: NDArray[Cell]) -> None: + def _render( + self, cells: NDArray[Cell], graphics: NDArray[np.uint8], kind: NDArray[np.uint8] + ) -> None: """Render visible region of gadget.""" def dispatch_key(self, key_event: KeyEvent) -> bool | None: @@ -738,13 +733,10 @@ def dispatch_terminal_focus(self, focus_event: FocusEvent) -> bool | None: if gadget.is_enabled ) or self.on_terminal_focus(focus_event) - def _invalidate_region(self, notify_root: bool = True) -> None: - """Invalidate region and all children's regions.""" - self._region_valid = False - if notify_root and self.root is not None: - self.root._all_regions_valid = False - for child in self.children: - child._invalidate_region(False) + def _invalidate_regions(self) -> None: + """Invalidate regions.""" + if self.root is not None: + self.root._regions_valid = False def apply_hints(self) -> None: """ @@ -839,7 +831,7 @@ def collides_point(self, point: Point) -> bool: if child.is_visible and child.is_enabled ) - def collides_gadget(self, other: Self) -> bool: + def collides_gadget(self, other: Gadget) -> bool: """ Return true if other is within gadget's bounding box. @@ -874,7 +866,7 @@ def pull_to_front(self) -> None: self.parent.children.remove(self) self.parent.children.append(self) - def walk(self) -> Iterator[Self]: + def walk(self) -> Iterator[Gadget]: """ Yield all descendents of this gadget (preorder traversal). @@ -887,7 +879,7 @@ def walk(self) -> Iterator[Self]: yield child yield from child.walk() - def walk_reverse(self) -> Iterator[Self]: + def walk_reverse(self) -> Iterator[Gadget]: """ Yield all descendents of this gadget (reverse postorder traversal). @@ -900,7 +892,7 @@ def walk_reverse(self) -> Iterator[Self]: yield from child.walk_reverse() yield child - def ancestors(self) -> Iterator[Self]: + def ancestors(self) -> Iterator[Gadget]: """ Yield all ancestors of this gadget. @@ -913,7 +905,7 @@ def ancestors(self) -> Iterator[Self]: yield self.parent yield from self.parent.ancestors() - def add_gadget(self, gadget: Self) -> None: + def add_gadget(self, gadget: Gadget) -> None: """ Add a child gadget. @@ -924,11 +916,12 @@ def add_gadget(self, gadget: Self) -> None: """ self.children.append(gadget) gadget.parent = self + self._invalidate_regions() if self.root is not None: gadget.on_add() - def add_gadgets(self, *gadgets: Self) -> None: + def add_gadgets(self, *gadgets: Gadget) -> None: r""" Add multiple child gadgets. @@ -944,7 +937,7 @@ def add_gadgets(self, *gadgets: Self) -> None: for gadget in gadgets: self.add_gadget(gadget) - def remove_gadget(self, gadget: Self) -> None: + def remove_gadget(self, gadget: Gadget) -> None: """ Remove a child gadget. @@ -958,6 +951,7 @@ def remove_gadget(self, gadget: Self) -> None: self.children.remove(gadget) gadget.parent = None + self._invalidate_regions() def prolicide(self) -> None: """Recursively remove all children.""" diff --git a/src/batgrl/gadgets/graphic_field.py b/src/batgrl/gadgets/graphic_field.py index 079f05b9..4cc98fa0 100644 --- a/src/batgrl/gadgets/graphic_field.py +++ b/src/batgrl/gadgets/graphic_field.py @@ -1,40 +1,45 @@ """ A graphic particle field. -A particle field specializes in handling many single "pixel" children. +A particle field specializes in rendering many single "pixel" children from an array of +particle positions and and an particle colors. """ from typing import Any import numpy as np -from numpy.lib.recfunctions import structured_to_unstructured from numpy.typing import NDArray -from ..geometry import rect_slice -from ..text_tools import cell_sans -from ..texture_tools import _composite +from .._rendering import graphics_field_render from .gadget import Cell, Gadget, Point, PosHint, Size, SizeHint, bindable, clamp +from .graphics import Blitter, Graphics, scale_geometry -__all__ = ["GraphicParticleField", "particle_data_from_texture", "Point", "Size"] +__all__ = ["GraphicParticleField", "Point", "Size"] class GraphicParticleField(Gadget): r""" A graphic particle field. - A particle field specializes in rendering many single "pixel" children. This is more - efficient than rendering many 1x1 gadgets. + A particle field specializes in rendering many single "pixel" children from an array + of particle positions and and an particle colors. Particles positions are a + ``(N, 2)`` shaped array of floats. The decimal part of the positions allows + different blitters to place correct "subpixel" characters. For instance a position + ``(0.0, 0.0)`` might render the character ``"⠁"``, while ``(0.5, 0.0)`` might render + ``"⠄"``. Parameters ---------- - particle_positions : NDArray[np.int32] | None, default: None - An array of particle positions with shape `(N, 2)`. + particle_positions : NDArray[np.float64] | None, default: None + An array of particle positions with shape ``(N, 2)``. particle_colors : NDArray[np.uint8] | None, default: None - An array of particle colors with shape `(N, 4)`. - particle_properties : dict[str, NDArray[Any]] | None, default: None + A RGBA array of particle colors with shape ``(N, 4)``. + particle_properties : dict[str, Any] | None, default: None Additional particle properties. alpha : float, default: 1.0 Transparency of gadget. + blitter : Blitter, default: "half" + Determines how graphics are rendered. size : Size, default: Size(10, 10) Size of gadget. pos : Point, default: Point(0, 0) @@ -56,14 +61,16 @@ class GraphicParticleField(Gadget): ---------- nparticles : int Number of particles in particle field. - particle_positions : NDArray[np.int32] + particle_positions : NDArray[np.float64] An array of particle positions with shape `(N, 2)`. particle_colors : NDArray[np.uint8] - An array of particle colors with shape `(N, 4)`. - particle_properties : dict[str, NDArray[Any]] + A RGBA array of particle colors with shape `(N, 4)`. + particle_properties : dict[str, Any] Additional particle properties. alpha : float Transparency of gadget. + blitter : Blitter + Determines how graphics are rendered. size : Size Size of gadget. height : int @@ -114,7 +121,7 @@ class GraphicParticleField(Gadget): Methods ------- particles_from_texture(texture) - Return positions and colors of visible pixels of an RGBA texture. + Set particle positions and colors from visible pixels of an RGBA texture. apply_hints() Apply size and pos hints. to_local(point) @@ -168,10 +175,11 @@ class GraphicParticleField(Gadget): def __init__( self, *, - particle_positions: NDArray[np.int32] | None = None, + particle_positions: NDArray[np.float64] | None = None, particle_colors: NDArray[np.uint8] | None = None, - particle_properties: dict[str, NDArray[Any]] = None, + particle_properties: dict[str, Any] | None = None, alpha: float = 1.0, + blitter: Blitter = "half", size: Size = Size(10, 10), pos: Point = Point(0, 0), size_hint: SizeHint | None = None, @@ -189,25 +197,35 @@ def __init__( is_visible=is_visible, is_enabled=is_enabled, ) - + self.particle_positions: NDArray[np.float64] + """An array of particle positions with shape `(N, 2)`.""" if particle_positions is None: - self.particle_positions = np.zeros((0, 2), dtype=int) + self.particle_positions = np.zeros((0, 2), dtype=np.float64) else: - self.particle_positions = np.asarray(particle_positions, dtype=int) + self.particle_positions = np.ascontiguousarray( + particle_positions, dtype=np.float64 + ) + self.particle_colors: NDArray[np.uint8] + """A RGBA array of particle colors with shape `(N, 4)`.""" if particle_colors is None: self.particle_colors = np.zeros( (len(self.particle_positions), 4), dtype=np.uint8 ) else: - self.particle_colors = np.asarray(particle_colors, dtype=np.uint8) + self.particle_colors = np.ascontiguousarray(particle_colors, dtype=np.uint8) + self.particle_properties: dict[str, Any] + """Additional particle properties.""" if particle_properties is None: self.particle_properties = {} else: self.particle_properties = particle_properties self.alpha = alpha + """Transparency of gadget.""" + self.blitter = blitter + """Determines how graphics are rendered.""" @property def alpha(self) -> float: @@ -220,66 +238,61 @@ def alpha(self, alpha: float): self._alpha = clamp(float(alpha), 0.0, 1.0) @property - def nparticles(self) -> int: - """Number of particles in particle field.""" - return len(self.particle_positions) + def blitter(self) -> Blitter: + """Determines how graphics are rendered.""" + return self._blitter - def _render(self, canvas: NDArray[Cell]): - """Render visible region of gadget.""" - chars = canvas["char"] - styles = canvas[cell_sans("char", "fg_color", "bg_color")] - colors = structured_to_unstructured(canvas[["fg_color", "bg_color"]], np.uint8) - root_pos = self.root._pos - abs_pos = self.absolute_pos - ppos = self.particle_positions - pcolors = self.particle_colors - for pos, (h, w) in self._region.rects(): - abs_ppos = ppos - (pos - abs_pos) - where_inbounds = np.nonzero( - (((0, 0) <= abs_ppos) & (abs_ppos < (2 * h, w))).all(axis=1) - ) - ys, xs = abs_ppos[where_inbounds].T - - dst = rect_slice(pos - root_pos, (h, w)) - color_rect = colors[dst] - - if self.is_transparent: - mask = chars[dst] != "▀" - color_rect[..., :3][mask] = color_rect[..., 3:][mask] - - texture = ( - color_rect.reshape(h, w, 2, 3).swapaxes(1, 2).reshape(2 * h, w, 3) - ) # Not a view. - painted = pcolors[where_inbounds] - - if self.is_transparent: - background = texture[ys, xs] - _composite(background, painted[:, :3], painted[:, 3, None], self.alpha) - texture[ys, xs] = background - else: - texture[ys, xs] = painted[..., :3] + @blitter.setter + def blitter(self, blitter: Blitter): + if blitter not in Blitter.__args__: + raise TypeError(f"{blitter} is not a valid blitter type.") + if blitter == "sixel" and not Graphics._sixel_support: + self._blitter = "half" + else: + self._blitter = blitter - color_rect[:] = texture.reshape(h, 2, w, 3).swapaxes(1, 2).reshape(h, w, 6) - chars[dst] = "▀" - styles[dst] = False + def on_add(self) -> None: + """Change sixel blitter if sixel is not supported on add.""" + if self._blitter == "sixel" and not Graphics._sixel_support: + self.blitter = "half" + super().on_add() + @property + def nparticles(self) -> int: + """Number of particles in particle field.""" + return len(self.particle_positions) -def particle_data_from_texture( - texture: NDArray[np.uint8], -) -> tuple[NDArray[np.int32], NDArray[np.uint8]]: - """ - Return positions and colors of visible pixels of an RGBA texture. + def particles_from_texture(self, texture: NDArray[np.uint8]) -> None: + """ + Set particle positions and colors from visible pixels of an RGBA texture. - Parameters - ---------- - texture : NDArray[np.uint8] - A uint8 RGBA numpy array. + Parameters + ---------- + texture : NDArray[np.uint8] + A uint8 RGBA numpy array. + """ + positions = np.argwhere(texture[..., 3]) + pys, pxs = positions.T + self.particle_colors = np.ascontiguousarray(texture[pys, pxs]) + self.particle_positions = np.ascontiguousarray(positions.astype(np.float64)) + self.particle_positions /= scale_geometry(self._blitter, Size(1, 1)) - Returns - ------- - tuple[NDArray[np.int32], NDArray[np.uint8]] - Position and colors of visible pixels of the texture. - """ - positions = np.argwhere(texture[..., 3]) - pys, pxs = positions.T - return positions, texture[pys, pxs] + def _render( + self, + cells: NDArray[Cell], + graphics: NDArray[np.uint8], + kind: NDArray[np.uint8], + ): + """Render visible region of gadget.""" + graphics_field_render( + cells, + graphics, + kind, + self.absolute_pos, + self._blitter, + self._is_transparent, + self.particle_positions, + self.particle_colors, + self._alpha, + self._region, + ) diff --git a/src/batgrl/gadgets/graphics.py b/src/batgrl/gadgets/graphics.py index 431750d6..486c4e26 100644 --- a/src/batgrl/gadgets/graphics.py +++ b/src/batgrl/gadgets/graphics.py @@ -1,28 +1,62 @@ """A graphic gadget.""" from pathlib import Path +from typing import Final, Literal import cv2 import numpy as np from numpy.typing import NDArray +from .._rendering import graphics_render from ..colors import TRANSPARENT, AColor -from ..geometry import rect_slice -from ..text_tools import cell_sans -from ..texture_tools import Interpolation, _composite, resize_texture +from ..texture_tools import Interpolation, resize_texture from .gadget import Cell, Gadget, Point, PosHint, Size, SizeHint, bindable, clamp -__all__ = ["Graphics", "Interpolation", "Point", "Size"] +__all__ = ["Blitter", "Graphics", "Interpolation", "Point", "Size", "scale_geometry"] + +Blitter = Literal["braille", "full", "half", "sixel"] +"""Determines how graphics are rendered.""" + +_BLITTER_GEOMETRY: Final[dict[Blitter, Size]] = { + "braille": Size(4, 2), + "full": Size(1, 1), + "half": Size(2, 1), + "sixel": Size(20, 10), +} + + +def scale_geometry[T: (Point, Size)](blitter: Blitter, point_or_size: T) -> T: + """ + Scale a point or size by some blitter geometry. + + Parameters + ---------- + blitter : Blitter + Blitter from which pixel geometry is chosen. + point_or_size : T + A point or size to scale. + + Returns + ------- + T + The scaled geometry. + """ + h, w = _BLITTER_GEOMETRY[blitter] + a, b = point_or_size + if blitter == "sixel": + ah, _ = Graphics._sixel_aspect_ratio + return type(point_or_size)(h * a // ah, w * b) + return type(point_or_size)(h * a, w * b) class Graphics(Gadget): r""" A graphic gadget. Displays arbitrary RGBA textures. - Graphic gadgets are gadgets that are rendered entirely with the upper half block - character, "▀". Graphic gadgets' color information is stored in a uint8 RGBA array, - :attr:`texture`. Note that the height of :attr:`texture` is twice the height of the - gadget. + Graphic gadgets' color information is stored in a uint8 RGBA array, :attr:`texture`. + The size of :attr:`texture` depends on the geometry of the chosen blitter. For + instance, if the chosen blitter is "half", then the texture will be twice the height + of the gadget and the same width. Parameters ---------- @@ -32,6 +66,8 @@ class Graphics(Gadget): Transparency of gadget. interpolation : Interpolation, default: "linear" Interpolation used when gadget is resized. + blitter : Blitter, default: "half" + Determines how graphics are rendered. size : Size, default: Size(10, 10) Size of gadget. pos : Point, default: Point(0, 0) @@ -59,6 +95,8 @@ class Graphics(Gadget): Transparency of gadget. interpolation : Interpolation Interpolation used when gadget is resized. + blitter : Blitter + Determines how graphics are rendered. size : Size Size of gadget. height : int @@ -162,26 +200,32 @@ class Graphics(Gadget): Handle a focus event. """ + _sixel_support: bool = False + """Whether sixel is supported.""" + _sixel_aspect_ratio: Size = Size(1, 1) + """Sixel aspect ratio.""" + def __init__( self, *, - is_transparent: bool = True, default_color: AColor = TRANSPARENT, alpha: float = 1.0, interpolation: Interpolation = "linear", + blitter: Blitter = "half", size: Size = Size(10, 10), pos: Point = Point(0, 0), size_hint: SizeHint | None = None, pos_hint: PosHint | None = None, + is_transparent: bool = True, is_visible: bool = True, is_enabled: bool = True, ): super().__init__( - is_transparent=is_transparent, size=size, pos=pos, size_hint=size_hint, pos_hint=pos_hint, + is_transparent=is_transparent, is_visible=is_visible, is_enabled=is_enabled, ) @@ -189,9 +233,8 @@ def __init__( self.default_color = default_color self.alpha = alpha self.interpolation = interpolation - - h, w = self.size - self.texture = np.full((2 * h, w, 4), default_color, dtype=np.uint8) + self.texture = np.full((1, 1, 4), default_color, np.uint8) + self.blitter = blitter # Property setter will correctly resize texture. @property def alpha(self) -> float: @@ -214,47 +257,56 @@ def interpolation(self, interpolation: Interpolation): raise TypeError(f"{interpolation} is not a valid interpolation type.") self._interpolation = interpolation - def on_size(self): - """Resize texture array.""" - h, w = self.size - self.texture = resize_texture(self.texture, (2 * h, w), self.interpolation) + @property + def blitter(self) -> Blitter: + """Determines how graphics are rendered.""" + return self._blitter - def _render(self, canvas: NDArray[Cell]): - """Render visible region of gadget.""" - texture = self.texture - chars = canvas["char"] - styles = canvas[cell_sans("char", "fg_color", "bg_color")] - foreground = canvas["fg_color"] - background = canvas["bg_color"] - root_pos = self.root._pos - abs_pos = self.absolute_pos - alpha = self.alpha - for pos, (h, w) in self._region.rects(): - dst = rect_slice(pos - root_pos, (h, w)) - src_top, src_left = pos - abs_pos - src_bottom, src_right = src_top + h, src_left + w - fg_rect = foreground[dst] - bg_rect = background[dst] - even_rows = texture[2 * src_top : 2 * src_bottom : 2, src_left:src_right] - odd_rows = texture[2 * src_top + 1 : 2 * src_bottom : 2, src_left:src_right] + @blitter.setter + def blitter(self, blitter: Blitter): + if blitter not in Blitter.__args__: + raise TypeError(f"{blitter} is not a valid blitter type.") + if blitter == "sixel" and not self._sixel_support: + self._blitter = "half" + else: + self._blitter = blitter + self.on_size() - if self.is_transparent: - mask = chars[dst] != "▀" - fg_rect[mask] = bg_rect[mask] - _composite(fg_rect, even_rows[..., :3], even_rows[..., 3, None], alpha) - _composite(bg_rect, odd_rows[..., :3], odd_rows[..., 3, None], alpha) - else: - fg_rect[:] = even_rows[..., :3] - bg_rect[:] = odd_rows[..., :3] + def on_size(self) -> None: + """Resize texture array.""" + self.texture = resize_texture( + self.texture, scale_geometry(self._blitter, self.size), self._interpolation + ) - chars[dst] = "▀" - styles[dst] = False + def on_add(self) -> None: + """Resize if geometry is incorrect on add.""" + if self._blitter == "sixel" and not Graphics._sixel_support: + self.blitter = "half" + elif self.texture.shape[:2] != scale_geometry(self._blitter, self.size): + self.on_size() + super().on_add() - def to_png(self, path: Path): + def to_png(self, path: Path) -> None: """Write :attr:`texture` to provided path as a `png` image.""" BGRA = cv2.cvtColor(self.texture, cv2.COLOR_RGBA2BGRA) cv2.imwrite(str(path.absolute()), BGRA) - def clear(self): + def clear(self) -> None: """Fill texture with default color.""" self.texture[:] = self.default_color + + def _render( + self, cells: NDArray[Cell], graphics: NDArray[np.uint8], kind: NDArray[np.uint8] + ) -> None: + """Render visible region of gadget.""" + graphics_render( + cells, + graphics, + kind, + self.absolute_pos, + self._blitter, + self._is_transparent, + self.texture, + self._alpha, + self._region, + ) diff --git a/src/batgrl/gadgets/image.py b/src/batgrl/gadgets/image.py index d202c469..63ba6868 100644 --- a/src/batgrl/gadgets/image.py +++ b/src/batgrl/gadgets/image.py @@ -8,7 +8,16 @@ from ..colors import TRANSPARENT, AColor from ..texture_tools import read_texture, resize_texture -from .graphics import Graphics, Interpolation, Point, PosHint, Size, SizeHint +from .graphics import ( + Blitter, + Graphics, + Interpolation, + Point, + PosHint, + Size, + SizeHint, + scale_geometry, +) __all__ = ["Image", "Interpolation", "Point", "Size"] @@ -27,6 +36,8 @@ class Image(Graphics): Transparency of gadget. interpolation : Interpolation, default: "linear" Interpolation used when gadget is resized. + blitter : Blitter, default: "half" + Determines how graphics are rendered. size : Size, default: Size(10, 10) Size of gadget. pos : Point, default: Point(0, 0) @@ -56,6 +67,8 @@ class Image(Graphics): Transparency of gadget. interpolation : Interpolation Interpolation used when gadget is resized. + blitter : Blitter + Determines how graphics are rendered. size : Size Size of gadget. height : int @@ -165,27 +178,29 @@ def __init__( self, *, path: Path | None = None, - is_transparent: bool = True, default_color: AColor = TRANSPARENT, alpha: float = 1.0, interpolation: Interpolation = "linear", + blitter: Blitter = "half", size: Size = Size(10, 10), pos: Point = Point(0, 0), size_hint: SizeHint | None = None, pos_hint: PosHint | None = None, + is_transparent: bool = True, is_visible: bool = True, is_enabled: bool = True, ): - self._otexture = np.zeros((2, 1, 4), dtype=np.uint8) + self._otexture = np.zeros((1, 1, 4), dtype=np.uint8) super().__init__( - is_transparent=is_transparent, default_color=default_color, alpha=alpha, interpolation=interpolation, + blitter=blitter, size=size, pos=pos, size_hint=size_hint, pos_hint=pos_hint, + is_transparent=is_transparent, is_visible=is_visible, is_enabled=is_enabled, ) @@ -211,8 +226,11 @@ def path(self, path: Path | None): def on_size(self): """Resize texture array.""" - h, w = self._size - self.texture = resize_texture(self._otexture, (2 * h, w), self.interpolation) + self.texture = resize_texture( + self._otexture, + scale_geometry(self._blitter, self.size), + self._interpolation, + ) @classmethod def from_texture( @@ -222,6 +240,7 @@ def from_texture( default_color: AColor = TRANSPARENT, alpha: float = 1.0, interpolation: Interpolation = "linear", + blitter: Blitter = "half", size: Size = Size(10, 10), pos: Point = Point(0, 0), size_hint: SizeHint | None = None, @@ -243,6 +262,8 @@ def from_texture( Transparency of gadget. interpolation : Interpolation, default: "linear" Interpolation used when gadget is resized. + blitter : Blitter, default: "half" + Determines how graphics are rendered. size : Size, default: Size(10, 10) Size of gadget. pos : Point, default: Point(0, 0) @@ -269,6 +290,7 @@ def from_texture( default_color=default_color, alpha=alpha, interpolation=interpolation, + blitter=blitter, size=size, pos=pos, size_hint=size_hint, diff --git a/src/batgrl/gadgets/line_plot.py b/src/batgrl/gadgets/line_plot.py index 4b6b2e67..60d29410 100644 --- a/src/batgrl/gadgets/line_plot.py +++ b/src/batgrl/gadgets/line_plot.py @@ -5,16 +5,16 @@ from collections.abc import Sequence from math import ceil from numbers import Real -from typing import Literal import cv2 import numpy as np +from ..char_width import str_width from ..colors import DEFAULT_PRIMARY_BG, DEFAULT_PRIMARY_FG, Color, rainbow_gradient from ..terminal.events import MouseEvent -from ..text_tools import binary_to_box, binary_to_braille, str_width from .behaviors.movable import Movable from .gadget import Gadget, Point, PosHint, Size, SizeHint, lerp +from .graphics import Blitter, Graphics, scale_geometry from .pane import Pane from .scroll_view import ScrollView from .text import Text, add_text, new_cell @@ -85,8 +85,8 @@ class LinePlot(Gadget): x-coordinates of each plot. ys : Sequence[Sequence[Real]] y-coordinates of each plot. - mode : Literal["braille", "box"], default: "braille" - Determines which characters are used to draw the plot. + blitter : Blitter, default: "braille" + Determines how the line plot is rendered. min_x : Real | None, default: None Minimum x-value of plot. If `None`, min_x will be minimum of all xs. max_x : Real | None, default: None @@ -132,8 +132,8 @@ class LinePlot(Gadget): x-coordinates of each plot. ys : Sequence[Sequence[Real]] y-coordinates of each plot. - mode : Literal["braille", "box"] - Determines which characters are used to draw the plot. + blitter : Blitter + Determines how the line plot is rendered. min_x : Real | None Minimum x-value of plot. If `None`, min_x will be minimum of all xs. max_x : Real | None @@ -259,7 +259,7 @@ class LinePlot(Gadget): """x-coordinates of each plot.""" ys: Sequence[Sequence[Real]] = _LinePlotProperty() """x-coordinates of each plot.""" - mode: Literal["box", "braille"] = _LinePlotProperty() + blitter: Blitter = _LinePlotProperty() """Determines which characters are used to draw the plot.""" min_x: Real | None = _LinePlotProperty() """Minimum x-value of plot.""" @@ -277,7 +277,7 @@ def __init__( *, xs: Sequence[Sequence[Real]], ys: Sequence[Sequence[Real]], - mode: Literal["box", "braille"] = "braille", + blitter: Blitter = "braille", min_x: Real | None = None, max_x: Real | None = None, min_y: Real | None = None, @@ -298,7 +298,7 @@ def __init__( is_enabled: bool = True, ): default_cell = new_cell(fg_color=plot_fg_color, bg_color=plot_bg_color) - self._traces = Text(default_cell=default_cell) + self._traces = Graphics(default_color=(*plot_bg_color, 0), is_transparent=True) self._scroll_view = ScrollView( show_vertical_bar=False, show_horizontal_bar=False, @@ -328,7 +328,7 @@ def __init__( self._xs = xs self._ys = ys - self._mode = mode + self._blitter = blitter self._min_x = min_x self._max_x = max_x self._min_y = min_y @@ -371,7 +371,6 @@ def set_y_top(): def on_transparency(self) -> None: """Update gadget after transparency is enabled/disabled.""" - self._traces.is_transparent = self.is_transparent self._scroll_view.is_transparent = self.is_transparent self._x_ticks.is_transparent = self.is_transparent self._y_ticks.is_transparent = self.is_transparent @@ -422,6 +421,8 @@ def plot_bg_color(self, plot_bg_color: Color): child.default_bg_color = plot_bg_color elif isinstance(child, Pane): child.bg_color = plot_bg_color + elif isinstance(child, Graphics): + child.default_color = (*plot_bg_color, 0) self._legend._build_legend() @@ -506,18 +507,15 @@ def _build_plot(self): if offset_h <= 1 or offset_w <= 1: return - self._traces.canvas["char"][:] = " " - self._traces.canvas["fg_color"] = self.plot_fg_color - self._traces.canvas["bg_color"] = self.plot_bg_color + self._traces.clear() + if self._traces.blitter != self.blitter: + self._traces.blitter = self.blitter min_x = min(xs.min() for xs in self.xs) if self.min_x is None else self.min_x max_x = max(xs.max() for xs in self.xs) if self.max_x is None else self.max_x min_y = min(ys.min() for ys in self.ys) if self.min_y is None else self.min_y max_y = max(ys.max() for ys in self.ys) if self.max_y is None else self.max_y - chars_view = self._traces.canvas["char"][:, TICK_HALF:plot_right] - colors_view = self._traces.canvas["fg_color"][:, TICK_HALF:plot_right] - if self.line_colors is None: line_colors = rainbow_gradient(len(self.xs)) else: @@ -526,34 +524,16 @@ def _build_plot(self): x_delta = max_x - min_x y_delta = max_y - min_y - plot_w = offset_w * 2 - if self.mode == "braille": - plot_h = offset_h * 4 - else: - plot_h = offset_h * 2 - + plot_h, plot_w = scale_geometry(self._blitter, Size(offset_h, offset_w)) + plot = np.zeros((plot_h, plot_w, 4), np.uint8) for xs, ys, color in zip(self.xs, self.ys, line_colors, strict=True): - plot = np.zeros((plot_h, plot_w), np.uint8) - scaled_ys = plot_h * (ys - min_y) / y_delta scaled_xs = plot_w * (xs - min_x) / x_delta coords = np.dstack((scaled_xs, plot_h - scaled_ys)).astype(int) - - cv2.polylines(plot, coords, isClosed=False, color=1) - - if self.mode == "braille": - sectioned = np.swapaxes(plot.reshape(offset_h, 4, offset_w, 2), 1, 2) - braille = binary_to_braille(sectioned) - where_braille = braille != chr(0x2800) # empty braille character - - chars_view[where_braille] = braille[where_braille] - colors_view[where_braille] = color - else: - sectioned = np.swapaxes(plot.reshape(offset_h, 2, offset_w, 2), 1, 2) - boxes = binary_to_box(sectioned) - where_boxes = boxes != " " - chars_view[where_boxes] = boxes[where_boxes] - colors_view[where_boxes] = color + cv2.polylines(plot, coords, isClosed=False, color=(*color, 255)) + _, left = scale_geometry(self._blitter, Size(1, TICK_HALF)) + _, right = scale_geometry(self._blitter, Size(1, plot_right)) + self._traces.texture[:, left:right] = plot # Regenerate Ticks self._y_ticks.size = self._traces.height, TICK_WIDTH diff --git a/src/batgrl/gadgets/markdown.py b/src/batgrl/gadgets/markdown.py index 7a736308..5b0353ff 100644 --- a/src/batgrl/gadgets/markdown.py +++ b/src/batgrl/gadgets/markdown.py @@ -21,7 +21,7 @@ from .behaviors.button_behavior import ButtonBehavior from .behaviors.themable import Themable from .gadget import Gadget, PosHint, SizeHint -from .graphics import Graphics +from .graphics import Blitter, Graphics from .grid_layout import GridLayout from .image import Image from .pane import Pane @@ -216,16 +216,16 @@ def update_hover(self): class _MarkdownImage(_HasTitle, Image): - def __init__(self, title: str, path: Path, width: int): - super().__init__(title=title, path=path) + def __init__(self, title: str, path: Path, width: int, blitter: Blitter): + super().__init__(title=title, path=path, blitter=blitter) oh, ow, _ = self._otexture.shape width = min(ow / _PIXELS_PER_CHAR, width) self.size = int(oh * width / ow) // 2, width class _MarkdownGif(_HasTitle, Video): - def __init__(self, title: str, path: Path, width: int): - super().__init__(title=title, source=path) + def __init__(self, title: str, path: Path, width: int, blitter: Blitter): + super().__init__(title=title, source=path, blitter=blitter) oh = self._resource.get(cv2.CAP_PROP_FRAME_HEIGHT) ow = self._resource.get(cv2.CAP_PROP_FRAME_WIDTH) width = min(ow / _PIXELS_PER_CHAR, width) @@ -318,11 +318,12 @@ class _BatgrlRenderer(BaseRenderer): quote_depth: int = 0 last_token: span_token.SpanToken | block_token.BlockToken | None = None - def __init__(self, width, syntax_highlighting_style): + def __init__(self, width, syntax_highlighting_style, blitter): super().__init__(BlankLine, Spaces, EmojiCode) block_token.remove_token(block_token.Footnote) self.width = max(width, _MIN_MARKDOWN_WIDTH) self.syntax_highlighting_style = syntax_highlighting_style + self.blitter = blitter self.render_map["SetextHeading"] = self.render_setext_heading self.render_map["CodeFence"] = self.render_block_code @@ -472,9 +473,17 @@ def render_image( if path.exists(): if path.suffix == ".gif": return _MarkdownGif( - path=path, title=token.title, width=self.render_width + path=path, + title=token.title, + width=self.render_width, + blitter=self.blitter, ) - return _MarkdownImage(path=path, title=token.title, width=self.render_width) + return _MarkdownImage( + path=path, + title=token.title, + width=self.render_width, + blitter=self.blitter, + ) token.children.insert(0, span_token.RawText("🖼️ ")) content = self.render_inner(token) content.canvas[["fg_color", "bg_color"]] = Themable.color_theme.markdown_image @@ -703,6 +712,12 @@ class Markdown(Themable, Gadget): Parameters ---------- + markdown : str + The markdown string. + syntax_highlighting_style : pygments.style.Style, default: Neptune + The syntax highlighting style for code blocks. + blitter : Blitter, default: "half" + Determines how images are rendered. size : Size, default: Size(10, 10) Size of gadget. pos : Point, default: Point(0, 0) @@ -726,6 +741,8 @@ class Markdown(Themable, Gadget): The markdown string. syntax_highlighting_style : pygments.style.Style The syntax highlighting style for code blocks. + blitter : Blitter, default: "half" + Determines how images are rendered. size : Size Size of gadget. height : int @@ -832,6 +849,7 @@ def __init__( *, markdown: str, syntax_highlighting_style: Style = Neptune, + blitter: Blitter = "half", size: Size = Size(10, 10), pos: Point = Point(0, 0), size_hint: SizeHint | None = None, @@ -867,9 +885,11 @@ def __init__( ) self._link_hint.is_enabled = False self.add_gadgets(self._scroll_view, self._link_hint) - self.markdown = markdown - self.syntax_highlighting_style = syntax_highlighting_style + self.markdown: str = markdown + self.syntax_highlighting_style: Style = syntax_highlighting_style """The syntax highlighting style for code blocks.""" + self.blitter = blitter + """Determines how images are rendered.""" @property def markdown(self) -> str: @@ -881,15 +901,39 @@ def markdown(self, markdown: str): self._markdown = markdown self._build_markdown() + @property + def syntax_highlighting_style(self) -> Style: + """The syntax highlighting style for code blocks.""" + return self._style + + @syntax_highlighting_style.setter + def syntax_highlighting_style(self, syntax_highlighting_style: Style): + self._style = syntax_highlighting_style + self._build_markdown() + + @property + def blitter(self) -> Blitter: + """Determines how images are rendered.""" + return self._blitter + + @blitter.setter + def blitter(self, blitter: Blitter): + self._blitter = blitter + for child in self.walk(): + if isinstance(child, Graphics): + child.blitter = blitter + def _build_markdown(self): if not self.root: return with _BatgrlRenderer( - self._scroll_view.port_width, self.syntax_highlighting_style + self._scroll_view.port_width, self.syntax_highlighting_style, self.blitter ) as renderer: rendered = renderer.render(Document(self.markdown)) + if self._scroll_view.view is not None: + self._scroll_view.view.destroy() self._scroll_view.view = rendered def update_theme(self): @@ -904,6 +948,7 @@ def update_theme(self): def on_size(self): """Rebuild markdown on resize.""" + super().on_size() self._build_markdown() def on_add(self): diff --git a/src/batgrl/gadgets/pane.py b/src/batgrl/gadgets/pane.py index 791d6dfe..fd280b9f 100644 --- a/src/batgrl/gadgets/pane.py +++ b/src/batgrl/gadgets/pane.py @@ -1,11 +1,10 @@ """A gadget with a background color that is composited if transparent.""" +import numpy as np from numpy.typing import NDArray +from .._rendering import pane_render from ..colors import BLACK, Color -from ..geometry import rect_slice -from ..text_tools import cell_sans -from ..texture_tools import _composite from .gadget import Cell, Gadget, Point, PosHint, Size, SizeHint, bindable, clamp __all__ = ["Pane", "Point", "Size"] @@ -179,21 +178,16 @@ def alpha(self) -> float: def alpha(self, alpha: float): self._alpha = clamp(float(alpha), 0.0, 1.0) - def _render(self, canvas: NDArray[Cell]): + def _render( + self, cells: NDArray[Cell], graphics: NDArray[np.uint8], kind: NDArray[np.uint8] + ) -> None: """Render visible region of gadget.""" - chars = canvas["char"] - styles = canvas[cell_sans("char", "fg_color", "bg_color")] - foreground = canvas["fg_color"] - background = canvas["bg_color"] - root_pos = self.root._pos - for pos, size in self._region.rects(): - dst = rect_slice(pos - root_pos, size) - fg_rect = foreground[dst] - bg_rect = background[dst] - if self.is_transparent: - _composite(fg_rect, self.bg_color, 255, self.alpha) - _composite(bg_rect, self.bg_color, 255, self.alpha) - else: - chars[dst] = " " - styles[dst] = False - fg_rect[:] = bg_rect[:] = self.bg_color + pane_render( + cells, + graphics, + kind, + self._is_transparent, + self._region, + self.bg_color, + self.alpha, + ) diff --git a/src/batgrl/gadgets/parallax.py b/src/batgrl/gadgets/parallax.py index 5a43d300..50a8c380 100644 --- a/src/batgrl/gadgets/parallax.py +++ b/src/batgrl/gadgets/parallax.py @@ -7,55 +7,42 @@ import numpy as np from numpy.typing import NDArray -from .gadget import ( - Cell, - Gadget, +from ..colors import TRANSPARENT, AColor +from ..texture_tools import composite, read_texture, resize_texture +from .graphics import ( + Blitter, + Graphics, + Interpolation, Point, PosHint, - Region, Size, SizeHint, - bindable, - clamp, + scale_geometry, ) -from .image import Image, Interpolation __all__ = ["Parallax", "Interpolation", "Point", "Size"] -def _check_layer_speeds( - layers: Sequence[Image], speeds: Sequence[float] | None -) -> Sequence[float]: - """ - Raise `ValueError` if `layers` and `speeds` are incompatible, else return a sequence - of layer speeds. - """ - nlayers = len(layers) - if speeds is None: - return [1 / (nlayers - i) for i in range(nlayers)] - - if len(speeds) != nlayers: - raise ValueError("number of layers doesn't match number of layer speeds") - - return speeds - - -class Parallax(Gadget): +class Parallax(Graphics): r""" A parallax gadget. Parameters ---------- path : Path | None, default: None - Path to directory of images for layers of the parallax (loaded - in lexographical order of filenames) layered from background to foreground. + Path to directory of images for layers of the parallax (loaded in lexographical + order of filenames) layered from background to foreground. speeds : Sequence[float] | None, default: None The scrolling speed of each layer. Default speeds are `1/(N - i)` where `N` is the number of layers and `i` is the index of a layer. + default_color : AColor, default: AColor(0, 0, 0, 0) + Default texture color. alpha : float, default: 1.0 Transparency of gadget. interpolation : Interpolation, default: "linear" Interpolation used when gadget is resized. + blitter : Blitter, default: "half" + Determines how graphics are rendered. size : Size, default: Size(10, 10) Size of gadget. pos : Point, default: Point(0, 0) @@ -75,20 +62,26 @@ class Parallax(Gadget): Attributes ---------- - offset : tuple[float, float] - Vertical and horizontal offset of first layer of the parallax. layers : list[Image] Layers of the parallax. speeds : Sequence[float] The scrolling speed of each layer. + offset : tuple[float, float] + Vertical and horizontal offset of the parallax. vertical_offset : float - Vertical offset of first layer of the parallax. + Vertical offset of the parallax. horizontal_offset : float - Horizontal offset of first layer of the parallax. + Horizontal offset of the parallax. + texture : NDArray[np.uint8] + uint8 RGBA color array. + default_color : AColor + Default texture color. alpha : float Transparency of gadget. interpolation : Interpolation Interpolation used when gadget is resized. + blitter : Blitter + Determines how graphics are rendered. size : Size Size of gadget. height : int @@ -140,8 +133,10 @@ class Parallax(Gadget): ------- from_textures(textures, ...) Create a :class:`Parallax` from an iterable of uint8 RGBA numpy array. - from_images(images, ...) - Create a :class:`Parallax` from an iterable of :class:`Image`. + to_png(path) + Write :attr:`texture` to provided path as a `png` image. + clear() + Fill texture with default color. apply_hints() Apply size and pos hints. to_local(point) @@ -197,137 +192,102 @@ def __init__( *, path: Path | None = None, speeds: Sequence[float] | None = None, + default_color: AColor = TRANSPARENT, alpha: float = 1.0, interpolation: Interpolation = "linear", - is_transparent: bool = True, + blitter: Blitter = "half", size: Size = Size(10, 10), pos: Point = Point(0, 0), size_hint: SizeHint | None = None, pos_hint: PosHint | None = None, + is_transparent: bool = True, is_visible: bool = True, is_enabled: bool = True, ): - self.layers: list[Image] + self.layers: list[NDArray[np.uint8]] """Layers of the parallax.""" if path is None: self.layers = [] else: paths = sorted(path.iterdir(), key=lambda file: file.name) - self.layers = [Image(path=path, is_transparent=True) for path in paths] - for layer in self.layers: - layer.parent = self + self.layers = [read_texture(path) for path in paths] + + self.speeds: Sequence[float] + """The scrolling speed of each layer.""" + + nlayers = len(self.layers) + if speeds is None: + self.speeds = [1 / (nlayers - i) for i in range(nlayers)] + elif nlayers != len(speeds): + raise ValueError("number of layers doesn't match number of layer speeds") + else: + self.speeds = speeds + + self._vertical_offset = self._horizontal_offset = 0.0 super().__init__( - is_transparent=is_transparent, + default_color=default_color, + alpha=alpha, + interpolation=interpolation, + blitter=blitter, size=size, pos=pos, size_hint=size_hint, pos_hint=pos_hint, + is_transparent=is_transparent, is_visible=is_visible, is_enabled=is_enabled, ) - self.speeds = _check_layer_speeds(self.layers, speeds) - self.alpha = alpha - self.interpolation = interpolation - self._vertical_offset = self._horizontal_offset = 0.0 - self.on_size() - - @property - def _region(self) -> Region: - """The visible portion of the gadget on the screen.""" - return self._region_value - - @_region.setter - def _region(self, region: Region): - self._region_value = region - for layer in self.layers: - layer._region = region - def on_size(self): """Resize parallax layers.""" - for layer in self.layers: - layer.size = self._size - self._otextures = [layer.texture for layer in self.layers] - - @property - def alpha(self) -> float: - """Transparency of gadget.""" - return self._alpha - - @alpha.setter - @bindable - def alpha(self, alpha: float): - self._alpha = clamp(float(alpha), 0.0, 1.0) - for layer in self.layers: - layer.alpha = alpha - - @property - def interpolation(self) -> Interpolation: - """Interpolation used when gadget is resized.""" - return self._interpolation - - @interpolation.setter - def interpolation(self, interpolation: Interpolation): - if interpolation not in {"nearest", "linear", "cubic", "area", "lanczos"}: - raise ValueError(f"{interpolation} is not a valid interpolation type.") - for layer in self.layers: - layer.interpolation = interpolation + h, w = scale_geometry(self._blitter, self._size) + self.texture = np.empty((h, w, 4), np.uint8) + self._sized_layers = [ + resize_texture(texture, (h, w), self.interpolation) + for texture in self.layers + ] + self._update_texture() @property def vertical_offset(self) -> float: - """Vertical offset of first layer of the parallax.""" + """Vertical offset of the parallax.""" return self._vertical_offset @vertical_offset.setter def vertical_offset(self, offset: float): self._vertical_offset = offset - self._adjust() + self._update_texture() @property def horizontal_offset(self) -> float: - """Horizontal offset of first layer of the parallax.""" + """Horizontal offset of the parallax.""" return self._horizontal_offset @horizontal_offset.setter def horizontal_offset(self, offset: float): self._horizontal_offset = offset - self._adjust() + self._update_texture() @property def offset(self) -> tuple[float, float]: - """ - Vertical and horizontal offset of first layer of the parallax. - - Other layers will be adjusted automatically when offset is set. - """ + """Vertical and horizontal offset of the parallax.""" return self._vertical_offset, self._horizontal_offset @offset.setter def offset(self, offset: tuple[float, float]): self._vertical_offset, self._horizontal_offset = offset - self._adjust() + self._update_texture() - def _adjust(self): - for speed, texture, layer in zip( - self.speeds, - self._otextures, - self.layers, - ): + def _update_texture(self): + self.clear() + for speed, texture in zip(self.speeds, self._sized_layers): rolls = ( -round(speed * self._vertical_offset), -round(speed * self._horizontal_offset), ) - layer.texture = np.roll(texture, rolls, axis=(0, 1)) - - def _render(self, canvas: NDArray[Cell]): - """Render visible region of gadget.""" - if self.layers: - for layer in self.layers: - layer._render(canvas) - else: - super()._render(canvas) + composite(np.roll(texture, rolls, axis=(0, 1)), self.texture) @classmethod def from_textures( @@ -335,13 +295,15 @@ def from_textures( textures: Iterable[NDArray[np.uint8]], *, speeds: Sequence[float] | None = None, + default_color: AColor = TRANSPARENT, alpha: float = 1.0, interpolation: Interpolation = "linear", - is_transparent: bool = True, + blitter: Blitter = "half", size: Size = Size(10, 10), pos: Point = Point(0, 0), size_hint: SizeHint | None = None, pos_hint: PosHint | None = None, + is_transparent: bool = True, is_visible: bool = True, is_enabled: bool = True, ) -> Self: @@ -355,10 +317,14 @@ def from_textures( speeds : Sequence[float] | None, default: None The scrolling speed of each layer. Default speeds are `1/(N - i)` where `N` is the number of layers and `i` is the index of a layer. + default_color : AColor, default: AColor(0, 0, 0, 0) + Default texture color. alpha : float, default: 1.0 Transparency of gadget. interpolation : Interpolation, default: "linear" Interpolation used when gadget is resized. + blitter : Blitter, default: "half" + Determines how graphics are rendered. size : Size, default: Size(10, 10) Size of gadget. pos : Point, default: Point(0, 0) @@ -381,99 +347,27 @@ def from_textures( Parallax A new parallax gadget. """ - parallax = cls( - alpha=alpha, - interpolation=interpolation, - is_transparent=is_transparent, - size=size, - pos=pos, - size_hint=size_hint, - pos_hint=pos_hint, - is_visible=is_visible, - is_enabled=is_enabled, - ) - parallax.layers = [ - Image.from_texture( - texture, - size=parallax.size, - alpha=parallax.alpha, - interpolation=parallax.interpolation, - ) - for texture in textures - ] - for layer in parallax.layers: - layer.parent = parallax - parallax.speeds = _check_layer_speeds(parallax.layers, speeds) - return parallax - - @classmethod - def from_images( - cls, - images: Iterable[Image], - *, - speeds: Sequence[float] | None = None, - alpha: float = 1.0, - interpolation: Interpolation = "linear", - is_transparent: bool = True, - size: Size = Size(10, 10), - pos: Point = Point(0, 0), - size_hint: SizeHint | None = None, - pos_hint: PosHint | None = None, - is_visible: bool = True, - is_enabled: bool = True, - ) -> Self: - """ - Create an :class:`Parallax` from an iterable of :class:`Image`. - - Parameters - ---------- - textures : Iterable[Image] - An iterable of images that will be the layers of the parallax. - speeds : Sequence[float] | None, default: None - The scrolling speed of each layer. Default speeds are `1/(N - i)` where `N` - is the number of layers and `i` is the index of a layer. - alpha : float, default: 1.0 - Transparency of gadget. - interpolation : Interpolation, default: "linear" - Interpolation used when gadget is resized. - size : Size, default: Size(10, 10) - Size of gadget. - pos : Point, default: Point(0, 0) - Position of upper-left corner in parent. - size_hint : SizeHint | None, default: None - Size as a proportion of parent's height and width. - pos_hint : PosHint | None, default: None - Position as a proportion of parent's height and width. - is_transparent : bool, default: True - Whether gadget is transparent. - is_visible : bool, default: True - Whether gadget is visible. Gadget will still receive input events if not - visible. - is_enabled : bool, default: True - Whether gadget is enabled. A disabled gadget is not painted and doesn't - receive input events. + layers = list(textures) + nlayers = len(layers) + if speeds is None: + speeds = [1 / (nlayers - i) for i in range(nlayers)] + elif nlayers != len(speeds): + raise ValueError("number of layers doesn't match number of layer speeds") - Returns - ------- - Parallax - A new parallax gadget. - """ parallax = cls( + default_color=default_color, alpha=alpha, interpolation=interpolation, - is_transparent=is_transparent, + blitter=blitter, size=size, pos=pos, size_hint=size_hint, pos_hint=pos_hint, + is_transparent=is_transparent, is_visible=is_visible, is_enabled=is_enabled, ) - parallax.layers = list(images) - for image in parallax.layers: - image.interpolation = parallax.interpolation - image.size = parallax.size - image.alpha = parallax.alpha - image.parent = parallax - parallax.speeds = _check_layer_speeds(parallax.layers, speeds) + parallax.layers = layers + parallax.speeds = speeds + parallax.on_size() return parallax diff --git a/src/batgrl/gadgets/raycaster.py b/src/batgrl/gadgets/raycaster.py index be5244b0..1638aa34 100644 --- a/src/batgrl/gadgets/raycaster.py +++ b/src/batgrl/gadgets/raycaster.py @@ -8,7 +8,17 @@ from ..colors import BLACK, TRANSPARENT, AColor, Color from ..texture_tools import _composite -from .graphics import Graphics, Interpolation, Point, PosHint, Size, SizeHint, clamp +from .graphics import ( + Blitter, + Graphics, + Interpolation, + Point, + PosHint, + Size, + SizeHint, + clamp, + scale_geometry, +) __all__ = ["Raycaster", "Sprite", "RaycasterCamera", "RgbaTexture", "Point", "Size"] @@ -164,6 +174,8 @@ class Raycaster(Graphics): Transparency of gadget. interpolation : Interpolation, default: "linear" Interpolation used when gadget is resized. + blitter : Blitter, default: "half" + Determines how graphics are rendered. size : Size, default: Size(10, 10) Size of gadget. pos : Point, default: Point(0, 0) @@ -213,6 +225,8 @@ class Raycaster(Graphics): Transparency of gadget. interpolation : Interpolation Interpolation used when gadget is resized. + blitter : Blitter + Determines how graphics are rendered. size : Size Size of gadget. height : int @@ -335,6 +349,7 @@ def __init__( default_color: AColor = TRANSPARENT, alpha: float = 1.0, interpolation: Interpolation = "linear", + blitter: Blitter = "half", size: Size = Size(10, 10), pos: Point = Point(0, 0), size_hint: SizeHint | None = None, @@ -347,6 +362,7 @@ def __init__( default_color=default_color, alpha=alpha, interpolation=interpolation, + blitter=blitter, size=size, pos=pos, size_hint=size_hint, @@ -387,9 +403,9 @@ def __init__( def on_size(self): """Resize texture array and re-make caster buffers.""" - h, w = self._size - - self.texture = np.zeros((2 * h, w, 4), dtype=np.uint8) + h, w = scale_geometry(self._blitter, self._size) + self.texture = np.zeros((h, w, 4), dtype=np.uint8) + h //= 2 # Precalculate angle of rays cast. self._ray_angles = angles = np.ones((w, 2), dtype=float) @@ -434,7 +450,7 @@ def cast_rays(self): self.texture[h:, :, :3] = self.floor_color self.texture[..., 3] = 255 - for column in range(self.width): + for column in range(self.texture.shape[1]): self._cast_ray(column) self._cast_sprites() diff --git a/src/batgrl/gadgets/shadow_caster.py b/src/batgrl/gadgets/shadow_caster.py index e3181bee..eb683c0e 100644 --- a/src/batgrl/gadgets/shadow_caster.py +++ b/src/batgrl/gadgets/shadow_caster.py @@ -13,7 +13,16 @@ from ..colors import AWHITE, BLACK, TRANSPARENT, WHITE, AColor, Color from ..geometry import Region, rect_slice from ..texture_tools import resize_texture -from .graphics import Graphics, Interpolation, Point, PosHint, Size, SizeHint, clamp +from .graphics import ( + Blitter, + Graphics, + Interpolation, + Point, + PosHint, + Size, + SizeHint, + clamp, +) __all__ = [ "ShadowCaster", @@ -188,6 +197,8 @@ class ShadowCaster(Graphics): Transparency of gadget. interpolation : Interpolation, default: "linear" Interpolation used when gadget is resized. + blitter : Blitter, default: "half" + Determines how graphics are rendered. size : Size, default: Size(10, 10) Size of gadget. pos : Point, default: Point(0, 0) @@ -235,6 +246,8 @@ class ShadowCaster(Graphics): Transparency of gadget. interpolation : Interpolation Interpolation used when gadget is resized. + blitter : Blitter + Determines how graphics are rendered. size : Size Size of gadget. height : int @@ -356,26 +369,28 @@ def __init__( radius: int = 20, smoothing: float = 1.0 / 3.0, not_visible_blocks: bool = True, - is_transparent: bool = True, default_color: AColor = TRANSPARENT, alpha: float = 1.0, + blitter: Blitter = "half", interpolation: Interpolation = "linear", size: Size = Size(10, 10), pos: Point = Point(0, 0), size_hint: SizeHint | None = None, pos_hint: PosHint | None = None, + is_transparent: bool = True, is_visible: bool = True, is_enabled: bool = True, ): super().__init__( - is_transparent=is_transparent, default_color=default_color, alpha=alpha, interpolation=interpolation, + blitter=blitter, size=size, pos=pos, size_hint=size_hint, pos_hint=pos_hint, + is_transparent=is_transparent, is_visible=is_visible, is_enabled=is_enabled, ) diff --git a/src/batgrl/gadgets/sparkline.py b/src/batgrl/gadgets/sparkline.py index abb1ae36..ec84a797 100644 --- a/src/batgrl/gadgets/sparkline.py +++ b/src/batgrl/gadgets/sparkline.py @@ -9,7 +9,7 @@ from ..colors import DEFAULT_PRIMARY_BG, DEFAULT_PRIMARY_FG, Color, lerp_colors from ..terminal.events import MouseEvent from ..text_tools import smooth_vertical_bar -from ._cursor import Cursor +from .cursor import Cursor from .gadget import Gadget, Point, PosHint, Size, SizeHint from .text import Text, add_text @@ -209,7 +209,7 @@ def __init__( is_enabled: bool = True, ): self._sparkline = Text(size_hint={"height_hint": 1.0, "width_hint": 1.0}) - self._selector = Cursor(size_hint={"height_hint": 1.0}) + self._selector = Cursor(reverse=False, size_hint={"height_hint": 1.0}) self._tooltip = _Tooltip(size=(7, 18), is_enabled=False) super().__init__( diff --git a/src/batgrl/gadgets/text.py b/src/batgrl/gadgets/text.py index 8617ace6..27ed1494 100644 --- a/src/batgrl/gadgets/text.py +++ b/src/batgrl/gadgets/text.py @@ -8,21 +8,21 @@ from pygments.lexers import guess_lexer from pygments.style import Style +from .._rendering import text_render +from ..char_width import char_width, str_width from ..colors import Color, Neptune -from ..geometry import rect_slice from ..text_tools import ( + Cell, + _Cell, _parse_batgrl_md, _text_to_cells, _write_lines_to_canvas, add_text, cell_sans, - char_width, coerce_cell, - str_width, + new_cell, ) -from ..texture_tools import _composite from .gadget import ( - Cell, Gadget, Point, PosHint, @@ -30,7 +30,6 @@ SizeHint, bindable, clamp, - new_cell, ) __all__ = [ @@ -509,27 +508,17 @@ def shift(self, n: int = 1): self.canvas[-n:] = self.canvas[:n] self.canvas[:-n] = self.default_cell - def _render(self, canvas: NDArray[Cell]): + def _render( + self, cells: NDArray[Cell], graphics: NDArray[np.uint8], kind: NDArray[np.uint8] + ) -> None: """Render visible region of gadget.""" - sans_bg = canvas[cell_sans("bg_color")] - foreground = canvas["fg_color"] - background = canvas["bg_color"] - text_chars = self.canvas["char"] - text_sans_bg = self.canvas[cell_sans("bg_color")] - text_bg = self.canvas["bg_color"] - root_pos = self.root._pos - abs_pos = self.absolute_pos - alpha = self.alpha - for pos, size in self._region.rects(): - dst = rect_slice(pos - root_pos, size) - src = rect_slice(pos - abs_pos, size) - if self.is_transparent: - visible = np.isin(text_chars[src], (" ", "⠀"), invert=True) - invisible = ~visible - sans_bg[dst][visible] = text_sans_bg[src][visible] - fg = foreground[dst][invisible] # Not a view. - _composite(fg, text_bg[src][invisible], 255, alpha) - foreground[dst][invisible] = fg - _composite(background[dst], text_bg[src], 255, alpha) - else: - canvas[dst] = self.canvas[src] + text_render( + cells, + graphics, + kind, + self.absolute_pos, + self._is_transparent, + self.canvas.view(_Cell), + self.alpha, + self._region, + ) diff --git a/src/batgrl/gadgets/text_animation.py b/src/batgrl/gadgets/text_animation.py index 4871de0e..0ed9d2a9 100644 --- a/src/batgrl/gadgets/text_animation.py +++ b/src/batgrl/gadgets/text_animation.py @@ -3,11 +3,11 @@ import asyncio from collections.abc import Iterable, Sequence -from ..colors import BLACK, WHITE, Color -from .animation import _check_frame_durations -from .gadget import Gadget, Point, PosHint, Size, SizeHint -from .pane import Pane -from .text import Text +import numpy as np +from numpy.typing import NDArray + +from ..text_tools import Cell, add_text, str_width +from .text import Point, PosHint, Size, SizeHint, Text __all__ = ["TextAnimation", "Point", "Size"] @@ -17,7 +17,7 @@ def on_size(self): pass -class TextAnimation(Gadget): +class TextAnimation(Text): r""" A text animation gadget. @@ -28,15 +28,13 @@ class TextAnimation(Gadget): frame_durations : float | int | Sequence[float| int], default: 1/12 Time each frame is displayed. If a sequence is provided, it's length should be equal to number of frames. - animation_fg_color : Color, default: WHITE - Foreground color of animation. - animation_bg_color : Color, default: BLACK - Background color of animation. loop : bool, default: True Whether to restart animation after last frame. reverse : bool, default: False Whether to play animation in reverse. - alpha : float, default: 1.0 + default_cell : NDArray[Cell] | str, default: " " + Default cell of text canvas. + alpha : float, default: 0.0 Transparency of gadget. size : Size, default: Size(10, 10) Size of gadget. @@ -57,18 +55,24 @@ class TextAnimation(Gadget): Attributes ---------- - frames : list[Text] + min_animation_size : Size + The minimum size needed to not clip any frames of the animation. + frames : list[str] Frames of the animation. frame_durations : list[int | float] Time each frame is displayed. - animation_fg_color : Color - Foreground color of animation. - animation_bg_color : Color - Background color of animation. loop : bool Whether to restart animation after last frame. reverse : bool Whether to play animation in reverse. + canvas : NDArray[Cell] + The array of characters for the gadget. + default_cell : NDArray[Cell] + Default cell of text canvas. + default_fg_color : Color + Foreground color of default cell. + default_bg_color : Color + Background color of default cell. alpha : float Transparency of gadget. size : Size @@ -126,6 +130,18 @@ class TextAnimation(Gadget): Pause the animation stop() Stop the animation and reset current frame. + add_border(style="light", ...) + Add a border to the gadget. + add_syntax_highlighting(lexer, style) + Add syntax highlighting to current text in canvas. + add_str(str, pos, ...) + Add a single line of text to the canvas. + set_text(text, ...) + Resize gadget to fit text, erase canvas, then fill canvas with text. + clear() + Fill canvas with default cell. + shift(n=1) + Shift content in canvas up (or down in case of negative `n`). apply_hints() Apply size and pos hints. to_local(point) @@ -181,11 +197,10 @@ def __init__( *, frames: Iterable[str] | None = None, frame_durations: float | Sequence[float] = 1 / 12, - animation_fg_color: Color = WHITE, - animation_bg_color: Color = BLACK, loop: bool = True, reverse: bool = False, - alpha: float = 1.0, + default_cell: NDArray[Cell] | str = " ", + alpha: float = 0.0, size: Size = Size(10, 10), pos: Point = Point(0, 0), size_hint: SizeHint | None = None, @@ -194,18 +209,24 @@ def __init__( is_visible: bool = True, is_enabled: bool = True, ): - self.frames: Iterable[Text] = [] - if frames is not None: - for frame in frames: - self.frames.append(Text()) - self.frames[-1].set_text(frame) - self.frames[-1].parent = self - self._pane = Pane( - size_hint={"height_hint": 1.0, "width_hint": 1.0}, - is_transparent=is_transparent, - ) - self._frame = _Frame(is_enabled=False, is_transparent=True) + self.frames: Iterable[str] = list(frames) + """Frames of the animation.""" + self.frame_durations: Sequence[float] + """Time each frame is displayed.""" + + nframes = len(frames) + if isinstance(frame_durations, Sequence): + if len(frame_durations) != nframes: + raise ValueError( + "number of frames doesn't match number of frame durations" + ) + self.frame_durations = frame_durations + else: + self.frame_durations = [frame_durations] * nframes + super().__init__( + default_cell=default_cell, + alpha=alpha, size=size, pos=pos, size_hint=size_hint, @@ -214,70 +235,62 @@ def __init__( is_visible=is_visible, is_enabled=is_enabled, ) - self.add_gadgets(self._pane, self._frame) - self.frame_durations: list[float] = _check_frame_durations( - self.frames, frame_durations - ) - self.animation_fg_color = animation_fg_color - self.animation_bg_color = animation_bg_color + + self._sized_frames: list[NDArray[Cell]] = [] + """Frames of the animation converted to NDArray[Cell] and sized to gadget.""" + for frame in self.frames: + canvas = self._canvas.copy() + add_text(canvas, frame, truncate_text=True) + self._sized_frames.append(canvas) + self.loop = loop self.reverse = reverse - self.alpha = alpha self._i = len(self.frames) - 1 if self.reverse else 0 self._animation_task = None @property - def animation_fg_color(self) -> Color: - """Foreground color pair of animation.""" - return self._pane.fg_color + def canvas(self) -> NDArray[Cell]: + """The array of characters for the gadget.""" + if self._i < len(self.frames): + return self._sized_frames[self._i] + return self._canvas - @animation_fg_color.setter - def animation_fg_color(self, animation_fg_color: Color): - self._pane.fg_color = animation_fg_color - self._animation_fg_color = animation_fg_color - for frame in self.frames: - frame.canvas["fg_color"] = animation_fg_color + @canvas.setter + def canvas(self, canvas: NDArray[Cell]) -> None: + self._canvas = canvas @property - def animation_bg_color(self) -> Color: - """Foreground color pair of animation.""" - return self._pane.bg_color - - @animation_bg_color.setter - def animation_bg_color(self, animation_bg_color: Color): - self._pane.bg_color = animation_bg_color - self._animation_bg_color = animation_bg_color + def min_animation_size(self) -> Size: + """The minimum size needed to not clip any frames of the animation.""" + h = w = 0 for frame in self.frames: - frame.canvas["bg_color"] = animation_bg_color - - def on_transparency(self) -> None: - """Update gadget after transparency is enabled/disabled.""" - self._pane.is_transparent = self.is_transparent - - @property - def alpha(self) -> bool: - """Transparency of gadget.""" - return self._pane.alpha - - @alpha.setter - def alpha(self, alpha: float): - self._pane.alpha = alpha + lines = frame.split("\n") + if len(lines) > h: + h = len(lines) + frame_width = max(str_width(line) for line in lines) + if frame_width > w: + w = frame_width + return Size(h, w) - def on_remove(self): + def on_remove(self) -> None: """Pause animation on remove.""" self.pause() super().on_remove() + def on_size(self) -> None: + """Update size of all frames on resize.""" + self._canvas = np.full(self._size, self._default_cell) + self._sized_frames = [] + for frame in self.frames: + canvas = self._canvas.copy() + add_text(canvas, frame, truncate_text=True) + self._sized_frames.append(canvas) + async def _play_animation(self): while self.frames: - current_frame = self.frames[self._i] - self._frame.canvas = current_frame.canvas - self._frame.size = current_frame.size - self._frame.is_enabled = True try: await asyncio.sleep(self.frame_durations[self._i]) except asyncio.CancelledError: - self._frame.is_enabled = False return if self.reverse: @@ -286,7 +299,6 @@ async def _play_animation(self): self._i = len(self.frames) - 1 if not self.loop: - self._frame.is_enabled = False return else: self._i += 1 @@ -294,7 +306,6 @@ async def _play_animation(self): self._i = 0 if not self.loop: - self._frame.is_enabled = False return def play(self) -> asyncio.Task: @@ -316,12 +327,12 @@ def play(self) -> asyncio.Task: self._animation_task = asyncio.create_task(self._play_animation()) return self._animation_task - def pause(self): + def pause(self) -> None: """Pause animation.""" if self._animation_task is not None: self._animation_task.cancel() - def stop(self): + def stop(self) -> None: """Stop the animation and reset current frame.""" self.pause() self._i = len(self.frames) - 1 if self.reverse else 0 diff --git a/src/batgrl/gadgets/text_effects/black_hole.py b/src/batgrl/gadgets/text_effects/black_hole.py index 8cc70a04..d84bd844 100644 --- a/src/batgrl/gadgets/text_effects/black_hole.py +++ b/src/batgrl/gadgets/text_effects/black_hole.py @@ -9,7 +9,7 @@ from ...colors import BLACK, WHITE, Color, gradient, lerp_colors from ...geometry import BezierCurve, Point, clamp, move_along_path, points_on_circle from ..text import Text -from ..text_field import TextParticleField, particle_data_from_canvas +from ..text_field import TextParticleField from ._particle import Particle STARS = "*✸✺✹✷✵✶⋆'.⬫⬪⬩⬨⬧⬦⬥" @@ -42,19 +42,12 @@ async def black_hole_effect(text: Text): -------- Modifying `text` size while effect is running will break the effect. """ - pos, cells = particle_data_from_canvas(text.canvas) - indices = np.arange(len(pos)) - RNG.shuffle(indices) - - field = TextParticleField( - particle_positions=pos[indices], - particle_cells=cells[indices], - size_hint={"height_hint": 1.0, "width_hint": 1.0}, - ) + field = TextParticleField(size_hint={"height_hint": 1.0, "width_hint": 1.0}) + field.particles_from_cells(text.canvas) all_particles = list(Particle.iter_from_field(field)) - positions = (RNG.random((field.nparticles, 2)) * text.size).astype(int) + positions = RNG.random((field.nparticles, 2)) * text.size for particle, position in zip(all_particles, positions): particle.final_char = particle.cell["char"] particle.final_fg_color = Color(*particle.cell["fg_color"].tolist()) @@ -138,7 +131,7 @@ async def _forming(particles: list[Particle], positions: NDArray[np.float32]): async def _rotating( - black_hole: TextParticleField, positions: NDArray[np.float32], center: Point + black_hole: TextParticleField, positions: NDArray[np.float64], center: Point ): angles = np.linspace(0, 2 * np.pi, 100, endpoint=False) cos = np.cos(angles) @@ -150,7 +143,7 @@ async def _rotating( new_positions = positions @ rot[i] new_positions[:, 1] *= 2 new_positions += center - black_hole.particle_positions = new_positions.astype(int) + black_hole.particle_positions = new_positions i += 1 i %= 100 await asyncio.sleep(0.01) @@ -220,7 +213,7 @@ async def _collapsing( async def _point_char(black_hole: TextParticleField, center: Point): - black_hole.particle_positions = np.array([center]) + black_hole.particle_positions = np.array([center]).astype(np.float64) black_hole.particle_cells = black_hole.particle_cells[:1] for _ in range(3): for char in UNSTABLE: diff --git a/src/batgrl/gadgets/text_effects/ring.py b/src/batgrl/gadgets/text_effects/ring.py index a61f7125..0f5100dc 100644 --- a/src/batgrl/gadgets/text_effects/ring.py +++ b/src/batgrl/gadgets/text_effects/ring.py @@ -8,9 +8,9 @@ import numpy as np from ...colors import BLUE, WHITE, Color, gradient, lerp_colors -from ...geometry import BezierCurve, Point, Size, move_along_path +from ...geometry import BezierCurve, Point, move_along_path from ..text import Text -from ..text_field import TextParticleField, particle_data_from_canvas +from ..text_field import TextParticleField from ._particle import Particle RNG = np.random.default_rng() @@ -31,20 +31,15 @@ async def ring_effect(text: Text): -------- Modifying `text` size while effect is running will break the effect. """ - pos, cells = particle_data_from_canvas(text.canvas) - indices = np.arange(len(pos)) - RNG.shuffle(indices) - - field = TextParticleField( - particle_positions=pos, - particle_cells=cells, - size_hint={"height_hint": 1.0, "width_hint": 1.0}, - ) + field = TextParticleField(size_hint={"height_hint": 1.0, "width_hint": 1.0}) + field.particles_from_cells(text.canvas) + # indices = np.arange(len(pos)) + # RNG.shuffle(indices) particles = list(Particle.iter_from_field(field)) for particle in particles: - particle.final_pos = particle.pos - positions = (RNG.random((field.nparticles, 2)) * text.size).astype(int) + particle.final_pos = Point(int(particle.pos.y), int(particle.pos.x)) + positions = RNG.random((field.nparticles, 2)) * text.size field.particle_positions = positions min_dim = min(text.height, text.width / 2) @@ -57,10 +52,10 @@ async def ring_effect(text: Text): radius_to_particles = await _move_to_rings(particles, radii, center) await _spin_rings(radius_to_particles, center) - await _disperse(particles, text.size) + await _disperse(particles) radius_to_particles = await _move_to_rings(particles, radii, center) await _spin_rings(radius_to_particles, center, reverse=True) - await _disperse(particles, text.size) + await _disperse(particles) radius_to_particles = await _move_to_rings(particles, radii, center) await _spin_rings(radius_to_particles, center) await _settle(particles, text) @@ -171,13 +166,13 @@ async def _spin_rings( y, x = _rotate_around_center( particle.ring_point, center, direction * theta ) - particle.pos = int(y), int(x) + particle.pos = y, x theta += tau / 100 await asyncio.sleep(0) -async def _disperse(particles: list[Particle], field_size: Size): +async def _disperse(particles: list[Particle]): paths = [] for particle in particles: controls = [particle.pos] + [ diff --git a/src/batgrl/gadgets/text_field.py b/src/batgrl/gadgets/text_field.py index bd545ee0..09e00d52 100644 --- a/src/batgrl/gadgets/text_field.py +++ b/src/batgrl/gadgets/text_field.py @@ -1,7 +1,8 @@ """ A text particle field. -A particle field specializes in handling many single "pixel" children. +A particle field specializes in rendering many single "pixel" children from an array of +particle positions and an array of particle cells. """ from typing import Any @@ -9,24 +10,24 @@ import numpy as np from numpy.typing import NDArray -from ..geometry import clamp, rect_slice -from ..text_tools import cell_sans -from ..texture_tools import _composite +from .._rendering import text_field_render +from ..geometry import clamp +from ..text_tools import _Cell from .gadget import Cell, Gadget, Point, PosHint, Size, SizeHint, new_cell -__all__ = ["TextParticleField", "particle_data_from_canvas", "Point", "Size"] +__all__ = ["TextParticleField", "Point", "Size"] class TextParticleField(Gadget): r""" A text particle field. - A particle field specializes in rendering many single "pixel" children. This is more - efficient than rendering many 1x1 gadgets. + A particle field specializes in rendering many single "pixel" children from an array + of particle positions and an array of particle cells. Parameters ---------- - particle_positions : NDArray[np.int32] | None, default: None + particle_positions : NDArray[np.float64] | None, default: None An array of particle positions with shape `(N, 2)`. particle_cells : NDArray[Cell] | None, default: None An array of Cells of particles with shape `(N,)`. @@ -55,7 +56,7 @@ class TextParticleField(Gadget): ---------- nparticles : int Number of particles in particle field. - particle_positions : NDArray[np.int32] + particle_positions : NDArray[np.float64] An array of particle positions with shape `(N, 2)`. particle_cells : NDArray[Cell] An array of Cells of particles with shape `(N,)`. @@ -113,7 +114,7 @@ class TextParticleField(Gadget): Methods ------- particles_from_cells(cells) - Return positions and cells of non-whitespace characters of a Cell array. + Set particle positions and cells from non-whitespace characters of a Cell array. apply_hints() Apply size and pos hints. to_local(point) @@ -167,7 +168,7 @@ class TextParticleField(Gadget): def __init__( self, *, - particle_positions: NDArray[np.int32] | None = None, + particle_positions: NDArray[np.float64] | None = None, particle_cells: NDArray[Cell] | None = None, particle_properties: dict[str, Any] = None, alpha: float = 0.0, @@ -188,23 +189,31 @@ def __init__( is_visible=is_visible, is_enabled=is_enabled, ) - + self.particle_positions: NDArray[np.float64] + """An array of particle positions with shape `(N, 2)`.""" if particle_positions is None: - self.particle_positions = np.zeros((0, 2), dtype=int) + self.particle_positions = np.zeros((0, 2), dtype=np.float64) else: - self.particle_positions = np.asarray(particle_positions, dtype=int) + self.particle_positions = np.ascontiguousarray( + particle_positions, dtype=np.float64 + ) + self.particle_cells: NDArray[Cell] + """An array of Cells of particles with shape `(N,)`""" if particle_cells is None: self.particle_cells = np.full(len(self.particle_positions), new_cell()) else: - self.particle_cells = np.asarray(particle_cells, dtype=Cell) + self.particle_cells = np.ascontiguousarray(particle_cells, dtype=Cell) + self.particle_properties: dict[str, Any] + """Additional particle properties.""" if particle_properties is None: self.particle_properties = {} else: self.particle_properties = particle_properties self.alpha = alpha + """Transparency of gadget.""" @property def alpha(self) -> float: @@ -220,54 +229,35 @@ def nparticles(self) -> int: """Number of particles in particle field.""" return len(self.particle_positions) - def _render(self, canvas: NDArray[Cell]): - """Render visible region of gadget.""" - chars = canvas[cell_sans("bg_color")] - bg_color = canvas["bg_color"] - root_pos = self.root._pos - abs_pos = self.absolute_pos - ppos = self.particle_positions - pchars = self.particle_cells[cell_sans("bg_color")] - pbg_color = self.particle_cells["bg_color"] - for pos, size in self._region.rects(): - abs_ppos = ppos - (pos - abs_pos) - inbounds = (((0, 0) <= abs_ppos) & (abs_ppos < size)).all(axis=1) - - if self.is_transparent: - not_whitespace = np.isin(pchars["char"], (" ", "⠀"), invert=True) - where_inbounds = np.nonzero(inbounds & not_whitespace) - else: - where_inbounds = np.nonzero(inbounds) - painted = pbg_color[where_inbounds] - - ys, xs = abs_ppos[where_inbounds].T - dst = rect_slice(pos - root_pos, size) - if self.is_transparent: - background = bg_color[dst][ys, xs] - _composite(background, painted, 255, self.alpha) - bg_color[dst][ys, xs] = background - else: - bg_color[dst][ys, xs] = painted - - chars[dst][ys, xs] = pchars[where_inbounds] - - -def particle_data_from_canvas( - canvas: NDArray[Cell], -) -> tuple[NDArray[np.int32], NDArray[Cell]]: - """ - Return positions and cells of non-whitespace characters of a Cell array. + def particles_from_cells(self, cells: NDArray[Cell]) -> None: + """ + Set particle positions and cells from non-whitespace characters of a Cell array. - Parameters - ---------- - canvas : NDArray[Cell] - A Cell numpy array (such as a :class:`Text` gadget's canvas). + Parameters + ---------- + cells : NDArray[Cell] + A Cell numpy array (such as a :class:`Text` gadget's canvas). + """ + positions = np.argwhere(np.isin(cells["char"], (" ", "⠀"), invert=True)) + pys, pxs = positions.T + self.particle_positions = np.ascontiguousarray(positions.astype(np.float64)) + self.particle_cells = np.ascontiguousarray(cells[pys, pxs]) - Returns - ------- - tuple[NDArray[np.int32], NDArray[Cell]] - Position and cells of non-whitespace characters of the canvas. - """ - positions = np.argwhere(np.isin(canvas["char"], (" ", "⠀"), invert=True)) - pys, pxs = positions.T - return positions, canvas[pys, pxs] + def _render( + self, + cells: NDArray[Cell], + graphics: NDArray[np.uint8], + kind: NDArray[np.uint8], + ): + """Render visible region of gadget.""" + text_field_render( + cells, + graphics, + kind, + self.absolute_pos, + self._is_transparent, + self.particle_positions, + self.particle_cells.view(_Cell), + self._alpha, + self._region, + ) diff --git a/src/batgrl/gadgets/text_pad.py b/src/batgrl/gadgets/text_pad.py index f78322b6..22e80aa3 100644 --- a/src/batgrl/gadgets/text_pad.py +++ b/src/batgrl/gadgets/text_pad.py @@ -2,12 +2,13 @@ from dataclasses import astuple +from ..char_width import str_width from ..terminal.events import KeyEvent, MouseEvent, PasteEvent -from ..text_tools import is_word_char, str_width -from ._cursor import Cursor +from ..text_tools import is_word_char from .behaviors.focusable import Focusable from .behaviors.grabbable import Grabbable from .behaviors.themable import Themable +from .cursor import Cursor from .gadget import Gadget, Point, PosHint, Size, SizeHint from .scroll_view import ScrollView from .text import Text @@ -264,8 +265,6 @@ def update_theme(self): fg = primary.fg bg = primary.bg - self._cursor.bg_color = fg - self._cursor.fg_color = bg self._pad.canvas["fg_color"] = self._pad.default_fg_color = fg self._pad.canvas["bg_color"] = self._pad.default_bg_color = bg self._highlight_selection() diff --git a/src/batgrl/gadgets/textbox.py b/src/batgrl/gadgets/textbox.py index f66bfa1c..cfe93710 100644 --- a/src/batgrl/gadgets/textbox.py +++ b/src/batgrl/gadgets/textbox.py @@ -7,13 +7,14 @@ from numpy.typing import NDArray +from ..char_width import str_width from ..geometry import rect_slice from ..terminal.events import KeyEvent, MouseButton, MouseEvent, PasteEvent -from ..text_tools import is_word_char, str_width -from ._cursor import Cursor +from ..text_tools import is_word_char from .behaviors.focusable import Focusable from .behaviors.grabbable import Grabbable from .behaviors.themable import Themable +from .cursor import Cursor from .gadget import Cell, Gadget, Point, PosHint, Region, Size, SizeHint from .text import Text @@ -21,8 +22,8 @@ class _Box(Text): - def _render(self, canvas): - super()._render(canvas) + def _render(self, canvas, graphics, kind): + super()._render(canvas, graphics, kind) textbox: Textbox = self.parent if textbox.hide_input: hider_rect = Region.from_rect(self.absolute_pos, (1, textbox._line_length)) @@ -31,27 +32,6 @@ def _render(self, canvas): canvas["char"][rect_slice(pos, size)] = textbox.hide_char -class _Cursor(Cursor): - def _render(self, canvas): - textbox: Textbox = self.parent.parent - placeholder = textbox._placeholder_gadget - root_pos = self.root._pos - abs_pos = self.parent.absolute_pos - for pos, size in self._region.rects(): - dst = rect_slice(pos - root_pos, size) - src = rect_slice(pos - abs_pos, size) - canvas[dst]["fg_color"] = self.fg_color - canvas[dst]["bg_color"] = self.bg_color - if pos.x > textbox._line_length: - continue - if placeholder.is_enabled: - canvas[dst]["char"] = placeholder.canvas[src]["char"] - elif textbox.hide_input: - canvas[dst]["char"] = textbox.hide_char - else: - canvas[dst]["char"] = textbox._box.canvas[src]["char"] - - class Textbox(Themable, Focusable, Grabbable, Gadget): r""" A textbox gadget for single-line editable text. @@ -288,7 +268,7 @@ def __init__( ): self._placeholder_gadget = Text(alpha=0.0, is_transparent=is_transparent) self._placeholder_gadget.set_text(placeholder) - self._cursor = _Cursor() + self._cursor = Cursor() self._box = _Box(size=size, is_transparent=is_transparent) super().__init__( is_grabbable=is_grabbable, @@ -355,8 +335,6 @@ def update_theme(self): bg = primary.bg self._box.canvas["fg_color"] = self._box.default_fg_color = fg self._box.canvas["bg_color"] = self._box.default_bg_color = bg - self._cursor.fg_color = bg - self._cursor.bg_color = fg placeholder = self.color_theme.textbox_placeholder self._placeholder_gadget.default_fg_color = placeholder.fg diff --git a/src/batgrl/gadgets/tiled_image.py b/src/batgrl/gadgets/tiled.py similarity index 70% rename from src/batgrl/gadgets/tiled_image.py rename to src/batgrl/gadgets/tiled.py index ab78a74d..061758bb 100644 --- a/src/batgrl/gadgets/tiled_image.py +++ b/src/batgrl/gadgets/tiled.py @@ -2,28 +2,20 @@ from math import ceil -import numpy as np +from ..geometry import Region +from .gadget import Gadget, Point, PosHint, Size, SizeHint -from ..colors import TRANSPARENT, AColor -from .graphics import Graphics, Interpolation, Point, PosHint, Size, SizeHint +__all__ = ["Tiled", "Point", "Size"] -__all__ = ["TiledImage", "Interpolation", "Point", "Size"] - -class TiledImage(Graphics): +class Tiled(Gadget): r""" - A tiled image. + Tile a gadget over the visible region of ``Tiled``. Parameters ---------- - tile : Graphics + tile : Gadget The gadget to tile. - default_color : AColor, default: AColor(0, 0, 0, 0) - Default texture color. - alpha : float, default: 1.0 - Transparency of gadget. - interpolation : Interpolation, default: "linear" - Interpolation used when gadget is resized. size : Size, default: Size(10, 10) Size of gadget. pos : Point, default: Point(0, 0) @@ -45,14 +37,6 @@ class TiledImage(Graphics): ---------- tile : Graphics The gadget to tile. - texture : NDArray[np.uint8] - uint8 RGBA color array. - default_color : AColor - Default texture color. - alpha : float - Transparency of gadget. - interpolation : Interpolation - Interpolation used when gadget is resized. size : Size Size of gadget. height : int @@ -102,10 +86,6 @@ class TiledImage(Graphics): Methods ------- - to_png(path) - Write :attr:`texture` to provided path as a `png` image. - clear() - Fill texture with default color. apply_hints() Apply size and pos hints. to_local(point) @@ -159,60 +139,35 @@ class TiledImage(Graphics): def __init__( self, *, - tile: Graphics, - is_transparent: bool = True, - default_color: AColor = TRANSPARENT, - alpha: float = 1.0, - interpolation: Interpolation = "linear", + tile: Gadget, size: Size = Size(10, 10), pos: Point = Point(0, 0), size_hint: SizeHint | None = None, pos_hint: PosHint | None = None, + is_transparent: bool = True, is_visible: bool = True, is_enabled: bool = True, ): super().__init__( - is_transparent=is_transparent, - default_color=default_color, - alpha=alpha, - interpolation=interpolation, size=size, pos=pos, size_hint=size_hint, pos_hint=pos_hint, + is_transparent=is_transparent, is_visible=is_visible, is_enabled=is_enabled, ) self.tile = tile - @property - def tile(self): - """ - The gadget to tile. - - Setting this attribute updates the texture immediately. - """ - return self._tile - - @tile.setter - def tile(self, new_tile): - self._tile = new_tile - self.on_size() - - def on_size(self): - """Retile gadget on resize.""" + def _render(self, cells, graphics, kind): + # ! Maybe add custom renderer? h, w = self._size - tile = self.tile - - v_repeat = ceil(h / tile.height) - h_repeat = ceil(w / tile.width) - - texture = np.tile(tile.texture, (v_repeat, h_repeat, 1)) - - vr = h % tile.height - hr = w % tile.width - - vertical_slice = np.s_[: (-tile.height + vr) if vr else None] - horizontal_slice = np.s_[: (-tile.width + hr) if hr else None] - - self.texture = texture[vertical_slice, horizontal_slice].copy() + th, tw = tsize = self.tile.size + oy, ox = self.absolute_pos + for y in range(ceil(h / th)): + for x in range(ceil(w / tw)): + self.tile.pos = oy + y * th, ox + x * tw + self.tile._region = ( + Region.from_rect(self.tile.pos, tsize) & self._region + ) + self.tile._render(cells, graphics, kind) diff --git a/src/batgrl/gadgets/toggle_button.py b/src/batgrl/gadgets/toggle_button.py index 21cea616..377a6ba3 100644 --- a/src/batgrl/gadgets/toggle_button.py +++ b/src/batgrl/gadgets/toggle_button.py @@ -15,7 +15,7 @@ __all__ = ["ToggleButton", "ToggleState", "ButtonState", "Point", "Size"] CHECK_OFF = "☐ " -CHECK_ON = "☑ " +CHECK_ON = "🗹 " TOGGLE_OFF = "○ " TOGGLE_ON = "● " diff --git a/src/batgrl/gadgets/video.py b/src/batgrl/gadgets/video.py index 335feb8c..212f6aad 100644 --- a/src/batgrl/gadgets/video.py +++ b/src/batgrl/gadgets/video.py @@ -12,7 +12,16 @@ from ..colors import ABLACK, AColor from ..texture_tools import resize_texture -from .graphics import Graphics, Interpolation, Point, PosHint, Size, SizeHint +from .graphics import ( + Blitter, + Graphics, + Interpolation, + Point, + PosHint, + Size, + SizeHint, + scale_geometry, +) __all__ = ["Video", "Interpolation", "Point", "Size"] @@ -36,6 +45,8 @@ class Video(Graphics): Transparency of gadget. interpolation : Interpolation, default: "linear" Interpolation used when gadget is resized. + blitter : Blitter, default: "half" + Determines how graphics are rendered. size : Size, default: Size(10, 10) Size of gadget. pos : Point, default: Point(0, 0) @@ -69,6 +80,8 @@ class Video(Graphics): Transparency of gadget. interpolation : Interpolation Interpolation used when gadget is resized. + blitter : Blitter + Determines how graphics are rendered. size : Size Size of gadget. height : int @@ -186,29 +199,31 @@ def __init__( source: Path | str | int, loop: bool = True, default_color: AColor = ABLACK, - is_transparent: bool = False, alpha: float = 1.0, interpolation: Interpolation = "linear", + blitter: Blitter = "half", size: Size = Size(10, 10), pos: Point = Point(0, 0), size_hint: SizeHint | None = None, pos_hint: PosHint | None = None, + is_transparent: bool = False, is_visible: bool = True, is_enabled: bool = True, ): + self._current_frame = None super().__init__( default_color=default_color, - is_transparent=is_transparent, alpha=alpha, interpolation=interpolation, + blitter=blitter, size=size, pos=pos, size_hint=size_hint, pos_hint=pos_hint, + is_transparent=is_transparent, is_visible=is_visible, is_enabled=is_enabled, ) - self._current_frame = None self._resource = None self._video_task = None self.source = source @@ -264,11 +279,17 @@ def _time_delta(self) -> float: def _display_current_frame(self): h, w = self.size if self._current_frame is None or h == 0 or w == 0: - return - - self.texture = resize_texture( - self._current_frame, (2 * h, w), self.interpolation - ) + self.texture = np.full( + (*scale_geometry(self._blitter, self.size), 4), + self.default_color, + np.uint8, + ) + else: + self.texture = resize_texture( + self._current_frame, + scale_geometry(self._blitter, self.size), + self._interpolation, + ) async def _play_video(self): if self._resource is None: @@ -301,8 +322,6 @@ async def _play_video(self): def on_size(self): """Resize current frame on resize.""" - h, w = self.size - self.texture = np.full((2 * h, w, 4), self.default_color, dtype=np.uint8) self._display_current_frame() def on_remove(self): diff --git a/src/batgrl/geometry/basic.py b/src/batgrl/geometry/basic.py index 3ffd80bf..3ad0f814 100644 --- a/src/batgrl/geometry/basic.py +++ b/src/batgrl/geometry/basic.py @@ -58,7 +58,7 @@ def points_on_circle( radius: float = 1.0, center: tuple[float, float] = (0.0, 0.0), offset: float = 0.0, -) -> NDArray[np.float32]: +) -> NDArray[np.float64]: """ Return `n` points on a circle. diff --git a/src/batgrl/geometry/regions.pxd b/src/batgrl/geometry/regions.pxd index 8dd7f57b..a7334a05 100644 --- a/src/batgrl/geometry/regions.pxd +++ b/src/batgrl/geometry/regions.pxd @@ -1,13 +1,17 @@ cdef struct Band: int y1, y2 - Py_ssize_t size, len - int* walls + size_t size, len + int *walls cdef struct CRegion: - Py_ssize_t size, len - Band* bands + size_t size, len + Band *bands cdef class Region: cdef CRegion cregion + + +cdef bint contains(CRegion *cregion, int y, int x) +cdef void bounding_rect(CRegion *cregion, int *y, int *x, size_t *h, size_t *w) diff --git a/src/batgrl/geometry/regions.pyx b/src/batgrl/geometry/regions.pyx index e670616d..c10a833b 100644 --- a/src/batgrl/geometry/regions.pyx +++ b/src/batgrl/geometry/regions.pyx @@ -31,7 +31,7 @@ cdef bint csub(bint a, bint b): cdef inline int add_wall(Band *band, int wall): - cdef int* new_walls + cdef int *new_walls if band.len == band.size: new_walls = realloc(band.walls, sizeof(Band) * (band.size << 1)) if new_walls is NULL: @@ -45,7 +45,7 @@ cdef inline int add_wall(Band *band, int wall): cdef inline int add_band(CRegion *region): - cdef Py_ssize_t i + cdef size_t i if region.len == region.size: new_bands = realloc(region.bands, sizeof(Band) * (region.size << 1)) if new_bands is NULL: @@ -75,7 +75,7 @@ cdef int merge_bands( cdef: Band *new_band = ®ion.bands[region.len - 1] - Py_ssize_t i = 0, j = 0 + size_t i = 0, j = 0 bint inside_r = 0, inside_s = 0, inside_region = 0 int threshold @@ -136,7 +136,8 @@ cdef int merge_regions(CRegion *a, CRegion *b, CRegion *result, bool_op op): cdef: Band *r Band *s - int i = 0, j = 0, scanline = 0 + unsigned int i = 0, j = 0 + int scanline = 0 if a.len > 0: if b.len > 0: @@ -223,8 +224,8 @@ cdef int merge_regions(CRegion *a, CRegion *b, CRegion *result, bool_op op): return 0 -cdef inline Py_ssize_t bisect_bands(CRegion *region, int y): - cdef Py_ssize_t lo = 0, hi = region.len, mid +cdef inline size_t bisect_bands(CRegion *region, int y): + cdef size_t lo = 0, hi = region.len, mid while lo < hi: mid = (lo + hi) // 2 if y < region.bands[mid].y1: @@ -234,8 +235,8 @@ cdef inline Py_ssize_t bisect_bands(CRegion *region, int y): return lo -cdef inline Py_ssize_t bisect_walls(Band *band, int x): - cdef Py_ssize_t lo = 0, hi = band.len, mid +cdef inline size_t bisect_walls(Band *band, int x): + cdef size_t lo = 0, hi = band.len, mid while lo < hi: mid = (lo + hi) // 2 if x < band.walls[mid]: @@ -245,6 +246,42 @@ cdef inline Py_ssize_t bisect_walls(Band *band, int x): return lo +cdef bint contains(CRegion *cregion, int y, int x): + cdef size_t i + + i = bisect_bands(cregion, y) + if i == 0: + return 0 + + if cregion.bands[i - 1].y2 <= y: + return 0 + + i = bisect_walls(&cregion.bands[i - 1], x) + return i % 2 == 1 + + +cdef void bounding_rect(CRegion *cregion, int *y, int *x, size_t *h, size_t *w): + if not cregion.len: + return + + cdef: + size_t i + int min_x = cregion.bands[0].walls[0], max_x = min_x + Band *band + + for i in range(cregion.len): + band = &cregion.bands[i] + if band.walls[0] < min_x: + min_x = band.walls[0] + if band.walls[band.len - 1] > max_x: + max_x = band.walls[band.len - 1] + + y[0] = cregion.bands[0].y1 + h[0] = cregion.bands[cregion.len - 1].y2 - y[0] + x[0] = min_x + w[0] = max_x - min_x + + cdef class Region: def __cinit__(self): self.cregion.bands = malloc(sizeof(Band) * 8) @@ -260,7 +297,7 @@ cdef class Region: if self.cregion.bands is NULL: return - cdef Py_ssize_t i + cdef size_t i for i in range(self.cregion.len): if self.cregion.bands[i].walls is not NULL: free(self.cregion.bands[i].walls) @@ -333,7 +370,7 @@ cdef class Region: def __eq__(self, other: Region) -> bool: cdef: - Py_ssize_t i, j + size_t i, j Band *r Band *s if self.cregion.len != other.cregion.len: @@ -349,24 +386,12 @@ cdef class Region: return True def __contains__(self, point: Point) -> bool: - cdef: - int y, x - Py_ssize_t i - - y, x = point - i = bisect_bands(&self.cregion, y) - if i == 0: - return False - - if self.cregion.bands[i - 1].y2 <= y: - return False - - i = bisect_walls(&self.cregion.bands[i - 1], x) - return i % 2 == 1 + cdef int y = point[0], x = point[1] + return contains(&self.cregion, y, x) def rects(self) -> Iterator[tuple[Point, Size]]: cdef: - Py_ssize_t i, j + size_t i, j Band *band for i in range(self.cregion.len): diff --git a/src/batgrl/rendering.py b/src/batgrl/rendering.py deleted file mode 100644 index f229c1f3..00000000 --- a/src/batgrl/rendering.py +++ /dev/null @@ -1,123 +0,0 @@ -r""" -Terminal rendering. - -``render_root`` is responsible for generating ansi from the diffs of the root's double -buffer. Ansi is generated in a bit of a naive way, where the cursor is moved to specific -coordinate then all sgr parameters of the cell is output, for each cell in the diff. -This generates a lot of ansi for large diffs. In the future, this function might decide -to try and output as little ansi as possible, if there is a noticable performance gain -to do so. -""" - -import numpy as np - -from .gadgets._root import _Root -from .terminal import Vt100Terminal -from .text_tools import char_width - - -def render_root(root: _Root, terminal: Vt100Terminal) -> None: - """ - Render root canvas into a terminal. - - Parameters - ---------- - root : _Root - Root gadget of gadget tree. - terminal : Vt100Terminal - A VT100 terminal. - """ - if terminal.expect_dsr(): - return - - w = root.width - inline_column = root.pos.x - write = terminal._out_buffer.append - inline = not terminal.in_alternate_screen - last_y = 0 - - resized = root._resized # ! Must grab before calling root._render - root._render() - canvas = root.canvas # ! Must grab after calling root._render - - if resized: - ys, xs = np.indices(root.size).reshape(2, -1) - else: - diffs = root._last_canvas != canvas - ys, xs = diffs.nonzero() - - # Save cursor - write("\x1b7") - if inline: - terminal.move_cursor(root._pos) - for y, x, cell in zip(ys, xs, canvas[ys, xs]): - ( - char, - bold, - italic, - underline, - strikethrough, - overline, - reverse, - (fr, fg, fb), # foreground color - (br, bg, bb), # background color - ) = cell.item() - - # The following conditions ensure full-width glyphs "have enough room" else - # they are not painted. - if char == "": - # `""` is used to indicate the character before it is a full-width - # character. If this char is appearing in the diffs, we probably need to - # repaint the full-width character before it, but if the character - # before it isn't full-width paint whitespace instead. - if x > 0 and char_width(canvas["char"][y, x - 1].item()) == 2: - x -= 1 - ( - char, - bold, - italic, - underline, - strikethrough, - overline, - reverse, - (fr, fg, fb), - (br, bg, bb), - ) = canvas[y, x].item() - else: - char = " " - elif ( - x + 1 < w - and canvas["char"][y, x + 1].item() != "" - and char_width(char) == 2 - ): - # If the character is full-width, but the following character isn't - # `""`, assume the full-width character is being clipped, and paint - # whitespace instead. - char = " " - - if inline: - # Note that `y`s are non-decreasing. - if last_y < y: - # Move down `y - last_y` rows. - write(f"\x1b[{y - last_y}B") - # Move to column `x + 1`. - write(f"\x1b[{inline_column + x + 1}G") - else: - # Move cursor to position `(y + 1, x + 1)`. - write(f"\x1b[{y + 1};{x + 1}H") - last_y = y - - write( - "\x1b[0;" # Reset attributes. - f"{'1;' if bold else ''}" - f"{'3;' if italic else ''}" - f"{'4;' if underline else ''}" - f"{'9;' if strikethrough else ''}" - f"{'53;' if overline else ''}" - f"{'7;' if reverse else ''}" - f"38;2;{fr};{fg};{fb};48;2;{br};{bg};{bb}m" # Set color pair. - f"{char}" - ) - # Restore cursor - write("\x1b8") - terminal.flush() diff --git a/src/batgrl/spinners.py b/src/batgrl/spinners.py index 865285e2..646d2682 100644 --- a/src/batgrl/spinners.py +++ b/src/batgrl/spinners.py @@ -1,32 +1,32 @@ """ Spinners can be used as frames in a `TextAnimation`. -Spinners credit to `cli-spinners` (https://github.com/sindresorhus/cli-spinners). -`cli-spinners` is licensed under the MIT License: - -Copyright (c) Sindre Sorhus (https://sindresorhus.com) -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in the -Software without restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the -Software, and to permit persons to whom the Software is furnished to do so, subject -to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies -or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, -INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR -PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE -OR OTHER DEALINGS IN THE SOFTWARE. - See Also -------- `examples/basic/spinners.py`. """ +# Spinners credit to `cli-spinners` (https://github.com/sindresorhus/cli-spinners). +# `cli-spinners` is licensed under the MIT License: +# +# Copyright (c) Sindre Sorhus (https://sindresorhus.com) +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in the +# Software without restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +# Software, and to permit persons to whom the Software is furnished to do so, subject +# to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all copies +# or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +# INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +# PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF +# CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE +# OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + SPINNERS = { "dots": ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], "dots2": ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"], @@ -196,6 +196,7 @@ "⠀⡀", ], "dots13": ["⣼", "⣹", "⢻", "⠿", "⡟", "⣏", "⣧", "⣶"], + "dots14": ["⠉⠉", "⠈⠙", "⠀⠹", "⠀⢸", "⠀⣰", "⢀⣠", "⣀⣀", "⣄⡀", "⣆⠀", "⡇⠀", "⠏⠀", "⠋⠁"], "dots8bit": [ "⠀", "⠁", @@ -454,6 +455,7 @@ "⣾", "⣿", ], + "dots_circle": ["⢎ ", "⠎⠁", "⠊⠑", "⠈⠱", " ⡱", "⢀⡰", "⢄⡠", "⢆⡀"], "sand": [ "⠁", "⠂", @@ -509,6 +511,18 @@ "box_bounce": ["▖", "▘", "▝", "▗"], "box_bounce2": ["▌", "▀", "▐", "▄"], "triangle": ["◢", "◣", "◤", "◥"], + "binary": [ + "010010", + "001100", + "100101", + "111010", + "111101", + "010111", + "101011", + "111000", + "110011", + "110101", + ], "arc": ["◜", "◠", "◝", "◞", "◡", "◟"], "circle": ["◡", "⊙", "◠"], "square_corners": ["◰", "◳", "◲", "◱"], @@ -536,6 +550,7 @@ "[= ]", "[== ]", "[=== ]", + "[====]", "[ ===]", "[ ==]", "[ =]", diff --git a/src/batgrl/terminal/__init__.py b/src/batgrl/terminal/__init__.py index 5f9951c2..32d07a11 100644 --- a/src/batgrl/terminal/__init__.py +++ b/src/batgrl/terminal/__init__.py @@ -1,51 +1,31 @@ """Platform specific VT100 terminals.""" +import asyncio import platform import sys -from collections.abc import Callable +from collections.abc import Callable, Iterator from contextlib import contextmanager -from typing import ContextManager +from typing import Final -from .events import Event -from .vt100_terminal import Vt100Terminal +from ..geometry import Size +from .events import DeviceAttributesReportEvent, Event, PixelGeometryReportEvent +from .vt100_terminal import DRS_REQUEST_TIMEOUT, Vt100Terminal -__all__ = ["Vt100Terminal", "get_platform_terminal", "app_mode"] +__all__ = [ + "Vt100Terminal", + "app_mode", + "get_platform_terminal", + "get_sixel_info", +] - -def get_platform_terminal() -> Vt100Terminal: - """ - Return a platform-specific terminal. - - Returns - ------- - Vt100Terminal - A platform-specific VT100 terminal. - - Raises - ------ - RuntimeError - If terminal isn't interactive or terminal doesn't support VT100 sequences. - """ - if not sys.stdin.isatty(): - raise RuntimeError("Terminal is non-interactive.") - - if platform.system() == "Windows": - from .windows_terminal import WindowsTerminal, is_vt100_enabled - - if not is_vt100_enabled(): - raise RuntimeError("Terminal doesn't support VT100 sequences.") - - return WindowsTerminal() - else: - from .linux_terminal import LinuxTerminal - - return LinuxTerminal() +_SIXEL_SUPPORT: Final = 4 +"""Terminal attribute for sixel support.""" @contextmanager def app_mode( terminal: Vt100Terminal, event_handler: Callable[[list[Event]], None] -) -> ContextManager[None]: +) -> Iterator[None]: """ Put terminal into app mode and dispatch input events. @@ -78,3 +58,91 @@ def app_mode( terminal.flush() terminal.unattach() terminal.restore_console() + + +def get_platform_terminal() -> Vt100Terminal: + """ + Return a platform-specific terminal. + + Returns + ------- + Vt100Terminal + A platform-specific VT100 terminal. + + Raises + ------ + RuntimeError + If terminal isn't interactive or terminal doesn't support VT100 sequences. + """ + if not sys.stdin.isatty(): + raise RuntimeError("Terminal is non-interactive.") + + if platform.system() == "Windows": + from .windows_terminal import WindowsTerminal, is_vt100_enabled + + if not is_vt100_enabled(): + raise RuntimeError("Terminal doesn't support VT100 sequences.") + + return WindowsTerminal() + else: + from .linux_terminal import LinuxTerminal + + return LinuxTerminal() + + +async def get_sixel_info(terminal: Vt100Terminal) -> tuple[bool, Size]: + """ + Determine if terminal has sixel support and, if supported, its pixel geometry. + + Parameters + ---------- + terminal : VT100Terminal + A VT100 terminal to query for sixel support. + + Returns + ------- + tuple[bool, Size] + Whether sixel is supported and the pixel geometry. + """ + sixel_support: bool = False + pixel_geometry: Size = Size(20, 10) + report_timeout: asyncio.TimerHandle + terminal_info_reported: asyncio.Event = asyncio.Event() + cell_reported: bool = False + + def report_handler(events: list[Event]) -> None: + """Handle terminal reports.""" + nonlocal sixel_support, pixel_geometry, cell_reported, report_timeout + + for event in events: + if isinstance(event, DeviceAttributesReportEvent): + report_timeout.cancel() + sixel_support = _SIXEL_SUPPORT in event.device_attributes + if sixel_support: + terminal.request_pixel_geometry() + terminal.request_terminal_geometry() + report_timeout = asyncio.get_running_loop().call_later( + DRS_REQUEST_TIMEOUT, terminal_info_reported.set + ) + else: + terminal_info_reported.set() + elif isinstance(event, PixelGeometryReportEvent): + report_timeout.cancel() + if event.kind == "cell": + pixel_geometry = event.geometry + cell_reported = True + elif not cell_reported: + ph, pw = event.geometry + th, tw = terminal.get_size() + pixel_geometry = Size(ph // th, pw // tw) + terminal_info_reported.set() + + old_handler = terminal._event_handler + terminal._event_handler = report_handler + terminal.request_device_attributes() + report_timeout = asyncio.get_running_loop().call_later( + DRS_REQUEST_TIMEOUT, terminal_info_reported.set + ) + await terminal_info_reported.wait() + terminal._event_handler = old_handler + return sixel_support, pixel_geometry diff --git a/src/batgrl/terminal/events.py b/src/batgrl/terminal/events.py index cf4317b5..4def80c5 100644 --- a/src/batgrl/terminal/events.py +++ b/src/batgrl/terminal/events.py @@ -7,16 +7,21 @@ from ..geometry import Point, Size __all__ = [ + "CharKey", "ColorReportEvent", - "CursorPositionResponseEvent", + "CursorPositionReportEvent", "DeviceAttributesReportEvent", "Event", "FocusEvent", "Key", "KeyEvent", + "MouseButton", "MouseEvent", + "MouseEventType", "PasteEvent", + "PixelGeometryReportEvent", "ResizeEvent", + "SpecialKey", ] # fmt: off @@ -92,9 +97,9 @@ class ResizeEvent(Event): @dataclass -class CursorPositionResponseEvent(Event): +class CursorPositionReportEvent(Event): """ - A cursor position response event. + A cursor position report event. Parameters ---------- @@ -160,6 +165,32 @@ class DeviceAttributesReportEvent(Event): """Reported terminal attributes.""" +@dataclass +class PixelGeometryReportEvent(Event): + """ + A pixel geometry report. + + Parameters + ---------- + kind : Literal["cell", "terminal"] + Whether report is pixels per cell or pixels in terminal. + geometry : Size + Size of the terminal cells or terminal (depending on ``kind``) in pixels. + + Attributes + ---------- + kind : Literal["cell", "terminal"] + Whether report is pixels per cell or pixels in terminal. + geometry : Size + Size of the terminal cells or terminal (depending on ``kind``) in pixels. + """ + + kind: Literal["cell", "terminal"] + """Whether report is pixels per cell or pixels in terminal.""" + geometry: Size + """Size of the terminal cells or terminal (depending on ``kind``) in pixels.""" + + @dataclass class KeyEvent(Event): """ diff --git a/src/batgrl/terminal/linux_terminal.py b/src/batgrl/terminal/linux_terminal.py index bbefe137..7809132f 100644 --- a/src/batgrl/terminal/linux_terminal.py +++ b/src/batgrl/terminal/linux_terminal.py @@ -75,6 +75,10 @@ class LinuxTerminal(Vt100Terminal): Report terminal background color. request_device_attributes() Report device attributes. + request_pixel_geometry() + Report pixel geometry per cell. + request_terminal_geometry() + Report pixel geometry of terminal. expect_dsr() Return whether a device status report is expected. move_cursor(pos) @@ -129,7 +133,8 @@ def attach(self, event_handler: Callable[[list[Event]], None]) -> None: def process(): self.process_stdin() - event_handler(self.events()) + if self._event_handler is not None: + self._event_handler(self.events()) loop = asyncio.get_running_loop() loop.add_reader(STDIN, process) diff --git a/src/batgrl/terminal/vt100_terminal.py b/src/batgrl/terminal/vt100_terminal.py index 208dbd36..d8901ba2 100644 --- a/src/batgrl/terminal/vt100_terminal.py +++ b/src/batgrl/terminal/vt100_terminal.py @@ -3,19 +3,19 @@ import asyncio import os import re -import sys from abc import ABC, abstractmethod from collections.abc import Callable from enum import Enum, auto from io import StringIO from typing import Final, Literal +from .._fbuf import FBufWrapper from ..colors import Color from ..geometry import Point, Size from .ansi_escapes import ANSI_ESCAPES from .events import ( ColorReportEvent, - CursorPositionResponseEvent, + CursorPositionReportEvent, DeviceAttributesReportEvent, Event, FocusEvent, @@ -24,6 +24,7 @@ MouseEvent, MouseEventType, PasteEvent, + PixelGeometryReportEvent, UnknownEscapeSequence, ) @@ -33,6 +34,7 @@ ) # \x1b[?61;4;6;7;14;21;22;23;24;28;32;42c DEVICE_ATTRIBUTES_RE: Final = re.compile(r"\x1b\[\?[0-9;]+c") +PIXEL_GEOMETRY_RE: Final = re.compile(r"\x1b\[([6|4]);(\d+);(\d+)t") MOUSE_SGR_RE: Final = re.compile(r"\x1b\[<(\d+);(\d+);(\d+)(m|M)") PARAMS_RE: Final = re.compile(r"[0-9;]") BRACKETED_PASTE_START: Final = "\x1b[200~" @@ -126,6 +128,10 @@ class Vt100Terminal(ABC): Report terminal background color. request_device_attributes() Report device attributes. + request_pixel_geometry() + Report pixel geometry per cell. + request_terminal_geometry() + Report pixel geometry of terminal. expect_dsr() Return whether a device status report is expected. move_cursor(pos) @@ -143,7 +149,7 @@ def __init__(self): """Paste buffer.""" self._event_buffer: list[Event] = [] """Events generated during input parsing.""" - self._out_buffer: list[str] = [] + self._out_buffer: FBufWrapper = FBufWrapper() """ Output buffer. @@ -279,34 +285,8 @@ def _execute(self) -> None: escape = self._escape_buffer.getvalue() self._escape_buffer = None - if self._dsr_pending: - if cpr_match := CPR_RE.fullmatch(escape): - self._dsr_pending -= 1 - y, x = cpr_match.groups() - self._event_buffer.append( - CursorPositionResponseEvent(Point(int(y) - 1, int(x) - 1)) - ) - return - - if color_match := COLOR_RE.fullmatch(escape): - self._dsr_pending -= 1 - kind, r, g, b = color_match.groups() - self._event_buffer.append( - ColorReportEvent( - kind="fg" if kind == "0" else "bg", - color=Color.from_hex(f"{r[:2]}{g[:2]}{b[:2]}"), - ) - ) - return - - if device_attributes_match := DEVICE_ATTRIBUTES_RE.fullmatch(escape): - self._dsr_pending -= 1 - device_attributes = device_attributes_match.group()[3:-1].split(";") - self._event_buffer.append( - DeviceAttributesReportEvent(frozenset(map(int, device_attributes))) - ) - return - + if self._dsr_pending and self._execute_dsr_request(escape): + return if escape == BRACKETED_PASTE_START: self._state = ParserState.PASTE self._paste_buffer = StringIO(newline=None) @@ -350,6 +330,34 @@ def _execute(self) -> None: else: self._event_buffer.append(UnknownEscapeSequence(escape)) + def _execute_dsr_request(self, escape: str) -> bool: + """Return whether a device status report was issued.""" + event: Event + if cpr_match := CPR_RE.fullmatch(escape): + y, x = cpr_match.groups() + event = CursorPositionReportEvent(Point(int(y) - 1, int(x) - 1)) + elif color_match := COLOR_RE.fullmatch(escape): + kind, r, g, b = color_match.groups() + event = ColorReportEvent( + kind="fg" if kind == "0" else "bg", + color=Color.from_hex(f"{r[:2]}{g[:2]}{b[:2]}"), + ) + elif device_attributes_match := DEVICE_ATTRIBUTES_RE.fullmatch(escape): + device_attributes = device_attributes_match.group()[3:-1].split(";") + event = DeviceAttributesReportEvent(frozenset(map(int, device_attributes))) + elif pixel_geometry_match := PIXEL_GEOMETRY_RE.fullmatch(escape): + kind, height, width = pixel_geometry_match.groups() + event = PixelGeometryReportEvent( + kind="cell" if kind == "6" else "terminal", + geometry=Size(int(height), int(width)), + ) + else: + return False + + self._dsr_pending -= 1 + self._event_buffer.append(event) + return True + def _reset_escape(self) -> None: """Execute escape buffer after a timeout period.""" if self._state is ParserState.PASTE: @@ -377,70 +385,67 @@ def flush(self) -> None: if not self._out_buffer: return - data = "".join(self._out_buffer).encode(errors="replace") - self._out_buffer.clear() - sys.__stdout__.buffer.write(data) - sys.__stdout__.flush() + self._out_buffer.flush() def set_title(self, title: str) -> None: """Set terminal title.""" - self._out_buffer.append(f"\x1b]2;{title}\x07") + self._out_buffer.write(b"\x1b]2;%b\x07" % title.encode()) def enter_alternate_screen(self) -> None: """Enter alternate screen buffer.""" - self._out_buffer.append("\x1b[?1049h\x1b[H") + self._out_buffer.write(b"\x1b[?1049h\x1b[H") self.in_alternate_screen = True def exit_alternate_screen(self) -> None: """Exit alternate screen buffer.""" - self._out_buffer.append("\x1b[?1049l") + self._out_buffer.write(b"\x1b[?1049l") self.in_alternate_screen = False def enable_mouse_support(self) -> None: """Enable mouse support in terminal.""" - self._out_buffer.append( - "\x1b[?1000h" # SET_VT200_MOUSE - "\x1b[?1003h" # SET_ANY_EVENT_MOUSE - "\x1b[?1006h" # SET_SGR_EXT_MODE_MOUSE + self._out_buffer.write( + b"\x1b[?1000h" # SET_VT200_MOUSE + b"\x1b[?1003h" # SET_ANY_EVENT_MOUSE + b"\x1b[?1006h" # SET_SGR_EXT_MODE_MOUSE ) def disable_mouse_support(self) -> None: """Disable mouse support in terminal.""" - self._out_buffer.append( - "\x1b[?1000l" # SET_VT200_MOUSE - "\x1b[?1003l" # SET_ANY_EVENT_MOUSE - "\x1b[?1006l" # SET_SGR_EXT_MODE_MOUSE + self._out_buffer.write( + b"\x1b[?1000l" # SET_VT200_MOUSE + b"\x1b[?1003l" # SET_ANY_EVENT_MOUSE + b"\x1b[?1006l" # SET_SGR_EXT_MODE_MOUSE ) def reset_attributes(self) -> None: """Reset character attributes.""" - self._out_buffer.append("\x1b[0m") + self._out_buffer.write(b"\x1b[0m") def enable_bracketed_paste(self) -> None: """Enable bracketed paste in terminal.""" - self._out_buffer.append("\x1b[?2004h") + self._out_buffer.write(b"\x1b[?2004h") def disable_bracketed_paste(self) -> None: """Disable bracketed paste in terminal.""" - self._out_buffer.append("\x1b[?2004l") + self._out_buffer.write(b"\x1b[?2004l") def show_cursor(self) -> None: """Show cursor in terminal.""" - self._out_buffer.append("\x1b[?25h") + self._out_buffer.write(b"\x1b[?25h") def hide_cursor(self) -> None: """Hide cursor in terminal.""" - self._out_buffer.append("\x1b[?25l") + self._out_buffer.write(b"\x1b[?25l") def enable_reporting_focus(self) -> None: """Enable reporting terminal focus.""" - self._out_buffer.append("\x1b[?1004h") + self._out_buffer.write(b"\x1b[?1004h") def disable_reporting_focus(self) -> None: """Disable reporting terminal focus.""" - self._out_buffer.append("\x1b[?1004l") + self._out_buffer.write(b"\x1b[?1004l") - def _dsr_request(self, escape: str) -> None: + def _dsr_request(self, escape: bytes) -> None: """Make a device status report request and schedule its cancellation.""" try: loop = asyncio.get_running_loop() @@ -449,7 +454,7 @@ def _dsr_request(self, escape: str) -> None: self._dsr_pending += 1 loop.call_later(DRS_REQUEST_TIMEOUT, self._timeout_dsr) - self._out_buffer.append(escape) + self._out_buffer.write(escape) self.flush() def _timeout_dsr(self) -> None: @@ -458,19 +463,27 @@ def _timeout_dsr(self) -> None: def request_cursor_position_report(self) -> None: """Report current cursor position.""" - self._dsr_request("\x1b[6n") + self._dsr_request(b"\x1b[6n") def request_foreground_color(self) -> None: """Report terminal foreground color.""" - self._dsr_request("\x1b]10;?\x1b\\") + self._dsr_request(b"\x1b]10;?\x1b\\") def request_background_color(self) -> None: """Report terminal background color.""" - self._dsr_request("\x1b]11;?\x1b\\") + self._dsr_request(b"\x1b]11;?\x1b\\") def request_device_attributes(self) -> None: """Report device attributes.""" - self._dsr_request("\x1b[c") + self._dsr_request(b"\x1b[c") + + def request_pixel_geometry(self) -> None: + """Report pixel geometry per cell.""" + self._dsr_request(b"\x1b[16t") + + def request_terminal_geometry(self) -> None: + """Report pixel geometry of terminal.""" + self._dsr_request(b"\x1b[14t") def expect_dsr(self) -> bool: """Return whether a device status report is expected.""" @@ -486,7 +499,7 @@ def move_cursor(self, pos: Point) -> None: Cursor's new position. """ y, x = pos - self._out_buffer.append(f"\x1b[{y + 1};{x + 1}H") + self._out_buffer.write(b"\x1b[%d;%dH" % (y + 1, x + 1)) def erase_in_display(self, n: Literal[0, 1, 2, 3] = 0) -> None: """ @@ -500,4 +513,4 @@ def erase_in_display(self, n: Literal[0, 1, 2, 3] = 0) -> None: of the screen. If n is ``2``, clear entire screen. If n is ``3``, clear entire screen and delete all lines in scrollback buffer. """ - self._out_buffer.append(f"\x1b[{n}J") + self._out_buffer.write(b"\x1b[%dJ" % n) diff --git a/src/batgrl/terminal/windows_terminal.py b/src/batgrl/terminal/windows_terminal.py index 3134b4d9..c5fe490c 100644 --- a/src/batgrl/terminal/windows_terminal.py +++ b/src/batgrl/terminal/windows_terminal.py @@ -211,6 +211,10 @@ class WindowsTerminal(Vt100Terminal): Report terminal background color. request_device_attributes() Report device attributes. + request_pixel_geometry() + Report pixel geometry per cell. + request_terminal_geometry() + Report pixel geometry of terminal. expect_dsr() Return whether a device status report is expected. move_cursor(pos) @@ -219,6 +223,21 @@ class WindowsTerminal(Vt100Terminal): Clear part of the screen. """ + def __init__(self) -> None: + super().__init__() + self._original_input_mode = DWORD() + """Original console input mode.""" + windll.kernel32.GetConsoleMode(STDIN, byref(self._original_input_mode)) + + self._original_output_mode = DWORD() + """Original console output mode.""" + windll.kernel32.GetConsoleMode(STDOUT, byref(self._original_output_mode)) + + self._original_input_cp = windll.kernel32.GetConsoleCP() + """Original console input code page.""" + self._original_output_cp = windll.kernel32.GetConsoleOutputCP() + """Original console output code page.""" + def _feed(self, data: str) -> None: # Some versions of Windows Terminal generate spurious null characters for a few # input events. For instance, ctrl+" " generates 3 null characters instead of 1 @@ -269,21 +288,20 @@ def _purge(self, chars: list[str]): def raw_mode(self) -> None: """Set terminal to raw mode.""" - self._original_output_mode = DWORD() - windll.kernel32.GetConsoleMode(STDOUT, byref(self._original_output_mode)) + windll.kernel32.SetConsoleMode(STDIN, ENABLE_VIRTUAL_TERMINAL_INPUT) windll.kernel32.SetConsoleMode( STDOUT, self._original_output_mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING, ) - self._original_input_mode = DWORD() - windll.kernel32.GetConsoleMode(STDIN, byref(self._original_input_mode)) - windll.kernel32.SetConsoleMode(STDIN, ENABLE_VIRTUAL_TERMINAL_INPUT) + windll.kernel32.SetConsoleCP(65001) + windll.kernel32.SetConsoleOutputCP(65001) def restore_console(self) -> None: """Restore console to its original mode.""" windll.kernel32.SetConsoleMode(STDIN, self._original_input_mode) windll.kernel32.SetConsoleMode(STDOUT, self._original_output_mode) - del self._original_input_mode, self._original_output_mode + windll.kernel32.SetConsoleCP(self._original_input_cp) + windll.kernel32.SetConsoleOutputCP(self._original_output_cp) def attach(self, event_handler: Callable[[list[Event]], None]) -> None: """ @@ -296,26 +314,26 @@ def attach(self, event_handler: Callable[[list[Event]], None]) -> None: """ self._event_buffer.clear() self._event_handler = event_handler - loop = asyncio.get_running_loop() - wait_for = windll.kernel32.WaitForMultipleObjects - self._remove_event = HANDLE( + self._remove_event = remove_event = HANDLE( windll.kernel32.CreateEventA( SECURITY_ATTRIBUTES(), BOOL(True), BOOL(False), None ) ) - EVENTS = (HANDLE * 2)(self._remove_event, STDIN) + loop = asyncio.get_running_loop() + wait_for = windll.kernel32.WaitForMultipleObjects + EVENTS = (HANDLE * 2)(remove_event, STDIN) def ready(): try: self.process_stdin() - event_handler(self.events()) + if self._event_handler is not None: + self._event_handler(self.events()) finally: loop.run_in_executor(None, wait) def wait(): if wait_for(2, EVENTS, BOOL(False), DWORD(-1)) == 0: - windll.kernel32.CloseHandle(self._remove_event) - del self._remove_event + windll.kernel32.CloseHandle(remove_event) else: loop.call_soon_threadsafe(ready) diff --git a/src/batgrl/text_tools.py b/src/batgrl/text_tools.py index d125e1e8..bdfa6132 100644 --- a/src/batgrl/text_tools.py +++ b/src/batgrl/text_tools.py @@ -1,26 +1,22 @@ """Tools for text.""" -from bisect import bisect -from functools import lru_cache -from operator import itemgetter +from functools import cache import numpy as np from numpy.typing import NDArray from ._batgrl_markdown import find_md_tokens -from ._char_widths import CHAR_WIDTHS # type: ignore +from .char_width import char_width, str_width from .colors import BLACK, WHITE, Color from .geometry import Size __all__ = [ "Cell", "add_text", - "binary_to_box", - "binary_to_braille", - "new_cell", "char_width", "coerce_cell", "is_word_char", + "new_cell", "smooth_horizontal_bar", "smooth_vertical_bar", "str_width", @@ -28,62 +24,6 @@ VERTICAL_BLOCKS = " ▁▂▃▄▅▆▇█" HORIZONTAL_BLOCKS = " ▏▎▍▌▋▊▉█" -_BRAILLE_ENUM = np.array([[1, 8], [2, 16], [4, 32], [64, 128]]) -_BOX_ENUM = np.array([[1, 4], [2, 8]]) - -_vectorized_chr = np.vectorize(chr) -"""Vectorized `chr`.""" - -_vectorized_box_map = np.vectorize(" ▘▖▌▝▀▞▛▗▚▄▙▐▜▟█".__getitem__) -"""Vectorized box enum to box char.""" - - -@lru_cache(maxsize=1024) -def char_width(char: str) -> int: - """ - Return the column width of a character. - - Parameters - ---------- - char : str - A unicode character. - - Returns - ------- - int - The character column width. - """ - if char == "": - return 0 - - char_ord = ord(char) - i = bisect(CHAR_WIDTHS, char_ord, key=itemgetter(0)) - if i == 0: - return 1 - - _, high, width = CHAR_WIDTHS[i - 1] - if char_ord <= high: - return width - - return 1 - - -@lru_cache(maxsize=256) -def str_width(chars: str) -> int: - """ - Return the total column width of a string. - - Parameters - ---------- - chars : str - A string. - - Returns - ------- - int - The total column width of the string. - """ - return sum(map(char_width, chars)) def is_word_char(char: str) -> bool: @@ -120,8 +60,24 @@ def is_word_char(char: str) -> bool: ) """A structured array type that represents a single cell in a terminal.""" +# Current bug with cython raises an error when passing type "w" (PY_UCS4, the "char" +# field). When calling cython functions, re-view "char" field as uint32. +_Cell = np.dtype( + [ + ("char", "uint32"), + ("bold", "?"), + ("italic", "?"), + ("underline", "?"), + ("strikethrough", "?"), + ("overline", "?"), + ("reverse", "?"), + ("fg_color", "u1", (3,)), + ("bg_color", "u1", (3,)), + ] +) -@lru_cache + +@cache def cell_sans(*names: str) -> list[str]: r""" Return all fields of ``Cell`` not in names. @@ -169,6 +125,8 @@ def new_cell( Whether cell is strikethrough. overline : bool, default: False Whether cell is overlined. + reverse : bool, default: False + Whether cell is reversed. fg_color : Color, default: WHITE Foreground color of cell. bg_color : Color, default: BLACK @@ -493,41 +451,3 @@ def smooth_horizontal_bar( The bar as a tuple of characters. """ return _smooth_bar(HORIZONTAL_BLOCKS, max_width, proportion, offset) - - -def binary_to_braille(array_4x2: NDArray[np.bool_]) -> NDArray[np.dtype(" NDArray[np.dtype("