More acurate name for alarm_api, => clock. Made the UI parts to their own package and separate them to smaller files.

This commit is contained in:
Kalzu Rekku
2025-02-06 23:04:50 +02:00
parent 3a3813e389
commit 5aa1f078bc
16 changed files with 2162 additions and 0 deletions

0
clock/ui/__init__.py Normal file
View File

69
clock/ui/active_alarm.py Normal file
View File

@ -0,0 +1,69 @@
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()
# 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
# 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))
# 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 (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))
# Get active alarm info
active_alarms = context.get('active_alarms', {})
if not active_alarms:
return
# 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']
# 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)
# 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
# Center the alarm info
info_x = max(0, width // 2 - len(alarm_str) // 2)
# Draw with green color
stdscr.attron(curses.color_pair(1))
stdscr.addstr(alarm_y, alarm_x, alarm_str)
stdscr.attroff(curses.color_pair(1))
# Instructions
stdscr.addstr(height - 2, width // 2 - 15, "S: Snooze D: Dismiss")

219
clock/ui/add_alarm.py Normal file
View File

@ -0,0 +1,219 @@
import curses
from datetime import datetime
from .utils import init_colors, draw_big_digit
def draw_add_alarm(stdscr, context):
"""Draw the add alarm screen"""
# Ensure context is a dictionary with default values
if context is None:
context = {}
# Provide default values with more explicit checks
context = {
'new_alarm_selected': context.get('new_alarm_selected', 0),
'new_alarm_name': context.get('new_alarm_name', 'New Alarm'),
'new_alarm_hour': context.get('new_alarm_hour', datetime.now().hour),
'new_alarm_minute': context.get('new_alarm_minute', datetime.now().minute),
'new_alarm_enabled': context.get('new_alarm_enabled', True),
'new_alarm_date': context.get('new_alarm_date') or None,
'new_alarm_weekdays': context.get('new_alarm_weekdays', []) or [],
'weekday_names': context.get('weekday_names', ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']),
'date_edit_pos': context.get('date_edit_pos', 2) # 0 = year, 1 = month, 2 = day
}
init_colors()
height, width = stdscr.getmaxyx()
# Center the form vertically with good spacing
form_y = height // 2 - 8
# Title with green color and bold
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)
# Helper function to draw a labeled field
def draw_field(y, label, value, is_selected, center_offset=0):
label_str = f"{label}: "
total_width = len(label_str) + len(str(value))
x = width // 2 - total_width // 2 + center_offset
# Draw label in green
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)) # Yellow for selected
stdscr.addstr(y, x + len(label_str), str(value))
stdscr.attroff(curses.color_pair(2))
else:
stdscr.attron(curses.color_pair(1)) # Green for normal
stdscr.addstr(y, x + len(label_str), str(value))
stdscr.attroff(curses.color_pair(1))
# Name field with proper spacing
name_str = str(context['new_alarm_name'])
draw_field(form_y + 2, "Name", name_str, context['new_alarm_selected'] == 4)
# Time selection with centered layout
time_y = form_y + 4
time_label = "Time: "
hour_str = f"{int(context['new_alarm_hour']):02d}"
minute_str = f"{int(context['new_alarm_minute']):02d}"
# Calculate center position for time
time_total_width = len(time_label) + 5 # 5 = HH:MM
time_x = width // 2 - time_total_width // 2
# Draw time label
stdscr.attron(curses.color_pair(1))
stdscr.addstr(time_y, time_x, time_label)
stdscr.attroff(curses.color_pair(1))
# Draw hour
if context['new_alarm_selected'] == 0:
stdscr.attron(curses.color_pair(2))
else:
stdscr.attron(curses.color_pair(1))
stdscr.addstr(time_y, time_x + len(time_label), hour_str)
if context['new_alarm_selected'] == 0:
stdscr.attroff(curses.color_pair(2))
else:
stdscr.attroff(curses.color_pair(1))
# Draw colon
stdscr.attron(curses.color_pair(1))
stdscr.addstr(time_y, time_x + len(time_label) + 2, ":")
stdscr.attroff(curses.color_pair(1))
# Draw minute
if context['new_alarm_selected'] == 1:
stdscr.attron(curses.color_pair(2))
else:
stdscr.attron(curses.color_pair(1))
stdscr.addstr(time_y, time_x + len(time_label) + 3, minute_str)
if context['new_alarm_selected'] == 1:
stdscr.attroff(curses.color_pair(2))
else:
stdscr.attroff(curses.color_pair(1))
# Draw weekdays
weekday_y = form_y + 6
weekdays_label = "Repeat: "
weekday_x = width // 2 - (len(weekdays_label) + len(context['weekday_names']) * 4) // 2
# Draw label
stdscr.attron(curses.color_pair(1))
stdscr.addstr(weekday_y, weekday_x, weekdays_label)
stdscr.attroff(curses.color_pair(1))
# Draw each weekday
for i, day in enumerate(context['weekday_names']):
x_pos = weekday_x + len(weekdays_label) + i * 4
is_selected = context['new_alarm_selected'] == 3 and i == context.get('weekday_edit_pos', 0)
is_active = i in context['new_alarm_weekdays']
if is_selected:
stdscr.attron(curses.color_pair(2))
elif is_active:
stdscr.attron(curses.color_pair(1) | curses.A_BOLD)
else:
stdscr.attron(curses.color_pair(1))
stdscr.addstr(weekday_y, x_pos, day)
if is_selected:
stdscr.attroff(curses.color_pair(2))
elif is_active:
stdscr.attroff(curses.color_pair(1) | curses.A_BOLD)
else:
stdscr.attroff(curses.color_pair(1))
# Date selection
date_y = form_y + 8
if context['new_alarm_weekdays']:
draw_field(date_y, "Date", "Repeating weekly", context['new_alarm_selected'] == 2)
else:
date_label = "Date: "
if context.get('new_alarm_date'):
date = context['new_alarm_date']
date_edit_pos = context.get('date_edit_pos', 0)
# Calculate center position
total_width = len(date_label) + len("YYYY-MM-DD")
date_x = width // 2 - total_width // 2
# Draw label
stdscr.attron(curses.color_pair(1))
stdscr.addstr(date_y, date_x, date_label)
stdscr.attroff(curses.color_pair(1))
# Draw date components
year_str = f"{date.year}"
month_str = f"{date.month:02d}"
day_str = f"{date.day:02d}"
current_x = date_x + len(date_label)
# Draw year
if context['new_alarm_selected'] == 2 and date_edit_pos == 0:
stdscr.attron(curses.color_pair(2))
else:
stdscr.attron(curses.color_pair(1))
stdscr.addstr(date_y, current_x, year_str)
if context['new_alarm_selected'] == 2 and date_edit_pos == 0:
stdscr.attroff(curses.color_pair(2))
else:
stdscr.attroff(curses.color_pair(1))
current_x += len(year_str)
# Draw first dash
stdscr.attron(curses.color_pair(1))
stdscr.addstr(date_y, current_x, "-")
stdscr.attroff(curses.color_pair(1))
current_x += 1
# Draw month
if context['new_alarm_selected'] == 2 and date_edit_pos == 1:
stdscr.attron(curses.color_pair(2))
else:
stdscr.attron(curses.color_pair(1))
stdscr.addstr(date_y, current_x, month_str)
if context['new_alarm_selected'] == 2 and date_edit_pos == 1:
stdscr.attroff(curses.color_pair(2))
else:
stdscr.attroff(curses.color_pair(1))
current_x += len(month_str)
# Draw second dash
stdscr.attron(curses.color_pair(1))
stdscr.addstr(date_y, current_x, "-")
stdscr.attroff(curses.color_pair(1))
current_x += 1
# Draw day
if context['new_alarm_selected'] == 2 and date_edit_pos == 2:
stdscr.attron(curses.color_pair(2))
else:
stdscr.attron(curses.color_pair(1))
stdscr.addstr(date_y, current_x, day_str)
if context['new_alarm_selected'] == 2 and date_edit_pos == 2:
stdscr.attroff(curses.color_pair(2))
else:
stdscr.attroff(curses.color_pair(1))
else:
draw_field(date_y, "Date", "No specific date", context['new_alarm_selected'] == 2)
# Enabled/Disabled toggle with visual indicator
status_y = form_y + 10
enabled_str = "● Enabled" if context['new_alarm_enabled'] else "○ Disabled"
draw_field(status_y, "Status", enabled_str, context['new_alarm_selected'] == 5, -2)
# Instructions in green at the bottom
instructions = "j/k: Change h/l: Switch 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))

102
clock/ui/big_digits.py Normal file
View File

@ -0,0 +1,102 @@
# Big digit patterns (15x7 size)
BIG_DIGITS = {
'0': [
" █████████ ",
" ███████████ ",
"███ ████",
"███ ████",
"███ ████",
" ███████████ ",
" █████████ "
],
'1': [
" ████ ",
" ██████ ",
" ████ ",
" ████ ",
" ████ ",
" ████ ",
" █████████ "
],
'2': [
" ██████████ ",
"████████████",
" ████",
" ██████████ ",
"████ ",
"████████████",
" ██████████ "
],
'3': [
" ██████████ ",
"████████████",
" ████",
" ██████ ",
" ████",
"████████████",
" ██████████ "
],
'4': [
"███ ████",
"███ ████",
"███ ████",
"████████████",
" ████",
" ████",
" ████"
],
'5': [
"████████████",
"████████████",
"████ ",
"████████████",
" ████",
"████████████",
"████████████"
],
'6': [
" ██████████ ",
"████████████",
"████ ",
"████████████",
"████ ████",
"████████████",
" ██████████ "
],
'7': [
"████████████",
"████████████",
" ████ ",
" ████ ",
" ████ ",
"████ ",
"████ "
],
'8': [
" ██████████ ",
"████████████",
"████ ████",
" ██████████ ",
"████ ████",
"████████████",
" ██████████ "
],
'9': [
" ██████████ ",
"████████████",
"████ ████",
"████████████",
" ████",
"████████████",
" ██████████ "
],
':': [
" ",
" ████ ",
" ████ ",
" ",
" ████ ",
" ████ ",
" "
]
}

202
clock/ui/input_handlers.py Normal file
View File

@ -0,0 +1,202 @@
import curses
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"""
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
# Date editing mode
if self.selected_item == 2: # Date field selected
if not hasattr(self, 'date_edit_pos'):
self.date_edit_pos = 2 # Default to day (2)
if key == 32: # SPACE
if alarm['date'] is None:
alarm['date'] = datetime.now().date()
else:
alarm['date'] = None
elif alarm['date'] is not None:
if key in [ord('h'), curses.KEY_LEFT]:
self.date_edit_pos = (self.date_edit_pos - 1) % 3
elif key in [ord('l'), curses.KEY_RIGHT]:
self.date_edit_pos = (self.date_edit_pos + 1) % 3
elif key in [ord('j'), curses.KEY_DOWN, ord('k'), curses.KEY_UP]:
is_up = key in [ord('k'), curses.KEY_UP]
delta = 1 if is_up else -1
try:
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))
return
# Navigation and editing
if not alarm['editing_name']:
if key in [ord('h'), curses.KEY_LEFT]:
self.selected_item = (self.selected_item - 1) % 6
self.date_edit_pos = 2 # Reset to day when moving away
elif key in [ord('l'), curses.KEY_RIGHT]:
self.selected_item = (self.selected_item + 1) % 6
self.date_edit_pos = 2 # Reset to day when moving away
# Up/Down for editing values
if key in [ord('k'), curses.KEY_UP, ord('j'), curses.KEY_DOWN]:
is_up = key in [ord('k'), curses.KEY_UP]
if self.selected_item == 0: # Hour
alarm['hour'] = (alarm['hour'] + (1 if is_up else -1)) % 24
elif self.selected_item == 1: # Minute
alarm['minute'] = (alarm['minute'] + (1 if is_up else -1)) % 60
elif self.selected_item == 3: # Weekdays
# Move selection through weekdays
if 'current_weekday' not in alarm:
alarm['current_weekday'] = 0
alarm['current_weekday'] = (alarm['current_weekday'] + (1 if is_up else -1)) % 7
# Space key for toggling/editing
if key == 32: # SPACE
if self.selected_item == 4: # Name
if not alarm['editing_name']:
alarm['editing_name'] = True
alarm['temp_name'] = alarm['name']
alarm['name'] = ''
elif self.selected_item == 5: # Enabled
alarm['enabled'] = not alarm['enabled']
elif self.selected_item == 3: # Weekdays
# Toggle current weekday
current_day = alarm.get('current_weekday', 0)
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
if 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)
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")

86
clock/ui/list_alarms.py Normal file
View File

@ -0,0 +1,86 @@
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()
# 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'])
# 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
# 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)
# 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)
# Draw alarms
for i in range(start_idx, end_idx):
y_pos = 4 + (i - start_idx)
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')
# 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']})"
# Status indicator (in green)
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)
max_length = width - 6
if len(display_str) > max_length:
display_str = display_str[:max_length-3] + "..."
# Center the item
x_pos = width // 2 - len(display_str) // 2
# Highlight selected item
if i == selected_index:
# 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)
stdscr.attroff(curses.color_pair(2))
else:
# Draw normal items in green
stdscr.attron(curses.color_pair(1))
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))

41
clock/ui/main_clock.py Normal file
View File

@ -0,0 +1,41 @@
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
def draw_main_clock(stdscr, context=None):
"""Draw the main clock screen"""
init_colors()
height, width = stdscr.getmaxyx()
current_time = datetime.now()
# 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
# 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))
# Date display
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))
# 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)

186
clock/ui/ncurses_ui.py Normal file
View File

@ -0,0 +1,186 @@
import curses
import time
from datetime import datetime, date, timedelta
import threading
import logging
import queue
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
class UI(InputHandling):
def __init__(self, alarm_system_manager, control_queue):
# UI State Management
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 = {}
# 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 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)
monitor_thread.start()
curses.wrapper(self._main_loop)
except Exception as e:
self.logger.error(f"UI Thread Error: {e}")
finally:
self.stop_event.set()
ui_thread_obj = threading.Thread(target=ui_thread, daemon=True)
ui_thread_obj.start()
return ui_thread_obj
def _monitor_control_queue(self):
"""Monitor the control queue for alarm events"""
while not self.stop_event.is_set():
try:
# Non-blocking check of control queue
try:
control_msg = self.control_queue.get(timeout=1)
# Handle different types of control messages
if control_msg['type'] == 'trigger':
# Store triggered alarm
alarm_id = control_msg['alarm_id']
self.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'
except queue.Empty:
pass
time.sleep(0.1)
except Exception as e:
self.logger.error(f"Error monitoring control queue: {e}")
time.sleep(1)
def _show_error(self, message, duration=30):
"""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

34
clock/ui/utils.py Normal file
View File

@ -0,0 +1,34 @@
import curses
from .big_digits import BIG_DIGITS
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)
def draw_error(stdscr, error_message):
"""Draw error message following specification"""
height, width = stdscr.getmaxyx()
# Truncate message if too long
error_message = error_message[:width-4]
error_x = max(0, width // 2 - len(error_message) // 2)
error_y = height - 4 # Show near bottom of screen
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, 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)