import calendar import subprocess from datetime import datetime, timedelta import gi from fabric.widgets.centerbox import CenterBox from fabric.widgets.label import Label import modules.icons as icons gi.require_version("Gtk", "3.0") from gi.repository import GLib, Gtk, Gio class Calendar(Gtk.Box): def __init__(self, view_mode="month"): super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8, name="calendar") self.view_mode = view_mode self.first_weekday = 0 # Default: Monday, will be updated async self.set_halign(Gtk.Align.CENTER) self.set_hexpand(False) self.current_day_date = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) if self.view_mode == "month": self.current_shown_date = self.current_day_date.replace(day=1) self.current_year = self.current_shown_date.year self.current_month = self.current_shown_date.month self.current_day = self.current_day_date.day # Solo para resaltar en create_month_view self.previous_key = (self.current_year, self.current_month) elif self.view_mode == "week": # current_shown_date es el primer día (según locale) de la semana actual days_to_subtract = (self.current_day_date.weekday() - self.first_weekday + 7) % 7 self.current_shown_date = self.current_day_date - timedelta(days=days_to_subtract) self.current_year = self.current_shown_date.year # Para el header self.current_month = self.current_shown_date.month # Para el header iso_year, iso_week, _ = self.current_shown_date.isocalendar() self.previous_key = (iso_year, iso_week) self.set_halign(Gtk.Align.FILL) self.set_hexpand(True) self.set_valign(Gtk.Align.CENTER) self.set_vexpand(False) self.cache_threshold = 3 # Umbral para mantener vistas en caché self.month_views = {} # Reutilizado para vistas de semana también self.prev_button = Gtk.Button( # Nombre genérico del botón name="prev-month-button", child=Label(name="month-button-label", markup=icons.chevron_left) # CSS puede ser genérico ) self.prev_button.connect("clicked", self.on_prev_clicked) self.month_label = Gtk.Label(name="month-label") # El nombre es histórico, pero muestra mes/año self.next_button = Gtk.Button( # Nombre genérico del botón name="next-month-button", child=Label(name="month-button-label", markup=icons.chevron_right) # CSS puede ser genérico ) self.next_button.connect("clicked", self.on_next_clicked) self.header = CenterBox( spacing=4, name="header", start_children=[self.prev_button], center_children=[self.month_label], end_children=[self.next_button], ) self.add(self.header) self.weekday_row = Gtk.Box(spacing=4, name="weekday-row") self.pack_start(self.weekday_row, False, False, 0) self.stack = Gtk.Stack(name="calendar-stack") self.stack.set_transition_duration(250) self.pack_start(self.stack, True, True, 0) self.update_header() # Llamar antes de update_calendar para que el primer header sea correcto self.update_calendar() self.setup_periodic_update() self.setup_dbus_listeners() # Initialize locale settings asynchronously GLib.Thread.new("calendar-locale", self._init_locale_settings_thread, None) def _init_locale_settings_thread(self, user_data): """Background thread to initialize locale settings without blocking UI.""" try: origin_date_str = subprocess.check_output(["locale", "week-1stday"], text=True).strip() first_weekday_val = int(subprocess.check_output(["locale", "first_weekday"], text=True).strip()) origin_date = datetime.fromisoformat(origin_date_str) # Esta lógica calcula el día de la semana (0-6, Lunes=0) que es considerado el primero # según la configuración regional combinada de week-1stday y first_weekday. date_of_first_day_of_week_config = origin_date + timedelta(days=first_weekday_val - 1) new_first_weekday = date_of_first_day_of_week_config.weekday() # Lunes=0, ..., Domingo=6 # Update the first_weekday on main thread and refresh calendar if needed GLib.idle_add(self._update_first_weekday, new_first_weekday) except Exception as e: print(f"Error getting locale first weekday: {e}") # Keep default value (0 = Monday) def _update_first_weekday(self, new_first_weekday): """Update first weekday setting and refresh calendar if changed.""" if self.first_weekday != new_first_weekday: self.first_weekday = new_first_weekday # Clear cache and refresh calendar with new locale settings self.month_views.clear() # Remove all current stack children to force regeneration for child in self.stack.get_children(): self.stack.remove(child) # Update header (which includes weekday labels) and calendar self.update_header() self.update_calendar() return False # Don't repeat this idle callback def setup_periodic_update(self): # Check for date changes every second GLib.timeout_add(1000, self.check_date_change) def setup_dbus_listeners(self): # Listen for system suspend/resume events bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None) bus.signal_subscribe( None, # sender 'org.freedesktop.login1.Manager', # interface 'PrepareForSleep', # signal '/org/freedesktop/login1', # path None, # arg0 Gio.DBusSignalFlags.NONE, self.on_suspend_resume, # callback None # user_data ) def check_date_change(self): now = datetime.now() current_date = now.replace(hour=0, minute=0, second=0, microsecond=0) if current_date != self.current_day_date: self.on_midnight() return True # Continue the timer def on_suspend_resume(self, connection, sender_name, object_path, interface_name, signal_name, parameters, user_data): # Check date when resuming from suspend self.check_date_change() def on_midnight(self): now = datetime.now() self.current_day_date = now.replace(hour=0, minute=0, second=0, microsecond=0) key_to_remove_for_today_highlight = None if self.view_mode == "month": # Actualizar la fecha base para la vista de mes si es necesario (aunque usualmente no cambia a medianoche) self.current_shown_date = self.current_day_date.replace(day=1) self.current_year = self.current_shown_date.year self.current_month = self.current_shown_date.month self.current_day = self.current_day_date.day # Actualizar el día actual key_to_remove_for_today_highlight = (self.current_year, self.current_month) elif self.view_mode == "week": days_to_subtract = (self.current_day_date.weekday() - self.first_weekday + 7) % 7 self.current_shown_date = self.current_day_date - timedelta(days=days_to_subtract) self.current_year = self.current_shown_date.year # Para el header self.current_month = self.current_shown_date.month # Para el header iso_year, iso_week, _ = self.current_shown_date.isocalendar() key_to_remove_for_today_highlight = (iso_year, iso_week) # Eliminar la vista actual de la caché para forzar la regeneración con el nuevo "hoy" resaltado if key_to_remove_for_today_highlight and key_to_remove_for_today_highlight in self.month_views: widget = self.month_views.pop(key_to_remove_for_today_highlight) self.stack.remove(widget) # Si la vista eliminada era la actual, previous_key podría quedar desactualizado # pero update_calendar lo corregirá al establecer la nueva vista. self.update_calendar() # Esto regenerará la vista si fue eliminada y actualizará el resaltado return False # Importante para que el timeout no se repita automáticamente def update_header(self): # self.current_shown_date es el primer día del mes (modo mes) o el primer día de la semana (modo semana) # El encabezado siempre muestra el mes y año de self.current_shown_date self.month_label.set_text( self.current_shown_date.strftime("%B %Y").capitalize() ) for child in self.weekday_row.get_children(): self.weekday_row.remove(child) day_initials = self.get_weekday_initials() for day_initial in day_initials: label = Gtk.Label(label=day_initial.upper(), name="weekday-label") self.weekday_row.pack_start(label, True, True, 0) self.weekday_row.show_all() def update_calendar(self): new_key = None child_name = "" # Renombrado de child_name_prefix view_widget = None if self.view_mode == "month": new_key = (self.current_year, self.current_month) child_name = f"{self.current_year}_{self.current_month}" if new_key not in self.month_views: view_widget = self.create_month_view(self.current_year, self.current_month) elif self.view_mode == "week": iso_year, iso_week, _ = self.current_shown_date.isocalendar() new_key = (iso_year, iso_week) child_name = f"{iso_year}_w{iso_week}" if new_key not in self.month_views: # Pasar self.current_shown_date directamente a create_week_view view_widget = self.create_week_view(self.current_shown_date) if new_key is None: return if new_key > self.previous_key: self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT) elif new_key < self.previous_key: self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_RIGHT) # else: no transition if key is the same (e.g. on_midnight for same month/week) self.previous_key = new_key if view_widget: # Si se creó una nueva vista self.month_views[new_key] = view_widget self.stack.add_titled(view_widget, child_name, child_name) self.stack.set_visible_child_name(child_name) # El encabezado se actualiza ANTES de llamar a update_calendar en __init__ y on_clicked, # y también en on_midnight si es necesario. # Pero si la vista cambia (ej. de Enero a Febrero), el encabezado debe reflejarlo. self.update_header() # Asegurar que el header está sincronizado con la vista actual self.stack.show_all() self.prune_cache() def prune_cache(self): def get_key_index(key_tuple): year, num = key_tuple # num es month o week_number if self.view_mode == "month": # Asumiendo que la clave es (año, mes) return year * 12 + (num - 1) else: # Asumiendo que la clave es (año_iso, semana_iso) return year * 53 + num # Usar 53 para cubrir años con 53 semanas ISO current_index = get_key_index(self.previous_key) # previous_key es la clave de la vista actual keys_to_remove = [] for key_iter in self.month_views: if abs(get_key_index(key_iter) - current_index) > self.cache_threshold: keys_to_remove.append(key_iter) for key_to_remove in keys_to_remove: widget = self.month_views.pop(key_to_remove) self.stack.remove(widget) def create_month_view(self, year, month): grid = Gtk.Grid(column_homogeneous=True, row_homogeneous=False, name="calendar-grid") cal = calendar.Calendar(firstweekday=self.first_weekday) month_days = cal.monthdayscalendar(year, month) while len(month_days) < 6: # Asegurar 6 filas para consistencia visual month_days.append([0] * 7) # [0] representa un día vacío for row, week in enumerate(month_days): for col, day_num in enumerate(week): day_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, name="day-box") top_spacer = Gtk.Box(hexpand=True, vexpand=True) middle_box = Gtk.Box(hexpand=True, vexpand=True) bottom_spacer = Gtk.Box(hexpand=True, vexpand=True) if day_num == 0: label = Label(name="day-empty", markup=icons.dot) else: label = Gtk.Label(label=str(day_num), name="day-label") day_date_obj = datetime(year, month, day_num) if day_date_obj == self.current_day_date: label.get_style_context().add_class("current-day") middle_box.pack_start(Gtk.Box(hexpand=True, vexpand=True), True, True, 0) middle_box.pack_start(label, False, False, 0) middle_box.pack_start(Gtk.Box(hexpand=True, vexpand=True), True, True, 0) day_box.pack_start(top_spacer, True, True, 0) day_box.pack_start(middle_box, True, True, 0) day_box.pack_start(bottom_spacer, True, True, 0) grid.attach(day_box, col, row, 1, 1) grid.show_all() return grid def create_week_view(self, first_day_of_week_to_display): grid = Gtk.Grid(column_homogeneous=True, row_homogeneous=False, name="calendar-grid-week-view") # Podría tener estilo diferente # El mes de referencia para atenuar es el mes de first_day_of_week_to_display # que es self.current_shown_date, y su mes es self.current_month (actualizado en nav) reference_month_for_dimming = first_day_of_week_to_display.month for col in range(7): current_day_in_loop = first_day_of_week_to_display + timedelta(days=col) day_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, name="day-box") # Reusar estilo de day-box top_spacer = Gtk.Box(hexpand=True, vexpand=True) middle_box = Gtk.Box(hexpand=True, vexpand=True) bottom_spacer = Gtk.Box(hexpand=True, vexpand=True) label = Gtk.Label(label=str(current_day_in_loop.day), name="day-label") if current_day_in_loop == self.current_day_date: label.get_style_context().add_class("current-day") if current_day_in_loop.month != reference_month_for_dimming: label.get_style_context().add_class("dim-label") # Necesita CSS: .dim-label { opacity: 0.5; } o similar middle_box.pack_start(Gtk.Box(hexpand=True, vexpand=True), True, True, 0) middle_box.pack_start(label, False, False, 0) middle_box.pack_start(Gtk.Box(hexpand=True, vexpand=True), True, True, 0) day_box.pack_start(top_spacer, True, True, 0) day_box.pack_start(middle_box, True, True, 0) day_box.pack_start(bottom_spacer, True, True, 0) grid.attach(day_box, col, 0, 1, 1) # Todos los días en la fila 0 # Para mantener una altura similar a la vista mensual, se podrían añadir filas vacías. # Esto es opcional y depende del diseño deseado. # for r_idx in range(1, 6): # Añadir 5 filas vacías # empty_row_placeholder = Gtk.Box(name="day-empty-placeholder", hexpand=True, vexpand=True, height_request=20) # Ajustar altura # grid.attach(empty_row_placeholder, 0, r_idx, 7, 1) # Abarca las 7 columnas grid.show_all() return grid def get_weekday_initials(self): # Genera las iniciales de los días de la semana comenzando por self.first_weekday # datetime(2024, 1, 1) es Lunes. Su weekday() es 0. # Si self.first_weekday es 0 (Lunes), queremos que el primer día sea Lunes. # i=0: datetime(2024, 1, 1 + 0) -> Lunes # Si self.first_weekday es 6 (Domingo), queremos que el primer día sea Domingo. # i=0: datetime(2024, 1, 1 + 6) -> Domingo # Esta lógica es correcta. return [(datetime(2024, 1, 1) + timedelta(days=(self.first_weekday + i) % 7)).strftime("%a")[:1] for i in range(7)] def on_prev_clicked(self, widget): if self.view_mode == "month": current_month_val = self.current_shown_date.month current_year_val = self.current_shown_date.year if current_month_val == 1: self.current_shown_date = self.current_shown_date.replace(year=current_year_val - 1, month=12) else: self.current_shown_date = self.current_shown_date.replace(month=current_month_val - 1) self.current_year = self.current_shown_date.year self.current_month = self.current_shown_date.month elif self.view_mode == "week": self.current_shown_date -= timedelta(days=7) self.current_year = self.current_shown_date.year # Actualizar para el header self.current_month = self.current_shown_date.month # Actualizar para el header y dimming # self.update_header() # Se llama dentro de update_calendar self.update_calendar() def on_next_clicked(self, widget): if self.view_mode == "month": current_month_val = self.current_shown_date.month current_year_val = self.current_shown_date.year if current_month_val == 12: self.current_shown_date = self.current_shown_date.replace(year=current_year_val + 1, month=1) else: self.current_shown_date = self.current_shown_date.replace(month=current_month_val + 1) self.current_year = self.current_shown_date.year self.current_month = self.current_shown_date.month elif self.view_mode == "week": self.current_shown_date += timedelta(days=7) self.current_year = self.current_shown_date.year # Actualizar para el header self.current_month = self.current_shown_date.month # Actualizar para el header y dimming # self.update_header() # Se llama dentro de update_calendar self.update_calendar()