diff --git a/python/PiFinder/main.py b/python/PiFinder/main.py index d447bf66..e58a8bf0 100644 --- a/python/PiFinder/main.py +++ b/python/PiFinder/main.py @@ -570,6 +570,7 @@ def main( if ( not location.source == "WEB" and not location.source.startswith("CONFIG:") + and not location.source == "MANUAL" and ( location.error_in_m == 0 or float(gps_content["error_in_m"]) @@ -610,18 +611,22 @@ def main( location.lon, location.altitude, ) - if gps_msg == "time": + if gps_msg in ("time", "time_force"): if isinstance(gps_content, datetime.datetime): gps_dt = gps_content else: gps_dt = gps_content["time"] - shared_state.set_datetime(gps_dt) + shared_state.set_datetime( + gps_dt, force=(gps_msg == "time_force") + ) if log_time: logger.info("GPS Time (logged only once): %s", gps_dt) log_time = False if gps_msg == "reset": location.reset() shared_state.set_location(location) + if gps_msg == "reset_datetime": + shared_state.reset_datetime() if gps_msg == "satellites": # logger.debug("Main: GPS nr sats seen: %s", gps_content) shared_state.set_sats(gps_content) diff --git a/python/PiFinder/server.py b/python/PiFinder/server.py index ff9db6d1..2ea84b71 100644 --- a/python/PiFinder/server.py +++ b/python/PiFinder/server.py @@ -9,6 +9,7 @@ import pydeepskylog as pds from PIL import Image from PiFinder import utils, calc_utils, config +from PiFinder.state import Location from PiFinder.db.observations_db import ( ObservationsDatabase, ) @@ -938,18 +939,7 @@ def serve_pil_image(): @auth_required def gps_lock(lat: float = 50, lon: float = 3, altitude: float = 10): - msg = ( - "fix", - { - "lat": lat, - "lon": lon, - "altitude": altitude, - "error_in_m": 0, - "source": "WEB", - "lock": True, - }, - ) - self.gps_queue.put(msg) + self.gps_queue.put(Location.make_fix(lat, lon, altitude, "WEB")) logger.debug("Putting location msg on gps_queue: {msg}") @auth_required diff --git a/python/PiFinder/state.py b/python/PiFinder/state.py index e96b1825..ab6e9d0a 100644 --- a/python/PiFinder/state.py +++ b/python/PiFinder/state.py @@ -206,6 +206,24 @@ def __str__(self): f"{f', last_lock={self.last_gps_lock}' if self.last_gps_lock else ''})" ) + @staticmethod + def make_fix( + lat: float, lon: float, altitude: float = 0, source: str = "MANUAL" + ) -> tuple: + """Build a GPS fix message tuple for the gps_queue.""" + return ( + "fix", + { + "lat": lat, + "lon": lon, + "altitude": altitude, + "error_in_m": 0, + "source": source, + "lock": True, + "lock_type": 2, + }, + ) + def reset(self): self.lat = 0.0 self.lon = 0.0 @@ -264,6 +282,7 @@ def __init__(self): self.__sqm_details: dict = {} # Full SQM calculation details for calibration self.__datetime = None self.__datetime_time = None + self.__datetime_manual = False # True when manually set, blocks GPS overrides self.__screen = None self.__solve_pixel = config.Config().get_option("solve_pixel") self.__arch = None @@ -416,11 +435,21 @@ def local_datetime(self): return dt.astimezone(pytz.timezone("UTC")) return dt.astimezone(pytz.timezone("UTC")) - def set_datetime(self, dt): + def set_datetime(self, dt, force=False): if dt.tzname() is None: utc_tz = pytz.timezone("UTC") dt = utc_tz.localize(dt) + if force: + self.__datetime_time = time.time() + self.__datetime = dt + self.__datetime_manual = True + return + + # Skip GPS time updates when time was manually set + if self.__datetime_manual: + return + if self.__datetime is None: self.__datetime_time = time.time() self.__datetime = dt @@ -435,6 +464,12 @@ def set_datetime(self, dt): self.__datetime_time = time.time() self.__datetime = dt + def reset_datetime(self): + """Clear manual datetime override, allowing GPS time updates again.""" + self.__datetime = None + self.__datetime_time = None + self.__datetime_manual = False + def screen(self): return self.__screen diff --git a/python/PiFinder/ui/callbacks.py b/python/PiFinder/ui/callbacks.py index 9f293c2a..f5e6fc91 100644 --- a/python/PiFinder/ui/callbacks.py +++ b/python/PiFinder/ui/callbacks.py @@ -11,10 +11,16 @@ import logging import gettext import time +from datetime import datetime + +import pytz from typing import Any, TYPE_CHECKING from PiFinder import utils, calc_utils +from PiFinder.locations import Location as SavedLocation +from PiFinder.state import Location from PiFinder.ui.base import UIModule +from PiFinder.ui.textentry import UITextEntry from PiFinder.catalogs import CatalogFilter from PiFinder.composite_object import CompositeObject, MagnitudeObject @@ -250,18 +256,68 @@ def get_wifi_mode(ui_module: UIModule) -> list[str]: return [wfs.read()] +def set_location(ui_module: UIModule) -> None: + """ + Sets location from the coordinate entry UI. + Reads lat, lon, alt from item_definition (passed through the chain). + """ + lat = ui_module.item_definition.get("lat", 0.0) + lon = ui_module.item_definition.get("lon", 0.0) + alt = ui_module.item_definition.get("alt", 0) + logger.info(f"Setting location to: lat={lat}, lon={lon}, alt={alt}") + + ui_module.command_queues["gps"].put(Location.make_fix(lat, lon, alt, "MANUAL")) + ui_module.message( + _("{lat:.2f}, {lon:.2f}\n{alt}m alt").format(lat=lat, lon=lon, alt=alt), + 2, + ) + + def gps_reset(ui_module: UIModule) -> None: ui_module.command_queues["gps"].put(("reset", {})) ui_module.message("Location Reset", 2) +def datetime_reset(ui_module: UIModule) -> None: + ui_module.command_queues["gps"].put(("reset_datetime", {})) + ui_module.message("Time/Date Reset", 2) + + +def save_location(ui_module: UIModule) -> None: + """Save current location — prompts for name via text entry.""" + location = ui_module.shared_state.location() + if not location.lock: + ui_module.message(_("No location lock"), 2) + return + + def _save(name): + new_loc = SavedLocation( + name=name, + latitude=location.lat, + longitude=location.lon, + height=location.altitude, + error_in_m=location.error_in_m, + source=location.source, + ) + ui_module.config_object.locations.add_location(new_loc) + ui_module.config_object.save_locations() + ui_module.message(_("Saved\n{name}").format(name=name), 2) + + num = len(ui_module.config_object.locations.locations) + 1 + item_definition = { + "name": _("Location Name"), + "class": UITextEntry, + "mode": "text_entry", + "initial_text": _("Loc {number}").format(number=num), + "callback": _save, + } + ui_module.add_to_stack(item_definition) + + def set_time(ui_module: UIModule, time_str: str) -> None: """ Sets the time from the time entry UI """ - from datetime import datetime - import pytz - logger.info(f"Setting time to: {time_str}") timezone_str = ui_module.shared_state.location().timezone @@ -278,10 +334,28 @@ def set_time(ui_module: UIModule, time_str: str) -> None: dt_with_date = datetime(now.year, now.month, now.day, dt.hour, dt.minute, dt.second) dt_with_timezone = timezone.localize(dt_with_date) - ui_module.command_queues["gps"].put(("time", {"time": dt_with_timezone})) + ui_module.command_queues["gps"].put(("time_force", {"time": dt_with_timezone})) ui_module.message(_("Time: {time}").format(time=time_str), 2) +def set_datetime(ui_module: UIModule, date_str: str) -> None: + """ + Sets both date and time from the date entry UI. + Reads the time_str from the item_definition (passed from UITimeEntry). + """ + time_str = ui_module.item_definition.get("time_str", "00:00:00") + logger.info(f"Setting datetime to: {date_str} {time_str}") + + timezone_str = ui_module.shared_state.location().timezone + timezone = pytz.timezone(timezone_str) + + dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M:%S") + dt_with_timezone = timezone.localize(dt) + + ui_module.command_queues["gps"].put(("time_force", {"time": dt_with_timezone})) + ui_module.message(_("{date}\n{time}").format(date=date_str, time=time_str), 2) + + def handle_radec_entry(ui_module: UIModule, ra_deg: float, dec_deg: float) -> None: """ Handles RA/DEC coordinate entry from the coordinate input UI diff --git a/python/PiFinder/ui/dateentry.py b/python/PiFinder/ui/dateentry.py new file mode 100644 index 00000000..e3d66a5f --- /dev/null +++ b/python/PiFinder/ui/dateentry.py @@ -0,0 +1,240 @@ +from typing import Any, TYPE_CHECKING + +from PIL import Image, ImageDraw + +from PiFinder.ui.base import UIModule + +if TYPE_CHECKING: + + def _(a) -> Any: + return a + + +class UIDateEntry(UIModule): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.callback = self.item_definition.get("callback") + self.custom_callback = self.item_definition.get("custom_callback") + self._confirmed = False + + # Initialize three boxes for year, month, day - pre-filled from shared state + self.boxes = ["", "", ""] + self.current_box = 0 + self.placeholders = [ + _("yyyy"), + _("mm"), + _("dd"), + ] # TRANSLATORS: Place holders for year, month, day in date entry + self.max_digits = [4, 2, 2] + + # Pre-fill from best-known date + local_dt = self.shared_state.local_datetime() if self.shared_state else None + if local_dt is not None: + self.boxes[0] = str(local_dt.year) + self.boxes[1] = f"{local_dt.month:02d}" + self.boxes[2] = f"{local_dt.day:02d}" + + # Screen setup + self.width = 128 + self.height = 128 + self.red = self.colors.get(255) + self.black = self.colors.get(0) + self.half_red = self.colors.get(128) + self.screen = Image.new("RGB", (self.width, self.height), "black") + self.draw = ImageDraw.Draw(self.screen) + self.bold = self.fonts.bold + + # Layout constants + self.text_y = 25 + self.year_box_width = 38 + self.md_box_width = 25 + self.box_height = 20 + self.box_spacing = 10 + + # Calculate start_x to center the boxes + total_width = self.year_box_width + 2 * self.md_box_width + 2 * self.box_spacing + self.start_x = (self.width - total_width) // 2 + + def _box_x(self, i): + """Get the x position for box i.""" + if i == 0: + return self.start_x + elif i == 1: + return self.start_x + self.year_box_width + self.box_spacing + else: + return ( + self.start_x + + self.year_box_width + + self.md_box_width + + 2 * self.box_spacing + ) + + def _box_width(self, i): + """Get the width for box i.""" + return self.year_box_width if i == 0 else self.md_box_width + + def draw_date_boxes(self): + for i in range(3): + x = self._box_x(i) + w = self._box_width(i) + + outline_color = self.red if i == self.current_box else self.half_red + outline_width = 2 if i == self.current_box else 1 + + self.draw.rectangle( + [x, self.text_y, x + w, self.text_y + self.box_height], + outline=outline_color, + width=outline_width, + ) + + text = self.boxes[i] + if not text and i != self.current_box: + text = self.placeholders[i] + color = self.colors.get(180) + else: + color = self.red + + placeholder = "0" * self.max_digits[i] + text_width = self.bold.font.getbbox(text if text else placeholder)[2] + text_x = x + (w - text_width) // 2 + text_y = self.text_y + 2 + + self.draw.text((text_x, text_y), text, font=self.bold.font, fill=color) + + # Draw dash separator after first two boxes + if i < 2: + next_x = self._box_x(i + 1) + dash_x = x + w + (next_x - x - w) // 2 - 2 + self.draw.text( + (dash_x, self.text_y + 2), "-", font=self.bold.font, fill=self.red + ) + + # Draw cursor in current box if empty + if not self.boxes[self.current_box]: + x = self._box_x(self.current_box) + cursor_x = x + 2 + self.draw.rectangle( + [ + cursor_x, + self.text_y + 2, + cursor_x + 8, + self.text_y + self.box_height - 2, + ], + fill=self.red, + ) + + def draw_local_date_note(self): + note_y = self.text_y + self.box_height + 10 + self.draw.text( + (10, note_y), + _("Enter Local Date"), + font=self.fonts.base.font, + fill=self.red, + ) + return note_y + 15 + + def draw_separator(self, start_y): + self.draw.line( + [(10, start_y), (self.width - 10, start_y)], fill=self.half_red, width=1 + ) + return start_y + 5 + + def draw_legend(self, start_y): + legend_y = start_y + legend_color = self.red + + self.draw.text( + (10, legend_y), + _("\uf054 Done"), + font=self.fonts.base.font, + fill=legend_color, + ) + legend_y += 12 + self.draw.text( + (10, legend_y), + _("\uf053 Cancel"), + font=self.fonts.base.font, + fill=legend_color, + ) + legend_y += 12 + self.draw.text( + (10, legend_y), + _("\U000f0374 Delete/Previous"), + font=self.fonts.base.font, + fill=legend_color, + ) + + def validate_box(self, box_index, value): + """Validate the entered value for the given box.""" + if not value: + return True + try: + num = int(value) + if box_index == 0: # Year + # Allow partial entry (e.g. "2", "20", "202") and full 2020-2099 + if len(value) < 4: + return True + return 2020 <= num <= 2099 + elif box_index == 1: # Month + return 1 <= num <= 12 + else: # Day + return 1 <= num <= 31 + except ValueError: + return False + + def key_number(self, number): + current = self.boxes[self.current_box] + new_value = current + str(number) + + max_d = self.max_digits[self.current_box] + if len(new_value) > max_d: + return + + if self.validate_box(self.current_box, new_value): + self.boxes[self.current_box] = new_value + if len(new_value) == max_d and self.current_box < 2: + self.current_box += 1 + + def key_minus(self): + """Delete last digit in current box or move to previous box if empty.""" + if self.boxes[self.current_box]: + self.boxes[self.current_box] = self.boxes[self.current_box][:-1] + else: + self.current_box = (self.current_box - 1) % 3 + + def key_right(self): + """Confirm if all boxes filled, otherwise cycle to next box.""" + if all(self.boxes) and self.current_box == 2: + self._confirmed = True + self.remove_from_stack() + return False + if self.current_box < 2: + self.current_box += 1 + return False + + def key_left(self) -> bool: + if self.current_box > 0: + self.current_box -= 1 + return False + self.message(_("Cancelled"), 1) + return True + + def inactive(self): + """Called when the module is no longer active.""" + if self._confirmed and self.custom_callback: + date_str = f"{self.boxes[0]}-{self.boxes[1]}-{self.boxes[2]}" + self.custom_callback(self, date_str) + + def update(self, force=False): + self.draw.rectangle((0, 0, 128, 128), fill=self.black) + + self.draw_date_boxes() + + note_y = self.draw_local_date_note() + separator_y = self.draw_separator(note_y + 15) + self.draw_legend(separator_y) + + if self.shared_state: + self.shared_state.set_screen(self.screen) + return self.screen_update() diff --git a/python/PiFinder/ui/gpsstatus.py b/python/PiFinder/ui/gpsstatus.py index c2cda6a5..435f7599 100644 --- a/python/PiFinder/ui/gpsstatus.py +++ b/python/PiFinder/ui/gpsstatus.py @@ -315,6 +315,15 @@ def update(self, force=False): fill=self.colors.get(128), ) draw_pos += 10 + self.draw.text( + (0, draw_pos), + _("Date: {date}").format( + date=time.strftime("%Y-%m-%d") if time else "---" + ), + font=self.fonts.base.font, + fill=self.colors.get(128), + ) + draw_pos += 10 self.draw.text( (0, draw_pos), _("From: {location_source}").format(location_source=location.source), diff --git a/python/PiFinder/ui/location_list.py b/python/PiFinder/ui/location_list.py index 84a728cb..cb28f5f7 100644 --- a/python/PiFinder/ui/location_list.py +++ b/python/PiFinder/ui/location_list.py @@ -1,11 +1,19 @@ +from typing import Any, TYPE_CHECKING + +from PiFinder.state import Location from PiFinder.ui.textentry import UITextEntry from PiFinder.ui.text_menu import UITextMenu +if TYPE_CHECKING: + + def _(a) -> Any: + return a + class UILocationList(UITextMenu): """UI for managing saved locations""" - __title__ = "Saved Locations" + __title__ = "Load Location" def __init__(self, *args, **kwargs): # Set up menu items before calling parent init @@ -73,19 +81,12 @@ def perform_action(self): action = self.actions[self.action_index] if action == "Load": - # Set location as current self.command_queues["gps"].put( - ( - "fix", - { - "lat": location.latitude, - "lon": location.longitude, - "altitude": location.height, - "source": f"CONFIG: {location.name}", - "lock": True, - "lock_type": 2, - "error_in_m": location.error_in_m, - }, + Location.make_fix( + location.latitude, + location.longitude, + location.height, + f"CONFIG: {location.name}", ) ) # Set as default if desired @@ -171,7 +172,16 @@ def key_left(self): return True def update(self, force=False): - if self.action_menu_active: + if not self.locations: + self.clear_screen() + draw_pos = self.display_class.titlebar_height + 20 + self.draw.text( + (10, draw_pos), + _("No locations"), + font=self.fonts.bold.font, + fill=self.colors.get(192), + ) + elif self.action_menu_active: self.draw_action_menu() else: super().update(force) diff --git a/python/PiFinder/ui/locationentry.py b/python/PiFinder/ui/locationentry.py new file mode 100644 index 00000000..0b8b03a5 --- /dev/null +++ b/python/PiFinder/ui/locationentry.py @@ -0,0 +1,355 @@ +from typing import Any, TYPE_CHECKING + +from PIL import Image, ImageDraw + +import PiFinder.ui.callbacks as callbacks +from PiFinder.ui.base import UIModule + +if TYPE_CHECKING: + + def _(a) -> Any: + return a + + +class UILocationEntry(UIModule): + """Entry screen for latitude, longitude, or altitude. + + Three-step flow: lat → lon → alt. + The 'coordinate' item_definition key controls the mode. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.coordinate = self.item_definition.get("coordinate", "lat") + self._skip_callback = False + self._confirmed = False + self.custom_callback = self.item_definition.get("custom_callback") + + if self.coordinate == "lat": + self.label = _("Enter Latitude") + self.max_degrees = 90 + self.deg_digits = 2 + self.placeholders = [_("DD"), _("dd")] + self.has_sign = True + self.num_boxes = 2 + elif self.coordinate == "lon": + self.label = _("Enter Longitude") + self.max_degrees = 180 + self.deg_digits = 3 + self.placeholders = [_("DDD"), _("dd")] + self.has_sign = True + self.num_boxes = 2 + else: # alt + self.label = _("Altitude (m)") + self.placeholders = [_("meters")] + self.has_sign = False + self.num_boxes = 1 + + # Sign: + = N/E, - = S/W + self.sign = "+" + + self.boxes = [""] * self.num_boxes + self.current_box = 0 + + # Pre-fill from shared state if available + location = self.shared_state.location() if self.shared_state else None + if location and location.lock: + if self.coordinate == "lat": + val = location.lat + if val < 0: + self.sign = "-" + val = abs(val) + self.boxes[0] = str(int(val)) + self.boxes[1] = f"{int(round((val - int(val)) * 100)):02d}" + elif self.coordinate == "lon": + val = location.lon + if val < 0: + self.sign = "-" + val = abs(val) + self.boxes[0] = str(int(val)) + self.boxes[1] = f"{int(round((val - int(val)) * 100)):02d}" + else: # alt + self.boxes[0] = str(int(location.altitude)) + + # Screen setup + self.width = 128 + self.height = 128 + self.red = self.colors.get(255) + self.black = self.colors.get(0) + self.half_red = self.colors.get(128) + self.screen = Image.new("RGB", (self.width, self.height), "black") + self.draw = ImageDraw.Draw(self.screen) + self.bold = self.fonts.bold + + # Layout + self.text_y = 25 + self.box_height = 20 + self.box_spacing = 12 + if self.coordinate == "alt": + self.box_widths = [50] + elif self.coordinate == "lon": + self.box_widths = [32, 28] + else: + self.box_widths = [28, 28] + + def _sign_label(self): + if self.coordinate == "lat": + return "N" if self.sign == "+" else "S" + return "E" if self.sign == "+" else "W" + + def draw_boxes(self): + total_width = sum(self.box_widths) + (self.num_boxes - 1) * self.box_spacing + start_x = (self.width - total_width) // 2 + + # Draw sign indicator for lat/lon + if self.has_sign: + sign_x = start_x - 14 + self.draw.text( + (sign_x, self.text_y + 2), + self._sign_label(), + font=self.bold.font, + fill=self.red, + ) + + x_pos = start_x + for i in range(self.num_boxes): + w = self.box_widths[i] + outline_color = self.red if i == self.current_box else self.half_red + outline_width = 2 if i == self.current_box else 1 + + self.draw.rectangle( + [x_pos, self.text_y, x_pos + w, self.text_y + self.box_height], + outline=outline_color, + width=outline_width, + ) + + text = self.boxes[i] + if not text and i != self.current_box: + text = self.placeholders[i] + color = self.colors.get(180) + else: + color = self.red + + if self.coordinate == "alt": + placeholder = "0000" + else: + placeholder = "0" * (self.deg_digits if i == 0 else 2) + text_width = self.bold.font.getbbox(text if text else placeholder)[2] + text_x = x_pos + (w - text_width) // 2 + text_y = self.text_y + 2 + + self.draw.text((text_x, text_y), text, font=self.bold.font, fill=color) + + # Draw decimal point between lat/lon boxes + if self.num_boxes == 2 and i == 0: + dot_x = x_pos + w + self.box_spacing // 2 - 2 + self.draw.text( + (dot_x, self.text_y + 2), + ".", + font=self.bold.font, + fill=self.red, + ) + + # Draw cursor in current box if empty + if i == self.current_box and not self.boxes[i]: + self.draw.rectangle( + [ + x_pos + 2, + self.text_y + 2, + x_pos + 10, + self.text_y + self.box_height - 2, + ], + fill=self.red, + ) + + x_pos += w + self.box_spacing + + # Draw unit suffix + if self.coordinate == "alt": + self.draw.text( + (x_pos - self.box_spacing + 4, self.text_y + 2), + "m", + font=self.bold.font, + fill=self.red, + ) + else: + self.draw.text( + (x_pos - self.box_spacing + 4, self.text_y + 2), + "\u00b0", + font=self.bold.font, + fill=self.red, + ) + + def draw_label(self): + note_y = self.text_y + self.box_height + 10 + self.draw.text( + (10, note_y), + self.label, + font=self.fonts.base.font, + fill=self.red, + ) + return note_y + 15 + + def draw_separator(self, start_y): + self.draw.line( + [(10, start_y), (self.width - 10, start_y)], fill=self.half_red, width=1 + ) + return start_y + 5 + + def draw_legend(self, start_y): + legend_y = start_y + legend_color = self.red + + self.draw.text( + (10, legend_y), + _("\uf054 Next/Done"), + font=self.fonts.base.font, + fill=legend_color, + ) + legend_y += 12 + self.draw.text( + (10, legend_y), + _("\uf053 Cancel"), + font=self.fonts.base.font, + fill=legend_color, + ) + legend_y += 12 + if self.coordinate == "lat": + hint = _("\U000f0374 Delete \U000f0415 N/S") + elif self.coordinate == "lon": + hint = _("\U000f0374 Delete \U000f0415 E/W") + else: + hint = _("\U000f0374 Delete/Previous") + self.draw.text( + (10, legend_y), + hint, + font=self.fonts.base.font, + fill=legend_color, + ) + + def validate_box(self, box_index, value): + if not value: + return True + try: + num = int(value) + if self.coordinate == "alt": + if len(value) > 5: + return False + return 0 <= num <= 99999 + if box_index == 0: + max_digits = self.deg_digits + if len(value) > max_digits: + return False + if len(value) == max_digits: + return 0 <= num <= self.max_degrees + return True + else: + if len(value) > 2: + return False + return 0 <= num <= 99 + except ValueError: + return False + + def key_number(self, number): + current = self.boxes[self.current_box] + new_value = current + str(number) + + if self.coordinate == "alt": + max_d = 5 + else: + max_d = self.deg_digits if self.current_box == 0 else 2 + + if len(new_value) > max_d: + return + + if self.validate_box(self.current_box, new_value): + self.boxes[self.current_box] = new_value + if len(new_value) == max_d and self.current_box < self.num_boxes - 1: + self.current_box += 1 + + def key_minus(self): + if self.boxes[self.current_box]: + self.boxes[self.current_box] = self.boxes[self.current_box][:-1] + elif self.num_boxes > 1: + self.current_box = (self.current_box - 1) % self.num_boxes + + def key_plus(self): + if self.has_sign: + self.sign = "-" if self.sign == "+" else "+" + + def _parse_value(self): + """Parse the current boxes into a numeric value.""" + if self.coordinate == "alt": + return int(self.boxes[0]) if self.boxes[0] else 0 + deg = int(self.boxes[0]) if self.boxes[0] else 0 + dec = int(self.boxes[1]) if self.boxes[1] else 0 + val = deg + dec / 100.0 + if self.sign == "-": + val = -val + return val + + def _last_box(self): + return self.num_boxes - 1 + + def key_right(self): + if all(self.boxes) and self.current_box == self._last_box(): + val = self._parse_value() + if self.coordinate == "lat": + self._skip_callback = True + self.remove_from_stack() + self.add_to_stack( + { + "name": _("Enter Coords"), + "class": UILocationEntry, + "coordinate": "lon", + "lat": val, + "custom_callback": callbacks.set_location, + } + ) + elif self.coordinate == "lon": + self._skip_callback = True + self.remove_from_stack() + self.add_to_stack( + { + "name": _("Enter Coords"), + "class": UILocationEntry, + "coordinate": "alt", + "lat": self.item_definition.get("lat", 0.0), + "lon": val, + "custom_callback": callbacks.set_location, + } + ) + else: # alt + self.item_definition["alt"] = val + self._confirmed = True + self.remove_from_stack() + return False + if self.current_box < self.num_boxes - 1: + self.current_box += 1 + return False + + def key_left(self) -> bool: + if self.current_box > 0: + self.current_box -= 1 + return False + self._skip_callback = True + self.message(_("Cancelled"), 1) + return True + + def inactive(self): + if not self._confirmed or self._skip_callback: + return + if self.coordinate == "alt" and self.custom_callback: + self.custom_callback(self) + + def update(self, force=False): + self.draw.rectangle((0, 0, 128, 128), fill=self.black) + self.draw_boxes() + note_y = self.draw_label() + separator_y = self.draw_separator(note_y + 15) + self.draw_legend(separator_y) + + if self.shared_state: + self.shared_state.set_screen(self.screen) + return self.screen_update() diff --git a/python/PiFinder/ui/menu_structure.py b/python/PiFinder/ui/menu_structure.py index e4ee4438..284b2979 100644 --- a/python/PiFinder/ui/menu_structure.py +++ b/python/PiFinder/ui/menu_structure.py @@ -13,6 +13,7 @@ from PiFinder.ui.sqm import UISQM from PiFinder.ui.equipment import UIEquipment from PiFinder.ui.location_list import UILocationList +from PiFinder.ui.locationentry import UILocationEntry from PiFinder.ui.radec_entry import UIRADecEntry import PiFinder.ui.callbacks as callbacks @@ -1088,14 +1089,30 @@ def _(key: str) -> Any: }, { "name": _("Set Location"), - "class": UILocationList, + "class": UITextMenu, + "select": "single", + "items": [ + { + "name": _("Enter Coords"), + "class": UILocationEntry, + }, + { + "name": _("Load Location"), + "class": UILocationList, + }, + { + "name": _("Save Location"), + "callback": callbacks.save_location, + }, + ], }, { - "name": _("Set Time"), + "name": _("Set Time/Date"), "class": UITimeEntry, "custom_callback": callbacks.set_time, }, - {"name": _("Reset"), "callback": callbacks.gps_reset}, + {"name": _("Reset Location"), "callback": callbacks.gps_reset}, + {"name": _("Reset Time/Date"), "callback": callbacks.datetime_reset}, ], }, {"name": _("Console"), "class": UIConsole}, diff --git a/python/PiFinder/ui/timeentry.py b/python/PiFinder/ui/timeentry.py index 9d921e47..730d00d9 100644 --- a/python/PiFinder/ui/timeentry.py +++ b/python/PiFinder/ui/timeentry.py @@ -1,5 +1,15 @@ +from typing import Any, TYPE_CHECKING + from PIL import Image, ImageDraw + +import PiFinder.ui.callbacks as callbacks from PiFinder.ui.base import UIModule +from PiFinder.ui.dateentry import UIDateEntry + +if TYPE_CHECKING: + + def _(a) -> Any: + return a class UITimeEntry(UIModule): @@ -8,6 +18,7 @@ def __init__(self, *args, **kwargs): self.callback = self.item_definition.get("callback") self.custom_callback = self.item_definition.get("custom_callback") + self._skip_callback = False # Initialize three empty boxes for hours, minutes, seconds self.boxes = ["", "", ""] self.current_box = 0 # Start with hours @@ -96,7 +107,7 @@ def draw_local_time_note(self): (10, note_y), _("Enter Local Time"), font=self.fonts.base.font, - fill=self.red, # Brighter color for better visibility + fill=self.red, ) return note_y + 15 # Return the Y position after this element @@ -109,26 +120,25 @@ def draw_separator(self, start_y): def draw_legend(self, start_y): legend_y = start_y - # Still using full red for better visibility but smaller font legend_color = self.red self.draw.text( (10, legend_y), - _(" Next box"), # Right - font=self.fonts.base.font, # Using base font + _("\uf054 Done"), + font=self.fonts.base.font, fill=legend_color, ) - legend_y += 12 # Standard spacing + legend_y += 12 self.draw.text( (10, legend_y), - _(" Done"), # Left + _("\uf053 Cancel"), font=self.fonts.base.font, fill=legend_color, ) - legend_y += 12 # Standard spacing + legend_y += 12 self.draw.text( (10, legend_y), - _("󰍴 Delete/Previous"), # minus + _("\U000f0374 Delete/Previous"), font=self.fonts.base.font, fill=legend_color, ) @@ -158,8 +168,8 @@ def key_number(self, number): if self.validate_box(self.current_box, new_value): self.boxes[self.current_box] = new_value # Auto-advance to next box if we have 2 digits - if len(new_value) == 2: - self.current_box = (self.current_box + 1) % 3 + if len(new_value) == 2 and self.current_box < 2: + self.current_box += 1 def key_minus(self): """Delete last digit in current box or move to previous box if empty""" @@ -171,12 +181,38 @@ def key_minus(self): self.current_box = (self.current_box - 1) % 3 def key_right(self): - """Move to next box""" - self.current_box = (self.current_box + 1) % 3 + """Confirm time and chain to date entry, or cycle to next box.""" + if all(self.boxes) and self.current_box == 2: + time_str = ( + f"{self.boxes[0] or '00'}:{self.boxes[1] or '00'}" + f":{self.boxes[2] or '00'}" + ) + self._skip_callback = True + self.remove_from_stack() + date_item = { + "name": "Set Date", + "class": UIDateEntry, + "time_str": time_str, + "custom_callback": callbacks.set_datetime, + } + self.add_to_stack(date_item) + return False + if self.current_box < 2: + self.current_box += 1 return False + def key_left(self) -> bool: + if self.current_box > 0: + self.current_box -= 1 + return False + self._skip_callback = True + self.message(_("Cancelled"), 1) + return True + def inactive(self): """Called when the module is no longer the active module""" + if self._skip_callback: + return if self.custom_callback: time_str = f"{self.boxes[0] or '00'}:{self.boxes[1] or '00'}:{self.boxes[2] or '00'}" self.custom_callback(self, time_str) @@ -184,17 +220,8 @@ def inactive(self): def update(self, force=False): self.draw.rectangle((0, 0, 128, 128), fill=self.black) - # Draw title - # self.draw.text( - # (7, 5), - # "Enter Time:", - # font=self.fonts.base.font, - # fill=self.half_red, - # ) - self.draw_time_boxes() - # Draw additional elements with proper positioning note_y = self.draw_local_time_note() separator_y = self.draw_separator(note_y + 15) self.draw_legend(separator_y)