diff --git a/CHANGELOG.md b/CHANGELOG.md index d874309c..b589749e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **(BREAKING!)** Redefined `KittyImage.clear()` ([97eceab]). +- **(BREAKING!)** Changed the valid values for the `z_index` style-specific parameter of the *kitty* render style ([#74]). + - `None` is no longer a valid value. + - The lower bound of the valid value range is now `-(2**31 - 1)`. ### Removed - The CLI and TUI ([#72]). @@ -31,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#70]: https://github.com/AnonymouX47/term-image/pull/70 [#72]: https://github.com/AnonymouX47/term-image/pull/72 +[#74]: https://github.com/AnonymouX47/term-image/pull/74 [b4533d5]: https://github.com/AnonymouX47/term-image/commit/b4533d5697d41fe0742c2ac895077da3b8d889dc [97eceab]: https://github.com/AnonymouX47/term-image/commit/b4533d5697d41fe0742c2ac895077da3b8d889dc diff --git a/src/term_image/image/kitty.py b/src/term_image/image/kitty.py index ee62a5e2..40006336 100644 --- a/src/term_image/image/kitty.py +++ b/src/term_image/image/kitty.py @@ -59,7 +59,7 @@ class KittyImage(GraphicsImage): :: - [method] [ z [index] ] [ m {0 | 1} ] [ c {0-9} ] + [method] [ z {index} ] [ m {0 | 1} ] [ c {0-9} ] * ``method``: Render method override. @@ -72,17 +72,17 @@ class KittyImage(GraphicsImage): * ``z``: Image/Text stacking order. - * ``index``: Image z-index. An integer in the **signed 32-bit range**. + * ``index``: Image z-index. An integer in the **signed 32-bit range** + (excluding ``-(2**31)``). - Images drawn in the same location with different z-index values will be - blended if they are semi-transparent. If ``index`` is: + Overlapping images with different z-indexes values will be blended if they are + semi-transparent. If ``index`` is: * ``>= 0``, the image will be drawn above text. * ``< 0``, the image will be drawn below text. * ``< -(2**31)/2``, the image will be drawn below cells with non-default background color. - * ``z`` without ``index`` is currently only used internally. * If *absent*, defaults to ``z0`` i.e z-index zero. * e.g ``z0``, ``z1``, ``z-1``, ``z2147483647``, ``z-2147483648``. @@ -113,7 +113,7 @@ class KittyImage(GraphicsImage): """ _FORMAT_SPEC: Tuple[re.Pattern] = tuple( - map(re.compile, r"[LW] z(-?\d+)? m[01] c[0-9]".split(" ")) + map(re.compile, r"[LW] z-?\d+ m[01] c[0-9]".split(" ")) ) _render_methods: Set[str] = {LINES, WHOLE} _default_render_method: str = LINES @@ -133,12 +133,14 @@ class KittyImage(GraphicsImage): "z_index": ( 0, ( - lambda x: x is None or isinstance(x, int), - "z-index must be `None` or an integer", + lambda x: isinstance(x, int), + "z-index must be an integer", ), ( - lambda x: x is None or -(2**31) <= x < 2**31, - "z-index must be within the 32-bit signed integer range", + # INT32_MIN is reserved for non-native animations + lambda x: -(2**31) < x < 2**31, + "z-index must be within the 32-bit signed integer range " + "(excluding ``-(2**31)``)", ), ), "mix": ( @@ -175,7 +177,8 @@ def clear( Args: cursor: If ``True``, all images intersecting with the current cursor position are cleared. - z_index: If given, all images on the given z-index are cleared. + z_index: An integer in the **signed 32-bit range**. If given, all images + on the given z-index are cleared. now: If ``True`` the images are cleared immediately. Otherwise they're cleared when next Python's standard output buffer is flushed. @@ -192,13 +195,16 @@ def clear( if not isinstance(cursor, bool): raise TypeError(f"Invalid type for 'cursor' (got: {type(cursor).__name__})") - _, (type_check, _), (value_check, value_msg) = cls._style_args["z_index"] - if not type_check(z_index): - raise TypeError( - f"Invalid type for 'z_index' (got: {type(z_index).__name__})" - ) - if not value_check(z_index): - raise ValueError(value_msg) + if z_index is not None: + if not isinstance(z_index, int): + raise TypeError( + f"Invalid type for 'z_index' (got: {type(z_index).__name__})" + ) + if not -(1 << 31) <= z_index < (1 << 31): + raise ValueError( + "z-index must be within the 32-bit signed integer range " + f"(got: {z_index})" + ) if not isinstance(now, bool): raise TypeError(f"Invalid type for 'now' (got: {type(now).__name__})") @@ -225,7 +231,7 @@ def draw( self, *args, method: Optional[str] = None, - z_index: Optional[int] = 0, + z_index: int = 0, mix: bool = False, compress: int = 4, **kwargs, @@ -240,14 +246,13 @@ def draw( effective render method of the instance is used. z_index: The stacking order of images and text **for non-animations**. - Images drawn in the same location with different z-index values will be - blended if they are semi-transparent. If *z_index* is: + Overlapping images with different z-indexes values will be blended if + they are semi-transparent. If *z_index* is: * ``>= 0``, the image will be drawn above text. * ``< 0``, the image will be drawn below text. * ``< -(2**31)/2``, the image will be drawn below cells with non-default background color. - * ``None``, internal use only, mentioned for the sake of completeness. To inter-mixing text with an image, see the *mix* parameter. @@ -322,7 +327,7 @@ def is_supported(cls): @classmethod def _check_style_format_spec(cls, spec: str, original: str) -> Dict[str, Any]: - parent, (method, (z, index), mix, compress) = cls._get_style_format_spec( + parent, (method, z_index, mix, compress) = cls._get_style_format_spec( spec, original ) args = {} @@ -330,8 +335,8 @@ def _check_style_format_spec(cls, spec: str, original: str) -> Dict[str, Any]: args.update(super()._check_style_format_spec(parent, original)) if method: args["method"] = LINES if method == "L" else WHOLE - if z: - args["z_index"] = index and int(index) + if z_index: + args["z_index"] = int(z_index[1:]) if mix: args["mix"] = bool(int(mix[-1])) if compress: @@ -343,7 +348,7 @@ def _check_style_format_spec(cls, spec: str, original: str) -> Dict[str, Any]: def _clear_frame(cls) -> bool: """Clears an animation frame on-screen. - | Only used on Kitty <= 0.25.0 because ``z_index=None`` is buggy on these + | Only used on Kitty <= 0.25.0 because ``blend=False`` is buggy on these versions. Does nothing on any other version or terminal. | Note that this implementation might do more than required since it clears all images on screen. @@ -351,18 +356,14 @@ def _clear_frame(cls) -> bool: See :py:meth:`~term_image.image.BaseImage._clear_frame` for description. """ if cls._KITTY_VERSION and cls._KITTY_VERSION <= (0, 25, 0): - cls.clear() + cls.clear(z_index=-(1 << 31)) return True return False def _display_animated(self, *args, **kwargs) -> None: + kwargs["z_index"] = -(1 << 31) if self._KITTY_VERSION > (0, 25, 0): - kwargs["z_index"] = None - else: - try: - del kwargs["z_index"] - except KeyError: - pass + kwargs["blend"] = False super()._display_animated(*args, **kwargs) @@ -392,10 +393,19 @@ def _render_image( *, frame: bool = False, method: Optional[str] = None, - z_index: Optional[int] = 0, + z_index: int = 0, mix: bool = False, compress: int = 4, + blend: bool = True, ) -> str: + """See :py:meth:`BaseImage._render_image` for the description of the method and + :py:meth:`draw` for parameters not described here. + + Args: + blend: If ``False``, the rendered image deletes overlapping/intersecting + images when drawn. Otherwise, the behaviour is dependent on the z-index + and/or the terminal emulator (for images with the same z-index). + """ # NOTE: It's more efficient to write separate strings to the buffer separately # than concatenate and write together. @@ -422,7 +432,7 @@ def _render_image( control_data = ControlData(f=format, s=width, c=r_width, z=z_index) erase = "" if mix else f"{CSI}{r_width}X" jump_right = f"{CSI}{r_width}C" - if z_index is None: + if not blend: delete = f"{START}a=d,d=C;{ST}" if render_method == LINES: @@ -434,7 +444,7 @@ def _render_image( trans = Transmission( control_data, raw_image.read(bytes_per_line), compress ) - z_index is None and buffer.write(delete) + blend or buffer.write(delete) for chunk in trans.get_chunks(): buffer.write(chunk) # Writing spaces clears any text under transparent areas of an image @@ -445,7 +455,7 @@ def _render_image( trans = Transmission( control_data, raw_image.read(bytes_per_line), compress ) - z_index is None and buffer.write(delete) + blend or buffer.write(delete) for chunk in trans.get_chunks(): buffer.write(chunk) buffer.write(erase) @@ -456,7 +466,7 @@ def _render_image( vars(control_data).update(v=height, r=r_height) return "".join( ( - z_index is None and delete or "", + ("" if blend else delete), Transmission(control_data, raw_image, compress).get_chunked(), f"{erase}{jump_right}\n" * (r_height - 1), f"{erase}{jump_right}", diff --git a/tests/test_kitty.py b/tests/test_kitty.py index 9b1e400c..24123f9e 100644 --- a/tests/test_kitty.py +++ b/tests/test_kitty.py @@ -81,6 +81,7 @@ def test_style_format_spec(): "c-1", "c10", "c4m1", + "z", " z1", "m0 ", " z1c1 ", @@ -96,8 +97,7 @@ def test_style_format_spec(): ("z1", {"z_index": 1}), ("z-1", {"z_index": -1}), (f"z{2**31 - 1}", {"z_index": 2**31 - 1}), - (f"z{-2**31}", {"z_index": -(2**31)}), - ("z", {"z_index": None}), + (f"z{-(2**31 - 1)}", {"z_index": -(2**31 - 1)}), ("m0", {}), ("m1", {"mix": True}), ("c4", {}), @@ -126,15 +126,15 @@ def test_method(self): assert KittyImage._check_style_args({"method": value}) == {"method": value} def test_z_index(self): - for value in (1.0, (), [], "2"): + for value in (None, 1.0, (), [], "2"): with pytest.raises(TypeError): KittyImage._check_style_args({"z_index": value}) - for value in (-(2**31) - 1, 2**31): + for value in (-(2**31), 2**31): with pytest.raises(ValueError): KittyImage._check_style_args({"z_index": value}) assert KittyImage._check_style_args({"z_index": 0}) == {} - for value in (None, 1, -1, -(2**31), 2**31 - 1): + for value in (1, -1, -(2**31 - 1), 2**31 - 1): assert ( KittyImage._check_style_args({"z_index": value}) == {"z_index": value} # fmt: skip @@ -239,9 +239,9 @@ class TestRenderLines: trans.height = _size trans.set_render_method(LINES) - def render_image(self, alpha=0.0, *, z=0, m=False, c=4): + def render_image(self, alpha=0.0, *, z=0, m=False, c=4, b=True): return self.trans._renderer( - self.trans._render_image, alpha, z_index=z, mix=m, compress=c + self.trans._render_image, alpha, z_index=z, mix=m, compress=c, blend=b ) def _test_image_size(self, image): @@ -384,20 +384,12 @@ def test_z_index(self): assert ("z", "0") in decode_image(line)[0] # z_index = - for value in (1, -1, -(2**31), 2**31 - 1): + for value in (1, -1, -(2**31 - 1), 2**31 - 1): render = self.render_image(None, z=value) assert render == f"{self.trans:1.1#+z{value}}" for line in render.splitlines(): assert ("z", f"{value}") in decode_image(line)[0] - # z_index = None - render = self.render_image(None, z=None) - assert render == f"{self.trans:1.1#+z}" - for line in render.splitlines(): - assert line.startswith(delete) - control_codes = decode_image(line.partition(delete)[2])[0] - assert all(key != "z" for key, value in control_codes) - def test_mix(self): self.trans.scale = 1.0 @@ -444,6 +436,13 @@ def test_compress(self): > len(self.render_image(c=9)) ) + def test_blend_false(self): + self.trans.scale = 1.0 + + render = self.render_image(None, b=False) + for line in render.splitlines(): + assert line.startswith(delete) + def test_scaled(self): # At varying scales for self.trans.scale in map(lambda x: x / 100, range(10, 101, 10)): @@ -467,9 +466,9 @@ class TestRenderWhole: trans.height = _size trans.set_render_method(WHOLE) - def render_image(self, alpha=0.0, z=0, m=False, c=4): + def render_image(self, alpha=0.0, z=0, m=False, c=4, b=True): return self.trans._renderer( - self.trans._render_image, alpha, z_index=z, mix=m, compress=c + self.trans._render_image, alpha, z_index=z, mix=m, compress=c, blend=b ) def _test_image_size(self, image): @@ -599,19 +598,12 @@ def test_z_index(self): assert ("z", "0") in decode_image(render)[0] # z_index = - for value in (1, -1, -(2**31), 2**31 - 1): + for value in (1, -1, -(2**31 - 1), 2**31 - 1): render = self.render_image(None, z=value) assert render == f"{self.trans:1.1#+z{value}}" control_codes = decode_image(render)[0] assert ("z", f"{value}") in control_codes - # z_index = None - render = self.render_image(None, z=None) - assert render == f"{self.trans:1.1#+z}" - assert render.startswith(delete) - control_codes = decode_image(render.partition(delete)[2])[0] - assert all(key != "z" for key, value in control_codes) - def test_mix(self): self.trans.scale = 1.0 @@ -650,6 +642,12 @@ def test_compress(self): assert render == f"{self.trans:1.1#+c{value}}" assert ("o", "z") in decode_image(render)[0] + def test_blend_false(self): + self.trans.scale = 1.0 + + render = self.render_image(None, b=False) + assert render.startswith(delete) + def test_scaled(self): # At varying scales for self.trans.scale in map(lambda x: x / 100, range(10, 101, 10)): @@ -693,7 +691,7 @@ def test_args(self): with pytest.raises(TypeError, match="'z_index'"): KittyImage.clear(z_index=value) - for value in (-(2**31) - 1, 2**31): + for value in (-(2**31 + 1), 2**31): with pytest.raises(ValueError, match="z-index .* range"): KittyImage.clear(z_index=value) @@ -727,7 +725,7 @@ def test_cursor(self): assert tty_buf.getvalue() == b"" def test_z_index(self): - for value in range(-10, 11): + for value in (-(2**31), *range(-10, 11), 2**31 - 1): with self.setup_buffer() as (buf, tty_buf): KittyImage.clear(z_index=value, now=True) assert buf.getvalue() == b""