diff --git a/clock/ui/active_alarm.py b/clock/ui/active_alarm.py index ad9d009..60514e5 100644 --- a/clock/ui/active_alarm.py +++ b/clock/ui/active_alarm.py @@ -2,68 +2,114 @@ import curses from datetime import datetime from .utils import init_colors, draw_big_digit -def draw_active_alarms(stdscr, context): - """Draw the active alarms""" - init_colors() - height, width = stdscr.getmaxyx() - current_time = datetime.now() +class ActiveAlarmView: + def __init__(self, storage, control_queue): + self.storage = storage + self.control_queue = control_queue + self.active_alarms = {} - # Draw the main clock (original position) - time_str = current_time.strftime("%H:%M:%S") - digit_width = 14 - total_width = digit_width * len(time_str) - start_x = (width - total_width) // 2 - start_y = (height - 7) // 2 - 4 # Original position from _draw_main_clock + def reset_state(self): + """Reset the view state""" + self.active_alarms.clear() - # Draw blinking dot - if int(current_time.timestamp()) % 2 == 0: + def draw(self, stdscr): + """Draw the active alarm screen""" + init_colors() + height, width = stdscr.getmaxyx() + current_time = datetime.now() + + self._draw_main_clock(stdscr, height, width, current_time) + self._draw_active_alarm_info(stdscr, height, width) + self._draw_instructions(stdscr, height, width) + + def handle_input(self, key): + """Handle user input and return the next view name or None to stay""" + if not self.active_alarms: + return 'CLOCK' + + alarm_id = list(self.active_alarms.keys())[0] + + if key == ord('s'): + self._handle_snooze(alarm_id) + return None + elif key == ord('d'): + self._handle_dismiss(alarm_id) + return 'CLOCK' + + return None + + def update_active_alarms(self, active_alarms): + """Update the active alarms state""" + self.active_alarms = active_alarms + + def _draw_main_clock(self, stdscr, height, width, current_time): + """Draw the main clock display""" + time_str = current_time.strftime("%H:%M:%S") + digit_width = 14 + total_width = digit_width * len(time_str) + start_x = (width - total_width) // 2 + start_y = (height - 7) // 2 - 4 + + # Draw blinking dot + if int(current_time.timestamp()) % 2 == 0: + stdscr.attron(curses.color_pair(1)) + stdscr.addstr(start_y - 1, start_x + total_width - 2, "•") + stdscr.attroff(curses.color_pair(1)) + + # Draw big time digits stdscr.attron(curses.color_pair(1)) - stdscr.addstr(start_y - 1, start_x + total_width - 2, "•") + for i, digit in enumerate(time_str): + draw_big_digit(stdscr, start_y, start_x + i * digit_width, digit) stdscr.attroff(curses.color_pair(1)) - # Green color for big time - stdscr.attron(curses.color_pair(1)) - for i, digit in enumerate(time_str): - draw_big_digit(stdscr, start_y, start_x + i * digit_width, digit) - stdscr.attroff(curses.color_pair(1)) + # Draw date + date_str = current_time.strftime("%Y-%m-%d") + date_x = width // 2 - len(date_str) // 2 + date_y = height // 2 + 4 + stdscr.attron(curses.color_pair(2)) + stdscr.addstr(date_y, date_x, date_str) + stdscr.attroff(curses.color_pair(2)) - # Draw date (as in main clock) - date_str = current_time.strftime("%Y-%m-%d") - date_x = width // 2 - len(date_str) // 2 - date_y = height // 2 + 4 - stdscr.attron(curses.color_pair(2)) - stdscr.addstr(date_y, date_x, date_str) - stdscr.attroff(curses.color_pair(2)) + def _draw_active_alarm_info(self, stdscr, height, width): + """Draw information about the active alarm""" + if not self.active_alarms: + return - # Get active alarm info - active_alarms = context.get('active_alarms', {}) - if not active_alarms: - return + alarm_id = list(self.active_alarms.keys())[0] + alarm_info = self.active_alarms[alarm_id] + alarm_config = alarm_info['config'] - # Get the first (or only) active alarm - alarm_id = list(active_alarms.keys())[0] - alarm_info = active_alarms[alarm_id] - alarm_config = alarm_info['config'] + alarm_name = alarm_config.get('name', 'Unnamed Alarm') + alarm_time = alarm_config.get('time', 'Unknown Time') + alarm_str = f"[ {alarm_name} - {alarm_time} ]" - # Format alarm info - alarm_name = alarm_config.get('name', 'Unnamed Alarm') - alarm_time = alarm_config.get('time', 'Unknown Time') - #snooze_count = alarm_info.get('snooze_count', 0) + # Position alarm info above the date + date_y = height // 2 + 4 # Same as in _draw_main_clock + alarm_y = date_y - 2 + alarm_x = max(0, width // 2 - len(alarm_str) // 2) - # Draw alarm info under the clock - info_y = start_y + 8 # Position below the clock - #alarm_str = f"[ {alarm_name} - {alarm_time} - Snoozed: {snooze_count}x ]" - alarm_str = f"[ {alarm_name} - {alarm_time} ]" - alarm_x = max(0, width // 2 - len(alarm_str) // 2) - alarm_y = date_y - 2 # Just above the date + stdscr.attron(curses.color_pair(1)) + stdscr.addstr(alarm_y, alarm_x, alarm_str) + stdscr.attroff(curses.color_pair(1)) - # Center the alarm info - info_x = max(0, width // 2 - len(alarm_str) // 2) + def _draw_instructions(self, stdscr, height, width): + """Draw the user instructions""" + if self.active_alarms: + instructions = "S: Snooze D: Dismiss" + stdscr.addstr(height - 2, width // 2 - len(instructions) // 2, instructions) - # Draw with green color - stdscr.attron(curses.color_pair(1)) - stdscr.addstr(alarm_y, alarm_x, alarm_str) - stdscr.attroff(curses.color_pair(1)) + def _handle_snooze(self, alarm_id): + """Handle snoozing the alarm""" + self.control_queue.put({ + 'type': 'snooze', + 'alarm_id': alarm_id + }) + del self.active_alarms[alarm_id] - # Instructions - stdscr.addstr(height - 2, width // 2 - 15, "S: Snooze D: Dismiss") + def _handle_dismiss(self, alarm_id): + """Handle dismissing the alarm""" + self.control_queue.put({ + 'type': 'dismiss', + 'alarm_id': alarm_id + }) + del self.active_alarms[alarm_id] diff --git a/clock/ui/add_alarm.py b/clock/ui/add_alarm.py index 4eed874..dbad0f7 100644 --- a/clock/ui/add_alarm.py +++ b/clock/ui/add_alarm.py @@ -1,11 +1,45 @@ import curses -from datetime import datetime -from .utils import init_colors +from datetime import datetime, date, timedelta -def draw_add_alarm(stdscr, alarm_draft): - """Draw the add alarm screen""" - if alarm_draft is None: - alarm_draft = { +class AddAlarmView: + def __init__(self, storage, control_queue): + self.storage = storage + self.control_queue = control_queue + self.weekday_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + self.reset_state() + + def set_alarm_data(self, alarm): + """Load existing alarm data for editing.""" + self.alarm_data = alarm # Store for pre-filling the form + + # Debug log to inspect the alarm structure + import logging + logging.getLogger(__name__).debug(f"set_alarm_data received: {alarm}") + + # Ensure alarm is a dictionary + if not isinstance(alarm, dict): + logging.getLogger(__name__).error(f"Invalid alarm format: {type(alarm)}") + + # Parse time string + hour, minute, _ = map(int, alarm['time'].split(':')) # Extract hour and minute return + + # Pre-fill the form fields with alarm data + self.alarm_draft = { + 'hour': hour, + 'minute': minute, + 'name': alarm.get('name', 'Unnamed Alarm'), + 'enabled': alarm.get('enabled', True), + 'date': None, # Your structure doesn't use date directly + 'weekdays': alarm.get('repeat_rule', {}).get('days_of_week', []), + 'current_weekday': 0, + 'editing_name': False, + 'temp_name': alarm.get('name', 'Unnamed Alarm'), + 'selected_item': 0 + } + + def reset_state(self): + """Reset all state variables to their initial values""" + self.alarm_draft = { 'hour': datetime.now().hour, 'minute': datetime.now().minute, 'name': 'New Alarm', @@ -15,76 +49,119 @@ def draw_add_alarm(stdscr, alarm_draft): 'current_weekday': 0, 'editing_name': False, 'temp_name': '', - 'selected_item': 0 # Added to handle selection + 'selected_item': 0 } + self.date_edit_pos = 2 # Default to editing the day - init_colors() - height, width = stdscr.getmaxyx() + def draw(self, stdscr): + """Draw the add alarm screen""" + height, width = stdscr.getmaxyx() + form_y = height // 2 - 8 - # Center the form vertically with good spacing - form_y = height // 2 - 8 + self._draw_title(stdscr, form_y, width) + self._draw_time_field(stdscr, form_y + 2, width) + self._draw_date_field(stdscr, form_y + 4, width) + self._draw_weekdays(stdscr, form_y + 6, width) + self._draw_name_field(stdscr, form_y + 8, width) + self._draw_status_field(stdscr, form_y + 10, width) + self._draw_instructions(stdscr, height - 2, width) - # Title - title = "Add New Alarm" - stdscr.attron(curses.color_pair(1) | curses.A_BOLD) - stdscr.addstr(form_y, width // 2 - len(title) // 2, title) - stdscr.attroff(curses.color_pair(1) | curses.A_BOLD) + def handle_input(self, key): + """Handle user input and return the next view name or None to stay""" + if key == 27: # ESC + return self._handle_escape() + elif key == 10: # ENTER + return self._handle_enter() - def draw_field(y, label, value, is_selected): + if not self.alarm_draft['editing_name']: + if key in [ord('h'), curses.KEY_LEFT]: + self.alarm_draft['selected_item'] = (self.alarm_draft['selected_item'] - 1) % 6 + elif key in [ord('l'), curses.KEY_RIGHT]: + self.alarm_draft['selected_item'] = (self.alarm_draft['selected_item'] + 1) % 6 + + self._handle_field_input(key) + return None + + def _handle_field_input(self, key): + """Handle input for the currently selected field""" + selected_item = self.alarm_draft['selected_item'] + + if key in [ord('k'), curses.KEY_UP, ord('j'), curses.KEY_DOWN]: + is_up = key in [ord('k'), curses.KEY_UP] + if selected_item == 0: + self._adjust_hour(is_up) + elif selected_item == 1: + self._adjust_minute(is_up) + elif selected_item == 2: + self._adjust_date(is_up) + elif selected_item == 3: + self._handle_weekday_input(key) + elif selected_item == 4: + self._handle_name_input(key) + elif selected_item == 5 and key == 32: + self.alarm_draft['enabled'] = not self.alarm_draft['enabled'] + + def _draw_title(self, stdscr, y, width): + """Draw the title of the form""" + title = "Add New Alarm" + stdscr.attron(curses.color_pair(1) | curses.A_BOLD) + stdscr.addstr(y, width // 2 - len(title) // 2, title) + stdscr.attroff(curses.color_pair(1) | curses.A_BOLD) + + def _draw_field(self, stdscr, y, label, value, is_selected): + """Draw a form field with label and value""" label_str = f"{label}: " - x = width // 2 - (len(label_str) + len(str(value))) // 2 + x = self._center_text_x(label_str + str(value)) + stdscr.attron(curses.color_pair(1)) stdscr.addstr(y, x, label_str) stdscr.attroff(curses.color_pair(1)) - # Draw value (highlighted if selected) if is_selected: - stdscr.attron(curses.color_pair(2)) # Highlighted selection + stdscr.attron(curses.color_pair(2)) stdscr.addstr(y, x + len(label_str), str(value)) if is_selected: stdscr.attroff(curses.color_pair(2)) - # Get selected_item either from alarm_draft or passed separately - selected_item = alarm_draft.get('selected_item', 0) + def _draw_time_field(self, stdscr, y, width): + """Draw the time field""" + self._draw_field(stdscr, y, "Time", + f"{self.alarm_draft['hour']:02d}:{self.alarm_draft['minute']:02d}", + self.alarm_draft['selected_item'] in [0, 1]) - # Order: Time → Date → Weekdays → Name → Enabled - draw_field(form_y + 2, "Time", f"{alarm_draft['hour']:02d}:{alarm_draft['minute']:02d}", - selected_item == 0 or selected_item == 1) + def _draw_date_field(self, stdscr, y, width): + """Draw the date field""" + date_str = "Repeating weekly" if self.alarm_draft['weekdays'] else ( + self.alarm_draft['date'].strftime("%Y-%m-%d") if self.alarm_draft['date'] else "None" + ) + self._draw_field(stdscr, y, "Date", date_str, + self.alarm_draft['selected_item'] == 2) - # Date Selection - date_y = form_y + 4 - if alarm_draft['weekdays']: - draw_field(date_y, "Date", "Repeating weekly", selected_item == 2) - else: - draw_field(date_y, "Date", alarm_draft['date'].strftime("%Y-%m-%d") if alarm_draft['date'] else "None", - selected_item == 2) + def _draw_weekdays(self, stdscr, y, width): + """Draw the weekday selection field""" + label_x = width // 2 - 20 + stdscr.attron(curses.color_pair(1)) + stdscr.addstr(y, label_x, "Repeat: ") + stdscr.attroff(curses.color_pair(1)) - # Weekday Selection - weekday_y = form_y + 6 - weekday_label = "Repeat: " - label_x = width // 2 - 20 # Adjust this value to move the entire "Repeat" section left or right + weekday_x = label_x + len("Repeat: ") + for i, day in enumerate(self.weekday_names): + self._draw_weekday(stdscr, y, weekday_x + i * 4, day, i) - # Draw the "Repeat: " label once - stdscr.attron(curses.color_pair(1)) - stdscr.addstr(weekday_y, label_x, weekday_label) - stdscr.attroff(curses.color_pair(1)) - - # Draw weekdays starting right after the label - weekday_x = label_x + len(weekday_label) - weekday_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] - for i, day in enumerate(weekday_names): - x_pos = weekday_x + i * 4 # Space between weekdays - is_selected = (selected_item == 3 and i == alarm_draft.get('current_weekday', 0)) - is_active = i in alarm_draft.get('weekdays', []) + def _draw_weekday(self, stdscr, y, x, day, index): + """Draw a single weekday""" + is_selected = (self.alarm_draft['selected_item'] == 3 and + index == self.alarm_draft['current_weekday']) + is_active = index in self.alarm_draft['weekdays'] if is_selected: - stdscr.attron(curses.color_pair(2)) # Highlight current selection + stdscr.attron(curses.color_pair(2)) elif is_active: - stdscr.attron(curses.color_pair(1) | curses.A_BOLD) # Selected weekday + stdscr.attron(curses.color_pair(1) | curses.A_BOLD) else: stdscr.attron(curses.color_pair(1)) - stdscr.addstr(weekday_y, x_pos, day) + stdscr.addstr(y, x, day) if is_selected: stdscr.attroff(curses.color_pair(2)) @@ -93,16 +170,129 @@ def draw_add_alarm(stdscr, alarm_draft): else: stdscr.attroff(curses.color_pair(1)) - # Name field - draw_field(form_y + 8, "Name", alarm_draft['name'], selected_item == 4) + def _draw_name_field(self, stdscr, y, width): + """Draw the name field""" + self._draw_field(stdscr, y, "Name", self.alarm_draft['name'], + self.alarm_draft['selected_item'] == 4) - # Enabled Toggle - status_y = form_y + 10 - enabled_str = "● Enabled" if alarm_draft['enabled'] else "○ Disabled" - draw_field(status_y, "Status", enabled_str, selected_item == 5) + def _draw_status_field(self, stdscr, y, width): + """Draw the enabled/disabled status field""" + enabled_str = "● Enabled" if self.alarm_draft['enabled'] else "○ Disabled" + self._draw_field(stdscr, y, "Status", enabled_str, + self.alarm_draft['selected_item'] == 5) - # Instructions - instructions = "j/k: Change h/l: Move Space: Toggle Enter: Save Esc: Cancel" - stdscr.attron(curses.color_pair(1)) - stdscr.addstr(height - 2, width // 2 - len(instructions) // 2, instructions) - stdscr.attroff(curses.color_pair(1)) + def _draw_instructions(self, stdscr, y, width): + """Draw the instructions at the bottom of the screen""" + instructions = "j/k: Change h/l: Move Space: Toggle Enter: Save Esc: Cancel" + stdscr.attron(curses.color_pair(1)) + stdscr.addstr(y, width // 2 - len(instructions) // 2, instructions) + stdscr.attroff(curses.color_pair(1)) + + def _center_text_x(self, text): + """Calculate x position to center text""" + return curses.COLS // 2 - len(text) // 2 + + def _adjust_hour(self, increase): + """Adjust the hour value""" + delta = 1 if increase else -1 + self.alarm_draft['hour'] = (self.alarm_draft['hour'] + delta) % 24 + + def _adjust_minute(self, increase): + """Adjust the minute value""" + delta = 1 if increase else -1 + self.alarm_draft['minute'] = (self.alarm_draft['minute'] + delta) % 60 + + def _adjust_date(self, increase): + """Adjust the date value""" + if not self.alarm_draft['date']: + self.alarm_draft['date'] = datetime.now().date() + return + + delta = 1 if increase else -1 + current_date = self.alarm_draft['date'] + + try: + if self.date_edit_pos == 0: # Year + new_year = max(current_date.year + delta, datetime.now().year) + self.alarm_draft['date'] = current_date.replace(year=new_year) + elif self.date_edit_pos == 1: # Month + new_month = max(1, min(12, current_date.month + delta)) + max_day = (datetime(current_date.year, new_month, 1) + + timedelta(days=31)).replace(day=1) - timedelta(days=1) + self.alarm_draft['date'] = current_date.replace( + month=new_month, + day=min(current_date.day, max_day.day) + ) + elif self.date_edit_pos == 2: # Day + max_day = (datetime(current_date.year, current_date.month, 1) + + timedelta(days=31)).replace(day=1) - timedelta(days=1) + new_day = max(1, min(max_day.day, current_date.day + delta)) + self.alarm_draft['date'] = current_date.replace(day=new_day) + except ValueError as e: + # Handle date validation errors + pass + + def _handle_weekday_input(self, key): + """Handle input for weekday selection""" + if key in [ord('h'), curses.KEY_LEFT]: + self.alarm_draft['current_weekday'] = (self.alarm_draft['current_weekday'] - 1) % 7 + elif key in [ord('l'), curses.KEY_RIGHT]: + self.alarm_draft['current_weekday'] = (self.alarm_draft['current_weekday'] + 1) % 7 + elif key == 32: # SPACE + current_day = self.alarm_draft['current_weekday'] + if current_day in self.alarm_draft['weekdays']: + self.alarm_draft['weekdays'].remove(current_day) + else: + self.alarm_draft['weekdays'].append(current_day) + self.alarm_draft['weekdays'].sort() + + if self.alarm_draft['weekdays']: + self.alarm_draft['date'] = None + + def _handle_name_input(self, key): + """Handle input for name editing""" + if key == 32: # SPACE + if not self.alarm_draft['editing_name']: + self.alarm_draft['editing_name'] = True + self.alarm_draft['temp_name'] = self.alarm_draft['name'] + self.alarm_draft['name'] = '' + elif self.alarm_draft['editing_name']: + if key == curses.KEY_BACKSPACE or key == 127: + self.alarm_draft['name'] = self.alarm_draft['name'][:-1] + elif 32 <= key <= 126: # Printable ASCII + self.alarm_draft['name'] += chr(key) + + def _handle_escape(self): + """Handle escape key press""" + if self.alarm_draft['editing_name']: + self.alarm_draft['name'] = self.alarm_draft['temp_name'] + self.alarm_draft['editing_name'] = False + return None + return 'CLOCK' + + def _handle_enter(self): + """Handle enter key press""" + if self.alarm_draft['editing_name']: + self.alarm_draft['editing_name'] = False + self.alarm_draft['selected_item'] = 0 + return None + + try: + alarm_data = { + "name": self.alarm_draft['name'], + "time": f"{self.alarm_draft['hour']:02d}:{self.alarm_draft['minute']:02d}:00", + "enabled": self.alarm_draft['enabled'], + "repeat_rule": { + "type": "weekly" if self.alarm_draft['weekdays'] else "once", + "days_of_week": [self.weekday_names[day].lower() + for day in self.alarm_draft['weekdays']], + "at": (self.alarm_draft['date'].strftime("%Y-%m-%d") + if self.alarm_draft['date'] and not self.alarm_draft['weekdays'] + else None) + } + } + self.storage.save_new_alert(alarm_data) + return 'CLOCK' + except Exception as e: + # Handle save errors + return None diff --git a/clock/ui/big_digits.py b/clock/ui/big_digits.py index df5d452..a26f2b3 100644 --- a/clock/ui/big_digits.py +++ b/clock/ui/big_digits.py @@ -98,5 +98,13 @@ BIG_DIGITS = { " ████ ", " ████ ", " " + ], + '?': [ + " ??? ", + " ????? ", + "?? ??", + " ??? ", + " ?? ?? ", + " ? " ] } diff --git a/clock/ui/input_handlers.py b/clock/ui/input_handlers.py deleted file mode 100644 index 5bc417a..0000000 --- a/clock/ui/input_handlers.py +++ /dev/null @@ -1,202 +0,0 @@ -import curses -from datetime import datetime, date, timedelta - -class InputHandling: - - def _handle_active_alarms_input(self, key): - """Handle input for active alarms view""" - if not self.active_alarms: - # No active alarms, return to clock view - self.current_view = 'CLOCK' - return - - # Get the first (or only) active alarm - alarm_id = list(self.active_alarms.keys())[0] - - if key == ord('s'): # Snooze - # Send snooze command to control queue - self.control_queue.put({ - 'type': 'snooze', - 'alarm_id': alarm_id - }) - # Remove from active alarms - del self.active_alarms[alarm_id] - - # Optional: show a snooze confirmation - self._show_error("Alarm Snoozed") - - elif key == ord('d'): # Dismiss - # Send dismiss command to control queue - self.control_queue.put({ - 'type': 'dismiss', - 'alarm_id': alarm_id - }) - # Remove from active alarms - del self.active_alarms[alarm_id] - - # Return to clock view - self.current_view = 'CLOCK' - - def _handle_clock_input(self, key): - """Handle input on the clock view""" - if key == ord('a'): - self.current_view = 'ADD_ALARM' - elif key == ord('s'): - self.current_view = 'LIST_ALARMS' - self.alarm_list = self.storage.get_saved_alerts() - - def _handle_add_alarm_input(self, key): - """Handle input for alarm creation""" - if not hasattr(self, 'alarm_draft'): - self.alarm_draft = { - 'hour': datetime.now().hour, - 'minute': datetime.now().minute, - 'name': 'New Alarm', - 'enabled': True, - 'date': None, - 'weekdays': [], - 'current_weekday': 0, # Add this to track currently selected weekday - 'editing_name': False, - 'temp_name': '' - } - - alarm = self.alarm_draft - - # Escape key handling - if key == 27: # ESC - if alarm['editing_name']: - # Cancel name editing - alarm['name'] = alarm['temp_name'] - alarm['editing_name'] = False - else: - # Return to clock view - self.current_view = 'CLOCK' - return - - # Enter key handling - if key == 10: # ENTER - if alarm['editing_name']: - # Finish name editing - alarm['editing_name'] = False - self.selected_item = 0 - else: - # Save alarm - try: - alarm_data = { - "name": alarm['name'], - "time": f"{alarm['hour']:02d}:{alarm['minute']:02d}:00", - "enabled": alarm['enabled'], - "repeat_rule": { - "type": "weekly" if alarm['weekdays'] else "once", - "days_of_week": [self.weekday_names[day].lower() for day in alarm['weekdays']], - "at": alarm['date'].strftime("%Y-%m-%d") if alarm['date'] and not alarm['weekdays'] else None - } - } - self.storage.save_new_alert(alarm_data) - self.current_view = 'CLOCK' - except Exception as e: - self._show_error(str(e)) - return - - # Move selection using Left (h) and Right (l) - if not alarm['editing_name']: - if key in [ord('h'), curses.KEY_LEFT]: - self.selected_item = (self.selected_item - 1) % 6 - elif key in [ord('l'), curses.KEY_RIGHT]: - self.selected_item = (self.selected_item + 1) % 6 - - # Handle numeric values (Hour, Minute, Date) - if key in [ord('k'), curses.KEY_UP, ord('j'), curses.KEY_DOWN]: - is_up = key in [ord('k'), curses.KEY_UP] - delta = 1 if is_up else -1 - - if self.selected_item == 0: # Hour - alarm['hour'] = (alarm['hour'] + delta) % 24 - elif self.selected_item == 1: # Minute - alarm['minute'] = (alarm['minute'] + delta) % 60 - elif self.selected_item == 2: # Date - if not alarm['date']: - alarm['date'] = datetime.now().date() - else: - try: - if not hasattr(self, 'date_edit_pos'): - self.date_edit_pos = 2 # Default to editing the day - current_date = alarm['date'] - if self.date_edit_pos == 0: # Year - alarm['date'] = current_date.replace(year=max(current_date.year + delta, datetime.now().year)) - elif self.date_edit_pos == 1: # Month - new_month = max(1, min(12, current_date.month + delta)) - max_day = (datetime(current_date.year, new_month, 1) + timedelta(days=31)).replace(day=1) - timedelta(days=1) - alarm['date'] = current_date.replace(month=new_month, day=min(current_date.day, max_day.day)) - elif self.date_edit_pos == 2: # Day - max_day = (datetime(current_date.year, current_date.month, 1) + timedelta(days=31)).replace(day=1) - timedelta(days=1) - alarm['date'] = current_date.replace(day=max(1, min(max_day.day, current_date.day + delta))) - except ValueError as e: - self._show_error(str(e)) - - # Weekday Selection - elif self.selected_item == 3: # When weekdays are selected - if key in [ord('h'), curses.KEY_LEFT]: - # Move selection left - alarm['current_weekday'] = (alarm['current_weekday'] - 1) % 7 - elif key in [ord('l'), curses.KEY_RIGHT]: - # Move selection right - alarm['current_weekday'] = (alarm['current_weekday'] + 1) % 7 - elif key == 32: # SPACE to toggle selection - current_day = alarm['current_weekday'] - if current_day in alarm['weekdays']: - alarm['weekdays'].remove(current_day) - else: - alarm['weekdays'].append(current_day) - alarm['weekdays'].sort() - - # Clear date if weekdays are selected - if alarm['weekdays']: - alarm['date'] = None - - # Name Editing - elif self.selected_item == 4: - if key == 32: # SPACE to start editing - if not alarm['editing_name']: - alarm['editing_name'] = True - alarm['temp_name'] = alarm['name'] - alarm['name'] = '' - elif alarm['editing_name']: - if key == curses.KEY_BACKSPACE or key == 127: - alarm['name'] = alarm['name'][:-1] - elif 32 <= key <= 126: # Printable ASCII - alarm['name'] += chr(key) - - # Enabled/Disabled Toggle - elif self.selected_item == 5 and key == 32: - alarm['enabled'] = not alarm['enabled'] - - def _handle_list_alarms_input(self, key): - """Handle input for alarm list view""" - total_items = len(self.alarm_list) + 1 # +1 for "Add new alarm" option - - if key == 27: # ESC - self.current_view = 'CLOCK' - elif key in [ord('j'), curses.KEY_DOWN]: - self.selected_item = (self.selected_item + 1) % total_items - elif key in [ord('k'), curses.KEY_UP]: - self.selected_item = (self.selected_item - 1) % total_items - elif key == ord('d'): - # Only delete if a real alarm is selected (not the "Add new" option) - if self.selected_item < len(self.alarm_list) and self.alarm_list: - try: - alarm_to_delete = self.alarm_list[self.selected_item] - self.storage.remove_saved_alert(alarm_to_delete['id']) - self.alarm_list = self.storage.get_saved_alerts() - # Adjust selected item if needed - if self.selected_item >= len(self.alarm_list): - self.selected_item = len(self.alarm_list) - except Exception as e: - self._show_error(f"Failed to delete alarm: {e}") - elif key in [ord('a'), 10]: # 'a' or Enter - if self.selected_item == len(self.alarm_list): - # "Add new alarm" option selected - self.current_view = 'ADD_ALARM' - else: - # TODO: Implement alarm editing - self._show_error("Alarm editing not implemented yet") diff --git a/clock/ui/list_alarms.py b/clock/ui/list_alarms.py index 9157fd3..9f1a561 100644 --- a/clock/ui/list_alarms.py +++ b/clock/ui/list_alarms.py @@ -1,74 +1,128 @@ import curses from datetime import datetime -from .utils import init_colors, draw_big_digit -def draw_list_alarms(stdscr, context): - """Draw the list of alarms screen""" - init_colors() - height, width = stdscr.getmaxyx() +class ListAlarmsView: + def __init__(self, storage): + """Initialize the list alarms view""" + self.storage = storage + self.weekday_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + self.selected_index = 0 + self.alarms = [] - # Get required data from context - alarms = context.get('alarms', []) - selected_index = context.get('selected_index', 0) - weekday_names = context.get('weekday_names', ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']) + def reset_state(self): + """Reset the view state""" + self.selected_index = 0 + self.alarms = self.storage.get_saved_alerts() - # Calculate visible range for scrolling - max_visible_items = height - 8 # Leave space for header and footer - total_items = len(alarms) + 1 # +1 for "Add new alarm" option + def update_alarms(self, alarms): + """Update the list of alarms to display.""" + self.alarms = alarms + self.selected_index = 0 - # Calculate scroll position - start_idx = max(0, min(selected_index - max_visible_items // 2, - total_items - max_visible_items)) - if start_idx < 0: - start_idx = 0 - end_idx = min(start_idx + max_visible_items, total_items) + def get_selected_alarm(self): + """Get the currently selected alarm.""" + if 0 <= self.selected_index < len(self.alarms): + return self.alarms[self.selected_index] + return None - # Header - header_text = "Alarms" - stdscr.attron(curses.color_pair(1) | curses.A_BOLD) - stdscr.addstr(2, width // 2 - len(header_text) // 2, header_text) - stdscr.attroff(curses.color_pair(1) | curses.A_BOLD) + def draw(self, stdscr): + """Draw the list of alarms screen""" + height, width = stdscr.getmaxyx() - # Draw alarms - for i in range(start_idx, end_idx): - y_pos = 4 + (i - start_idx) + self._draw_header(stdscr, width) + visible_range = self._calculate_visible_range(height) + self._draw_alarm_list(stdscr, height, width, visible_range) + self._draw_instructions(stdscr, height, width) - if i == len(alarms): # "Add new alarm" option - display_str = "Add new alarm..." - else: - alarm = alarms[i] - # Format time - time_str = alarm.get('time', 'Unknown') + def handle_input(self, key): + """Handle user input and return the next view name or None to stay""" + total_items = len(self.alarms) + 1 # +1 for "Add new alarm" option - # Format repeat info - repeat_info = "" - repeat_rule = alarm.get('repeat_rule', {}) - if repeat_rule: - if repeat_rule.get('type') == 'weekly': - days = repeat_rule.get('days', []) - repeat_info = f" (Every {', '.join(weekday_names[d] for d in days)})" - elif repeat_rule.get('type') == 'once' and repeat_rule.get('date'): - repeat_info = f" (On {repeat_rule['date']})" + if key == 27: # ESC + return 'CLOCK' + elif key in [ord('j'), curses.KEY_DOWN]: + self.selected_index = (self.selected_index + 1) % total_items + elif key in [ord('k'), curses.KEY_UP]: + self.selected_index = (self.selected_index - 1) % total_items + elif key == ord('d'): + return self._handle_delete() + elif key in [ord('a'), 10]: # 'a' or Enter + return self._handle_add_edit() - # Status indicator (in green) - status = "✓" if alarm.get('enabled', True) else "✗" - display_str = f"{status} {time_str} {alarm.get('name', 'Unnamed')}{repeat_info}" + return None - # Truncate if too long (leaving space for selection brackets) - max_length = width - 6 - if len(display_str) > max_length: - display_str = display_str[:max_length-3] + "..." + def _calculate_visible_range(self, height): + """Calculate the visible range for scrolling""" + max_visible_items = height - 8 # Space for header and footer + total_items = len(self.alarms) + 1 # +1 for "Add new alarm" option - # Center the item - x_pos = width // 2 - len(display_str) // 2 + # Calculate scroll position + start_idx = max(0, min(self.selected_index - max_visible_items // 2, + total_items - max_visible_items)) + if start_idx < 0: + start_idx = 0 + end_idx = min(start_idx + max_visible_items, total_items) - # Highlight selected item - if i == selected_index: + return (start_idx, end_idx) + + def _draw_header(self, stdscr, width): + """Draw the header text""" + header_text = "Alarms" + stdscr.attron(curses.color_pair(1) | curses.A_BOLD) + stdscr.addstr(2, self._center_x(width, header_text), header_text) + stdscr.attroff(curses.color_pair(1) | curses.A_BOLD) + + def _draw_alarm_list(self, stdscr, height, width, visible_range): + """Draw the list of alarms""" + start_idx, end_idx = visible_range + + for i in range(start_idx, end_idx): + y_pos = 4 + (i - start_idx) + display_str = self._format_alarm_display(i) + + # Truncate if too long + max_length = width - 6 + if len(display_str) > max_length: + display_str = display_str[:max_length-3] + "..." + + x_pos = self._center_x(width, display_str) + self._draw_alarm_item(stdscr, y_pos, x_pos, display_str, i == self.selected_index) + + def _format_alarm_display(self, index): + """Format the display string for an alarm""" + if index == len(self.alarms): + return "Add new alarm..." + + alarm = self.alarms[index] + time_str = alarm.get('time', 'Unknown') + repeat_info = self._format_repeat_info(alarm) + status = "✓" if alarm.get('enabled', True) else "✗" + + return f"{status} {time_str} {alarm.get('name', 'Unnamed')}{repeat_info}" + + def _format_repeat_info(self, alarm): + """Format the repeat information for an alarm""" + repeat_rule = alarm.get('repeat_rule', {}) + if not repeat_rule: + return "" + + if repeat_rule.get('type') == 'weekly': + days = repeat_rule.get('days', []) + return f" (Every {', '.join(self.weekday_names[d] for d in days)})" + elif repeat_rule.get('type') == 'once' and repeat_rule.get('date'): + return f" (On {repeat_rule['date']})" + + return "" + + def _draw_alarm_item(self, stdscr, y_pos, x_pos, display_str, is_selected): + """Draw a single alarm item""" + if is_selected: # Draw selection brackets in green stdscr.attron(curses.color_pair(1)) stdscr.addstr(y_pos, x_pos - 2, "[ ") stdscr.addstr(y_pos, x_pos + len(display_str), " ]") stdscr.attroff(curses.color_pair(1)) + # Draw text in yellow (highlighted) stdscr.attron(curses.color_pair(2)) stdscr.addstr(y_pos, x_pos, display_str) @@ -79,8 +133,38 @@ def draw_list_alarms(stdscr, context): stdscr.addstr(y_pos, x_pos, display_str) stdscr.attroff(curses.color_pair(1)) - # Instructions - instructions = "j/k: Move d: Delete a: Add/Edit Esc: Back" - stdscr.attron(curses.color_pair(1)) - stdscr.addstr(height - 2, width // 2 - len(instructions) // 2, instructions) - stdscr.attroff(curses.color_pair(1)) + def _draw_instructions(self, stdscr, height, width): + """Draw the instructions at the bottom of the screen""" + instructions = "j/k: Move d: Delete a: Add/Edit Esc: Back" + stdscr.attron(curses.color_pair(1)) + stdscr.addstr(height - 2, self._center_x(width, instructions), instructions) + stdscr.attroff(curses.color_pair(1)) + + def _center_x(self, width, text): + """Calculate x coordinate to center text""" + return width // 2 - len(text) // 2 + + def _handle_delete(self): + """Handle alarm deletion""" + if self.selected_index < len(self.alarms): + try: + alarm_to_delete = self.alarms[self.selected_index] + self.storage.remove_saved_alert(alarm_to_delete['id']) + self.alarms = self.storage.get_saved_alerts() + # Adjust selected item if needed + if self.selected_index >= len(self.alarms): + self.selected_index = len(self.alarms) + except Exception as e: + # You might want to add error handling here + pass + return None + + def _handle_add_edit(self): + """Handle add/edit action""" + if self.selected_index == len(self.alarms): + # "Add new alarm" option selected + return 'ADD_ALARM' + else: + # Edit existing alarm + selected_alarm = self.alarms[self.selected_index] + return ('ADD_ALARM', selected_alarm) # Pass alarm to AddAlarmView diff --git a/clock/ui/main_clock.py b/clock/ui/main_clock.py index 3fbbf0e..40380ea 100644 --- a/clock/ui/main_clock.py +++ b/clock/ui/main_clock.py @@ -2,40 +2,70 @@ import curses from datetime import datetime from .utils import init_colors, draw_big_digit from .big_digits import BIG_DIGITS -from .add_alarm import draw_add_alarm -from .active_alarm import draw_active_alarms -from .list_alarms import draw_list_alarms +class MainClockView: + def __init__(self): + """Initialize the main clock view""" + self.digit_width = 14 # Width of each digit pattern in the big clock display + + def draw(self, stdscr): + """Draw the main clock screen""" + init_colors() + height, width = stdscr.getmaxyx() + current_time = datetime.now() + + self._draw_big_time(stdscr, current_time, height, width) + self._draw_date(stdscr, current_time, height, width) + self._draw_menu(stdscr, height, width) -def draw_main_clock(stdscr, context=None): - """Draw the main clock screen""" - init_colors() - height, width = stdscr.getmaxyx() - current_time = datetime.now() + def handle_input(self, key): + """Handle user input and return the next view name or None to stay""" + if key == ord('a'): + return 'ADD_ALARM' + elif key == ord('s'): + return 'LIST_ALARMS' + elif key == ord('q'): + return 'QUIT' + return None - # Big time display - time_str = current_time.strftime("%H:%M:%S") - digit_width = 14 # Width of each digit pattern - total_width = digit_width * len(time_str) - start_x = (width - total_width) // 2 - start_y = (height - 7) // 2 - 4 + def _draw_big_time(self, stdscr, current_time, height, width): + """Draw the big time display""" + time_str = current_time.strftime("%H:%M:%S") + total_width = self.digit_width * len(time_str) + start_x = self._center_x(width, total_width) + start_y = self._center_y(height, 7) - 4 # 7 is the height of digits - # Green color for big time - stdscr.attron(curses.color_pair(1)) - for i, digit in enumerate(time_str): - draw_big_digit(stdscr, start_y, start_x + i * digit_width, digit) - stdscr.attroff(curses.color_pair(1)) + # Draw each digit in green + stdscr.attron(curses.color_pair(1)) + for i, digit in enumerate(time_str): + self._draw_digit(stdscr, start_y, start_x + i * self.digit_width, digit) + stdscr.attroff(curses.color_pair(1)) - # Date display - date_str = current_time.strftime("%Y-%m-%d") - date_x = width // 2 - len(date_str) // 2 - date_y = height // 2 + 4 + def _draw_date(self, stdscr, current_time, height, width): + """Draw the current date""" + date_str = current_time.strftime("%Y-%m-%d") + date_x = self._center_x(width, len(date_str)) + date_y = height // 2 + 4 - stdscr.attron(curses.color_pair(2)) - stdscr.addstr(date_y, date_x, date_str) - stdscr.attroff(curses.color_pair(2)) + stdscr.attron(curses.color_pair(2)) + stdscr.addstr(date_y, date_x, date_str) + stdscr.attroff(curses.color_pair(2)) - # Menu options - menu_str = "A: Add Alarm S: List Alarms Q: Quit" - menu_x = width // 2 - len(menu_str) // 2 - stdscr.addstr(height - 2, menu_x, menu_str) + def _draw_menu(self, stdscr, height, width): + """Draw the menu options""" + menu_str = "A: Add Alarm S: List Alarms Q: Quit" + menu_x = self._center_x(width, len(menu_str)) + stdscr.addstr(height - 2, menu_x, menu_str) + + def _draw_digit(self, stdscr, y, x, digit): + """Draw a single big digit""" + # Delegate to the existing draw_big_digit utility function + draw_big_digit(stdscr, y, x, digit) + + def _center_x(self, width, text_width): + """Calculate x coordinate to center text horizontally""" + return (width - text_width) // 2 + + def _center_y(self, height, text_height): + """Calculate y coordinate to center text vertically""" + return (height - text_height) // 2 diff --git a/clock/ui/ncurses_ui.py b/clock/ui/ncurses_ui.py index 1e5dc6f..4cc80bc 100644 --- a/clock/ui/ncurses_ui.py +++ b/clock/ui/ncurses_ui.py @@ -1,75 +1,57 @@ import curses import time -from datetime import datetime, date, timedelta import threading import logging import queue +from datetime import datetime -from .utils import draw_error, init_colors -from .active_alarm import draw_active_alarms -from .add_alarm import draw_add_alarm -from .list_alarms import draw_list_alarms -from .main_clock import draw_main_clock -from .input_handlers import InputHandling +from .utils import init_colors, draw_error, ColorScheme +from .active_alarm import ActiveAlarmView +from .add_alarm import AddAlarmView +from .list_alarms import ListAlarmsView +from .main_clock import MainClockView -class UI(InputHandling): +class UI: def __init__(self, alarm_system_manager, control_queue): - # UI State Management + """Initialize the UI system""" + # System components self.alarm_system = alarm_system_manager self.stop_event = alarm_system_manager.stop_event self.storage = alarm_system_manager.storage - - # Control queue for interacting with AlarmSiren self.control_queue = control_queue # Logging self.logger = logging.getLogger(__name__) - # Active alarm tracking - self.active_alarms = {} + # Initialize views + self._init_views() # UI State - self.reset_ui_state() - - def reset_ui_state(self): - """Reset all UI state variables to their initial values""" - # Menu states self.current_view = 'CLOCK' - self.selected_item = 0 - - # Alarm Creation State - self.alarm_draft = { - 'name': 'New Alarm', - 'hour': datetime.now().hour, - 'minute': datetime.now().minute, - 'enabled': True, - 'date': None, - 'weekdays': [], - 'editing_name': False, - 'temp_name': '' - } - - # Error handling self.error_message = None self.error_timestamp = None - # Alarm list - self.alarm_list = [] - - # Weekday names (to match specification) - self.weekday_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] - - # Clear active alarms - self.active_alarms.clear() + def _init_views(self): + """Initialize all view classes""" + self.views = { + 'CLOCK': MainClockView(), + 'ADD_ALARM': AddAlarmView(self.storage, self.control_queue), + 'LIST_ALARMS': ListAlarmsView(self.storage), + 'ACTIVE_ALARMS': ActiveAlarmView(self.storage, self.control_queue) + } def run(self): """Start the ncurses UI in a separate thread""" def ui_thread(): try: - # Start a thread to monitor control queue - monitor_thread = threading.Thread(target=self._monitor_control_queue, daemon=True) + # Start control queue monitor + monitor_thread = threading.Thread( + target=self._monitor_control_queue, + daemon=True + ) monitor_thread.start() + # Start UI curses.wrapper(self._main_loop) except Exception as e: self.logger.error(f"UI Thread Error: {e}") @@ -88,15 +70,16 @@ class UI(InputHandling): try: control_msg = self.control_queue.get(timeout=1) - # Handle different types of control messages if control_msg['type'] == 'trigger': - # Store triggered alarm + # Update active alarms view + active_view = self.views['ACTIVE_ALARMS'] alarm_id = control_msg['alarm_id'] - self.active_alarms[alarm_id] = control_msg['info'] + active_view.update_active_alarms({ + alarm_id: control_msg['info'] + }) - # If not already in alarm view, switch to it - if self.current_view != 'ACTIVE_ALARMS': - self.current_view = 'ACTIVE_ALARMS' + # Switch to active alarms view + self.current_view = 'ACTIVE_ALARMS' except queue.Empty: pass @@ -106,81 +89,91 @@ class UI(InputHandling): self.logger.error(f"Error monitoring control queue: {e}") time.sleep(1) - def _show_error(self, message, duration=30): + def _show_error(self, message, duration=3): """Display an error message""" self.error_message = message self.error_timestamp = time.time() - def _main_loop(self, stdscr): - """Main ncurses event loop""" - curses.curs_set(0) - stdscr.keypad(1) - stdscr.timeout(100) - - time.sleep(0.2) - stdscr.clear() - - while not self.stop_event.is_set(): - stdscr.erase() - - # Draw view based on current state - if self.current_view == 'CLOCK': - draw_main_clock(stdscr) - elif self.current_view == 'ADD_ALARM': - draw_add_alarm(stdscr, { - 'new_alarm_selected': self.selected_item, - 'new_alarm_name': self.alarm_draft['name'], - 'new_alarm_hour': self.alarm_draft['hour'], - 'new_alarm_minute': self.alarm_draft['minute'], - 'new_alarm_enabled': self.alarm_draft['enabled'], - 'new_alarm_date': self.alarm_draft['date'], - 'new_alarm_weekdays': self.alarm_draft['weekdays'], - 'weekday_names': self.weekday_names, - 'date_edit_pos': getattr(self, 'date_edit_pos', 2) - }) - elif self.current_view == 'LIST_ALARMS': - draw_list_alarms(stdscr, { - 'alarms': self.alarm_list or [], - 'weekday_names': self.weekday_names, - 'selected_index': self.selected_item - }) - elif self.current_view == 'ACTIVE_ALARMS': - # Draw active alarm view - draw_active_alarms(stdscr, {'active_alarms': self.active_alarms }) - - # Render error if exists - if self.error_message: - draw_error(stdscr, self.error_message) - self._clear_error_if_expired() - - stdscr.refresh() - - # Handle input - key = stdscr.getch() - if key != -1: - if key == ord('q'): - # Context-sensitive 'q' key handling - if self.current_view == 'CLOCK': - break # Exit the application only from clock view - else: - self.current_view = 'CLOCK' # Return to clock view from other views - continue - - # Context-specific input handling - if self.current_view == 'CLOCK': - self._handle_clock_input(key) - elif self.current_view == 'ADD_ALARM': - self._handle_add_alarm_input(key) - elif self.current_view == 'LIST_ALARMS': - self._handle_list_alarms_input(key) - elif self.current_view == 'ACTIVE_ALARMS': - self._handle_active_alarms_input(key) - - time.sleep(0.2) - def _clear_error_if_expired(self): """Clear error message if expired""" if self.error_message and self.error_timestamp: if time.time() - self.error_timestamp > 3: self.error_message = None self.error_timestamp = None + + def _main_loop(self, stdscr): + """Main ncurses event loop""" + # Setup curses + curses.curs_set(0) # Hide cursor + stdscr.keypad(1) # Enable keypad + stdscr.timeout(100) # Non-blocking input + init_colors() # Initialize color pairs + + while not self.stop_event.is_set(): + # Clear screen + stdscr.erase() + + # Get current view + current_view = self.views.get(self.current_view) + if not current_view: + self.logger.error(f"Invalid view: {self.current_view}") + break + + try: + # Draw current view + current_view.draw(stdscr) + + # Handle any error messages + if self.error_message: + draw_error(stdscr, self.error_message) + self._clear_error_if_expired() + + # Refresh screen + stdscr.refresh() + + # Handle input + key = stdscr.getch() + if key != -1: + # Handle quit key globally + if key == ord('q'): + if self.current_view == 'CLOCK': + break # Exit application from clock view + else: + self.current_view = 'CLOCK' # Return to clock from other views + continue + + # Let current view handle input + result = current_view.handle_input(key) + + # Handle tuple result (view, data) or just a view change + if isinstance(result, tuple): + next_view, data = result + else: + next_view, data = result, None + + # Handle quitting + if next_view == 'QUIT': + break + + elif next_view: + # Update list alarms view data when switching to it + if next_view == 'LIST_ALARMS': + self.views['LIST_ALARMS'].update_alarms( + self.storage.get_saved_alerts() + ) + + # Handle editing an alarm by passing data to AddAlarmView + elif next_view == 'ADD_ALARM' and data: + self.views['ADD_ALARM'].set_alarm_data(data) + + self.current_view = next_view + + except Exception as e: + self.logger.error(f"Error in main loop: {e}") + self._show_error(str(e)) + + time.sleep(0.1) # Prevent CPU hogging + + def stop(self): + """Stop the UI system""" + self.stop_event.set() diff --git a/clock/ui/utils.py b/clock/ui/utils.py index 252aeea..620dd85 100644 --- a/clock/ui/utils.py +++ b/clock/ui/utils.py @@ -1,34 +1,138 @@ import curses +from dataclasses import dataclass +from typing import Optional from .big_digits import BIG_DIGITS +@dataclass +class ColorScheme: + """Color scheme configuration for the UI""" + PRIMARY = 1 # Green on black + HIGHLIGHT = 2 # Yellow on black + ERROR = 3 # Red on black + +class ViewUtils: + """Common utility functions for view classes""" + @staticmethod + def center_x(width: int, text_width: int) -> int: + """Calculate x coordinate to center text horizontally""" + return max(0, (width - text_width) // 2) + + @staticmethod + def center_y(height: int, text_height: int) -> int: + """Calculate y coordinate to center text vertically""" + return max(0, (height - text_height) // 2) + + @staticmethod + def draw_centered_text(stdscr, y: int, text: str, color_pair: Optional[int] = None, attrs: int = 0): + """Draw text centered horizontally on the screen with optional color and attributes""" + height, width = stdscr.getmaxyx() + x = ViewUtils.center_x(width, len(text)) + + if color_pair is not None: + stdscr.attron(curses.color_pair(color_pair) | attrs) + + try: + stdscr.addstr(y, x, text) + except curses.error: + pass # Ignore errors from writing at invalid positions + + if color_pair is not None: + stdscr.attroff(curses.color_pair(color_pair) | attrs) def init_colors(): - """Initialize color pairs matching specification""" - curses.start_color() - curses.use_default_colors() - # Green text on black background (primary color) - curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK) - # Highlight color (yellow) - curses.init_pair(2, curses.COLOR_YELLOW, curses.COLOR_BLACK) - # Error color (red) - curses.init_pair(3, curses.COLOR_RED, curses.COLOR_BLACK) + """Initialize color pairs for the application""" + try: + curses.start_color() + curses.use_default_colors() + + # Primary color (green text on black background) + curses.init_pair(ColorScheme.PRIMARY, curses.COLOR_GREEN, curses.COLOR_BLACK) + + # Highlight color (yellow text on black background) + curses.init_pair(ColorScheme.HIGHLIGHT, curses.COLOR_YELLOW, curses.COLOR_BLACK) + + # Error color (red text on black background) + curses.init_pair(ColorScheme.ERROR, curses.COLOR_RED, curses.COLOR_BLACK) + except Exception as e: + # Log error or handle gracefully if color initialization fails + pass -def draw_error(stdscr, error_message): - """Draw error message following specification""" +def draw_error(stdscr, error_message: str, duration_sec: int = 3): + """ + Draw error message at the bottom of the screen + + Args: + stdscr: Curses window object + error_message: Message to display + duration_sec: How long the error should be displayed (for reference by caller) + """ height, width = stdscr.getmaxyx() - + # Truncate message if too long - error_message = error_message[:width-4] + max_width = width - 4 + if len(error_message) > max_width: + error_message = error_message[:max_width-3] + "..." - error_x = max(0, width // 2 - len(error_message) // 2) - error_y = height - 4 # Show near bottom of screen + # Position near bottom of screen + error_y = height - 4 + + ViewUtils.draw_centered_text( + stdscr, + error_y, + error_message, + ColorScheme.ERROR, + curses.A_BOLD + ) - stdscr.attron(curses.color_pair(3) | curses.A_BOLD) - stdscr.addstr(error_y, error_x, error_message) - stdscr.attroff(curses.color_pair(3) | curses.A_BOLD) +def draw_big_digit(stdscr, y: int, x: int, digit: str): + """ + Draw a large digit using the predefined patterns + + Args: + stdscr: Curses window object + y: Starting y coordinate + x: Starting x coordinate + digit: Character to draw ('0'-'9', ':', etc) + """ + try: + patterns = BIG_DIGITS.get(digit, BIG_DIGITS['?']) + for i, line in enumerate(patterns): + try: + stdscr.addstr(y + i, x, line) + except curses.error: + continue # Skip lines that would write outside the window + except (curses.error, IndexError): + pass # Ignore any drawing errors -def draw_big_digit(stdscr, y, x, digit): - """Draw a big digit using predefined patterns""" - patterns = BIG_DIGITS[digit] - for i, line in enumerate(patterns): - stdscr.addstr(y + i, x, line) +def safe_addstr(stdscr, y: int, x: int, text: str, color_pair: Optional[int] = None, attrs: int = 0): + """ + Safely add a string to the screen, handling boundary conditions + + Args: + stdscr: Curses window object + y: Y coordinate + x: X coordinate + text: Text to draw + color_pair: Optional color pair number + attrs: Additional curses attributes + """ + height, width = stdscr.getmaxyx() + + # Check if the position is within bounds + if y < 0 or y >= height or x < 0 or x >= width: + return + + # Truncate text if it would extend beyond screen width + if x + len(text) > width: + text = text[:width - x] + + try: + if color_pair is not None: + stdscr.attron(curses.color_pair(color_pair) | attrs) + + stdscr.addstr(y, x, text) + + if color_pair is not None: + stdscr.attroff(curses.color_pair(color_pair) | attrs) + except curses.error: + pass # Ignore any drawing errors