Restructure of ui package is now in working state. And we now support editing old alarms.

This commit is contained in:
Kalzu Rekku 2025-02-09 22:31:24 +02:00
parent 7eafbf5ebd
commit f59048c2d0
8 changed files with 796 additions and 543 deletions

View File

@ -2,68 +2,114 @@ import curses
from datetime import datetime from datetime import datetime
from .utils import init_colors, draw_big_digit from .utils import init_colors, draw_big_digit
def draw_active_alarms(stdscr, context): class ActiveAlarmView:
"""Draw the active alarms""" def __init__(self, storage, control_queue):
init_colors() self.storage = storage
height, width = stdscr.getmaxyx() self.control_queue = control_queue
current_time = datetime.now() self.active_alarms = {}
# Draw the main clock (original position) def reset_state(self):
time_str = current_time.strftime("%H:%M:%S") """Reset the view state"""
digit_width = 14 self.active_alarms.clear()
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
# Draw blinking dot def draw(self, stdscr):
if int(current_time.timestamp()) % 2 == 0: """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.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)) stdscr.attroff(curses.color_pair(1))
# Green color for big time # Draw date
stdscr.attron(curses.color_pair(1)) date_str = current_time.strftime("%Y-%m-%d")
for i, digit in enumerate(time_str): date_x = width // 2 - len(date_str) // 2
draw_big_digit(stdscr, start_y, start_x + i * digit_width, digit) date_y = height // 2 + 4
stdscr.attroff(curses.color_pair(1)) 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) def _draw_active_alarm_info(self, stdscr, height, width):
date_str = current_time.strftime("%Y-%m-%d") """Draw information about the active alarm"""
date_x = width // 2 - len(date_str) // 2 if not self.active_alarms:
date_y = height // 2 + 4 return
stdscr.attron(curses.color_pair(2))
stdscr.addstr(date_y, date_x, date_str)
stdscr.attroff(curses.color_pair(2))
# Get active alarm info alarm_id = list(self.active_alarms.keys())[0]
active_alarms = context.get('active_alarms', {}) alarm_info = self.active_alarms[alarm_id]
if not active_alarms: alarm_config = alarm_info['config']
return
# Get the first (or only) active alarm alarm_name = alarm_config.get('name', 'Unnamed Alarm')
alarm_id = list(active_alarms.keys())[0] alarm_time = alarm_config.get('time', 'Unknown Time')
alarm_info = active_alarms[alarm_id] alarm_str = f"[ {alarm_name} - {alarm_time} ]"
alarm_config = alarm_info['config']
# Format alarm info # Position alarm info above the date
alarm_name = alarm_config.get('name', 'Unnamed Alarm') date_y = height // 2 + 4 # Same as in _draw_main_clock
alarm_time = alarm_config.get('time', 'Unknown Time') alarm_y = date_y - 2
#snooze_count = alarm_info.get('snooze_count', 0) alarm_x = max(0, width // 2 - len(alarm_str) // 2)
# Draw alarm info under the clock stdscr.attron(curses.color_pair(1))
info_y = start_y + 8 # Position below the clock stdscr.addstr(alarm_y, alarm_x, alarm_str)
#alarm_str = f"[ {alarm_name} - {alarm_time} - Snoozed: {snooze_count}x ]" stdscr.attroff(curses.color_pair(1))
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
# Center the alarm info def _draw_instructions(self, stdscr, height, width):
info_x = max(0, width // 2 - len(alarm_str) // 2) """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 def _handle_snooze(self, alarm_id):
stdscr.attron(curses.color_pair(1)) """Handle snoozing the alarm"""
stdscr.addstr(alarm_y, alarm_x, alarm_str) self.control_queue.put({
stdscr.attroff(curses.color_pair(1)) 'type': 'snooze',
'alarm_id': alarm_id
})
del self.active_alarms[alarm_id]
# Instructions def _handle_dismiss(self, alarm_id):
stdscr.addstr(height - 2, width // 2 - 15, "S: Snooze D: Dismiss") """Handle dismissing the alarm"""
self.control_queue.put({
'type': 'dismiss',
'alarm_id': alarm_id
})
del self.active_alarms[alarm_id]

View File

@ -1,11 +1,45 @@
import curses import curses
from datetime import datetime from datetime import datetime, date, timedelta
from .utils import init_colors
def draw_add_alarm(stdscr, alarm_draft): class AddAlarmView:
"""Draw the add alarm screen""" def __init__(self, storage, control_queue):
if alarm_draft is None: self.storage = storage
alarm_draft = { 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, 'hour': datetime.now().hour,
'minute': datetime.now().minute, 'minute': datetime.now().minute,
'name': 'New Alarm', 'name': 'New Alarm',
@ -15,76 +49,119 @@ def draw_add_alarm(stdscr, alarm_draft):
'current_weekday': 0, 'current_weekday': 0,
'editing_name': False, 'editing_name': False,
'temp_name': '', 'temp_name': '',
'selected_item': 0 # Added to handle selection 'selected_item': 0
} }
self.date_edit_pos = 2 # Default to editing the day
init_colors() def draw(self, stdscr):
height, width = stdscr.getmaxyx() """Draw the add alarm screen"""
height, width = stdscr.getmaxyx()
form_y = height // 2 - 8
# Center the form vertically with good spacing self._draw_title(stdscr, form_y, width)
form_y = height // 2 - 8 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 def handle_input(self, key):
title = "Add New Alarm" """Handle user input and return the next view name or None to stay"""
stdscr.attron(curses.color_pair(1) | curses.A_BOLD) if key == 27: # ESC
stdscr.addstr(form_y, width // 2 - len(title) // 2, title) return self._handle_escape()
stdscr.attroff(curses.color_pair(1) | curses.A_BOLD) 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}: " 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.attron(curses.color_pair(1))
stdscr.addstr(y, x, label_str) stdscr.addstr(y, x, label_str)
stdscr.attroff(curses.color_pair(1)) stdscr.attroff(curses.color_pair(1))
# Draw value (highlighted if selected)
if is_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)) stdscr.addstr(y, x + len(label_str), str(value))
if is_selected: if is_selected:
stdscr.attroff(curses.color_pair(2)) stdscr.attroff(curses.color_pair(2))
# Get selected_item either from alarm_draft or passed separately def _draw_time_field(self, stdscr, y, width):
selected_item = alarm_draft.get('selected_item', 0) """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 def _draw_date_field(self, stdscr, y, width):
draw_field(form_y + 2, "Time", f"{alarm_draft['hour']:02d}:{alarm_draft['minute']:02d}", """Draw the date field"""
selected_item == 0 or selected_item == 1) 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 def _draw_weekdays(self, stdscr, y, width):
date_y = form_y + 4 """Draw the weekday selection field"""
if alarm_draft['weekdays']: label_x = width // 2 - 20
draw_field(date_y, "Date", "Repeating weekly", selected_item == 2) stdscr.attron(curses.color_pair(1))
else: stdscr.addstr(y, label_x, "Repeat: ")
draw_field(date_y, "Date", alarm_draft['date'].strftime("%Y-%m-%d") if alarm_draft['date'] else "None", stdscr.attroff(curses.color_pair(1))
selected_item == 2)
# Weekday Selection weekday_x = label_x + len("Repeat: ")
weekday_y = form_y + 6 for i, day in enumerate(self.weekday_names):
weekday_label = "Repeat: " self._draw_weekday(stdscr, y, weekday_x + i * 4, day, i)
label_x = width // 2 - 20 # Adjust this value to move the entire "Repeat" section left or right
# Draw the "Repeat: " label once def _draw_weekday(self, stdscr, y, x, day, index):
stdscr.attron(curses.color_pair(1)) """Draw a single weekday"""
stdscr.addstr(weekday_y, label_x, weekday_label) is_selected = (self.alarm_draft['selected_item'] == 3 and
stdscr.attroff(curses.color_pair(1)) index == self.alarm_draft['current_weekday'])
is_active = index in self.alarm_draft['weekdays']
# 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', [])
if is_selected: if is_selected:
stdscr.attron(curses.color_pair(2)) # Highlight current selection stdscr.attron(curses.color_pair(2))
elif is_active: elif is_active:
stdscr.attron(curses.color_pair(1) | curses.A_BOLD) # Selected weekday stdscr.attron(curses.color_pair(1) | curses.A_BOLD)
else: else:
stdscr.attron(curses.color_pair(1)) stdscr.attron(curses.color_pair(1))
stdscr.addstr(weekday_y, x_pos, day) stdscr.addstr(y, x, day)
if is_selected: if is_selected:
stdscr.attroff(curses.color_pair(2)) stdscr.attroff(curses.color_pair(2))
@ -93,16 +170,129 @@ def draw_add_alarm(stdscr, alarm_draft):
else: else:
stdscr.attroff(curses.color_pair(1)) stdscr.attroff(curses.color_pair(1))
# Name field def _draw_name_field(self, stdscr, y, width):
draw_field(form_y + 8, "Name", alarm_draft['name'], selected_item == 4) """Draw the name field"""
self._draw_field(stdscr, y, "Name", self.alarm_draft['name'],
self.alarm_draft['selected_item'] == 4)
# Enabled Toggle def _draw_status_field(self, stdscr, y, width):
status_y = form_y + 10 """Draw the enabled/disabled status field"""
enabled_str = "● Enabled" if alarm_draft['enabled'] else "○ Disabled" enabled_str = "● Enabled" if self.alarm_draft['enabled'] else "○ Disabled"
draw_field(status_y, "Status", enabled_str, selected_item == 5) self._draw_field(stdscr, y, "Status", enabled_str,
self.alarm_draft['selected_item'] == 5)
# Instructions def _draw_instructions(self, stdscr, y, width):
instructions = "j/k: Change h/l: Move Space: Toggle Enter: Save Esc: Cancel" """Draw the instructions at the bottom of the screen"""
stdscr.attron(curses.color_pair(1)) instructions = "j/k: Change h/l: Move Space: Toggle Enter: Save Esc: Cancel"
stdscr.addstr(height - 2, width // 2 - len(instructions) // 2, instructions) stdscr.attron(curses.color_pair(1))
stdscr.attroff(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

View File

@ -98,5 +98,13 @@ BIG_DIGITS = {
" ████ ", " ████ ",
" ████ ", " ████ ",
" " " "
],
'?': [
" ??? ",
" ????? ",
"?? ??",
" ??? ",
" ?? ?? ",
" ? "
] ]
} }

View File

@ -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")

View File

@ -1,74 +1,128 @@
import curses import curses
from datetime import datetime from datetime import datetime
from .utils import init_colors, draw_big_digit
def draw_list_alarms(stdscr, context): class ListAlarmsView:
"""Draw the list of alarms screen""" def __init__(self, storage):
init_colors() """Initialize the list alarms view"""
height, width = stdscr.getmaxyx() self.storage = storage
self.weekday_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
self.selected_index = 0
self.alarms = []
# Get required data from context def reset_state(self):
alarms = context.get('alarms', []) """Reset the view state"""
selected_index = context.get('selected_index', 0) self.selected_index = 0
weekday_names = context.get('weekday_names', ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']) self.alarms = self.storage.get_saved_alerts()
# Calculate visible range for scrolling def update_alarms(self, alarms):
max_visible_items = height - 8 # Leave space for header and footer """Update the list of alarms to display."""
total_items = len(alarms) + 1 # +1 for "Add new alarm" option self.alarms = alarms
self.selected_index = 0
# Calculate scroll position def get_selected_alarm(self):
start_idx = max(0, min(selected_index - max_visible_items // 2, """Get the currently selected alarm."""
total_items - max_visible_items)) if 0 <= self.selected_index < len(self.alarms):
if start_idx < 0: return self.alarms[self.selected_index]
start_idx = 0 return None
end_idx = min(start_idx + max_visible_items, total_items)
# Header def draw(self, stdscr):
header_text = "Alarms" """Draw the list of alarms screen"""
stdscr.attron(curses.color_pair(1) | curses.A_BOLD) height, width = stdscr.getmaxyx()
stdscr.addstr(2, width // 2 - len(header_text) // 2, header_text)
stdscr.attroff(curses.color_pair(1) | curses.A_BOLD)
# Draw alarms self._draw_header(stdscr, width)
for i in range(start_idx, end_idx): visible_range = self._calculate_visible_range(height)
y_pos = 4 + (i - start_idx) self._draw_alarm_list(stdscr, height, width, visible_range)
self._draw_instructions(stdscr, height, width)
if i == len(alarms): # "Add new alarm" option def handle_input(self, key):
display_str = "Add new alarm..." """Handle user input and return the next view name or None to stay"""
else: total_items = len(self.alarms) + 1 # +1 for "Add new alarm" option
alarm = alarms[i]
# Format time
time_str = alarm.get('time', 'Unknown')
# Format repeat info if key == 27: # ESC
repeat_info = "" return 'CLOCK'
repeat_rule = alarm.get('repeat_rule', {}) elif key in [ord('j'), curses.KEY_DOWN]:
if repeat_rule: self.selected_index = (self.selected_index + 1) % total_items
if repeat_rule.get('type') == 'weekly': elif key in [ord('k'), curses.KEY_UP]:
days = repeat_rule.get('days', []) self.selected_index = (self.selected_index - 1) % total_items
repeat_info = f" (Every {', '.join(weekday_names[d] for d in days)})" elif key == ord('d'):
elif repeat_rule.get('type') == 'once' and repeat_rule.get('date'): return self._handle_delete()
repeat_info = f" (On {repeat_rule['date']})" elif key in [ord('a'), 10]: # 'a' or Enter
return self._handle_add_edit()
# Status indicator (in green) return None
status = "" if alarm.get('enabled', True) else ""
display_str = f"{status} {time_str} {alarm.get('name', 'Unnamed')}{repeat_info}"
# Truncate if too long (leaving space for selection brackets) def _calculate_visible_range(self, height):
max_length = width - 6 """Calculate the visible range for scrolling"""
if len(display_str) > max_length: max_visible_items = height - 8 # Space for header and footer
display_str = display_str[:max_length-3] + "..." total_items = len(self.alarms) + 1 # +1 for "Add new alarm" option
# Center the item # Calculate scroll position
x_pos = width // 2 - len(display_str) // 2 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 return (start_idx, end_idx)
if i == selected_index:
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 # Draw selection brackets in green
stdscr.attron(curses.color_pair(1)) stdscr.attron(curses.color_pair(1))
stdscr.addstr(y_pos, x_pos - 2, "[ ") stdscr.addstr(y_pos, x_pos - 2, "[ ")
stdscr.addstr(y_pos, x_pos + len(display_str), " ]") stdscr.addstr(y_pos, x_pos + len(display_str), " ]")
stdscr.attroff(curses.color_pair(1)) stdscr.attroff(curses.color_pair(1))
# Draw text in yellow (highlighted) # Draw text in yellow (highlighted)
stdscr.attron(curses.color_pair(2)) stdscr.attron(curses.color_pair(2))
stdscr.addstr(y_pos, x_pos, display_str) 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.addstr(y_pos, x_pos, display_str)
stdscr.attroff(curses.color_pair(1)) stdscr.attroff(curses.color_pair(1))
# Instructions def _draw_instructions(self, stdscr, height, width):
instructions = "j/k: Move d: Delete a: Add/Edit Esc: Back" """Draw the instructions at the bottom of the screen"""
stdscr.attron(curses.color_pair(1)) instructions = "j/k: Move d: Delete a: Add/Edit Esc: Back"
stdscr.addstr(height - 2, width // 2 - len(instructions) // 2, instructions) stdscr.attron(curses.color_pair(1))
stdscr.attroff(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

View File

@ -2,40 +2,70 @@ import curses
from datetime import datetime from datetime import datetime
from .utils import init_colors, draw_big_digit from .utils import init_colors, draw_big_digit
from .big_digits import BIG_DIGITS 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): def handle_input(self, key):
"""Draw the main clock screen""" """Handle user input and return the next view name or None to stay"""
init_colors() if key == ord('a'):
height, width = stdscr.getmaxyx() return 'ADD_ALARM'
current_time = datetime.now() elif key == ord('s'):
return 'LIST_ALARMS'
elif key == ord('q'):
return 'QUIT'
return None
# Big time display def _draw_big_time(self, stdscr, current_time, height, width):
time_str = current_time.strftime("%H:%M:%S") """Draw the big time display"""
digit_width = 14 # Width of each digit pattern time_str = current_time.strftime("%H:%M:%S")
total_width = digit_width * len(time_str) total_width = self.digit_width * len(time_str)
start_x = (width - total_width) // 2 start_x = self._center_x(width, total_width)
start_y = (height - 7) // 2 - 4 start_y = self._center_y(height, 7) - 4 # 7 is the height of digits
# Green color for big time # Draw each digit in green
stdscr.attron(curses.color_pair(1)) stdscr.attron(curses.color_pair(1))
for i, digit in enumerate(time_str): for i, digit in enumerate(time_str):
draw_big_digit(stdscr, start_y, start_x + i * digit_width, digit) self._draw_digit(stdscr, start_y, start_x + i * self.digit_width, digit)
stdscr.attroff(curses.color_pair(1)) stdscr.attroff(curses.color_pair(1))
# Date display def _draw_date(self, stdscr, current_time, height, width):
date_str = current_time.strftime("%Y-%m-%d") """Draw the current date"""
date_x = width // 2 - len(date_str) // 2 date_str = current_time.strftime("%Y-%m-%d")
date_y = height // 2 + 4 date_x = self._center_x(width, len(date_str))
date_y = height // 2 + 4
stdscr.attron(curses.color_pair(2)) stdscr.attron(curses.color_pair(2))
stdscr.addstr(date_y, date_x, date_str) stdscr.addstr(date_y, date_x, date_str)
stdscr.attroff(curses.color_pair(2)) stdscr.attroff(curses.color_pair(2))
# Menu options def _draw_menu(self, stdscr, height, width):
menu_str = "A: Add Alarm S: List Alarms Q: Quit" """Draw the menu options"""
menu_x = width // 2 - len(menu_str) // 2 menu_str = "A: Add Alarm S: List Alarms Q: Quit"
stdscr.addstr(height - 2, menu_x, menu_str) 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

View File

@ -1,75 +1,57 @@
import curses import curses
import time import time
from datetime import datetime, date, timedelta
import threading import threading
import logging import logging
import queue import queue
from datetime import datetime
from .utils import draw_error, init_colors from .utils import init_colors, draw_error, ColorScheme
from .active_alarm import draw_active_alarms from .active_alarm import ActiveAlarmView
from .add_alarm import draw_add_alarm from .add_alarm import AddAlarmView
from .list_alarms import draw_list_alarms from .list_alarms import ListAlarmsView
from .main_clock import draw_main_clock from .main_clock import MainClockView
from .input_handlers import InputHandling
class UI(InputHandling): class UI:
def __init__(self, alarm_system_manager, control_queue): def __init__(self, alarm_system_manager, control_queue):
# UI State Management """Initialize the UI system"""
# System components
self.alarm_system = alarm_system_manager self.alarm_system = alarm_system_manager
self.stop_event = alarm_system_manager.stop_event self.stop_event = alarm_system_manager.stop_event
self.storage = alarm_system_manager.storage self.storage = alarm_system_manager.storage
# Control queue for interacting with AlarmSiren
self.control_queue = control_queue self.control_queue = control_queue
# Logging # Logging
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
# Active alarm tracking # Initialize views
self.active_alarms = {} self._init_views()
# UI State # 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.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_message = None
self.error_timestamp = None self.error_timestamp = None
# Alarm list def _init_views(self):
self.alarm_list = [] """Initialize all view classes"""
self.views = {
# Weekday names (to match specification) 'CLOCK': MainClockView(),
self.weekday_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] 'ADD_ALARM': AddAlarmView(self.storage, self.control_queue),
'LIST_ALARMS': ListAlarmsView(self.storage),
# Clear active alarms 'ACTIVE_ALARMS': ActiveAlarmView(self.storage, self.control_queue)
self.active_alarms.clear() }
def run(self): def run(self):
"""Start the ncurses UI in a separate thread""" """Start the ncurses UI in a separate thread"""
def ui_thread(): def ui_thread():
try: try:
# Start a thread to monitor control queue # Start control queue monitor
monitor_thread = threading.Thread(target=self._monitor_control_queue, daemon=True) monitor_thread = threading.Thread(
target=self._monitor_control_queue,
daemon=True
)
monitor_thread.start() monitor_thread.start()
# Start UI
curses.wrapper(self._main_loop) curses.wrapper(self._main_loop)
except Exception as e: except Exception as e:
self.logger.error(f"UI Thread Error: {e}") self.logger.error(f"UI Thread Error: {e}")
@ -88,15 +70,16 @@ class UI(InputHandling):
try: try:
control_msg = self.control_queue.get(timeout=1) control_msg = self.control_queue.get(timeout=1)
# Handle different types of control messages
if control_msg['type'] == 'trigger': if control_msg['type'] == 'trigger':
# Store triggered alarm # Update active alarms view
active_view = self.views['ACTIVE_ALARMS']
alarm_id = control_msg['alarm_id'] 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 # Switch to active alarms view
if self.current_view != 'ACTIVE_ALARMS': self.current_view = 'ACTIVE_ALARMS'
self.current_view = 'ACTIVE_ALARMS'
except queue.Empty: except queue.Empty:
pass pass
@ -106,81 +89,91 @@ class UI(InputHandling):
self.logger.error(f"Error monitoring control queue: {e}") self.logger.error(f"Error monitoring control queue: {e}")
time.sleep(1) time.sleep(1)
def _show_error(self, message, duration=30): def _show_error(self, message, duration=3):
"""Display an error message""" """Display an error message"""
self.error_message = message self.error_message = message
self.error_timestamp = time.time() 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): def _clear_error_if_expired(self):
"""Clear error message if expired""" """Clear error message if expired"""
if self.error_message and self.error_timestamp: if self.error_message and self.error_timestamp:
if time.time() - self.error_timestamp > 3: if time.time() - self.error_timestamp > 3:
self.error_message = None self.error_message = None
self.error_timestamp = 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()

View File

@ -1,34 +1,138 @@
import curses import curses
from dataclasses import dataclass
from typing import Optional
from .big_digits import BIG_DIGITS 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(): def init_colors():
"""Initialize color pairs matching specification""" """Initialize color pairs for the application"""
curses.start_color() try:
curses.use_default_colors() curses.start_color()
# Green text on black background (primary color) curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK)
# Highlight color (yellow) # Primary color (green text on black background)
curses.init_pair(2, curses.COLOR_YELLOW, curses.COLOR_BLACK) curses.init_pair(ColorScheme.PRIMARY, curses.COLOR_GREEN, curses.COLOR_BLACK)
# Error color (red)
curses.init_pair(3, curses.COLOR_RED, 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): def draw_error(stdscr, error_message: str, duration_sec: int = 3):
"""Draw error message following specification""" """
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() height, width = stdscr.getmaxyx()
# Truncate message if too long # 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) # Position near bottom of screen
error_y = height - 4 # Show 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) def draw_big_digit(stdscr, y: int, x: int, digit: str):
stdscr.addstr(error_y, error_x, error_message) """
stdscr.attroff(curses.color_pair(3) | curses.A_BOLD) 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): def safe_addstr(stdscr, y: int, x: int, text: str, color_pair: Optional[int] = None, attrs: int = 0):
"""Draw a big digit using predefined patterns""" """
patterns = BIG_DIGITS[digit] Safely add a string to the screen, handling boundary conditions
for i, line in enumerate(patterns):
stdscr.addstr(y + i, x, line) 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