diff --git a/default_config.json b/default_config.json index aa10ca9f..b8132e47 100644 --- a/default_config.json +++ b/default_config.json @@ -6,6 +6,7 @@ "auto_exposure_zero_star_handler": "sweep", "menu_anim_speed": 0.1, "text_scroll_speed": "Med", + "t9_search": false, "screen_direction": "right", "mount_type": "Alt/Az", "solver_debug": 0, diff --git a/python/PiFinder/catalogs.py b/python/PiFinder/catalogs.py index e8a527e3..715a0197 100644 --- a/python/PiFinder/catalogs.py +++ b/python/PiFinder/catalogs.py @@ -1,5 +1,6 @@ # mypy: ignore-errors import logging +import re import time import datetime import pytz @@ -26,6 +27,31 @@ logger = logging.getLogger("Catalog") +# Mapping from keypad numbers to characters (non-conventional layout) +KEYPAD_DIGIT_TO_CHARS = { + "7": "abc", + "8": "def", + "9": "ghi", + "4": "jkl", + "5": "mno", + "6": "pqrs", + "1": "tuv", + "2": "wxyz", + "3": "'-+/", +} + +LETTER_TO_DIGIT_MAP: dict[str, str] = {} +for _digit, _chars in KEYPAD_DIGIT_TO_CHARS.items(): + # Map the digit to itself so numbers in names still match + LETTER_TO_DIGIT_MAP[_digit] = _digit + for _char in _chars: + LETTER_TO_DIGIT_MAP[_char] = _digit + LETTER_TO_DIGIT_MAP[_char.upper()] = _digit + +translator = str.maketrans(LETTER_TO_DIGIT_MAP) +VALID_T9_DIGITS = "".join(KEYPAD_DIGIT_TO_CHARS.keys()) +INVALID_T9_DIGITS_RE = re.compile(f"[^{VALID_T9_DIGITS}]") + # collection of all catalog-related classes # CatalogBase : just the CompositeObjects (imported from catalog_base) @@ -345,6 +371,8 @@ class Catalogs: def __init__(self, catalogs: List[Catalog]): self.__catalogs: List[Catalog] = catalogs self.catalog_filter: Union[CatalogFilter, None] = None + self._t9_cache: dict[tuple[str, int], list[str]] = {} + self._t9_cache_dirty = True def filter_catalogs(self): """ @@ -400,6 +428,56 @@ def get_object(self, catalog_code: str, sequence: int) -> Optional[CompositeObje # this is memory efficient and doesn't hit the sdcard, but could be faster # also, it could be cached + def _name_to_t9_digits(self, name: str) -> str: + translated_name = name.translate(translator) + return INVALID_T9_DIGITS_RE.sub("", translated_name) + + def _object_cache_key(self, obj: CompositeObject) -> tuple[str, int]: + return (obj.catalog_code, obj.sequence) + + def _invalidate_t9_cache(self) -> None: + self._t9_cache_dirty = True + + def _rebuild_t9_cache(self, objs: list[CompositeObject]) -> None: + self._t9_cache = {} + for obj in objs: + self._t9_cache[self._object_cache_key(obj)] = [ + self._name_to_t9_digits(name) for name in obj.names + ] + self._t9_cache_dirty = False + + def _ensure_t9_cache(self, objs: list[CompositeObject]) -> None: + current_keys = {self._object_cache_key(obj) for obj in objs} + if self._t9_cache_dirty or current_keys != set(self._t9_cache.keys()): + self._rebuild_t9_cache(objs) + + def search_by_t9(self, search_digits: str) -> List[CompositeObject]: + """Search catalog objects using keypad digits. + + Uses the existing keypad letter mapping (including its non-conventional + layout) to convert object names to their digit representation and + returns all objects whose digit string contains the search pattern. + """ + + objs = self.get_objects(only_selected=False, filtered=False) + result: list[CompositeObject] = [] + if not search_digits: + return result + + self._ensure_t9_cache(objs) + + for obj in objs: + for digits in self._t9_cache.get(self._object_cache_key(obj), []): + if len(digits) < len(search_digits): + continue + if search_digits in digits: + result.append(obj) + logger.debug( + "Found %s in %s %i via T9", digits, obj.catalog_code, obj.sequence + ) + break + return result + def search_by_text(self, search_text: str) -> List[CompositeObject]: objs = self.get_objects(only_selected=False, filtered=False) result = [] @@ -419,12 +497,14 @@ def search_by_text(self, search_text: str) -> List[CompositeObject]: def set(self, catalogs: List[Catalog]): self.__catalogs = catalogs self.select_all_catalogs() + self._invalidate_t9_cache() def add(self, catalog: Catalog, select: bool = False): if catalog.catalog_code not in [x.catalog_code for x in self.__catalogs]: if select: self.catalog_filter.selected_catalogs.add(catalog.catalog_code) self.__catalogs.append(catalog) + self._invalidate_t9_cache() else: logger.warning( "Catalog %s already exists, not replaced (in Catalogs.add)", @@ -435,6 +515,7 @@ def remove(self, catalog_code: str): for catalog in self.__catalogs: if catalog.catalog_code == catalog_code: self.__catalogs.remove(catalog) + self._invalidate_t9_cache() return logger.warning("Catalog %s does not exist, cannot remove", catalog_code) diff --git a/python/PiFinder/ui/menu_structure.py b/python/PiFinder/ui/menu_structure.py index b6783fc8..09ca9ae3 100644 --- a/python/PiFinder/ui/menu_structure.py +++ b/python/PiFinder/ui/menu_structure.py @@ -679,6 +679,22 @@ def _(key: str) -> Any: }, ], }, + { + "name": _("T9 Search"), + "class": UITextMenu, + "select": "single", + "config_option": "t9_search", + "items": [ + { + "name": _("Off"), + "value": False, + }, + { + "name": _("On"), + "value": True, + }, + ], + }, { "name": _("Az Arrows"), "class": UITextMenu, diff --git a/python/PiFinder/ui/textentry.py b/python/PiFinder/ui/textentry.py index 228ea4e2..a719eb7e 100644 --- a/python/PiFinder/ui/textentry.py +++ b/python/PiFinder/ui/textentry.py @@ -123,6 +123,10 @@ def __init__(self, *args, **kwargs): self.SEARCH_DEBOUNCE_MS = 250 # milliseconds + @property + def t9_search_enabled(self) -> bool: + return bool(self.config_object.get_option("t9_search", False)) + def draw_text_entry(self): line_text_y = self.text_y + 15 self.draw.line( @@ -273,7 +277,10 @@ def _perform_search(self, search_text, search_version): # Priority catalogs (NGC, IC, M) are loaded first, WDS loads in background # So search will work immediately with those, WDS results appear when loading completes logger.info(f"Starting search for '{search_text}'") - results = self.catalogs.search_by_text(search_text) + if self.t9_search_enabled: + results = self.catalogs.search_by_t9(search_text) + else: + results = self.catalogs.search_by_text(search_text) logger.info(f"Search for '{search_text}' found {len(results)} results") # Only update if this search is still current (not superseded by newer search) @@ -335,6 +342,15 @@ def key_long_minus(self): def key_number(self, number): current_time = time.time() number_key = str(number) + if not self.text_entry_mode and self.t9_search_enabled: + # In T9 mode we simply append the pressed digit + self.last_key_press_time = current_time + self.last_key = number + if number_key in self.keys: + self.char_index = 0 + self.add_char(number_key) + return + # Check if the same key is pressed within a short time if self.last_key == number and self.within_keypress_window(current_time): self.char_index = (self.char_index + 1) % self.keys.get_nr_entries( diff --git a/python/tests/test_t9_search.py b/python/tests/test_t9_search.py new file mode 100644 index 00000000..bef71b50 --- /dev/null +++ b/python/tests/test_t9_search.py @@ -0,0 +1,172 @@ +import sys +import types + +import pytest + + +@pytest.fixture() +def catalogs_api(monkeypatch): + """Provide catalog helpers while isolating the calc_utils stub.""" + + # Avoid expensive ephemeris downloads triggered during PiFinder.calc_utils import + stub_calc_utils = types.ModuleType("PiFinder.calc_utils") + stub_calc_utils.FastAltAz = None + stub_calc_utils.sf_utils = None + monkeypatch.setitem(sys.modules, "PiFinder.calc_utils", stub_calc_utils) + + # Avoid optional timezone dependency required by the catalogs module + stub_pytz = types.ModuleType("pytz") + stub_pytz.timezone = lambda name: name + stub_pytz.utc = "UTC" + monkeypatch.setitem(sys.modules, "pytz", stub_pytz) + + # Avoid optional dataclasses JSON dependency required by config/equipment imports + stub_dataclasses_json = types.ModuleType("dataclasses_json") + + def dataclass_json(cls=None, **_kwargs): + def decorator(inner_cls): + return inner_cls + + return decorator(cls) if cls is not None else decorator + + stub_dataclasses_json.dataclass_json = dataclass_json + monkeypatch.setitem(sys.modules, "dataclasses_json", stub_dataclasses_json) + + # Avoid optional numpy dependency pulled in via CompositeObject + stub_numpy = types.ModuleType("numpy") + stub_numpy.array = lambda *args, **kwargs: None + monkeypatch.setitem(sys.modules, "numpy", stub_numpy) + + # Avoid timezone lookup dependency required by SharedState + stub_timezonefinder = types.ModuleType("timezonefinder") + + class _TimezoneFinder: + def timezone_at(self, **_kwargs): + return "UTC" + + stub_timezonefinder.TimezoneFinder = _TimezoneFinder + monkeypatch.setitem(sys.modules, "timezonefinder", stub_timezonefinder) + + # Avoid skyfield dependency pulled in by comets module + stub_skyfield = types.ModuleType("skyfield") + stub_skyfield_data = types.ModuleType("skyfield.data") + stub_skyfield_constants = types.ModuleType("skyfield.constants") + stub_skyfield_data.mpc = types.SimpleNamespace(COMET_URL="") + stub_skyfield_constants.GM_SUN_Pitjeva_2005_km3_s2 = 0 + monkeypatch.setitem(sys.modules, "skyfield", stub_skyfield) + monkeypatch.setitem(sys.modules, "skyfield.data", stub_skyfield_data) + monkeypatch.setitem(sys.modules, "skyfield.constants", stub_skyfield_constants) + + from PiFinder import catalogs as catalogs_module + + return catalogs_module.Catalogs, catalogs_module.KEYPAD_DIGIT_TO_CHARS, catalogs_module.LETTER_TO_DIGIT_MAP + + +class DummyObject: + def __init__(self, names, catalog_code="TST", sequence=1): + self.names = names + self.catalog_code = catalog_code + self.sequence = sequence + + +class DummyCatalog: + def __init__(self, catalog_code, objects): + self.catalog_code = catalog_code + self._objects = objects + + def is_selected(self): + return True + + def get_objects(self): + return self._objects + + +@pytest.mark.unit +def test_letter_mapping_uses_keypad_layout(catalogs_api): + _, KEYPAD_DIGIT_TO_CHARS, LETTER_TO_DIGIT_MAP = catalogs_api + # spot-check the non-conventional keypad mapping + assert LETTER_TO_DIGIT_MAP["t"] == "1" + assert LETTER_TO_DIGIT_MAP["v"] == "1" + assert LETTER_TO_DIGIT_MAP["m"] == "5" + assert LETTER_TO_DIGIT_MAP["'"] == "3" + # ensure every keypad character is represented in the mapping + for digit, chars in KEYPAD_DIGIT_TO_CHARS.items(): + for char in chars: + assert LETTER_TO_DIGIT_MAP[char] == digit + + +@pytest.mark.unit +def test_search_by_t9_matches_objects(catalogs_api): + Catalogs, _, _ = catalogs_api + objects = [ + DummyObject(["Vega"], sequence=1), + DummyObject(["M31", "Andromeda"], sequence=2), + DummyObject(["Polaris"], sequence=3), + ] + catalogs = Catalogs([DummyCatalog("TST", objects)]) + + # Vega -> v(1)e(8)g(9)a(7) + vega_results = catalogs.search_by_t9("1897") + assert len(vega_results) == 1 + assert vega_results[0].sequence == 1 + + # M31 -> m(5)3(3)1(1) + m31_results = catalogs.search_by_t9("531") + assert len(m31_results) == 1 + assert m31_results[0].sequence == 2 + + # No matches should return an empty list + assert catalogs.search_by_t9("9999") == [] + + +@pytest.mark.unit +def test_search_by_t9_uses_cached_digits(monkeypatch, catalogs_api): + Catalogs, _, _ = catalogs_api + objects = [DummyObject(["Vega"], sequence=1)] + catalogs = Catalogs([DummyCatalog("TST", objects)]) + + call_count = 0 + original = Catalogs._name_to_t9_digits + + def counting(self, name): + nonlocal call_count + call_count += 1 + return original(self, name) + + monkeypatch.setattr(Catalogs, "_name_to_t9_digits", counting) + + catalogs.search_by_t9("1") + first_count = call_count + + # Subsequent searches should use cached digit strings + catalogs.search_by_t9("18") + assert call_count == first_count + + +@pytest.mark.unit +def test_search_by_t9_cache_invalidation_on_catalog_change(monkeypatch, catalogs_api): + Catalogs, _, _ = catalogs_api + objects = [DummyObject(["Vega"], sequence=1)] + dummy_catalog = DummyCatalog("TST", objects) + catalogs = Catalogs([dummy_catalog]) + + catalogs.search_by_t9("1897") + + new_object = DummyObject(["Deneb"], sequence=2) + dummy_catalog._objects.append(new_object) + + # Update tracker to ensure cache rebuild triggers conversion for new object + call_count = 0 + original = Catalogs._name_to_t9_digits + + def counting(self, name): + nonlocal call_count + call_count += 1 + return original(self, name) + + monkeypatch.setattr(Catalogs, "_name_to_t9_digits", counting) + + results = catalogs.search_by_t9("88587") + + assert any(obj.sequence == 2 for obj in results) + assert call_count > 0