From 285e771ae6a364ad2af33bddb950295d7806cc34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Thu, 28 May 2026 18:22:55 +0100 Subject: [PATCH 01/21] test: add performance tests for list and tuple creation --- ...ion.py => 02.array.10.List.creation.01.py} | 0 tests/timeit/02.array.10.List.creation.02.py | 75 +++++++++++++++++++ 2 files changed, 75 insertions(+) rename tests/timeit/{02.array.10.List.creation.py => 02.array.10.List.creation.01.py} (100%) create mode 100755 tests/timeit/02.array.10.List.creation.02.py diff --git a/tests/timeit/02.array.10.List.creation.py b/tests/timeit/02.array.10.List.creation.01.py similarity index 100% rename from tests/timeit/02.array.10.List.creation.py rename to tests/timeit/02.array.10.List.creation.01.py diff --git a/tests/timeit/02.array.10.List.creation.02.py b/tests/timeit/02.array.10.List.creation.02.py new file mode 100755 index 000000000..1bf69133c --- /dev/null +++ b/tests/timeit/02.array.10.List.creation.02.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 + +# MIT License +# +# Copyright (c) 2025 Eugenio Parodi +# +# 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. + +from __future__ import annotations + +import sys, os + +from dataclasses import dataclass +from enum import Enum,Flag,auto +import timeit + +from typing import List, Tuple, Iterator + +sys.path.append(os.path.join(sys.path[0],'../../libs/pyTermTk')) + +import TermTk as ttk + +txt = "Eugenio" + +l = [txt] * 1000 +t = tuple(l) + +def test_ti_l_01(): + _ret = l[:50] + ['PIPPO'] + l[50:] + return len(_ret) + +def test_ti_l_02(): + _ret = [*l[:50],'PIPPO', *l[50:]] + return len(_ret) + +def test_ti_l_03(): + _ret = l.copy() + return len(_ret) + +def test_ti_t_01(): + _ret = t[:50] + ('PIPPO',) + t[50:] + return len(_ret) + +def test_ti_t_02(): + _ret = (*t[:50], 'PIPPO', *t[50:]) + return len(_ret) + +def test_ti_t_03(): + _ret = t + return len(_ret) + +loop = 10000 + +a:dict = {} + +for testName in sorted([tn for tn in globals() if tn.startswith('test_ti_')]): + result = timeit.timeit(f'{testName}(*a)', globals=globals(), number=loop) + # print(f"test{iii}) fps {loop / result :.3f} - s {result / loop:.10f} - {result / loop} {globals()[testName](*a)}") + print(f"{testName} | {result / loop:.10f} sec. | {loop / result : 15.3f} Fps ╞╡-> {globals()[testName](*a)}") From 5ba7f15d1f256dc08ff3c8f8a6be604b1e9935f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Thu, 28 May 2026 18:23:30 +0100 Subject: [PATCH 02/21] test(color): add comprehensive tests for TTkColor and TTkColorGradient functionality --- tests/pytest/test_003_string.py | 241 +++++++++ tests/pytest/test_008_color_logic.py | 698 +++++++++++++++++++++++++++ 2 files changed, 939 insertions(+) create mode 100644 tests/pytest/test_008_color_logic.py diff --git a/tests/pytest/test_003_string.py b/tests/pytest/test_003_string.py index 6bca19d02..0a95d4b50 100644 --- a/tests/pytest/test_003_string.py +++ b/tests/pytest/test_003_string.py @@ -22,6 +22,7 @@ # SOFTWARE. import sys, os +import pytest sys.path.append(os.path.join(sys.path[0],'../../libs/pyTermTk')) @@ -65,3 +66,243 @@ def test_stringAlign1(): assert ' Yes⌛⌛⌛ '== str(test1.align(width=13, alignment=TermTk.TTkK.CENTER_ALIGN)) # width=14: | Yes⌛⌛⌛ | assert ' Yes⌛⌛⌛ '==str(test1.align(width=14, alignment=TermTk.TTkK.CENTER_ALIGN)) + + +def test_ttkstring_copy_constructor_is_independent(): + original = TermTk.TTkString('abc', TermTk.TTkColor.fg('#00ff00')) + copied = TermTk.TTkString(original) + original_ansi = original.toAnsi(strip=True) + + updated = copied.setColorAt(0, TermTk.TTkColor.fg('#ff0000')) + + assert updated is not copied + assert original.toAnsi(strip=True) == original_ansi + assert updated.toAnsi(strip=True) != original_ansi + + +def test_ttkstring_add_color_returns_independent_instance(): + original = TermTk.TTkString('abc', TermTk.TTkColor.fg('#00ff00')) + recolored = original + TermTk.TTkColor.fg('#0000ff') + original_ansi = original.toAnsi(strip=True) + + updated = recolored.setColorAt(1, TermTk.TTkColor.fg('#ffffff')) + + assert updated is not recolored + assert original.toAnsi(strip=True) == original_ansi + assert updated.toAnsi(strip=True) != original_ansi + + +def test_replace_expanding_match_keeps_full_output_text(): + txt = TermTk.TTkString('abc') + + replaced = txt.replace('a', 'ZZ') + + assert str(replaced) == 'ZZbc' + assert replaced.toAnsi(strip=True) == 'ZZbc' + + +def test_complete_color_applies_match_at_start_of_text(): + txt = TermTk.TTkString('abc') + txt_ansi = txt.toAnsi(strip=True) + + colorized = txt.completeColor(TermTk.TTkColor.BOLD, match='a') + + assert txt.toAnsi(strip=True) == txt_ansi + assert colorized.toAnsi(strip=True) != txt_ansi + + +def test_extract_shortcuts_trailing_ampersand_does_not_crash(): + txt = TermTk.TTkString('Save &') + + extracted, shortcuts = txt.extractShortcuts() + + assert str(extracted) == 'Save ' + assert shortcuts == [] + + +def test_lstrip_preserves_combining_char_display_width(): + txt = TermTk.TTkString('a\u0301') + + stripped = txt.lstrip(' ') + + assert stripped.termWidth() == 1 + + +def test_set_color_at_out_of_range_raises_index_error(): + txt = TermTk.TTkString('abc') + + with pytest.raises(IndexError): + txt.setColorAt(100, TermTk.TTkColor.BOLD) + + +def test_basic_dunder_conversions_and_comparisons(): + txt = TermTk.TTkString('12') + + assert len(txt) == 2 + assert bool(txt) is True + assert int(txt) == 12 + assert float(txt) == 12.0 + assert complex(txt) == complex(12) + assert txt == '12' + assert txt < '99' + assert txt >= TermTk.TTkString('12') + + +def test_sameas_distinguishes_text_and_color(): + a = TermTk.TTkString('abc', TermTk.TTkColor.fg('#101010')) + b = TermTk.TTkString('abc', TermTk.TTkColor.fg('#101010')) + c = TermTk.TTkString('abc', TermTk.TTkColor.fg('#202020')) + d = TermTk.TTkString('abd', TermTk.TTkColor.fg('#101010')) + + assert a.sameAs(b) + assert not a.sameAs(c) + assert not a.sameAs(d) + + +def test_char_and_color_accessors(): + txt = TermTk.TTkString('abc', TermTk.TTkColor.fg('#00ff00')) + + assert txt.charAt(1) == 'b' + assert txt.colorAt(0) == TermTk.TTkColor.fg('#00ff00') + assert txt.colorAt(99) == TermTk.TTkColor() + + +def test_tab2spaces_and_tab_char_pos_mapping(): + txt = TermTk.TTkString('a\tb') + expanded = txt.tab2spaces(4) + + assert str(expanded) == 'a b' + assert txt.tabCharPos(0, 4) == 0 + assert txt.tabCharPos(1, 4) == 1 + assert txt.tabCharPos(2, 4) == 1 + assert txt.tabCharPos(4, 4) == 2 + + +def test_tab_char_pos_with_wide_chars(): + txt = TermTk.TTkString('界a\tb') + + assert txt.termWidth() == 5 + assert txt.tabCharPos(0, 4) == 0 + assert txt.tabCharPos(1, 4) == 0 + assert txt.tabCharPos(2, 4) == 1 + assert txt.tabCharPos(5, 4) == 4 + + +def test_plain_text_ascii_and_ansi_roundtrip_plain(): + txt = TermTk.TTkString('plain text') + + assert txt.isPlainText() + assert txt.toAscii() == 'plain text' + assert txt.toAnsi(strip=True) == 'plain text' + + +def test_align_left_right_center_and_justify(): + txt = TermTk.TTkString('ab') + + assert str(txt.align(width=5, alignment=TermTk.TTkK.LEFT_ALIGN)) == 'ab ' + assert str(txt.align(width=5, alignment=TermTk.TTkK.RIGHT_ALIGN)) == ' ab' + assert str(txt.align(width=5, alignment=TermTk.TTkK.CENTER_ALIGN)) == ' ab ' + + just = TermTk.TTkString('a b c').align(width=7, alignment=TermTk.TTkK.JUSTIFY) + assert str(just) == 'a b c' + + +def test_extract_shortcuts_regular_case(): + txt = TermTk.TTkString('&File &Edit') + + extracted, shortcuts = txt.extractShortcuts() + + assert str(extracted) == 'File Edit' + assert shortcuts == ['F', 'E'] + + +def test_replace_equal_shorter_longer_and_count(): + txt = TermTk.TTkString('aabbcc') + + assert str(txt.replace('bb', 'XX')) == 'aaXXcc' + assert str(txt.replace('aa', 'Q')) == 'Qbbcc' + assert str(txt.replace('cc', 'YYY')) == 'aabbYYY' + assert str(txt.replace('a', 'Z', 1)) == 'Zabbcc' + + +def test_complete_color_by_range_and_full(): + txt = TermTk.TTkString('abcdef') + + full = txt.completeColor(TermTk.TTkColor.BOLD) + part = txt.completeColor(TermTk.TTkColor.ITALIC, posFrom=2, posTo=4) + + assert full.toAnsi(strip=True) != txt.toAnsi(strip=True) + assert part.toAnsi(strip=True) != txt.toAnsi(strip=True) + assert str(part) == 'abcdef' + + +def test_set_color_by_range_match_and_full(): + txt = TermTk.TTkString('abcabc') + + full = txt.setColor(TermTk.TTkColor.fg('#112233')) + match = txt.setColor(TermTk.TTkColor.fg('#445566'), match='bc') + part = txt.setColor(TermTk.TTkColor.fg('#778899'), posFrom=1, posTo=3) + + assert str(full) == 'abcabc' + assert str(match) == 'abcabc' + assert str(part) == 'abcabc' + assert full.toAnsi(strip=True) != txt.toAnsi(strip=True) + assert match.toAnsi(strip=True) != txt.toAnsi(strip=True) + assert part.toAnsi(strip=True) != txt.toAnsi(strip=True) + + +def test_substring_split_join_and_indexes(): + txt = TermTk.TTkString('one,two,three') + + assert str(txt.substring(4, 7)) == 'two' + parts = txt.split(',') + assert [str(p) for p in parts] == ['one', 'two', 'three'] + assert txt.getIndexes('o') == [0, 6] + + joined = TermTk.TTkString('|').join(parts) + assert str(joined) == 'one|two|three' + + +def test_split_multi_char_separator_not_supported(): + txt = TermTk.TTkString('a::b') + + with pytest.raises(NotImplementedError): + txt.split('::') + + +def test_search_find_findall_variants(): + txt = TermTk.TTkString('Abc abc ABC') + + assert txt.search(r'abc') is not None + assert txt.search(r'abc', ignoreCase=True) is not None + assert txt.find('abc') == 4 + assert txt.findall(r'abc') == ['abc'] + assert txt.findall(r'abc', ignoreCase=True) == ['Abc', 'abc', 'ABC'] + + +def test_get_data_zero_width_combining_and_wide_chars(): + combining = TermTk.TTkString('a\u0301') + wide = TermTk.TTkString('界x') + + c_text, c_colors = combining.getData() + w_text, w_colors = wide.getData() + + assert ''.join(c_text) == 'a\u0301' + assert len(c_colors) == 1 + assert ''.join(w_text).startswith('界') + assert len(w_colors) == 3 + + +def test_next_and_prev_positions_skip_combining_chars(): + txt = TermTk.TTkString('a\u0301b') + + assert txt.nextPos(0) == 2 + assert txt.prevPos(2) == 0 + + +def test_radd_with_plain_string_preserves_text(): + txt = TermTk.TTkString('world') + + out = 'hello ' + txt + + assert str(out) == 'hello world' diff --git a/tests/pytest/test_008_color_logic.py b/tests/pytest/test_008_color_logic.py new file mode 100644 index 000000000..cb7ee24e6 --- /dev/null +++ b/tests/pytest/test_008_color_logic.py @@ -0,0 +1,698 @@ +#!/usr/bin/env python3 +# MIT License +# +# Copyright (c) 2026 Eugenio Parodi +# +# 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. + +import os +import sys + +import pytest + +sys.path.append(os.path.join(sys.path[0], '../../libs/pyTermTk')) + +from TermTk.TTkCore.color import TTkColor +from TermTk.TTkCore.color import TTkColorGradient +from TermTk.TTkCore.color import TTkLinearGradient +from TermTk.TTkCore.constant import TTkK + + +def test_ttkcolor_fg_bg_and_fgbg_helpers_set_expected_channels(): + fg = TTkColor.fg('#010203') + bg = TTkColor.bg('#a0b0c0') + both = TTkColor.fgbg('#112233', '#445566') + + assert fg.fgToRGB() == (1, 2, 3) + assert fg.bgToRGB() == (0, 0, 0) + assert bg.bgToRGB() == (160, 176, 192) + assert bg.fgToRGB() == (0, 0, 0) + assert both.fgToRGB() == (17, 34, 51) + assert both.bgToRGB() == (68, 85, 102) + + +def test_ttkcolor_style_modifier_flags_combine_with_or(): + style = TTkColor.BOLD + TTkColor.ITALIC + TTkColor.UNDERLINE + TTkColor.STRIKETROUGH + TTkColor.BLINKING + + assert style.bold() + assert style.italic() + assert style.underline() + assert style.strikethrough() + assert style.blinking() + + +def test_ttkcolor_add_prefers_rhs_channels_and_keeps_lhs_when_missing(): + lhs = TTkColor.fg('#001122') + TTkColor.bg('#334455') + rhs_fg_only = TTkColor.fg('#aabbcc') + + mixed = lhs + rhs_fg_only + + assert mixed.fgToRGB() == (170, 187, 204) + assert mixed.bgToRGB() == (51, 68, 85) + + +def test_ttkcolor_invert_foreground_background(): + color = TTkColor.fgbg('#112233', '#445566') + inverted = color.invertFgBg() + + assert inverted.fgToRGB() == (68, 85, 102) + assert inverted.bgToRGB() == (17, 34, 51) + + +def test_ttkcolor_rgb_hsl_roundtrip_stays_close(): + original = (23, 111, 207) + hsl = TTkColor.rgb2hsl(original) + reconstructed = TTkColor.hsl2rgb(hsl) + + assert abs(reconstructed[0] - original[0]) <= 2 + assert abs(reconstructed[1] - original[1]) <= 2 + assert abs(reconstructed[2] - original[2]) <= 2 + + +def test_ttkcolor_link_keeps_link_type_in_colortype(): + linked = TTkColor.fg('#abcdef', link='https://example.invalid') + + assert linked.colorType() & TTkK.ColorType.Link + + +def test_ttkcolorgradient_copy_and_modparam_do_not_mutate_original_modifier_state(): + base = TTkColor.fg('#202020', modifier=TTkColorGradient(increment=10)) + before = base.mod(0, 0).fgToRGB() + + _derived = base.modParam(val=5, step=1) + after = base.mod(0, 0).fgToRGB() + + assert after == before + + +def test_ttkcolorgradient_handles_large_coordinates_without_index_error(): + base = TTkColor.fg('#000000', modifier=TTkColorGradient(increment=1)) + + result = base.mod(0, 5000) + + assert isinstance(result, TTkColor) + + +def test_ttkcolorgradient_handles_zero_step_without_division_error(): + base = TTkColor.fg('#101010', modifier=TTkColorGradient(increment=2)) + tweaked = base.modParam(val=1, step=0) + + result = tweaked.mod(0, 1) + + assert isinstance(result, TTkColor) + + +def test_ttklineargradient_zero_direction_is_safe_and_returns_base_color(): + gradient = TTkLinearGradient(direction=(0, 0), target_color=TTkColor.fgbg('#ffffff', '#ffffff')) + base = TTkColor.fgbg('#111111', '#222222') + + result = gradient.exec(4, 3, base) + + assert result == base + + +def test_ttkcolor_eq_with_truthy_non_color_returns_false_not_exception(): + color = TTkColor.fg('#123456') + + assert (color == object()) is False + + +def test_ttkcolor_hsl2rgb_handles_out_of_range_hue_like_wrapped_angle(): + zero_hue = TTkColor.hsl2rgb((0, 100, 50)) + wrapped_hue = TTkColor.hsl2rgb((360, 100, 50)) + + assert wrapped_hue == zero_hue + + +def test_ttkcolor_mod_link_radd_does_not_corrupt_bitflags(): + linked_bold = TTkColor.fg('#123456', link='https://example.invalid') + TTkColor.BOLD + + merged = linked_bold.__radd__(TTkColor.BOLD) + + assert merged.bold() + assert not merged.italic() + + +# --------------------------------------------------------------------------- +# Constructors / static helpers +# --------------------------------------------------------------------------- + +def test_ttkcolor_ansi_parses_plain_rgb_without_modifier(): + color = TTkColor.ansi('\033[38;2;10;20;30m') + + assert color.fgToRGB() == (10, 20, 30) + assert color.bold() is False + + +def test_ttkcolor_ansi_parses_rgb_with_bold_modifier(): + color = TTkColor.ansi('\033[1;38;2;10;20;30m') + + assert color.fgToRGB() == (10, 20, 30) + assert color.bold() is True + + +def test_ttkcolor_fg_bg_fgbg_with_link_produce_link_color_type(): + linked_fg = TTkColor.fg('#010203', link='https://a.invalid') + linked_bg = TTkColor.bg('#040506', link='https://b.invalid') + linked_both = TTkColor.fgbg('#070809', '#0a0b0c', link='https://c.invalid') + + assert linked_fg.colorType() & TTkK.ColorType.Link + assert linked_bg.colorType() & TTkK.ColorType.Link + assert linked_both.colorType() & TTkK.ColorType.Link + + +def test_ttkcolor_fg_bg_accept_keyword_argument(): + fg = TTkColor.fg(color='#0a0b0c') + bg = TTkColor.bg(color='#0d0e0f') + + assert fg.fgToRGB() == (10, 11, 12) + assert bg.bgToRGB() == (13, 14, 15) + + +def test_ttkcolor_fgbg_without_link_is_not_link_type(): + plain = TTkColor.fgbg('#101112', '#131415') + + assert not (plain.colorType() & TTkK.ColorType.Link) + + +# --------------------------------------------------------------------------- +# foreground() / background() / has* +# --------------------------------------------------------------------------- + +def test_ttkcolor_foreground_returns_fg_only_color_or_rst(): + color = TTkColor.fgbg('#112233', '#445566') + + fg_only = color.foreground() + + assert fg_only.fgToRGB() == (17, 34, 51) + assert not fg_only.hasBackground() + + assert TTkColor.bg('#112233').foreground() is TTkColor.RST + + +def test_ttkcolor_background_returns_bg_only_color_or_rst(): + color = TTkColor.fgbg('#112233', '#445566') + + bg_only = color.background() + + assert bg_only.bgToRGB() == (68, 85, 102) + assert not bg_only.hasForeground() + + assert TTkColor.fg('#112233').background() is TTkColor.RST + + +def test_ttkcolor_has_foreground_background_flags(): + assert TTkColor.fg('#010203').hasForeground() + assert not TTkColor.fg('#010203').hasBackground() + assert TTkColor.bg('#040506').hasBackground() + assert not TTkColor.bg('#040506').hasForeground() + + +# --------------------------------------------------------------------------- +# Base TTkColor style probes are all False +# --------------------------------------------------------------------------- + +def test_ttkcolor_base_modifier_probes_are_false_for_plain_color(): + color = TTkColor.fg('#abcdef') + + assert color.bold() is False + assert color.italic() is False + assert color.underline() is False + assert color.strikethrough() is False + assert color.blinking() is False + + +def test_ttkcolor_colortype_reports_fg_bg_and_modifier_bits(): + fg_only = TTkColor.fg('#010203') + bg_only = TTkColor.bg('#040506') + with_mod = TTkColor.fg('#070809', modifier=TTkColorGradient(increment=1)) + + assert fg_only.colorType() & TTkK.ColorType.Foreground + assert not (fg_only.colorType() & TTkK.ColorType.Background) + + assert bg_only.colorType() & TTkK.ColorType.Background + assert not (bg_only.colorType() & TTkK.ColorType.Foreground) + + assert with_mod.colorType() & TTkK.ColorType.ColorModifier + + +def test_ttkcolor_without_modifiers_strips_modifier_state(): + styled = TTkColor.fgbg('#112233', '#445566') + TTkColor.BOLD + TTkColor.ITALIC + + stripped = styled.withoutModifiers() + + assert stripped.fgToRGB() == (17, 34, 51) + assert stripped.bgToRGB() == (68, 85, 102) + assert stripped.bold() is False + assert stripped.italic() is False + + +def test_ttkcolor_base_without_modifiers_returns_self(): + plain = TTkColor.fg('#abcdef') + + assert plain.withoutModifiers() is plain + + +# --------------------------------------------------------------------------- +# RGB <-> HSL conversion paths +# --------------------------------------------------------------------------- + +def test_ttkcolor_rgb2hsl_for_grayscale_returns_zero_hue_zero_sat(): + h, s, _ = TTkColor.rgb2hsl((127, 127, 127)) + + assert h == 0 + assert s == 0 + + +def test_ttkcolor_rgb2hsl_branches_for_each_max_channel(): + h_r, _, _ = TTkColor.rgb2hsl((200, 50, 50)) + h_g, _, _ = TTkColor.rgb2hsl((50, 200, 50)) + h_b, _, _ = TTkColor.rgb2hsl((50, 50, 200)) + + assert 0 <= h_r < 60 or h_r >= 300 # red hue + assert 90 < h_g < 150 + assert 210 < h_b < 270 + + +def test_ttkcolor_hsl2rgb_covers_each_hue_range(): + # one sample per branch, sanity-check primary channel dominance + assert TTkColor.hsl2rgb((30, 100, 50))[0] > 0 # 0-60 + assert TTkColor.hsl2rgb((90, 100, 50))[1] > 0 # 60-120 + assert TTkColor.hsl2rgb((150, 100, 50))[1] > 0 # 120-180 + assert TTkColor.hsl2rgb((210, 100, 50))[2] > 0 # 180-240 + assert TTkColor.hsl2rgb((270, 100, 50))[2] > 0 # 240-300 + assert TTkColor.hsl2rgb((330, 100, 50))[0] > 0 # 300-360 + + +# --------------------------------------------------------------------------- +# Hex / RGB inspection +# --------------------------------------------------------------------------- + +def test_ttkcolor_get_hex_for_fg_and_bg(): + color = TTkColor.fgbg('#0a0b0c', '#0d0e0f') + + assert color.getHex(TTkK.ColorType.Foreground) == '#0a0b0c' + assert color.getHex(TTkK.ColorType.Background) == '#0d0e0f' + + +def test_ttkcolor_fg_bg_to_rgb_default_to_black_when_unset(): + assert TTkColor.bg('#010203').fgToRGB() == (0, 0, 0) + assert TTkColor.fg('#010203').bgToRGB() == (0, 0, 0) + + +# --------------------------------------------------------------------------- +# str caching / ansi output +# --------------------------------------------------------------------------- + +def test_ttkcolor_str_is_cached_and_stable(): + color = TTkColor.fg('#010203') + + a = str(color) + b = str(color) + + assert a == b + assert a != '' + + +def test_ttkcolor_mod_str_includes_modifier_bytes(): + plain = str(TTkColor.fg('#010203')) + bold = str(TTkColor.fg('#010203') + TTkColor.BOLD) + + assert bold != plain + + +def test_ttkcolor_mod_link_str_includes_link_escape(): + linked = TTkColor.fg('#010203', link='https://example.invalid') + + rendered = str(linked) + + assert 'example.invalid' in rendered + + +# --------------------------------------------------------------------------- +# Operators: __or__ / __add__ / __sub__ / __rsub__ +# --------------------------------------------------------------------------- + +def test_ttkcolor_or_with_self_returns_self(): + color = TTkColor.fg('#010203') + + assert (color | color) is color + + +def test_ttkcolor_or_combines_two_colors(): + fg = TTkColor.fg('#010203') + bg = TTkColor.bg('#040506') + + merged = fg | bg + + assert merged.fgToRGB() == (1, 2, 3) + assert merged.bgToRGB() == (4, 5, 6) + + +def test_ttkcolor_add_with_rst_returns_rst(): + color = TTkColor.fg('#010203') + + assert (color + TTkColor.RST) is TTkColor.RST + + +def test_ttkcolor_sub_emits_clean_ansi_when_channel_appears(): + prev = TTkColor.RST + curr = TTkColor.fg('#010203') + + diff = curr - prev + + assert isinstance(diff, str) + assert diff != '' + + +def test_ttkcolor_sub_emits_full_str_when_no_None_transition(): + a = TTkColor.fg('#010203') + b = TTkColor.fg('#0a0b0c') + + diff = b - a + + assert diff == str(b) + + +def test_ttkcolor_sub_emits_clean_ansi_when_bg_appears_from_none(): + fg_only = TTkColor.fg('#010203') + both = TTkColor.fgbg('#040506', '#070809') + + # `fg_only - both` flips bg from a value back to None → triggers reset path + diff = fg_only - both + + assert isinstance(diff, str) + assert diff != str(fg_only) + + +# --------------------------------------------------------------------------- +# _TTkColor_mod operators +# --------------------------------------------------------------------------- + +def test_ttkcolor_mod_eq_against_plain_color_compares_mod_to_zero(): + plain = TTkColor.fg('#010203') + same_with_no_mod = plain + TTkColor.RST # collapsed via clean path + + assert (TTkColor.fg('#010203') + TTkColor.BOLD) != plain + assert same_with_no_mod == plain or same_with_no_mod == TTkColor.RST + + +def test_ttkcolor_mod_or_with_self_returns_self(): + bold = TTkColor.BOLD + + assert (bold | bold) is bold + + +def test_ttkcolor_mod_or_combines_with_other_modifiers(): + merged = TTkColor.BOLD | TTkColor.ITALIC + + assert merged.bold() + assert merged.italic() + + +def test_ttkcolor_mod_add_with_rst_returns_rst(): + bold = TTkColor.BOLD + + assert (bold + TTkColor.RST) is TTkColor.RST + + +def test_ttkcolor_mod_radd_combines_with_plain_color(): + # `_TTkColor_mod.__radd__` is reachable directly; combining BOLD with a + # plain fg color must preserve both the bold flag and the color. + bold = TTkColor.BOLD + plain = TTkColor.fg('#010203') + + combined = bold.__radd__(plain) + + assert combined.bold() + assert combined.fgToRGB() == (1, 2, 3) + + +def test_ttkcolor_mod_sub_detects_modifier_change(): + a = TTkColor.fg('#010203') + TTkColor.BOLD + b = TTkColor.fg('#010203') # no bold + + diff = a - b + + assert isinstance(diff, str) + assert diff != '' + + +def test_ttkcolor_mod_sub_same_returns_str_self(): + a = TTkColor.fg('#010203') + TTkColor.BOLD + b = TTkColor.fg('#010203') + TTkColor.BOLD + + diff = a - b + + assert diff == str(a) + + +def test_ttkcolor_mod_rsub_produces_clean_reset_string(): + mod = TTkColor.fg('#010203') + TTkColor.BOLD + plain = TTkColor.fg('#040506') + + diff = mod.__rsub__(plain) + + assert isinstance(diff, str) + + +def test_ttkcolor_mod_copy_preserves_state(): + src = TTkColor.fg('#010203') + TTkColor.BOLD + TTkColor.ITALIC + + dup = src.copy() + + assert dup.fgToRGB() == src.fgToRGB() + assert dup.bold() == src.bold() + assert dup.italic() == src.italic() + assert dup is not src + + +def test_ttkcolor_mod_copy_propagates_colormod(): + grad = TTkColorGradient(increment=2) + src = TTkColor.fg('#010203', modifier=grad) + TTkColor.BOLD + + dup = src.copy() + + assert dup._colorMod is not None + assert dup.bold() + + +# --------------------------------------------------------------------------- +# _TTkColor_mod_link operators +# --------------------------------------------------------------------------- + +def test_ttkcolor_link_eq_with_non_link_is_false(): + linked = TTkColor.fg('#010203', link='https://x.invalid') + plain = TTkColor.fg('#010203') + + assert linked != plain + + +def test_ttkcolor_link_or_with_self_returns_self(): + linked = TTkColor.fg('#010203', link='https://x.invalid') + + assert (linked | linked) is linked + + +def test_ttkcolor_link_or_keeps_link(): + linked = TTkColor.fg('#010203', link='https://x.invalid') + other = TTkColor.bg('#040506') + + merged = linked | other + + assert 'x.invalid' in str(merged) + + +def test_ttkcolor_link_add_with_rst_returns_rst(): + linked = TTkColor.fg('#010203', link='https://x.invalid') + + assert (linked + TTkColor.RST) is TTkColor.RST + + +def test_ttkcolor_link_sub_detects_link_change(): + a = TTkColor.fg('#010203', link='https://x.invalid') + b = TTkColor.fg('#010203', link='https://y.invalid') + + diff = a - b + + assert isinstance(diff, str) + + +def test_ttkcolor_link_sub_same_returns_empty_string(): + a = TTkColor.fg('#010203', link='https://x.invalid') + b = TTkColor.fg('#010203', link='https://x.invalid') + + diff = a - b + + assert diff == '' + + +def test_ttkcolor_link_rsub_for_plain_and_mod_other(): + linked = TTkColor.fg('#010203', link='https://x.invalid') + plain = TTkColor.fg('#040506') + bold_mod = TTkColor.fg('#040506') + TTkColor.BOLD + + diff_plain = linked.__rsub__(plain) + diff_mod = linked.__rsub__(bold_mod) + + assert isinstance(diff_plain, str) + assert isinstance(diff_mod, str) + + +def test_ttkcolor_link_copy_preserves_link_and_mod(): + src = TTkColor.fg('#010203', link='https://x.invalid') + TTkColor.BOLD + + dup = src.copy() + + assert dup.bold() == src.bold() + assert dup.fgToRGB() == src.fgToRGB() + assert 'x.invalid' in str(dup) + + +def test_ttkcolor_link_copy_propagates_colormod(): + grad = TTkColorGradient(increment=2) + src = TTkColor.fg('#010203', link='https://x.invalid', modifier=grad) + + dup = src.copy() + + assert dup._colorMod is not None + assert 'x.invalid' in str(dup) + + +# --------------------------------------------------------------------------- +# TTkColorGradient +# --------------------------------------------------------------------------- + +def test_ttkcolorgradient_independent_fg_bg_increments_when_increment_kw_absent(): + grad = TTkColorGradient(fgincrement=5, bgincrement=-5) + base = TTkColor.fgbg('#202020', '#404040', modifier=grad) + + shifted = base.mod(0, 1) + + # fg should be brighter (+5), bg should be darker (-5) + assert shifted.fgToRGB()[0] > base.fgToRGB()[0] + assert shifted.bgToRGB()[0] < base.bgToRGB()[0] + + +def test_ttkcolorgradient_cached_value_returned_on_repeated_lookup(): + grad = TTkColorGradient(increment=3) + base = TTkColor.fg('#404040', modifier=grad) + + first = base.mod(0, 1) + second = base.mod(0, 1) + + assert first is second + + +def test_ttkcolorgradient_copy_returns_same_instance(): + grad = TTkColorGradient(increment=3) + + assert grad.copy() is grad + + +def test_ttkcolorgradient_horizontal_orientation_uses_x_axis(): + grad = TTkColorGradient(increment=5, orientation=TTkK.HORIZONTAL) + base = TTkColor.fg('#202020', modifier=grad) + + horizontal_step = base.mod(2, 0) + no_step = base.mod(0, 0) + + assert horizontal_step.fgToRGB() != no_step.fgToRGB() + + +# --------------------------------------------------------------------------- +# TTkLinearGradient +# --------------------------------------------------------------------------- + +def test_ttklineargradient_returns_base_color_before_origin(): + gradient = TTkLinearGradient( + base_pos=(10, 10), direction=(5, 0), + target_color=TTkColor.fgbg('#ffffff', '#ffffff')) + base = TTkColor.fgbg('#000000', '#000000') + + # x=0 is upstream of base_pos along direction → beta<=0 + assert gradient.exec(0, 10, base) is base + + +def test_ttklineargradient_returns_target_color_past_unit_distance(): + target = TTkColor.fgbg('#ffffff', '#ffffff') + gradient = TTkLinearGradient( + base_pos=(0, 0), direction=(1, 0), + target_color=target) + base = TTkColor.fgbg('#000000', '#000000') + + # x=10 well past direction length (squared=1) → beta>>1 + assert gradient.exec(10, 0, base) is target + + +def test_ttklineargradient_interpolates_between_base_and_target(): + target = TTkColor.fgbg('#ffffff', '#ffffff') + gradient = TTkLinearGradient( + base_pos=(0, 0), direction=(10, 0), + target_color=target) + base = TTkColor.fgbg('#000000', '#000000') + + midpoint = gradient.exec(5, 0, base) + + fr, fg, fb = midpoint.fgToRGB() + assert 100 < fr < 200 + assert 100 < fg < 200 + assert 100 < fb < 200 + + +def test_ttklineargradient_skips_channel_when_missing_on_either_side(): + # target has fg only, base has bg only → neither channel interpolation + # condition can be satisfied + target = TTkColor.fg('#ffffff') + gradient = TTkLinearGradient( + base_pos=(0, 0), direction=(10, 0), + target_color=target) + base = TTkColor.bg('#000000') + + midpoint = gradient.exec(5, 0, base) + + assert midpoint.bgToRGB() == (0, 0, 0) + assert midpoint.fgToRGB() == (0, 0, 0) + + +# --------------------------------------------------------------------------- +# TTkAlternateColor +# --------------------------------------------------------------------------- + +def test_ttkalternatecolor_returns_alternate_on_odd_rows(): + from TermTk.TTkCore.color import TTkAlternateColor + alt = TTkColor.fg('#abcdef') + mod = TTkAlternateColor(alternateColor=alt) + base = TTkColor.fg('#010203', modifier=mod) + + odd = base.mod(0, 1) + + assert odd is alt + + +def test_ttkalternatecolor_returns_base_copy_on_even_rows(): + from TermTk.TTkCore.color import TTkAlternateColor + alt = TTkColor.fg('#abcdef') + mod = TTkAlternateColor(alternateColor=alt) + base = TTkColor.fg('#010203', modifier=mod) + + even = base.mod(0, 0) + + assert even.fgToRGB() == (1, 2, 3) + assert even is not alt \ No newline at end of file From 22e46fe93d38fbd884b5ecfcfb1aa628142ffd22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Thu, 28 May 2026 18:23:41 +0100 Subject: [PATCH 03/21] chore(dependencies): add coverage to optional test dependencies --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9eaa755f2..b7d580825 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,8 @@ "ttkode[test]", "pytest>=8.3.4", "flake8>=7.2.0", - "mypy>=1.15.0" + "mypy>=1.15.0", + "coverage>=7.14.1" ] docs = [ "Sphinx==8.2.3; python_version>='3.11'", From 37a02eed68380bcac70bd61bad58c43a4bfa3cf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Thu, 28 May 2026 18:24:37 +0100 Subject: [PATCH 04/21] refactor(color): improve parameter handling in TTkColor methods --- libs/pyTermTk/TermTk/TTkCore/color.py | 54 ++++++++++++--------------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/libs/pyTermTk/TermTk/TTkCore/color.py b/libs/pyTermTk/TermTk/TTkCore/color.py index 81449b9b0..c28de9831 100644 --- a/libs/pyTermTk/TermTk/TTkCore/color.py +++ b/libs/pyTermTk/TermTk/TTkCore/color.py @@ -180,7 +180,7 @@ def __init__(self, clean=False) -> None: self._fg = fg self._bg = bg - self._clean = clean or not (fg or bg) + self._clean = clean or (fg is None and bg is None) self._colorMod = colorMod self._buffer = '' @@ -210,7 +210,7 @@ def ansi(ansi:str) -> TTkColor: return TTkColor(fg=fg, bg=bg, clean=clean) @staticmethod - def fg(*args, **kwargs) -> TTkColor: + def fg(color:str, *, link:str='', modifier:Optional[TTkColorModifier]=None) -> TTkColor: ''' Helper to generate a Foreground color Example: @@ -221,26 +221,22 @@ def fg(*args, **kwargs) -> TTkColor: color_2 = TTkColor.fg(color='#00FF00') color_3 = TTkColor.fg('#0000FF', modifier=TTkColorGradient(increment=6)) - :param str color: the color representation in (str)HEX + :param color: the color representation in (str)HEX :type color: str - :param str modifier: (experimental) the color modifier to be used to improve the **kinkiness** + :param link: (optional) hyperlink URL to associate with the color + :type link: str + :param modifier: (experimental) the color modifier to be used to improve the **kinkiness** :type modifier: TTkColorModifier, optional :return: :py:class:`TTkColor` ''' - mod = kwargs.get('modifier', None ) - link = kwargs.get('link', '' ) - if len(args) > 0: - color = args[0] - else: - color = kwargs.get('color', "" ) if link: - return _TTkColor_mod_link(fg=TTkColor.hexToRGB(color), colorMod=mod, link=link) + return _TTkColor_mod_link(fg=TTkColor.hexToRGB(color), colorMod=modifier, link=link) else: - return TTkColor(fg=TTkColor.hexToRGB(color), colorMod=mod) + return TTkColor(fg=TTkColor.hexToRGB(color), colorMod=modifier) @staticmethod - def bg(*args, **kwargs) -> TTkColor: + def bg(color:str, *, link:str='', modifier:Optional[TTkColorModifier]=None) -> TTkColor: ''' Helper to generate a Background color Example: @@ -251,27 +247,23 @@ def bg(*args, **kwargs) -> TTkColor: color_2 = TTkColor.bg(color='#00FF00') color_3 = TTkColor.bg('#0000FF', modifier=TTkColorGradient(increment=6)) - :param str color: the color representation in (str)HEX + :param color: the color representation in (str)HEX :type color: str - :param str modifier: (experimental) the color modifier to be used to improve the **kinkiness** + :param link: (optional) hyperlink URL to associate with the color + :type link: str + :param modifier: (experimental) the color modifier to be used to improve the **kinkiness** :type modifier: TTkColorModifier, optional :return: :py:class:`TTkColor` ''' - mod = kwargs.get('modifier', None ) - link = kwargs.get('link', '' ) - if len(args) > 0: - color = args[0] - else: - color = kwargs.get('color', "" ) if link: - return _TTkColor_mod_link(bg=TTkColor.hexToRGB(color), colorMod=mod, link=link) + return _TTkColor_mod_link(bg=TTkColor.hexToRGB(color), colorMod=modifier, link=link) else: - return TTkColor(bg=TTkColor.hexToRGB(color), colorMod=mod) + return TTkColor(bg=TTkColor.hexToRGB(color), colorMod=modifier) @staticmethod - def fgbg(fg:str='', bg:str='', link:str='', modifier:Optional[TTkColorModifier]=None) -> TTkColor: - ''' Helper to generate a Background color + def fgbg(fg:str='', bg:str='', *, link:str='', modifier:Optional[TTkColorModifier]=None) -> TTkColor: + ''' Helper to generate a Foreground and Background color Example: @@ -281,11 +273,13 @@ def fgbg(fg:str='', bg:str='', link:str='', modifier:Optional[TTkColorModifier]= color_2 = TTkColor.fgbg(fg='#00FF00',bg='#0000FF') color_3 = TTkColor.fgbg('#0000FF','#0000FF', modifier=TTkColorGradient(increment=6)) - :param str fg: the foreground color representation in (str)HEX + :param fg: the foreground color representation in (str)HEX :type fg: str - :param str bg: the background color representation in (str)HEX + :param bg: the background color representation in (str)HEX :type bg: str - :param str modifier: (experimental) the color modifier to be used to improve the **kinkiness** + :param link: (optional) hyperlink URL to associate with the color + :type link: str + :param modifier: (experimental) the color modifier to be used to improve the **kinkiness** :type modifier: TTkColorModifier, optional :return: :py:class:`TTkColor` @@ -309,10 +303,10 @@ def background(self) -> TTkColor: return TTkColor.RST def hasForeground(self) -> bool: - return True if self._fg else False + return self._fg is not None def hasBackground(self) -> bool: - return True if self._bg else False + return self._bg is not None def bold(self) -> bool: return False From e3bc53cf2938b524d66bf7281c9a68cfc0bdf1f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Thu, 28 May 2026 18:25:13 +0100 Subject: [PATCH 05/21] refactor(string): enhance error handling and improve character manipulation methods --- libs/pyTermTk/TermTk/TTkCore/string.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/libs/pyTermTk/TermTk/TTkCore/string.py b/libs/pyTermTk/TermTk/TTkCore/string.py index 6431c2d30..d4109465d 100644 --- a/libs/pyTermTk/TermTk/TTkCore/string.py +++ b/libs/pyTermTk/TermTk/TTkCore/string.py @@ -193,14 +193,19 @@ def lstrip(self, ch:str) -> TTkString: ret = TTkString() ret._text = self._text.lstrip(ch) ret._colors = self._colors[-len(ret._text):] + ret._checkWidth() return ret def charAt(self, pos:int) -> str: return self._text[pos] def setCharAt(self, pos:int, char:str) -> TTkString: - self._text = self._text[:pos]+char+self._text[pos+1:] - self._checkWidth() + if not (0 <= pos < len(self._text)): + raise IndexError() + ret = TTkString() + ret._text = self._text[:pos]+char+self._text[pos+1:] + ret._colors = self._colors + ret._checkWidth() return self def colorAt(self, pos:int) -> TTkColor: @@ -209,7 +214,12 @@ def colorAt(self, pos:int) -> TTkColor: return self._colors[pos] def setColorAt(self, pos, color) -> TTkString: - self._colors[pos] = color + if not (0 <= pos < len(self._colors)): + raise IndexError() + ret = TTkString() + ret._text = self._text + ret._colors = [*self._colors[:pos], color, *self._colors[pos+1:]] + ret._hasSpecialWidth = self._hasSpecialWidth return self def tab2spaces(self, tabSpaces=4) -> TTkString: @@ -400,9 +410,13 @@ def _chGenerator(): _newColors = [] _ret = [] _gen = _chGenerator() + _found = False for ch,color in _gen: if ch == '&': - ch,color = next(_gen) + _found = True + continue + if _found: + _found = False _ret.append(ch) color += TTkColor.UNDERLINE _newText += ch @@ -436,7 +450,7 @@ def replace(self, *args, **kwargs) -> TTkString: ret._text = self._text.replace(*args, **kwargs) elif oldLen > newLen: start = 0 - while pos := self._text.index(old, start) if old in self._text[start:] else None: + while (pos := (self._text.index(old, start) if old in self._text[start:] else None)) is not None: ret._colors += self._colors[start:pos+newLen] start = pos+oldLen count -= 1 @@ -445,7 +459,7 @@ def replace(self, *args, **kwargs) -> TTkString: ret._text = self._text.replace(*args, **kwargs) else: start = 0 - while pos := self._text.index(old, start) if old in self._text[start:] else None: + while (pos := (self._text.index(old, start) if old in self._text[start:] else None)) is not None: ret._colors += self._colors[start:pos+oldLen] + [self._colors[pos+oldLen-1]]*(newLen-oldLen) start = pos+oldLen if count == 0: break From 875e13156b001e916b8bf4b4d0247077687958e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Thu, 28 May 2026 20:31:41 +0100 Subject: [PATCH 06/21] test: add comparison and hashing tests for class A --- ...mp.py => test.generic.008.class.cmp.01.py} | 0 .../test.generic.008.class.cmp.02.py | 58 +++++++++++++++++++ 2 files changed, 58 insertions(+) rename tests/t.generic/{test.generic.008.class.cmp.py => test.generic.008.class.cmp.01.py} (100%) create mode 100755 tests/t.generic/test.generic.008.class.cmp.02.py diff --git a/tests/t.generic/test.generic.008.class.cmp.py b/tests/t.generic/test.generic.008.class.cmp.01.py similarity index 100% rename from tests/t.generic/test.generic.008.class.cmp.py rename to tests/t.generic/test.generic.008.class.cmp.01.py diff --git a/tests/t.generic/test.generic.008.class.cmp.02.py b/tests/t.generic/test.generic.008.class.cmp.02.py new file mode 100755 index 000000000..8428530d9 --- /dev/null +++ b/tests/t.generic/test.generic.008.class.cmp.02.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 + +# MIT License +# +# Copyright (c) 2023 Eugenio Parodi +# +# 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. + +class A(): + def __init__(self,a:int,b:int) -> None: + self.a = a + self.b = b + + def __hash__(self) -> int: + print('HASH:',self) + return hash(self.a) + + def __str__(self) -> str: + return f"{(self.a,self.b)}" + + def __eq__(self, value: object) -> bool: + # print(f"{self=},{value=}") + if not isinstance(value, A): + return False + return self.a==value.a and self.b==value.b + + +a = A(1,2) +b = a +c = A(1,2) +d = A(1,3) + +print(f"{(a==b)=}") +print(f"{(a==c)=}") +print(f"{(a==d)=}") +print(f"{(a is b)=}") +print(f"{(a is c)=}") +print(f"{(a is d)=}") +print(a,hash(a),b,hash(b),c,hash(c),d,hash(d)) + +d = {a:1, b:2, c:3, d:4} +print(d) \ No newline at end of file From c97420b6d5ea481576e4fa858c7877766ca48b11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Thu, 28 May 2026 20:32:20 +0100 Subject: [PATCH 07/21] refactor(color): replace default color return values with TTkColor.RST --- libs/pyTermTk/TermTk/TTkCore/string.py | 2 +- libs/pyTermTk/TermTk/TTkGui/textcursor.py | 2 +- libs/pyTermTk/TermTk/TTkWidgets/TTkPickers/textpicker.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/pyTermTk/TermTk/TTkCore/string.py b/libs/pyTermTk/TermTk/TTkCore/string.py index d4109465d..47340b9a0 100644 --- a/libs/pyTermTk/TermTk/TTkCore/string.py +++ b/libs/pyTermTk/TermTk/TTkCore/string.py @@ -210,7 +210,7 @@ def setCharAt(self, pos:int, char:str) -> TTkString: def colorAt(self, pos:int) -> TTkColor: if pos >= len(self._colors): - return TTkColor() + return TTkColor.RST return self._colors[pos] def setColorAt(self, pos, color) -> TTkString: diff --git a/libs/pyTermTk/TermTk/TTkGui/textcursor.py b/libs/pyTermTk/TermTk/TTkGui/textcursor.py index 98967b359..9b469eefa 100644 --- a/libs/pyTermTk/TermTk/TTkGui/textcursor.py +++ b/libs/pyTermTk/TermTk/TTkGui/textcursor.py @@ -263,7 +263,7 @@ def positionColor(self, cID:int=-1) -> TTkColor: if pos < len(l): color = l.colorAt(pos) else: - color = TTkColor() + color = TTkColor.RST return color def setPosition(self, line:int, pos:int, moveMode:MoveMode=MoveMode.MoveAnchor, cID:int=0) -> None: diff --git a/libs/pyTermTk/TermTk/TTkWidgets/TTkPickers/textpicker.py b/libs/pyTermTk/TermTk/TTkWidgets/TTkPickers/textpicker.py index d264013dc..feb2468b2 100644 --- a/libs/pyTermTk/TermTk/TTkWidgets/TTkPickers/textpicker.py +++ b/libs/pyTermTk/TermTk/TTkWidgets/TTkPickers/textpicker.py @@ -196,7 +196,7 @@ def _currentColorChangedCB(format:TTkColor): def _setStyle(): - color = TTkColor() + color = TTkColor.RST if cb_fg.checkState() == TTkK.Checked: color += btn_fgColor.color() if cb_bg.checkState() == TTkK.Checked: From 1a0e4fe65ad14ddd61aabafe5e778f197de35ac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Thu, 28 May 2026 20:32:49 +0100 Subject: [PATCH 08/21] refactor(type): update type hints for _data and _colors attributes in TTkCanvas --- libs/pyTermTk/TermTk/TTkCore/canvas.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/libs/pyTermTk/TermTk/TTkCore/canvas.py b/libs/pyTermTk/TermTk/TTkCore/canvas.py index 0939a735b..7ac52e371 100644 --- a/libs/pyTermTk/TermTk/TTkCore/canvas.py +++ b/libs/pyTermTk/TermTk/TTkCore/canvas.py @@ -22,7 +22,7 @@ __all__ = ['TTkCanvas'] -from typing import Tuple +from typing import List, Tuple from TermTk.TTkCore.TTkTerm.term import TTkTerm from TermTk.TTkCore.constant import TTkK @@ -44,8 +44,8 @@ class TTkCanvas(): '_data', '_colors', '_bufferedData', '_bufferedColors', '_visible', '_transparent', '_doubleBuffer') - _data:list[list[str]] - _colors:list[list[TTkColor]] + _data:List[List[str]] + _colors:List[List[TTkColor]] def __init__(self, width:int=0, height:int=0) -> None: @@ -730,8 +730,8 @@ def pushToTerminalBuffered(self, x, y, w, h): lastcolor = TTkColor.RST empty = True ansi = "" - for y,(lda,ldb,lca,lcb) in enumerate(zip(data,oldData,colors,oldColors)): - for x,(da,db,ca,cb) in enumerate(zip(lda,ldb,lca,lcb)): + for y,(lda, ldb, lca, lcb) in enumerate(zip(data, oldData, colors, oldColors)): + for x,(da, db, ca, cb) in enumerate(zip(lda, ldb, lca, lcb)): if da==db and ca==cb: if not empty: TTkTerm.push(ansi) From 278b3d83d894611d9cd55de5e0d3e4218427aa63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Thu, 28 May 2026 20:33:07 +0100 Subject: [PATCH 09/21] refactor(doc): enhance docstrings for TTkColor methods and modifiers --- libs/pyTermTk/TermTk/TTkCore/color.py | 293 +++++++++++++++++++++++--- 1 file changed, 269 insertions(+), 24 deletions(-) diff --git a/libs/pyTermTk/TermTk/TTkCore/color.py b/libs/pyTermTk/TermTk/TTkCore/color.py index c28de9831..b7c21dfd8 100644 --- a/libs/pyTermTk/TermTk/TTkCore/color.py +++ b/libs/pyTermTk/TermTk/TTkCore/color.py @@ -178,6 +178,17 @@ def __init__(self, bg:Optional[Tuple[int,int,int]]=None, colorMod=None, clean=False) -> None: + '''Create a color container with optional foreground/background and modifier. + + :param fg: foreground RGB triplet + :type fg: tuple[int, int, int] | None + :param bg: background RGB triplet + :type bg: tuple[int, int, int] | None + :param colorMod: optional runtime color modifier + :type colorMod: TTkColorModifier | None + :param clean: force emitting a full reset before this color + :type clean: bool + ''' self._fg = fg self._bg = bg self._clean = clean or (fg is None and bg is None) @@ -188,6 +199,14 @@ def __init__(self, @staticmethod def hexToRGB(val) -> Tuple[int,int,int]: + '''Convert a hexadecimal color string (``#rrggbb``) to RGB. + + :param val: hexadecimal color string + :type val: str + + :return: RGB tuple + :rtype: tuple[int, int, int] + ''' r = int(val[1:3],base=16) g = int(val[3:5],base=16) b = int(val[5:7],base=16) @@ -291,49 +310,118 @@ def fgbg(fg:str='', bg:str='', *, link:str='', modifier:Optional[TTkColorModifie def foreground(self) -> TTkColor: + '''Return a color object containing only the foreground component. + + :return: a color with only foreground information if available, otherwise :py:class:`TTkColor.RST` + :rtype: :py:class:`TTkColor` + ''' if self._fg: return TTkColor(fg=self._fg) else: return TTkColor.RST def background(self) -> TTkColor: + '''Return a color object containing only the background component. + + :return: a color with only background information if available, otherwise :py:class:`TTkColor.RST` + :rtype: :py:class:`TTkColor` + ''' if self._bg: return TTkColor(bg=self._bg) else: return TTkColor.RST def hasForeground(self) -> bool: + '''Check whether this color has a foreground component. + + :return: True if a foreground color is set + :rtype: bool + ''' return self._fg is not None def hasBackground(self) -> bool: + '''Check whether this color has a background component. + + :return: True if a background color is set + :rtype: bool + ''' return self._bg is not None def bold(self) -> bool: + '''Check whether bold style is active. + + :return: False for base colors without style flags + :rtype: bool + ''' return False def italic(self) -> bool: + '''Check whether italic style is active. + + :return: False for base colors without style flags + :rtype: bool + ''' return False def underline(self) -> bool: + '''Check whether underline style is active. + + :return: False for base colors without style flags + :rtype: bool + ''' return False def strikethrough(self) -> bool: + '''Check whether strikethrough style is active. + + :return: False for base colors without style flags + :rtype: bool + ''' return False def blinking(self) -> bool: + '''Check whether blinking style is active. + + :return: False for base colors without style flags + :rtype: bool + ''' return False def colorType(self) -> int: + '''Return the bitmask describing which color features are active. + + The result combines values from :py:class:`TTkK.ColorType` for + foreground, background, and color modifiers. + + :return: bitmask with active color feature flags + :rtype: int + ''' return ( ( TTkK.ColorType.ColorModifier if self._colorMod else TTkK.NONE ) | ( TTkK.ColorType.Foreground if self._fg else TTkK.NONE ) | ( TTkK.ColorType.Background if self._bg else TTkK.NONE ) ) def withoutModifiers(self) -> TTkColor: + '''Return this color without style modifiers. + + For base :py:class:`TTkColor`, no text-style modifiers are stored, + so this method returns the current instance unchanged. + + :return: the color instance without text-style modifiers + :rtype: :py:class:`TTkColor` + ''' return self @staticmethod def rgb2hsl(rgb) -> Tuple[int,int,int]: + '''Convert RGB values to HSL. + + :param rgb: RGB tuple where each component is in ``0..255`` + :type rgb: tuple[int, int, int] + + :return: HSL tuple as ``(hue[0..359], saturation[0..100], lightness[0..100])`` + :rtype: tuple[int, int, int] + ''' r = rgb[0]/255 g = rgb[1]/255 b = rgb[2]/255 @@ -361,6 +449,14 @@ def rgb2hsl(rgb) -> Tuple[int,int,int]: @staticmethod def hsl2rgb(hsl) -> Tuple[int,int,int]: + '''Convert HSL values to RGB. + + :param hsl: HSL tuple as ``(hue[0..359], saturation[0..100], lightness[0..100])`` + :type hsl: tuple[int, int, int] + + :return: RGB tuple where each component is in ``0..255`` + :rtype: tuple[int, int, int] + ''' hue = hsl[0] sat = hsl[1] / 100 lum = hsl[2] / 100 @@ -389,6 +485,14 @@ def hsl2rgb(hsl) -> Tuple[int,int,int]: return r,g,b def getHex(self, ctype) -> str: + '''Return the selected component as a hexadecimal color string. + + :param ctype: target component, usually one of :py:class:`TTkK.ColorType` + :type ctype: int + + :return: lowercase hexadecimal RGB string in the form ``#rrggbb`` + :rtype: str + ''' if ctype == TTkK.ColorType.Foreground: r,g,b = self.fgToRGB() else: @@ -396,12 +500,27 @@ def getHex(self, ctype) -> str: return f"#{r<<16|g<<8|b:06x}" def fgToRGB(self) -> Tuple[int,int,int]: + '''Return foreground RGB values. + + :return: foreground RGB tuple, or ``(0,0,0)`` when unset + :rtype: tuple[int, int, int] + ''' return self._fg if self._fg else (0,0,0) def bgToRGB(self) -> Tuple[int,int,int]: + '''Return background RGB values. + + :return: background RGB tuple, or ``(0,0,0)`` when unset + :rtype: tuple[int, int, int] + ''' return self._bg if self._bg else (0,0,0) def invertFgBg(self) -> TTkColor: + '''Return a copy with foreground and background swapped. + + :return: color copy with foreground/background inverted + :rtype: :py:class:`TTkColor` + ''' ret = self.copy() ret._fg = self._bg ret._bg = self._fg @@ -454,23 +573,45 @@ def __sub__(self, other) -> str: return str(self) def modParam(self, *args, **kwargs) -> TTkColor: + '''Return a copy with updated color-modifier parameters. + + :return: updated color instance; unchanged instance when no modifier is set + :rtype: :py:class:`TTkColor` + ''' if not self._colorMod: return self ret = self.copy() ret._colorMod.setParam(*args, **kwargs) return ret def mod(self, x , y) -> TTkColor: + '''Apply the configured color modifier at position ``(x, y)``. + + :param x: horizontal coordinate + :type x: int + :param y: vertical coordinate + :type y: int + + :return: transformed color, or self when no modifier is set + :rtype: :py:class:`TTkColor` + ''' if not self._colorMod: return self return self._colorMod.exec(x,y,self) def copy(self, modifier=True) -> TTkColor: - ret = TTkColor() - ret._fg = self._fg - ret._bg = self._bg - ret._clean = self._clean - if modifier and self._colorMod: - ret._colorMod = self._colorMod.copy() - return ret + '''Create a copy of this color. + + :param modifier: include a copied color modifier when available + :type modifier: bool + + :return: color copy + :rtype: :py:class:`TTkColor` + ''' + return TTkColor( + fg=self._fg, + bg=self._bg, + clean=self._clean, + colorMod=self._colorMod.copy() if modifier else None + ) TTkColor.RST = TTkColor() @@ -507,31 +648,43 @@ def __init__(self, *, mod:int=0, **kwargs ) -> None: + '''Create a color with terminal style modifier flags. + + :param mod: bitmask from :py:class:`TTkTermColor` modifier constants + :type mod: int + ''' self._mod = mod super().__init__(**kwargs) self._clean = self._clean and not mod def bold(self) -> bool: + '''Check whether bold flag is enabled.''' return bool(self._mod & TTkTermColor.BOLD) def italic(self) -> bool: + '''Check whether italic flag is enabled.''' return bool(self._mod & TTkTermColor.ITALIC) def underline(self) -> bool: + '''Check whether underline flag is enabled.''' return bool(self._mod & TTkTermColor.UNDERLINE) def strikethrough(self) -> bool: + '''Check whether strikethrough flag is enabled.''' return bool(self._mod & TTkTermColor.STRIKETROUGH) def blinking(self) -> bool: + '''Check whether blinking flag is enabled.''' return bool(self._mod & TTkTermColor.BLINKING) def colorType(self) -> int: + '''Return the feature bitmask including style modifier presence.''' return ( super().colorType() | ( TTkK.ColorType.Modifier if self._mod else TTkK.NONE )) def withoutModifiers(self) -> TTkColor: + '''Return a base color stripped of style flags.''' return TTkColor(fg=self._fg, bg=self._bg) def __str__(self) -> str: @@ -600,14 +753,21 @@ def __rsub__(self, other) -> str: return TTkTermColor.rgb2ansi(fg=other._fg, bg=other._bg, clean=True) def copy(self, modifier=True) -> TTkColor: - ret = _TTkColor_mod() - ret._fg = self._fg - ret._bg = self._bg - ret._mod = self._mod - ret._clean = self._clean - if modifier and self._colorMod: - ret._colorMod = self._colorMod.copy() - return ret + '''Create a copy preserving style flags. + + :param modifier: include a copied color modifier when available + :type modifier: bool + + :return: copied modifiable color + :rtype: :py:class:`TTkColor` + ''' + return _TTkColor_mod( + fg=self._fg, + bg=self._bg, + clean=self._clean, + mod=self._mod, + colorMod=self._colorMod.copy() if modifier else None + ) TTkColor.BOLD = _TTkColor_mod(mod=TTkTermColor.BOLD) TTkColor.ITALIC = _TTkColor_mod(mod=TTkTermColor.ITALIC) @@ -622,11 +782,17 @@ def __init__(self, *, link:str='', **kwargs ) -> None: + '''Create a styled color carrying an optional hyperlink. + + :param link: URL associated with this color span + :type link: str + ''' self._link = link super().__init__(**kwargs) self._clean = self._clean and not link def colorType(self) -> int: + '''Return the feature bitmask including hyperlink presence.''' return ( super().colorType() | ( TTkK.ColorType.Link if self._link else TTkK.NONE )) @@ -707,18 +873,27 @@ def __rsub__(self, other) -> str: return TTkTermColor.rgb2ansi_link(fg=other._fg, bg=other._bg, mod=other._mod, clean=True, cleanLink=True) def copy(self, modifier=True) -> TTkColor: - ret = _TTkColor_mod_link() - ret._fg = self._fg - ret._bg = self._bg - ret._mod = self._mod - ret._link = self._link - ret._clean = self._clean - if modifier and self._colorMod: - ret._colorMod = self._colorMod.copy() - return ret + '''Create a copy preserving style flags and hyperlink. + + :param modifier: include a copied color modifier when available + :type modifier: bool + + :return: copied linked color + :rtype: :py:class:`TTkColor` + ''' + return _TTkColor_mod_link( + fg=self._fg, + bg=self._bg, + clean=self._clean, + mod=self._mod, + link=self._link, + colorMod=self._colorMod.copy() if modifier else None + ) class TTkColorModifier(): + '''Base interface for runtime color modifiers.''' + def __init__(self, *args, **kwargs) -> None: pass def setParam(self, *args, **kwargs) -> None: pass def copy(self) -> TTkColorModifier: return self @@ -730,6 +905,14 @@ class TTkColorGradient(TTkColorModifier): _increment: int; _val: int _buffer: Dict[str,List[Optional[TTkColor]]] def __init__(self, *args, **kwargs) -> None: + '''Create a linear incremental gradient modifier. + + Supported keyword arguments: + + - ``increment`` to apply the same increment to foreground/background + - ``fgincrement`` and ``bgincrement`` for independent increments + - ``orientation`` as :py:class:`TTkK.VERTICAL` or :py:class:`TTkK.HORIZONTAL` + ''' super().__init__(*args, **kwargs) if "increment" in kwargs: self._fgincrement = kwargs.get("increment") @@ -743,10 +926,26 @@ def __init__(self, *args, **kwargs) -> None: self._buffer = {} def setParam(self, *args, **kwargs) -> None: + '''Set runtime parameters used during gradient evaluation. + + Accepted kwargs are ``val`` (base offset) and ``step`` (scaling factor). + ''' self._val = kwargs.get("val",0) self._step = kwargs.get("step",1) def exec(self, x, y, color) -> TTkColor: + '''Apply the gradient to a color at the given canvas position. + + :param x: horizontal coordinate + :type x: int + :param y: vertical coordinate + :type y: int + :param color: source color + :type color: :py:class:`TTkColor` + + :return: transformed color + :rtype: :py:class:`TTkColor` + ''' vx = x if self._orientation == TTkK.HORIZONTAL else y step = self._step def _applyGradient(c,incr): @@ -774,6 +973,13 @@ def _applyGradient(c,incr): return self._buffer[bname][id] def copy(self): + '''Return this modifier instance. + + Gradients are shared and internally buffered, so the same instance is reused. + + :return: self + :rtype: :py:class:`TTkColorGradient` + ''' return self class TTkLinearGradient(TTkColorModifier): @@ -786,6 +992,7 @@ class TTkLinearGradient(TTkColorModifier): default_target_color = TTkColor(fg=(0,255,0), bg=(255,0,0)) def __init__(self, *args, **kwargs) -> None: + '''Create a directional gradient interpolating toward a target color.''' super().__init__(*args, **kwargs) self._base_pos = (0, 0) self._direction = (30, 30) @@ -793,6 +1000,10 @@ def __init__(self, *args, **kwargs) -> None: self.setParam(*args, **kwargs) def setParam(self, *args, **kwargs) -> None: + '''Update linear gradient parameters. + + Supported kwargs are ``base_pos``, ``direction``, and ``target_color``. + ''' self._base_pos = tuple(kwargs.get('base_pos', self._base_pos)) direct = tuple(kwargs.get('direction', self._direction)) self._direction = direct @@ -800,6 +1011,18 @@ def setParam(self, *args, **kwargs) -> None: self._target_color = kwargs.get('target_color', self._target_color) def exec(self, x, y, base_color) -> TTkColor: + '''Evaluate the directional gradient at a point and return the blended color. + + :param x: horizontal coordinate + :type x: int + :param y: vertical coordinate + :type y: int + :param base_color: starting color for interpolation + :type base_color: :py:class:`TTkColor` + + :return: interpolated color + :rtype: :py:class:`TTkColor` + ''' diffx, diffy = x - self._base_pos[0], y - self._base_pos[1] prod = diffx * self._direction[0] + diffy * self._direction[1] beta = prod/self._direction_squaredlength @@ -827,12 +1050,34 @@ class TTkAlternateColor(TTkColorModifier): __slots__ = ('_alternateColor',) def __init__(self, alternateColor:TTkColor=TTkColor.RST, **kwargs) -> None: + '''Create a row-alternating modifier. + + :param alternateColor: color used for odd rows + :type alternateColor: :py:class:`TTkColor` + ''' super().__init__(**kwargs) self.setParam(alternateColor) def setParam(self, alternateColor:TTkColor) -> None: + '''Set the color used on odd rows. + + :param alternateColor: replacement color for odd ``y`` values + :type alternateColor: :py:class:`TTkColor` + ''' self._alternateColor = alternateColor def exec(self, x:int, y:int, base_color:TTkColor) -> TTkColor: + '''Return alternate color on odd rows, base color copy on even rows. + + :param x: horizontal coordinate (unused) + :type x: int + :param y: vertical coordinate + :type y: int + :param base_color: source color + :type base_color: :py:class:`TTkColor` + + :return: row-selected color + :rtype: :py:class:`TTkColor` + ''' if y%2: return self._alternateColor else: return base_color.copy(modifier=False) From 71b352713cbb6373e6d0ad9fcf268e17f827fe53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Thu, 28 May 2026 20:47:09 +0100 Subject: [PATCH 10/21] refactor(color): improve color modifier handling to prevent copying None values --- libs/pyTermTk/TermTk/TTkCore/color.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/pyTermTk/TermTk/TTkCore/color.py b/libs/pyTermTk/TermTk/TTkCore/color.py index b7c21dfd8..791730a4d 100644 --- a/libs/pyTermTk/TermTk/TTkCore/color.py +++ b/libs/pyTermTk/TermTk/TTkCore/color.py @@ -610,7 +610,7 @@ def copy(self, modifier=True) -> TTkColor: fg=self._fg, bg=self._bg, clean=self._clean, - colorMod=self._colorMod.copy() if modifier else None + colorMod=self._colorMod.copy() if modifier and self._colorMod else None ) @@ -766,7 +766,7 @@ def copy(self, modifier=True) -> TTkColor: bg=self._bg, clean=self._clean, mod=self._mod, - colorMod=self._colorMod.copy() if modifier else None + colorMod=self._colorMod.copy() if modifier and self._colorMod else None ) TTkColor.BOLD = _TTkColor_mod(mod=TTkTermColor.BOLD) @@ -887,7 +887,7 @@ def copy(self, modifier=True) -> TTkColor: clean=self._clean, mod=self._mod, link=self._link, - colorMod=self._colorMod.copy() if modifier else None + colorMod=self._colorMod.copy() if modifier and self._colorMod else None ) From 97b6cd9c3027ce02e033cd96a98b9fe534ca3477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Thu, 28 May 2026 21:00:19 +0100 Subject: [PATCH 11/21] refactor(color): fix saturation calculation in TTkColor class --- libs/pyTermTk/TermTk/TTkCore/color.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/pyTermTk/TermTk/TTkCore/color.py b/libs/pyTermTk/TermTk/TTkCore/color.py index 791730a4d..5d8f2ebb6 100644 --- a/libs/pyTermTk/TermTk/TTkCore/color.py +++ b/libs/pyTermTk/TermTk/TTkCore/color.py @@ -440,7 +440,7 @@ def rgb2hsl(rgb) -> Tuple[int,int,int]: else: hue = (r-g)/delta+4 - sat = delta / (1 - abs(delta-1)) + sat = delta / (1 - abs(2*lum-1)) hue = int(hue*60) + ( 360 if hue < 0 else 0 ) sat = int(sat*100) lum = int(lum*100) From 135b64fa128bf9e9a50be852e2230fc674c34607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Thu, 28 May 2026 21:40:29 +0100 Subject: [PATCH 12/21] refactor(color): improve TTkColor and TTkColorGradient implementations for better performance and correctness --- libs/pyTermTk/TermTk/TTkCore/color.py | 44 +++++++++++++++++---------- tests/pytest/test_008_color_logic.py | 6 ---- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/libs/pyTermTk/TermTk/TTkCore/color.py b/libs/pyTermTk/TermTk/TTkCore/color.py index 5d8f2ebb6..55e19b025 100644 --- a/libs/pyTermTk/TermTk/TTkCore/color.py +++ b/libs/pyTermTk/TermTk/TTkCore/color.py @@ -457,7 +457,7 @@ def hsl2rgb(hsl) -> Tuple[int,int,int]: :return: RGB tuple where each component is in ``0..255`` :rtype: tuple[int, int, int] ''' - hue = hsl[0] + hue = hsl[0] % 360 sat = hsl[1] / 100 lum = hsl[2] / 100 @@ -534,7 +534,7 @@ def __str__(self) -> str: return self._buffer def __eq__(self, other) -> bool: - if not other: return False + if not isinstance(other, TTkColor): return False return ( self._fg == other._fg and self._bg == other._bg ) @@ -844,7 +844,7 @@ def __radd__(self, other) -> TTkColor: clean = self._clean fg = self._fg or other._fg bg = self._bg or other._bg - mod = self._mod + otherMod + mod = self._mod | otherMod link = self._link colorMod = self._colorMod or other._colorMod return _TTkColor_mod_link( @@ -903,8 +903,13 @@ class TTkColorGradient(TTkColorModifier): __slots__ = ('_fgincrement', '_bgincrement', '_val', '_step', '_buffer', '_orientation') _increment: int; _val: int - _buffer: Dict[str,List[Optional[TTkColor]]] - def __init__(self, *args, **kwargs) -> None: + _buffer: Dict[str,Dict[int,TTkColor]] + def __init__( + self, + increment:Optional[int] = None, + fgincrement:int = 0, + bgincrement:int = 0, + orientation:TTkK.Direction = TTkK.Direction.VERTICAL) -> None: '''Create a linear incremental gradient modifier. Supported keyword arguments: @@ -913,14 +918,13 @@ def __init__(self, *args, **kwargs) -> None: - ``fgincrement`` and ``bgincrement`` for independent increments - ``orientation`` as :py:class:`TTkK.VERTICAL` or :py:class:`TTkK.HORIZONTAL` ''' - super().__init__(*args, **kwargs) - if "increment" in kwargs: - self._fgincrement = kwargs.get("increment") - self._bgincrement = kwargs.get("increment") + if increment is not None: + self._fgincrement = increment + self._bgincrement = increment else: - self._fgincrement = kwargs.get("fgincrement",0) - self._bgincrement = kwargs.get("bgincrement",0) - self._orientation = kwargs.get("orientation", TTkK.VERTICAL) + self._fgincrement = fgincrement + self._bgincrement = bgincrement + self._orientation = orientation self._val = 0 self._step = 1 self._buffer = {} @@ -949,7 +953,7 @@ def exec(self, x, y, color) -> TTkColor: vx = x if self._orientation == TTkK.HORIZONTAL else y step = self._step def _applyGradient(c,incr): - if not c: return c + if not step or not c: return c multiplier = abs(self._val + vx) r = int(c[0])+ incr * multiplier // step g = int(c[1])+ incr * multiplier // step @@ -962,9 +966,9 @@ def _applyGradient(c,incr): bname = str(color) # I made a buffer to keep all the gradient values to speed up the paint process if bname not in self._buffer: - self._buffer[bname] = [None]*(256*2) + self._buffer[bname] = {} id = self._val + vx - 256 - if self._buffer[bname][id] is not None: + if id in self._buffer[bname]: return self._buffer[bname][id] copy = color.copy(modifier=False) copy._fg = _applyGradient(color._fg, self._fgincrement) @@ -980,7 +984,13 @@ def copy(self): :return: self :rtype: :py:class:`TTkColorGradient` ''' - return self + ret = TTkColorGradient( + fgincrement=self._fgincrement, + bgincrement=self._bgincrement, + orientation=self._orientation + ) + ret._buffer = self._buffer + return ret class TTkLinearGradient(TTkColorModifier): '''TTkLinearGradient''' @@ -1023,6 +1033,8 @@ def exec(self, x, y, base_color) -> TTkColor: :return: interpolated color :rtype: :py:class:`TTkColor` ''' + if not self._direction_squaredlength: + return base_color diffx, diffy = x - self._base_pos[0], y - self._base_pos[1] prod = diffx * self._direction[0] + diffy * self._direction[1] beta = prod/self._direction_squaredlength diff --git a/tests/pytest/test_008_color_logic.py b/tests/pytest/test_008_color_logic.py index cb7ee24e6..126b8981e 100644 --- a/tests/pytest/test_008_color_logic.py +++ b/tests/pytest/test_008_color_logic.py @@ -600,12 +600,6 @@ def test_ttkcolorgradient_cached_value_returned_on_repeated_lookup(): assert first is second -def test_ttkcolorgradient_copy_returns_same_instance(): - grad = TTkColorGradient(increment=3) - - assert grad.copy() is grad - - def test_ttkcolorgradient_horizontal_orientation_uses_x_axis(): grad = TTkColorGradient(increment=5, orientation=TTkK.HORIZONTAL) base = TTkColor.fg('#202020', modifier=grad) From 639483efa1e20b3b6dc06927bbfbe1dffd6df813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Thu, 28 May 2026 21:49:09 +0100 Subject: [PATCH 13/21] refactor(import): update TermTk import to use alias for consistency across tests --- tests/pytest/test_003_string.py | 130 ++++++++++++++++---------------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/tests/pytest/test_003_string.py b/tests/pytest/test_003_string.py index 0a95d4b50..be7b1a616 100644 --- a/tests/pytest/test_003_string.py +++ b/tests/pytest/test_003_string.py @@ -26,54 +26,54 @@ sys.path.append(os.path.join(sys.path[0],'../../libs/pyTermTk')) -import TermTk +import TermTk as ttk def test_stringAlign1(): - test1 = TermTk.TTkString('Yes\u231b\u231b\u231b') # 'Yes⌛⌛⌛' + test1 = ttk.TTkString('Yes\u231b\u231b\u231b') # 'Yes⌛⌛⌛' print(f"Testcase: |{str(test1)}|") for width in range(0, 15): - aligned = test1.align(width=width, alignment=TermTk.TTkK.CENTER_ALIGN) + aligned = test1.align(width=width, alignment=ttk.TTkK.CENTER_ALIGN) print(f"width={width:2}: |{aligned}|") # width= 0: |Yes⌛⌛⌛| - assert 'Yes⌛⌛⌛' == str(test1.align(width= 0, alignment=TermTk.TTkK.CENTER_ALIGN)) + assert 'Yes⌛⌛⌛' == str(test1.align(width= 0, alignment=ttk.TTkK.CENTER_ALIGN)) # width= 1: |Y| - assert 'Y' == str(test1.align(width= 1, alignment=TermTk.TTkK.CENTER_ALIGN)) + assert 'Y' == str(test1.align(width= 1, alignment=ttk.TTkK.CENTER_ALIGN)) # width= 2: |Ye| - assert 'Ye' == str(test1.align(width= 2, alignment=TermTk.TTkK.CENTER_ALIGN)) + assert 'Ye' == str(test1.align(width= 2, alignment=ttk.TTkK.CENTER_ALIGN)) # width= 3: |Yes| - assert 'Yes' == str(test1.align(width= 3, alignment=TermTk.TTkK.CENTER_ALIGN)) + assert 'Yes' == str(test1.align(width= 3, alignment=ttk.TTkK.CENTER_ALIGN)) # width= 4: |Yes≽| - assert 'Yes≽' == str(test1.align(width= 4, alignment=TermTk.TTkK.CENTER_ALIGN)) + assert 'Yes≽' == str(test1.align(width= 4, alignment=ttk.TTkK.CENTER_ALIGN)) # width= 5: |Yes⌛| - assert 'Yes⌛' == str(test1.align(width= 5, alignment=TermTk.TTkK.CENTER_ALIGN)) + assert 'Yes⌛' == str(test1.align(width= 5, alignment=ttk.TTkK.CENTER_ALIGN)) # width= 6: |Yes⌛≽| - assert 'Yes⌛≽' == str(test1.align(width= 6, alignment=TermTk.TTkK.CENTER_ALIGN)) + assert 'Yes⌛≽' == str(test1.align(width= 6, alignment=ttk.TTkK.CENTER_ALIGN)) # width= 7: |Yes⌛⌛| - assert 'Yes⌛⌛' == str(test1.align(width= 7, alignment=TermTk.TTkK.CENTER_ALIGN)) + assert 'Yes⌛⌛' == str(test1.align(width= 7, alignment=ttk.TTkK.CENTER_ALIGN)) # width= 8: |Yes⌛⌛≽| - assert 'Yes⌛⌛≽' == str(test1.align(width= 8, alignment=TermTk.TTkK.CENTER_ALIGN)) + assert 'Yes⌛⌛≽' == str(test1.align(width= 8, alignment=ttk.TTkK.CENTER_ALIGN)) # width= 9: |Yes⌛⌛⌛| - assert 'Yes⌛⌛⌛' == str(test1.align(width= 9, alignment=TermTk.TTkK.CENTER_ALIGN)) + assert 'Yes⌛⌛⌛' == str(test1.align(width= 9, alignment=ttk.TTkK.CENTER_ALIGN)) # width=10: |Yes⌛⌛⌛ | - assert 'Yes⌛⌛⌛ ' == str(test1.align(width=10, alignment=TermTk.TTkK.CENTER_ALIGN)) + assert 'Yes⌛⌛⌛ ' == str(test1.align(width=10, alignment=ttk.TTkK.CENTER_ALIGN)) # width=11: | Yes⌛⌛⌛ | - assert ' Yes⌛⌛⌛ ' == str(test1.align(width=11, alignment=TermTk.TTkK.CENTER_ALIGN)) + assert ' Yes⌛⌛⌛ ' == str(test1.align(width=11, alignment=ttk.TTkK.CENTER_ALIGN)) # width=12: | Yes⌛⌛⌛ | - assert ' Yes⌛⌛⌛ ' == str(test1.align(width=12, alignment=TermTk.TTkK.CENTER_ALIGN)) + assert ' Yes⌛⌛⌛ ' == str(test1.align(width=12, alignment=ttk.TTkK.CENTER_ALIGN)) # width=13: | Yes⌛⌛⌛ | - assert ' Yes⌛⌛⌛ '== str(test1.align(width=13, alignment=TermTk.TTkK.CENTER_ALIGN)) + assert ' Yes⌛⌛⌛ '== str(test1.align(width=13, alignment=ttk.TTkK.CENTER_ALIGN)) # width=14: | Yes⌛⌛⌛ | - assert ' Yes⌛⌛⌛ '==str(test1.align(width=14, alignment=TermTk.TTkK.CENTER_ALIGN)) + assert ' Yes⌛⌛⌛ '==str(test1.align(width=14, alignment=ttk.TTkK.CENTER_ALIGN)) def test_ttkstring_copy_constructor_is_independent(): - original = TermTk.TTkString('abc', TermTk.TTkColor.fg('#00ff00')) - copied = TermTk.TTkString(original) + original = ttk.TTkString('abc', ttk.TTkColor.fg('#00ff00')) + copied = ttk.TTkString(original) original_ansi = original.toAnsi(strip=True) - updated = copied.setColorAt(0, TermTk.TTkColor.fg('#ff0000')) + updated = copied.setColorAt(0, ttk.TTkColor.fg('#ff0000')) assert updated is not copied assert original.toAnsi(strip=True) == original_ansi @@ -81,11 +81,11 @@ def test_ttkstring_copy_constructor_is_independent(): def test_ttkstring_add_color_returns_independent_instance(): - original = TermTk.TTkString('abc', TermTk.TTkColor.fg('#00ff00')) - recolored = original + TermTk.TTkColor.fg('#0000ff') + original = ttk.TTkString('abc', ttk.TTkColor.fg('#00ff00')) + recolored = original + ttk.TTkColor.fg('#0000ff') original_ansi = original.toAnsi(strip=True) - updated = recolored.setColorAt(1, TermTk.TTkColor.fg('#ffffff')) + updated = recolored.setColorAt(1, ttk.TTkColor.fg('#ffffff')) assert updated is not recolored assert original.toAnsi(strip=True) == original_ansi @@ -93,7 +93,7 @@ def test_ttkstring_add_color_returns_independent_instance(): def test_replace_expanding_match_keeps_full_output_text(): - txt = TermTk.TTkString('abc') + txt = ttk.TTkString('abc') replaced = txt.replace('a', 'ZZ') @@ -102,17 +102,17 @@ def test_replace_expanding_match_keeps_full_output_text(): def test_complete_color_applies_match_at_start_of_text(): - txt = TermTk.TTkString('abc') + txt = ttk.TTkString('abc') txt_ansi = txt.toAnsi(strip=True) - colorized = txt.completeColor(TermTk.TTkColor.BOLD, match='a') + colorized = txt.completeColor(ttk.TTkColor.BOLD, match='a') assert txt.toAnsi(strip=True) == txt_ansi assert colorized.toAnsi(strip=True) != txt_ansi def test_extract_shortcuts_trailing_ampersand_does_not_crash(): - txt = TermTk.TTkString('Save &') + txt = ttk.TTkString('Save &') extracted, shortcuts = txt.extractShortcuts() @@ -121,7 +121,7 @@ def test_extract_shortcuts_trailing_ampersand_does_not_crash(): def test_lstrip_preserves_combining_char_display_width(): - txt = TermTk.TTkString('a\u0301') + txt = ttk.TTkString('a\u0301') stripped = txt.lstrip(' ') @@ -129,14 +129,14 @@ def test_lstrip_preserves_combining_char_display_width(): def test_set_color_at_out_of_range_raises_index_error(): - txt = TermTk.TTkString('abc') + txt = ttk.TTkString('abc') with pytest.raises(IndexError): - txt.setColorAt(100, TermTk.TTkColor.BOLD) + txt.setColorAt(100, ttk.TTkColor.BOLD) def test_basic_dunder_conversions_and_comparisons(): - txt = TermTk.TTkString('12') + txt = ttk.TTkString('12') assert len(txt) == 2 assert bool(txt) is True @@ -145,14 +145,14 @@ def test_basic_dunder_conversions_and_comparisons(): assert complex(txt) == complex(12) assert txt == '12' assert txt < '99' - assert txt >= TermTk.TTkString('12') + assert txt >= ttk.TTkString('12') def test_sameas_distinguishes_text_and_color(): - a = TermTk.TTkString('abc', TermTk.TTkColor.fg('#101010')) - b = TermTk.TTkString('abc', TermTk.TTkColor.fg('#101010')) - c = TermTk.TTkString('abc', TermTk.TTkColor.fg('#202020')) - d = TermTk.TTkString('abd', TermTk.TTkColor.fg('#101010')) + a = ttk.TTkString('abc', ttk.TTkColor.fg('#101010')) + b = ttk.TTkString('abc', ttk.TTkColor.fg('#101010')) + c = ttk.TTkString('abc', ttk.TTkColor.fg('#202020')) + d = ttk.TTkString('abd', ttk.TTkColor.fg('#101010')) assert a.sameAs(b) assert not a.sameAs(c) @@ -160,15 +160,15 @@ def test_sameas_distinguishes_text_and_color(): def test_char_and_color_accessors(): - txt = TermTk.TTkString('abc', TermTk.TTkColor.fg('#00ff00')) + txt = ttk.TTkString('abc', ttk.TTkColor.fg('#00ff00')) assert txt.charAt(1) == 'b' - assert txt.colorAt(0) == TermTk.TTkColor.fg('#00ff00') - assert txt.colorAt(99) == TermTk.TTkColor() + assert txt.colorAt(0) == ttk.TTkColor.fg('#00ff00') + assert txt.colorAt(99) == ttk.TTkColor() def test_tab2spaces_and_tab_char_pos_mapping(): - txt = TermTk.TTkString('a\tb') + txt = ttk.TTkString('a\tb') expanded = txt.tab2spaces(4) assert str(expanded) == 'a b' @@ -179,7 +179,7 @@ def test_tab2spaces_and_tab_char_pos_mapping(): def test_tab_char_pos_with_wide_chars(): - txt = TermTk.TTkString('界a\tb') + txt = ttk.TTkString('界a\tb') assert txt.termWidth() == 5 assert txt.tabCharPos(0, 4) == 0 @@ -189,7 +189,7 @@ def test_tab_char_pos_with_wide_chars(): def test_plain_text_ascii_and_ansi_roundtrip_plain(): - txt = TermTk.TTkString('plain text') + txt = ttk.TTkString('plain text') assert txt.isPlainText() assert txt.toAscii() == 'plain text' @@ -197,18 +197,18 @@ def test_plain_text_ascii_and_ansi_roundtrip_plain(): def test_align_left_right_center_and_justify(): - txt = TermTk.TTkString('ab') + txt = ttk.TTkString('ab') - assert str(txt.align(width=5, alignment=TermTk.TTkK.LEFT_ALIGN)) == 'ab ' - assert str(txt.align(width=5, alignment=TermTk.TTkK.RIGHT_ALIGN)) == ' ab' - assert str(txt.align(width=5, alignment=TermTk.TTkK.CENTER_ALIGN)) == ' ab ' + assert str(txt.align(width=5, alignment=ttk.TTkK.LEFT_ALIGN)) == 'ab ' + assert str(txt.align(width=5, alignment=ttk.TTkK.RIGHT_ALIGN)) == ' ab' + assert str(txt.align(width=5, alignment=ttk.TTkK.CENTER_ALIGN)) == ' ab ' - just = TermTk.TTkString('a b c').align(width=7, alignment=TermTk.TTkK.JUSTIFY) + just = ttk.TTkString('a b c').align(width=7, alignment=ttk.TTkK.JUSTIFY) assert str(just) == 'a b c' def test_extract_shortcuts_regular_case(): - txt = TermTk.TTkString('&File &Edit') + txt = ttk.TTkString('&File &Edit') extracted, shortcuts = txt.extractShortcuts() @@ -217,7 +217,7 @@ def test_extract_shortcuts_regular_case(): def test_replace_equal_shorter_longer_and_count(): - txt = TermTk.TTkString('aabbcc') + txt = ttk.TTkString('aabbcc') assert str(txt.replace('bb', 'XX')) == 'aaXXcc' assert str(txt.replace('aa', 'Q')) == 'Qbbcc' @@ -226,10 +226,10 @@ def test_replace_equal_shorter_longer_and_count(): def test_complete_color_by_range_and_full(): - txt = TermTk.TTkString('abcdef') + txt = ttk.TTkString('abcdef') - full = txt.completeColor(TermTk.TTkColor.BOLD) - part = txt.completeColor(TermTk.TTkColor.ITALIC, posFrom=2, posTo=4) + full = txt.completeColor(ttk.TTkColor.BOLD) + part = txt.completeColor(ttk.TTkColor.ITALIC, posFrom=2, posTo=4) assert full.toAnsi(strip=True) != txt.toAnsi(strip=True) assert part.toAnsi(strip=True) != txt.toAnsi(strip=True) @@ -237,11 +237,11 @@ def test_complete_color_by_range_and_full(): def test_set_color_by_range_match_and_full(): - txt = TermTk.TTkString('abcabc') + txt = ttk.TTkString('abcabc') - full = txt.setColor(TermTk.TTkColor.fg('#112233')) - match = txt.setColor(TermTk.TTkColor.fg('#445566'), match='bc') - part = txt.setColor(TermTk.TTkColor.fg('#778899'), posFrom=1, posTo=3) + full = txt.setColor(ttk.TTkColor.fg('#112233')) + match = txt.setColor(ttk.TTkColor.fg('#445566'), match='bc') + part = txt.setColor(ttk.TTkColor.fg('#778899'), posFrom=1, posTo=3) assert str(full) == 'abcabc' assert str(match) == 'abcabc' @@ -252,26 +252,26 @@ def test_set_color_by_range_match_and_full(): def test_substring_split_join_and_indexes(): - txt = TermTk.TTkString('one,two,three') + txt = ttk.TTkString('one,two,three') assert str(txt.substring(4, 7)) == 'two' parts = txt.split(',') assert [str(p) for p in parts] == ['one', 'two', 'three'] assert txt.getIndexes('o') == [0, 6] - joined = TermTk.TTkString('|').join(parts) + joined = ttk.TTkString('|').join(parts) assert str(joined) == 'one|two|three' def test_split_multi_char_separator_not_supported(): - txt = TermTk.TTkString('a::b') + txt = ttk.TTkString('a::b') with pytest.raises(NotImplementedError): txt.split('::') def test_search_find_findall_variants(): - txt = TermTk.TTkString('Abc abc ABC') + txt = ttk.TTkString('Abc abc ABC') assert txt.search(r'abc') is not None assert txt.search(r'abc', ignoreCase=True) is not None @@ -281,8 +281,8 @@ def test_search_find_findall_variants(): def test_get_data_zero_width_combining_and_wide_chars(): - combining = TermTk.TTkString('a\u0301') - wide = TermTk.TTkString('界x') + combining = ttk.TTkString('a\u0301') + wide = ttk.TTkString('界x') c_text, c_colors = combining.getData() w_text, w_colors = wide.getData() @@ -294,14 +294,14 @@ def test_get_data_zero_width_combining_and_wide_chars(): def test_next_and_prev_positions_skip_combining_chars(): - txt = TermTk.TTkString('a\u0301b') + txt = ttk.TTkString('a\u0301b') assert txt.nextPos(0) == 2 assert txt.prevPos(2) == 0 def test_radd_with_plain_string_preserves_text(): - txt = TermTk.TTkString('world') + txt = ttk.TTkString('world') out = 'hello ' + txt From 7ed0849c297d2fb97bd0c0bdb999ecf071c748b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Thu, 28 May 2026 21:49:18 +0100 Subject: [PATCH 14/21] refactor(string): fix return value in color modification methods for consistency --- libs/pyTermTk/TermTk/TTkCore/string.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/pyTermTk/TermTk/TTkCore/string.py b/libs/pyTermTk/TermTk/TTkCore/string.py index 47340b9a0..63f2d3141 100644 --- a/libs/pyTermTk/TermTk/TTkCore/string.py +++ b/libs/pyTermTk/TermTk/TTkCore/string.py @@ -220,7 +220,7 @@ def setColorAt(self, pos, color) -> TTkString: ret._text = self._text ret._colors = [*self._colors[:pos], color, *self._colors[pos+1:]] ret._hasSpecialWidth = self._hasSpecialWidth - return self + return ret def tab2spaces(self, tabSpaces=4) -> TTkString: '''Return the string representation with the tabs (converted in spaces) trimmed and aligned''' @@ -488,14 +488,14 @@ def completeColor(self, color:TTkColor, match=None, posFrom=None, posTo=None) -> :type posTo: int, optional ''' ret = TTkString() - ret._text += self._text + ret._text = self._text ret._hasTab = self._hasTab ret._hasSpecialWidth = self._hasSpecialWidth if match: ret._colors = self._colors.copy() start=0 lenMatch = len(match) - while pos := self._text.index(match, start) if match in self._text[start:] else None: + while (pos := self._text.index(match, start) if match in self._text[start:] else None) is not None: start = pos+lenMatch for i in range(pos, pos+lenMatch): ret._colors[i] |= color From a5bd8a0c789bc4d66eb167e43b59c9dde61f88e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Thu, 28 May 2026 21:58:44 +0100 Subject: [PATCH 15/21] refactor(tests): add comprehensive tests for TTkString operations and behaviors --- tests/pytest/test_003_string.py | 164 ++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/tests/pytest/test_003_string.py b/tests/pytest/test_003_string.py index be7b1a616..a9f475812 100644 --- a/tests/pytest/test_003_string.py +++ b/tests/pytest/test_003_string.py @@ -306,3 +306,167 @@ def test_radd_with_plain_string_preserves_text(): out = 'hello ' + txt assert str(out) == 'hello world' + + +def test_add_with_plain_string_and_ttkstring_variants_preserves_text(): + left = ttk.TTkString('ab', ttk.TTkColor.fg('#00ff00')) + right = ttk.TTkString('cd', ttk.TTkColor.fg('#ff0000')) + + combined_str = left + 'ef' + combined_ttk = left + right + prepended_ttk = ttk.TTkString('00') + left + + assert str(combined_str) == 'abef' + assert str(combined_ttk) == 'abcd' + assert str(prepended_ttk) == '00ab' + + +def test_set_char_at_returns_updated_copy_and_preserves_original(): + original = ttk.TTkString('abc', ttk.TTkColor.fg('#00ff00')) + + updated = original.setCharAt(1, 'Z') + + assert str(original) == 'abc' + assert str(updated) == 'aZc' + assert updated is not original + assert updated.colorAt(0) == original.colorAt(0) + + +def test_item_accessors_raise_not_implemented(): + txt = ttk.TTkString('abc') + + with pytest.raises(NotImplementedError): + _ = txt[0] + + with pytest.raises(NotImplementedError): + txt[0] = 'x' + + +def test_isdigit_and_plain_get_data_behavior(): + digits = ttk.TTkString('1234') + plain = ttk.TTkString('ab', ttk.TTkColor.fg('#112233')) + + text, colors = plain.getData() + + assert digits.isdigit() is True + assert ttk.TTkString('12a4').isdigit() is False + assert text == ('a', 'b') + assert len(colors) == 2 + assert colors[0] == ttk.TTkColor.fg('#112233') + + +def test_to_ansi_without_strip_includes_escape_wrappers(): + txt = ttk.TTkString('ab', ttk.TTkColor.fg('#00ff00')) + + ansi = txt.toAnsi(strip=False) + + assert ansi.startswith('\u001b[0m') + assert 'ab' in ansi + assert ansi.endswith('\u001b[0m') + + +def test_tab_char_pos_align_tab_right_maps_inside_tab_to_next_char(): + txt = ttk.TTkString('a\tb') + + assert txt.tabCharPos(2, 4, alignTabRight=True) == 2 + assert txt.tabCharPos(3, 4, alignTabRight=True) == 2 + + +def test_join_accepts_generator_and_empty_input(): + separator = ttk.TTkString('|') + + generated = separator.join(ttk.TTkString(part) for part in ('a', 'b', 'c')) + empty = separator.join([]) + + assert str(generated) == 'a|b|c' + assert str(empty) == '' + + +def test_next_prev_pos_handle_terminal_boundaries(): + txt = ttk.TTkString('a\u0301') + + assert txt.nextPos(1) == 2 + assert txt.prevPos(0) == 0 + + +def test_set_color_with_invalid_range_leaves_colors_unchanged(): + txt = ttk.TTkString('abc', ttk.TTkColor.fg('#123456')) + + unchanged = txt.setColor(ttk.TTkColor.fg('#abcdef'), posFrom=3, posTo=1) + + assert str(unchanged) == 'abc' + assert unchanged.sameAs(txt) + + +def test_radd_with_ttkstring_preserves_text_and_colors(): + left = ttk.TTkString('hi', ttk.TTkColor.fg('#111111')) + right = ttk.TTkString('yo', ttk.TTkColor.fg('#222222')) + + combined = right.__radd__(left) + + assert str(combined) == 'hiyo' + assert combined.colorAt(0) == ttk.TTkColor.fg('#111111') + assert combined.colorAt(2) == ttk.TTkColor.fg('#222222') + + +def test_comparison_dunders_cover_string_and_ttkstring_operands(): + a = ttk.TTkString('abc') + b = ttk.TTkString('abd') + + assert a <= b + assert a <= 'abc' + assert a != b + assert a != 'abd' + assert b > a + assert b > 'abc' + + +def test_align_returns_self_for_noop_and_single_word_justify(): + txt = ttk.TTkString('word') + + assert txt.align(width=0) is txt + assert txt.align(width=txt.termWidth()) is txt + assert txt.align(width=8, alignment=ttk.TTkK.JUSTIFY) is txt + + +def test_align_wide_chars_trims_exact_and_overflow_widths(): + txt = ttk.TTkString('界x', ttk.TTkColor.fg('#00ff00')) + + exact = txt.align(width=2, alignment=ttk.TTkK.CENTER_ALIGN) + overflow = txt.align(width=1, alignment=ttk.TTkK.CENTER_ALIGN) + + assert str(exact) == '界' + assert exact.colorAt(0) == ttk.TTkColor.fg('#00ff00') + assert str(overflow) != '' + assert overflow.termWidth() == 1 + + +def test_complete_color_with_invalid_range_applies_entire_string(): + txt = ttk.TTkString('abc') + + colorized = txt.completeColor(ttk.TTkColor.BOLD, posFrom=3, posTo=1) + + assert str(colorized) == 'abc' + assert colorized.toAnsi(strip=True) != txt.toAnsi(strip=True) + + +def test_wide_char_helper_statics_cover_empty_combining_and_wide_text(): + assert ttk.TTkString._isWideCharData('界') is True + assert ttk.TTkString._isWideCharData('界x') is True + assert ttk.TTkString._isWideCharData('') is False + assert ttk.TTkString._isSpecialWidthChar('\u0301') is True + assert ttk.TTkString._isSpecialWidthChar('a') is False + assert ttk.TTkString._getWidthText('a\u0301界') == 3 + assert ttk.TTkString._getLenTextWoZero('a\u0301界') == 2 + + +def test_wide_char_data_helpers_cover_points_and_tty_variants(): + txt = ttk.TTkString('界\u0301a', ttk.TTkColor.fg('#00ff00')) + + pts_text, pts_colors = txt._getDataW_pts() + tty_text, tty_colors = txt._getDataW_tty() + + assert pts_text == ['界\u0301', '', 'a'] + assert len(pts_colors) == 3 + assert tty_text == ['■', '■', 'a'] + assert len(tty_colors) == 3 From 963e7a017f67376083700d44e7d269cb6028fd8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Thu, 28 May 2026 21:58:54 +0100 Subject: [PATCH 16/21] refactor(string): return a new instance in the character replacement method for better immutability --- libs/pyTermTk/TermTk/TTkCore/string.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/pyTermTk/TermTk/TTkCore/string.py b/libs/pyTermTk/TermTk/TTkCore/string.py index 63f2d3141..7385c9bf1 100644 --- a/libs/pyTermTk/TermTk/TTkCore/string.py +++ b/libs/pyTermTk/TermTk/TTkCore/string.py @@ -206,7 +206,7 @@ def setCharAt(self, pos:int, char:str) -> TTkString: ret._text = self._text[:pos]+char+self._text[pos+1:] ret._colors = self._colors ret._checkWidth() - return self + return ret def colorAt(self, pos:int) -> TTkColor: if pos >= len(self._colors): From b7f0deab578a6be44ef3307e9b51410cbd72021a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Thu, 28 May 2026 22:22:14 +0100 Subject: [PATCH 17/21] refactor(docs): enhance docstrings for TTkColor and TTkString methods for clarity and consistency --- libs/pyTermTk/TermTk/TTkCore/color.py | 64 ++++-- libs/pyTermTk/TermTk/TTkCore/string.py | 282 +++++++++++++++++++++---- 2 files changed, 288 insertions(+), 58 deletions(-) diff --git a/libs/pyTermTk/TermTk/TTkCore/color.py b/libs/pyTermTk/TermTk/TTkCore/color.py index 55e19b025..64bcabec1 100644 --- a/libs/pyTermTk/TermTk/TTkCore/color.py +++ b/libs/pyTermTk/TermTk/TTkCore/color.py @@ -214,12 +214,12 @@ def hexToRGB(val) -> Tuple[int,int,int]: @staticmethod def ansi(ansi:str) -> TTkColor: - ''' Parse an ansi string and return the color representing it + '''Parse an ANSI escape sequence and return the represented color. - :param str ansi: the ansi string to be parsed + :param ansi: ANSI escape sequence to parse :type ansi: str - :return: the :py:class:`TTkColor` representing the ansi value + :return: the :py:class:`TTkColor` representing the ANSI value :rtype: :py:class:`TTkColor` ''' fg,bg,mod,clean = TTkTermColor.ansi2rgb(ansi) @@ -562,8 +562,16 @@ def __add__(self, other) -> TTkColor: clean=clean) def __sub__(self, other) -> str: - ''' - I am abusing this operator in order to save time in the diff resolv between two adjacent colors + '''Return a transition ANSI sequence from ``other`` to ``self``. + + The subtraction operator is used as an optimization while diffing + adjacent colors during rendering. + + :param other: previous color used for diffing + :type other: :py:class:`TTkColor` + + :return: ANSI sequence required to switch from ``other`` to ``self`` + :rtype: str ''' if ( None == self._bg != other._bg or None == self._fg != other._fg ): @@ -894,9 +902,23 @@ def copy(self, modifier=True) -> TTkColor: class TTkColorModifier(): '''Base interface for runtime color modifiers.''' - def __init__(self, *args, **kwargs) -> None: pass - def setParam(self, *args, **kwargs) -> None: pass - def copy(self) -> TTkColorModifier: return self + def __init__(self, *args, **kwargs) -> None: + '''Initialize a color modifier.''' + pass + + def setParam(self, *args, **kwargs) -> None: + '''Update runtime parameters used by the modifier.''' + pass + + def copy(self) -> TTkColorModifier: + '''Return a copy of the modifier. + + Base modifiers are immutable and return ``self``. + + :return: modifier copy or shared instance + :rtype: :py:class:`TTkColorModifier` + ''' + return self class TTkColorGradient(TTkColorModifier): '''TTkColorGradient''' @@ -912,11 +934,20 @@ def __init__( orientation:TTkK.Direction = TTkK.Direction.VERTICAL) -> None: '''Create a linear incremental gradient modifier. - Supported keyword arguments: - - - ``increment`` to apply the same increment to foreground/background - - ``fgincrement`` and ``bgincrement`` for independent increments - - ``orientation`` as :py:class:`TTkK.VERTICAL` or :py:class:`TTkK.HORIZONTAL` + :param increment: shared increment for foreground and background; + when provided it overrides ``fgincrement`` and + ``bgincrement`` + :type increment: int | None + :param fgincrement: foreground incremental step when ``increment`` is + not provided + :type fgincrement: int + :param bgincrement: background incremental step when ``increment`` is + not provided + :type bgincrement: int + :param orientation: gradient direction, either + :py:attr:`TTkK.Direction.VERTICAL` or + :py:attr:`TTkK.Direction.HORIZONTAL` + :type orientation: :py:class:`TTkK.Direction` ''' if increment is not None: self._fgincrement = increment @@ -977,11 +1008,12 @@ def _applyGradient(c,incr): return self._buffer[bname][id] def copy(self): - '''Return this modifier instance. + '''Return a gradient modifier copy sharing the computed cache. - Gradients are shared and internally buffered, so the same instance is reused. + The returned instance keeps the same increments and orientation, + and shares the internal cache to avoid recomputing gradient steps. - :return: self + :return: copied gradient modifier :rtype: :py:class:`TTkColorGradient` ''' ret = TTkColorGradient( diff --git a/libs/pyTermTk/TermTk/TTkCore/string.py b/libs/pyTermTk/TermTk/TTkCore/string.py index 7385c9bf1..4b9d39cc0 100644 --- a/libs/pyTermTk/TermTk/TTkCore/string.py +++ b/libs/pyTermTk/TermTk/TTkCore/string.py @@ -84,7 +84,7 @@ def __init__(self, # raise AttributeError(f"{type(text)} not supported in TTkString") @staticmethod - def _importString1(text, colors): + def _importString1(text, colors:List[TTkColor]): ret = TTkString() if text and colors: ret._text = text @@ -95,7 +95,7 @@ def _importString1(text, colors): return ret @staticmethod - def _parseAnsi(text, color = TTkColor.RST): + def _parseAnsi(text, color:TTkColor = TTkColor.RST): pos = 0 txtret = "" colret = [] @@ -111,6 +111,11 @@ def _parseAnsi(text, color = TTkColor.RST): return txtret, colret def termWidth(self) -> int: + '''Return the rendered terminal width of this string. + + :return: rendered width accounting for tabs and wide/combining chars + :rtype: int + ''' return self._hasSpecialWidth if self._hasSpecialWidth is not None else len(self) def __len__(self) -> int: @@ -180,6 +185,14 @@ def __gt__(self, other): return self._text > other._text if issubclass(type(oth def __ge__(self, other): return self._text >= other._text if issubclass(type(other),TTkString) else self._text >= other def sameAs(self, other:TTkStringType) -> bool: + '''Check whether text and per-character colors are identical. + + :param other: string to compare against + :type other: :py:class:`TTkString` | str + + :return: True when text and colors match exactly + :rtype: bool + ''' if not isinstance(other,TTkString): return False return ( self==other and @@ -187,9 +200,22 @@ def sameAs(self, other:TTkStringType) -> bool: all(s==o for s,o in zip(self._colors,other._colors)) ) def isdigit(self) -> bool: + '''Check whether the string contains only digit characters. + + :return: True if all characters are digits and the string is non-empty + :rtype: bool + ''' return self._text.isdigit() def lstrip(self, ch:str) -> TTkString: + '''Return a copy with leading characters removed. + + :param ch: characters to strip from the left side + :type ch: str + + :return: left-stripped string preserving color alignment + :rtype: :py:class:`TTkString` + ''' ret = TTkString() ret._text = self._text.lstrip(ch) ret._colors = self._colors[-len(ret._text):] @@ -197,9 +223,29 @@ def lstrip(self, ch:str) -> TTkString: return ret def charAt(self, pos:int) -> str: + '''Return the character at the given position. + + :param pos: character index + :type pos: int + + :return: character at ``pos`` + :rtype: str + ''' return self._text[pos] def setCharAt(self, pos:int, char:str) -> TTkString: + '''Return a copy with one character replaced. + + :param pos: character index to replace + :type pos: int + :param char: replacement character + :type char: str + + :return: updated string + :rtype: :py:class:`TTkString` + + :raises IndexError: if ``pos`` is out of range + ''' if not (0 <= pos < len(self._text)): raise IndexError() ret = TTkString() @@ -209,11 +255,31 @@ def setCharAt(self, pos:int, char:str) -> TTkString: return ret def colorAt(self, pos:int) -> TTkColor: + '''Return the color assigned to the character at ``pos``. + + :param pos: character index + :type pos: int + + :return: color at ``pos`` or :py:class:`TTkColor.RST` when out of range + :rtype: :py:class:`TTkColor` + ''' if pos >= len(self._colors): return TTkColor.RST return self._colors[pos] - def setColorAt(self, pos, color) -> TTkString: + def setColorAt(self, pos:int, color:TTkColor) -> TTkString: + '''Return a copy with one character color replaced. + + :param pos: character index to recolor + :type pos: int + :param color: replacement color + :type color: :py:class:`TTkColor` + + :return: recolored string + :rtype: :py:class:`TTkString` + + :raises IndexError: if ``pos`` is out of range + ''' if not (0 <= pos < len(self._colors)): raise IndexError() ret = TTkString() @@ -222,8 +288,15 @@ def setColorAt(self, pos, color) -> TTkString: ret._hasSpecialWidth = self._hasSpecialWidth return ret - def tab2spaces(self, tabSpaces=4) -> TTkString: - '''Return the string representation with the tabs (converted in spaces) trimmed and aligned''' + def tab2spaces(self, tabSpaces:int=4) -> TTkString: + '''Expand tab characters into aligned spaces. + + :param tabSpaces: tab stop size + :type tabSpaces: int + + :return: string with tabs replaced by spaces + :rtype: :py:class:`TTkString` + ''' if not self._hasTab: return self ret = TTkString() slices = self._text.split("\t") @@ -240,8 +313,21 @@ def tab2spaces(self, tabSpaces=4) -> TTkString: pos+=len(s)+1 return ret - def tabCharPos(self, pos, tabSpaces=4, alignTabRight=False) -> int: - '''Return the char position in the string from the position in its representation with the tab and variable char sizes are solved + def tabCharPos(self, pos:int, tabSpaces:int=4, alignTabRight:bool=False) -> int: + '''Map a rendered column position to the internal character index. + + Tabs and variable-width characters are resolved against the visual + terminal representation. + + :param pos: target visual column + :type pos: int + :param tabSpaces: tab stop size + :type tabSpaces: int + :param alignTabRight: map positions inside a tab to the tab right edge + :type alignTabRight: bool + + :return: character index in ``_text`` + :rtype: int i.e. @@ -278,20 +364,18 @@ def tabCharPos(self, pos, tabSpaces=4, alignTabRight=False) -> int: postxt += 1 return len(self._text) - def _tabCharPosWideChar(self, pos, tabSpaces=4, alignTabRight=False): - '''Return the char position in the string from the position in its representation with the tab and variable char sizes are solved - - i.e. - - :: + def _tabCharPosWideChar(self, pos:int, tabSpaces:int=4, alignTabRight:bool=False): + '''Wide-char aware implementation for :py:meth:`tabCharPos`. - pos X = 11 - tab2Spaces |----------|---------------------| - Tabs |-| | |-| |-| | - _text L😁rem ipsum dolor sit amet, - chars .. ...t .....t .....t ...t..... - ret x = 7 (tab is a char) + :param pos: target visual column + :type pos: int + :param tabSpaces: tab stop size + :type tabSpaces: int + :param alignTabRight: unused in this implementation + :type alignTabRight: bool + :return: character index in ``_text`` + :rtype: int ''' # get pos in the slice: dx = pos @@ -310,15 +394,30 @@ def _tabCharPosWideChar(self, pos, tabSpaces=4, alignTabRight=False): return len(self._text) def isPlainText(self) -> bool: - ''' Return True if the string does not include colors or modifications ''' + '''Return True if the string has no color/style information. + + :return: True when all chars use :py:class:`TTkColor.RST` + :rtype: bool + ''' return all(TTkColor.RST == c for c in self._colors) def toAscii(self) -> str: - ''' Return the ascii representation of the string ''' + '''Return the plain-text content. + + :return: raw text without terminal escapes + :rtype: str + ''' return self._text - def toAnsi(self, strip=False): - ''' Return the ansii (terminal colors/events) representation of the string ''' + def toAnsi(self, strip:bool=False): + '''Return the ANSI escaped representation of the string. + + :param strip: remove leading/trailing reset sequences + :type strip: bool + + :return: ANSI escaped text + :rtype: str + ''' out = "" color = None for ch, col in zip(self._text, self._colors): @@ -336,15 +435,18 @@ def toAnsi(self, strip=False): return out return out+str(TTkColor.RST) - def align(self, width=None, color=TTkColor.RST, alignment=TTkK.NONE) -> TTkString: + def align(self, width:int=0, color:TTkColor=TTkColor.RST, alignment:TTkK.Alignment=TTkK.Alignment.NONE) -> TTkString: ''' Align the string :param width: the new width :type width: int, optional :param color: the color of the padding, defaults to :py:class:`TTkColor.RST` :type color: :py:class:`TTkColor`, optional - :param alignment: the alignment of the text to the full width :py:class:`~TermTk.TTkCore.constant.TTkConstant.Alignment.NONE` - :type alignment: :py:class:`TTkConstant.Alignment`, optional + :param alignment: text alignment within the requested width + :type alignment: :py:class:`TTkK.Alignment`, optional + + :return: aligned string with preserved styling + :rtype: :py:class:`TTkString` ''' lentxt = self.termWidth() if not width or width == lentxt: return self @@ -353,7 +455,7 @@ def align(self, width=None, color=TTkColor.RST, alignment=TTkK.NONE) -> TTkStrin if lentxt < width: pad = width-lentxt - if alignment in [TTkK.NONE, TTkK.LEFT_ALIGN]: + if alignment in [TTkK.Alignment.NONE, TTkK.LEFT_ALIGN]: ret._text = self._text + " " *pad ret._colors = self._colors + [color]*pad elif alignment == TTkK.RIGHT_ALIGN: @@ -403,6 +505,11 @@ def align(self, width=None, color=TTkColor.RST, alignment=TTkK.NONE) -> TTkStrin return ret def extractShortcuts(self) -> Tuple[TTkString,List[str]]: + '''Extract ``&`` shortcuts and underline the mnemonic characters. + + :return: tuple of processed string and extracted shortcut characters + :rtype: tuple[:py:class:`TTkString`, list[str]] + ''' def _chGenerator(): for ch,color in zip(self._text,self._colors): yield ch,color @@ -428,12 +535,15 @@ def replace(self, *args, **kwargs) -> TTkString: Replace "**old**" match with "**new**" string for "**count**" times - :param old: the match to be placed + :param old: substring to be replaced :type old: str - :param new: the match to replace + :param new: replacement substring :type new: str, optional - :param count: the number of occurrences + :param count: maximum number of replacements :type count: int, optional + + :return: updated string preserving color spans + :rtype: :py:class:`TTkString` ''' old = args[0] new = args[1] @@ -471,7 +581,7 @@ def replace(self, *args, **kwargs) -> TTkString: return ret - def completeColor(self, color:TTkColor, match=None, posFrom=None, posTo=None) -> TTkString: + def completeColor(self, color:TTkColor, match:Optional[str]=None, posFrom:Optional[int]=None, posTo:Optional[int]=None) -> TTkString: ''' Complete the color of the entire string or a slice of it The Fg and/or Bg of the string is replaced with the selected Fg/Bg color only if missing @@ -486,6 +596,9 @@ def completeColor(self, color:TTkColor, match=None, posFrom=None, posTo=None) -> :type posFrom: int, optional :param posTo: the final position of the color :type posTo: int, optional + + :return: recolored string + :rtype: :py:class:`TTkString` ''' ret = TTkString() ret._text = self._text @@ -499,9 +612,7 @@ def completeColor(self, color:TTkColor, match=None, posFrom=None, posTo=None) -> start = pos+lenMatch for i in range(pos, pos+lenMatch): ret._colors[i] |= color - elif posFrom is posTo is None: - ret._colors = [c|color for c in self._colors] - elif posFrom < posTo: + elif isinstance(posFrom,int) and isinstance(posTo, int) and posFrom < posTo: ret._colors = self._colors.copy() posFrom = min(len(self._text),posFrom) posTo = min(len(self._text),posTo) @@ -512,7 +623,7 @@ def completeColor(self, color:TTkColor, match=None, posFrom=None, posTo=None) -> return ret - def setColor(self, color, match=None, posFrom=None, posTo=None) -> TTkString: + def setColor(self, color:TTkColor, match:Optional[str]=None, posFrom:Optional[int]=None, posTo:Optional[int]=None) -> TTkString: ''' Set the color of the entire string or a slice of it If only the color is specified, the entire string is colorized @@ -525,6 +636,9 @@ def setColor(self, color, match=None, posFrom=None, posTo=None) -> TTkString: :type posFrom: int, optional :param posTo: the final position of the color :type posTo: int, optional + + :return: recolored string + :rtype: :py:class:`TTkString` ''' ret = TTkString() ret._text += self._text @@ -539,7 +653,7 @@ def setColor(self, color, match=None, posFrom=None, posTo=None) -> TTkString: ret._colors[pos: pos+lenMatch] = [color]*lenMatch elif posFrom is posTo is None: ret._colors = [color]*len(self._text) - elif posFrom < posTo: + elif isinstance(posFrom,int) and isinstance(posTo, int) and posFrom < posTo: ret._colors += self._colors posFrom = min(len(self._text),posFrom) posTo = min(len(self._text),posTo) @@ -548,13 +662,16 @@ def setColor(self, color, match=None, posFrom=None, posTo=None) -> TTkString: ret._colors += self._colors return ret - def substring(self, fr=None, to=None) -> TTkString: + def substring(self, fr:Optional[int]=None, to:Optional[int]=None) -> TTkString: ''' Return the substring :param fr: the starting of the slice, defaults to 0 :type fr: int, optional :param to: the ending of the slice, defaults to the end of the string :type to: int, optional + + :return: sliced string + :rtype: :py:class:`TTkString` ''' ret = TTkString() ret._text = self._text[fr:to] @@ -563,13 +680,16 @@ def substring(self, fr=None, to=None) -> TTkString: ret._fastCheckWidth(self._hasSpecialWidth) return ret - def split(self, separator ) -> list[TTkString]: + def split(self, separator:str) -> list[TTkString]: ''' Split the string using a separator .. note:: Only a one char separator is currently supported :param separator: the "**char**" separator to be used :type separator: str + + :return: list of split chunks + :rtype: list[:py:class:`TTkString`] ''' ret = [] pos = 0 @@ -585,35 +705,59 @@ def split(self, separator ) -> list[TTkString]: return ret def getData(self): + '''Return text and color data in terminal-rendered form. + + :return: tuple of characters and colors + :rtype: tuple + ''' if self._hasSpecialWidth is not None: return self._getDataW() else: return (tuple(self._text), self._colors) - def search(self, regexp, ignoreCase=False): + def search(self, regexp:str, ignoreCase:bool=False): ''' Return the **re.match** of the **regexp** :param regexp: the regular expression to be matched :type regexp: str :param ignoreCase: Ignore case, defaults to **False** :type ignoreCase: bool + + :return: first regular-expression match or None + :rtype: re.Match | None ''' return re.search(regexp, self._text, re.IGNORECASE if ignoreCase else 0) def find(self, *args, **kwargs) -> Any: + '''Return the first index of a substring using ``str.find`` semantics. + + :return: start index of the first match, or ``-1`` if not found + :rtype: int + ''' return self._text.find(*args, **kwargs) - def findall(self, regexp, ignoreCase=False): + def findall(self, regexp:str, ignoreCase:bool=False) -> List[Any]: ''' FindAll the **regexp** matches in the string :param regexp: the regular expression to be matched :type regexp: str :param ignoreCase: Ignore case, defaults to **False** :type ignoreCase: bool + + :return: list of all matches + :rtype: list[str] | list[tuple] ''' return re.findall(regexp, self._text, re.IGNORECASE if ignoreCase else 0) - def getIndexes(self, char): + def getIndexes(self, char:str) -> List[int]: + '''Return indexes where ``char`` appears. + + :param char: target character + :type char: str + + :return: matching character positions + :rtype: list[int] + ''' return [i for i,c in enumerate(self._text) if c==char] def join(self, strings:Union[GeneratorType[TTkStringType,None,None],List[TTkStringType],List[TTkString],List[str]]) -> TTkString: @@ -621,6 +765,9 @@ def join(self, strings:Union[GeneratorType[TTkStringType,None,None],List[TTkStri :param strings: the list of strings to be joined :type strings: list + + :return: joined string + :rtype: :py:class:`TTkString` ''' if not strings: return TTkString() @@ -633,7 +780,15 @@ def join(self, strings:Union[GeneratorType[TTkStringType,None,None],List[TTkStri # Unicode Zero/Half/Normal sized chars helpers: @staticmethod - def _isWideCharData(ch:str): + def _isWideCharData(ch:str) -> bool: + '''Check whether ``ch`` starts with a wide character. + + :param ch: input text chunk + :type ch: str + + :return: True when first character is wide + :rtype: bool + ''' if len(ch) == 1: return unicodedata.east_asian_width(ch)=='W' if len(ch) > 1: @@ -642,21 +797,53 @@ def _isWideCharData(ch:str): @staticmethod def _isSpecialWidthChar(ch): + '''Check whether a character has non-standard display width. + + :param ch: input character + :type ch: str + + :return: True for wide or combining characters + :rtype: bool + ''' return ( unicodedata.east_asian_width(ch) == 'W' or unicodedata.category(ch) in ('Me','Mn') ) @staticmethod def _getWidthText(txt:str): + '''Compute rendered width for a text snippet. + + :param txt: input text + :type txt: str + + :return: rendered width + :rtype: int + ''' return ( len(txt) + sum(unicodedata.east_asian_width(ch) == 'W' for ch in txt) - sum(unicodedata.category(ch) in ('Me','Mn') for ch in txt) ) @staticmethod def _getLenTextWoZero(txt:str) -> int: + '''Count text length excluding zero-width combining marks. + + :param txt: input text + :type txt: str + + :return: logical length without combining marks + :rtype: int + ''' return ( len(txt) - sum(unicodedata.category(ch) in ('Me','Mn') for ch in txt) ) def nextPos(self, pos): + '''Return next editable character position. + + :param pos: current position + :type pos: int + + :return: next non-combining character index + :rtype: int + ''' pos += 1 for i,ch in enumerate(self._text[pos:]): if unicodedata.category(ch) not in ('Me','Mn'): @@ -664,6 +851,14 @@ def nextPos(self, pos): return len(self._text) def prevPos(self, pos): + '''Return previous editable character position. + + :param pos: current position + :type pos: int + + :return: previous non-combining character index + :rtype: int + ''' # from TermTk.TTkCore.log import TTkLog # TTkLog.debug(f"->{self._text[:pos]}<- {pos=}") # TTkLog.debug(f"{str(reversed(self._text[:pos]))} {pos=}") @@ -689,6 +884,9 @@ def _termWidthW(self): ''' String displayed length This value consider the displayed size (Zero, Half, Full) of each character. + + :return: rendered width + :rtype: int ''' return ( len(self._text) + sum(unicodedata.east_asian_width(ch) == 'W' for ch in self._text) - From a0ffb7ae48a5391c9b136012d439201c2f113ac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Thu, 28 May 2026 22:24:43 +0100 Subject: [PATCH 18/21] refactor(string): enhance docstring for _tabCharPosWideChar method with usage example --- libs/pyTermTk/TermTk/TTkCore/string.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/libs/pyTermTk/TermTk/TTkCore/string.py b/libs/pyTermTk/TermTk/TTkCore/string.py index 4b9d39cc0..29c8826b5 100644 --- a/libs/pyTermTk/TermTk/TTkCore/string.py +++ b/libs/pyTermTk/TermTk/TTkCore/string.py @@ -367,6 +367,17 @@ def tabCharPos(self, pos:int, tabSpaces:int=4, alignTabRight:bool=False) -> int: def _tabCharPosWideChar(self, pos:int, tabSpaces:int=4, alignTabRight:bool=False): '''Wide-char aware implementation for :py:meth:`tabCharPos`. + i.e. + + :: + + pos X = 11 + tab2Spaces |----------|---------------------| + Tabs |-| | |-| |-| | + _text L😁rem ipsum dolor sit amet, + chars .. ...t .....t .....t ...t..... + ret x = 7 (tab is a char) + :param pos: target visual column :type pos: int :param tabSpaces: tab stop size From 797b33d15f59ad1f4d95fcf84bcce5e0ddbf8b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Thu, 28 May 2026 22:31:27 +0100 Subject: [PATCH 19/21] refactor(string): add type hints to methods in TTkString class for improved clarity --- libs/pyTermTk/TermTk/TTkCore/string.py | 65 ++++++++++++++++---------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/libs/pyTermTk/TermTk/TTkCore/string.py b/libs/pyTermTk/TermTk/TTkCore/string.py index 29c8826b5..c4d3b6fcc 100644 --- a/libs/pyTermTk/TermTk/TTkCore/string.py +++ b/libs/pyTermTk/TermTk/TTkCore/string.py @@ -161,10 +161,10 @@ def __radd__(self, other:TTkStringType) -> TTkString: ret._checkWidth() return ret - def __setitem__(self, index:int, value:Any): + def __setitem__(self, index:int, value:Any) -> None: raise NotImplementedError() - def __getitem__(self, index:int): + def __getitem__(self, index:int) -> Any: raise NotImplementedError() def __bool__(self) -> bool: @@ -177,12 +177,27 @@ def __complex__(self) -> complex: return complex(self._text) # Operators - def __lt__(self, other): return self._text < other._text if issubclass(type(other),TTkString) else self._text < other - def __le__(self, other): return self._text <= other._text if issubclass(type(other),TTkString) else self._text <= other - def __eq__(self, other): return self._text == other._text if issubclass(type(other),TTkString) else self._text == other - def __ne__(self, other): return self._text != other._text if issubclass(type(other),TTkString) else self._text != other - def __gt__(self, other): return self._text > other._text if issubclass(type(other),TTkString) else self._text > other - def __ge__(self, other): return self._text >= other._text if issubclass(type(other),TTkString) else self._text >= other + def __lt__(self, other:TTkStringType) -> bool: + return self._text < other._text if isinstance(other, TTkString) else self._text < other + + def __le__(self, other:TTkStringType) -> bool: + return self._text <= other._text if isinstance(other, TTkString) else self._text <= other + + def __eq__(self, other:object) -> bool: + if isinstance(other, TTkString): + return self._text == other._text + return self._text == other + + def __ne__(self, other:object) -> bool: + if isinstance(other, TTkString): + return self._text != other._text + return self._text != other + + def __gt__(self, other:TTkStringType) -> bool: + return self._text > other._text if isinstance(other, TTkString) else self._text > other + + def __ge__(self, other:TTkStringType) -> bool: + return self._text >= other._text if isinstance(other, TTkString) else self._text >= other def sameAs(self, other:TTkStringType) -> bool: '''Check whether text and per-character colors are identical. @@ -364,7 +379,7 @@ def tabCharPos(self, pos:int, tabSpaces:int=4, alignTabRight:bool=False) -> int: postxt += 1 return len(self._text) - def _tabCharPosWideChar(self, pos:int, tabSpaces:int=4, alignTabRight:bool=False): + def _tabCharPosWideChar(self, pos:int, tabSpaces:int=4, alignTabRight:bool=False) -> int: '''Wide-char aware implementation for :py:meth:`tabCharPos`. i.e. @@ -420,7 +435,7 @@ def toAscii(self) -> str: ''' return self._text - def toAnsi(self, strip:bool=False): + def toAnsi(self, strip:bool=False) -> str: '''Return the ANSI escaped representation of the string. :param strip: remove leading/trailing reset sequences @@ -715,7 +730,7 @@ def split(self, separator:str) -> list[TTkString]: return ret - def getData(self): + def getData(self) -> Tuple[Union[Tuple[str,...],List[str]],List[TTkColor]]: '''Return text and color data in terminal-rendered form. :return: tuple of characters and colors @@ -726,7 +741,7 @@ def getData(self): else: return (tuple(self._text), self._colors) - def search(self, regexp:str, ignoreCase:bool=False): + def search(self, regexp:str, ignoreCase:bool=False) -> Optional[re.Match[str]]: ''' Return the **re.match** of the **regexp** :param regexp: the regular expression to be matched @@ -739,7 +754,7 @@ def search(self, regexp:str, ignoreCase:bool=False): ''' return re.search(regexp, self._text, re.IGNORECASE if ignoreCase else 0) - def find(self, *args, **kwargs) -> Any: + def find(self, *args, **kwargs) -> int: '''Return the first index of a substring using ``str.find`` semantics. :return: start index of the first match, or ``-1`` if not found @@ -807,7 +822,7 @@ def _isWideCharData(ch:str) -> bool: return False @staticmethod - def _isSpecialWidthChar(ch): + def _isSpecialWidthChar(ch:str) -> bool: '''Check whether a character has non-standard display width. :param ch: input character @@ -820,7 +835,7 @@ def _isSpecialWidthChar(ch): unicodedata.category(ch) in ('Me','Mn') ) @staticmethod - def _getWidthText(txt:str): + def _getWidthText(txt:str) -> int: '''Compute rendered width for a text snippet. :param txt: input text @@ -846,7 +861,7 @@ def _getLenTextWoZero(txt:str) -> int: return ( len(txt) - sum(unicodedata.category(ch) in ('Me','Mn') for ch in txt) ) - def nextPos(self, pos): + def nextPos(self, pos:int) -> int: '''Return next editable character position. :param pos: current position @@ -861,7 +876,7 @@ def nextPos(self, pos): return pos+i return len(self._text) - def prevPos(self, pos): + def prevPos(self, pos:int) -> int: '''Return previous editable character position. :param pos: current position @@ -879,7 +894,7 @@ def prevPos(self, pos): return pos-i-1 return 0 - def _fastCheckWidth(self,a,b=None): + def _fastCheckWidth(self, a:Optional[int], b:Optional[int]=None) -> None: self._hasSpecialWidth = None if ( a is None and b is None ) else self._termWidthW() @@ -891,7 +906,7 @@ def _checkWidth(self): tw = self._termWidthW() if any(ord(ch)>=0x300 for ch in self._text) else None self._hasSpecialWidth = tw if tw != len(self._text) else None - def _termWidthW(self): + def _termWidthW(self) -> int: ''' String displayed length This value consider the displayed size (Zero, Half, Full) of each character. @@ -903,9 +918,9 @@ def _termWidthW(self): sum(unicodedata.east_asian_width(ch) == 'W' for ch in self._text) - sum(unicodedata.category(ch) in ('Me','Mn') for ch in self._text) ) - def _getDataW_pts(self): - retTxt = [] - retCol = [] + def _getDataW_pts(self) -> Tuple[List[str],List[TTkColor]]: + retTxt = [] # type: List[str] + retCol = [] # type: List[TTkColor] for ch,color in zip(self._text,self._colors): if unicodedata.east_asian_width(ch) == 'W': retTxt += (ch,'') @@ -924,9 +939,9 @@ def _getDataW_pts(self): retCol.append(color) return (retTxt, retCol) - def _getDataW_tty(self): - retTxt = [] - retCol = [] + def _getDataW_tty(self) -> Tuple[List[str],List[TTkColor]]: + retTxt = [] # type: List[str] + retCol = [] # type: List[TTkColor] for ch,color in zip(self._text,self._colors): if unicodedata.east_asian_width(ch) == 'W': retTxt += ('■','■') From cdcc880bd2f8db35cf695e507b36ddfa51299083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Thu, 28 May 2026 22:33:10 +0100 Subject: [PATCH 20/21] refactor(dependencies): fix formatting in optional dependencies section of pyproject.toml --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b7d580825..8b925897f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,9 @@ "ttkode[test]", "pytest>=8.3.4", "flake8>=7.2.0", - "mypy>=1.15.0", + "mypy>=1.15.0" + ] + coverage = [ "coverage>=7.14.1" ] docs = [ From 0e031accd9066484b7b6a81e955b4468fef9ebbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Parodi=2C=20Eugenio=20=F0=9F=8C=B6?= Date: Thu, 28 May 2026 22:42:42 +0100 Subject: [PATCH 21/21] refactor(cursor): update character replacement to return new instance for immutability --- libs/pyTermTk/TermTk/TTkGui/textcursor.py | 2 +- .../pytest/widgets/textedit/test_textcursor.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/libs/pyTermTk/TermTk/TTkGui/textcursor.py b/libs/pyTermTk/TermTk/TTkGui/textcursor.py index 9b469eefa..3d2a5913d 100644 --- a/libs/pyTermTk/TermTk/TTkGui/textcursor.py +++ b/libs/pyTermTk/TermTk/TTkGui/textcursor.py @@ -634,7 +634,7 @@ def _getBlinkingCursors(self, fr:int, to:int, lines, color:TTkColor) -> list[TTk if cp.pos == len(ret[cp.line-fr]): ret[cp.line-fr] = ret[cp.line-fr]+TTkString('↵',color+TTkColor.BLINKING) elif ret[cp.line-fr].charAt(cp.pos) == ' ': - ret[cp.line-fr].setCharAt(pos=cp.pos, char='∙') + ret[cp.line-fr] = ret[cp.line-fr].setCharAt(pos=cp.pos, char='∙') # ret[p.line-fr].setColorAt(pos=p.pos, color=TTkCfg.theme.treeLineColor+TTkColor.BLINKING) #elif ret[p.line-fr].charAt(p.pos) == '\t': # ret[p.line-fr].setCharAt(pos=p.pos, char='\t') diff --git a/tests/pytest/widgets/textedit/test_textcursor.py b/tests/pytest/widgets/textedit/test_textcursor.py index 7d136726f..c8ddee30d 100644 --- a/tests/pytest/widgets/textedit/test_textcursor.py +++ b/tests/pytest/widgets/textedit/test_textcursor.py @@ -160,3 +160,20 @@ def _on_change(line: int, removed: int, added: int) -> None: assert doc.toPlainText() == 'hello ' assert calls[-1] == (0, 1, 1) + + +def test_blinking_cursor_on_space_renders_visible_dot_marker() -> None: + _doc, cur = _mk_cursor('a b') + + # Use multi-cursor mode so blinking cursor rendering is enabled. + cur.setPosition(line=0, pos=1) + cur.addCursor(line=0, pos=0) + + out_lines = cur._getBlinkingCursors( + fr=0, + to=0, + lines=[ttk.TTkString('a b')], + color=ttk.TTkColor.RST, + ) + + assert str(out_lines[0]) == 'a∙b'