diff --git a/src/window/controlcenter/main.py b/src/window/controlcenter/main.py index ee605f9..9b500cf 100644 --- a/src/window/controlcenter/main.py +++ b/src/window/controlcenter/main.py @@ -43,6 +43,7 @@ class ModusControlCenter(AppletWindow): anchor="top right", margin="2px 10px 0px 0px", exclusivity="auto", + edge_margin=5, name="control-center-menu", visible=False, **kwargs, diff --git a/src/window/panel/components/pomodoro.py b/src/window/panel/components/pomodoro.py new file mode 100644 index 0000000..571d753 --- /dev/null +++ b/src/window/panel/components/pomodoro.py @@ -0,0 +1,449 @@ +import os +from enum import Enum +from pathlib import Path + +from fabric.utils import GLib, Gtk +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.circularprogressbar import CircularProgressBar +from fabric.widgets.label import Label + +from shared.widgets.flat_scale import FlatScale +from shared.widgets.smooth_switch import SmoothSwitch +from shared.window.applet_window import AppletWindow + + +class PomodoroState(Enum): + STOPPED = 0 + FOCUS = 1 + SHORT_BREAK = 2 + LONG_BREAK = 3 + + +class SettingsSpinner(Box): + def __init__(self, value, min_val, max_val, callback, **kwargs): + super().__init__(orientation="h", spacing=8, v_align="center", **kwargs) + self.value = value + self.min_val = min_val + self.max_val = max_val + self.callback = callback + + self.value_label = Label(label=str(self.value), name="pomodoro-spin-value") + self.value_label.set_size_request(24, -1) + + up_icon = Label(label="▲") + down_icon = Label(label="▼") + + up_btn = Button(child=up_icon, name="pomodoro-spin-btn") + up_btn.connect("clicked", self._on_up) + + down_btn = Button(child=down_icon, name="pomodoro-spin-btn") + down_btn.connect("clicked", self._on_down) + + btn_box = Box(orientation="v", spacing=0, children=[up_btn, down_btn]) + btn_box.set_name("pomodoro-spin-box") + + self.add(self.value_label) + self.add(btn_box) + + def _on_up(self, *_): + if self.value < self.max_val: + self.value += 1 + self.value_label.set_label(str(self.value)) + if self.callback: + self.callback() + + def _on_down(self, *_): + if self.value > self.min_val: + self.value -= 1 + self.value_label.set_label(str(self.value)) + if self.callback: + self.callback() + + def get_value(self): + return self.value + + +class Pomodoro(AppletWindow): + def __init__(self, parent=None, pointing_to=None, **kwargs): + super().__init__( + parent=parent, + pointing_to=pointing_to, + layer="top", + anchor="top right", + margin="2px 10px 0px 0px", + edge_margin=5, + name="pomodoro-popup", + title="modus-pomodoro", + **kwargs, + ) + + self.state = PomodoroState.STOPPED + self.current_cycle = 1 + + # Default settings + self.focus_mins = 25 + self.short_break_mins = 5 + self.long_break_mins = 15 + self.max_cycles = 4 + self.time_left = self.focus_mins * 60 + self.total_time = self.time_left + + self.is_running = False + self.auto_start = True + self.volume = 100 + + self._timeout_id = None + + self._build_ui() + self._update_display() + + def _build_ui(self): + # Progress Bar and Timer Display + self.time_label = Label(label="25:00", name="pomodoro-time") + self.time_label.set_margin_bottom(5) + self.cycle_label = Label(label="Cycle 1 / 4", name="pomodoro-cycle") + + timer_box = Box( + orientation="v", + v_align="center", + h_align="center", + children=[self.time_label, self.cycle_label], + ) + + self.progress_bar = CircularProgressBar( + value=1.0, + size=180, + line_width=12, + child=timer_box, + name="pomodoro-progress", + ) + + self.phase_buttons = {} + phase_box = Box(orientation="h", spacing=8, h_align="center") + + for phase in ["Focus", "Short Break", "Long Break"]: + btn = Button( + label=phase, + name="pomodoro-phase-btn", + on_clicked=lambda _, p=phase: self._switch_phase(p), + ) + self.phase_buttons[phase] = btn + phase_box.add(btn) + + header_box = Box( + orientation="v", + h_align="center", + spacing=10, + children=[phase_box, self.progress_bar], + ) + header_box.set_margin_top(15) + + # Primary Buttons (Start/Pause, Reset) + self.start_btn = Button( + label="Start", name="pomodoro-start-btn", on_clicked=self.toggle_timer + ) + self.reset_btn = Button( + label="Reset", name="pomodoro-reset-btn", on_clicked=self.reset_timer + ) + + primary_btn_box = Box( + orientation="h", + spacing=10, + h_align="center", + children=[self.start_btn, self.reset_btn], + ) + + # Secondary Buttons (Skip, Restart) + self.skip_btn = Button( + label="Skip", name="pomodoro-secondary-btn", on_clicked=self.skip_session + ) + self.restart_btn = Button( + label="Restart", + name="pomodoro-secondary-btn", + on_clicked=self.restart_session, + ) + + secondary_btn_box = Box( + orientation="h", + spacing=50, + h_align="center", + children=[self.skip_btn, self.restart_btn], + ) + secondary_btn_box.set_margin_bottom(15) + secondary_btn_box.set_margin_top(5) + + # Settings + self.focus_spin = SettingsSpinner( + self.focus_mins, 1, 120, self._on_settings_changed + ) + self.sbreak_spin = SettingsSpinner( + self.short_break_mins, 1, 60, self._on_settings_changed + ) + self.lbreak_spin = SettingsSpinner( + self.long_break_mins, 1, 60, self._on_settings_changed + ) + self.cycles_spin = SettingsSpinner( + self.max_cycles, 1, 10, self._on_settings_changed + ) + + settings_list = Box( + orientation="v", + spacing=10, + children=[ + self._setting_row("Focus", self.focus_spin, "min"), + self._setting_row("Short Break", self.sbreak_spin, "min"), + self._setting_row("Long Break", self.lbreak_spin, "min"), + self._setting_row("Cycles", self.cycles_spin, ""), + ], + ) + settings_list.set_margin_bottom(15) + + # Options + self.auto_start_switch = SmoothSwitch( + name="settings-switch", + active=self.auto_start, + on_user_toggle=self._on_auto_start_toggled, + ) + + auto_start_box = Box( + orientation="h", + spacing=10, + children=[ + Label(label="Auto-start next session", h_expand=True, h_align="start"), + self.auto_start_switch, + ], + ) + auto_start_box.set_margin_bottom(10) + + # Volume + self.volume_scale = FlatScale( + min_value=0, + max_value=100, + value=self.volume, + h_expand=True, + on_value_changed=self._on_volume_changed, + ) + self.volume_scale.set_size_request(-1, 30) + self.volume_scale.add_style_class("flat-scale") + + volume_box = Box( + orientation="h", + spacing=10, + children=[Label(label="Volume", h_align="start"), self.volume_scale], + ) + + main_box = Box( + orientation="v", + spacing=10, + name="pomodoro-container", + children=[ + header_box, + primary_btn_box, + secondary_btn_box, + Gtk.Separator(), + settings_list, + Gtk.Separator(), + auto_start_box, + volume_box, + ], + ) + main_box.set_margin_left(20) + main_box.set_margin_right(20) + main_box.set_margin_bottom(20) + + self.children = main_box + + def _setting_row(self, label, widget, suffix): + suffix_label = Label(label=suffix) if suffix else None + widget_box = Box(orientation="h", spacing=5, children=[widget]) + if suffix_label: + widget_box.add(suffix_label) + + return Box( + orientation="h", + children=[Label(label=label, h_expand=True, h_align="start"), widget_box], + ) + + def _on_settings_changed(self, *_): + self.focus_mins = int(self.focus_spin.get_value()) + self.short_break_mins = int(self.sbreak_spin.get_value()) + self.long_break_mins = int(self.lbreak_spin.get_value()) + self.max_cycles = int(self.cycles_spin.get_value()) + + if self.state == PomodoroState.STOPPED: + self.reset_timer() + self._update_display() + + def _on_auto_start_toggled(self, active: bool): + self.auto_start = active + + def _on_volume_changed(self, widget, value): + self.volume = value + + def toggle_timer(self, *_): + if self.is_running: + self.pause_timer() + else: + self.start_timer() + + def _switch_phase(self, phase): + self.pause_timer() + if phase == "Focus": + self.state = PomodoroState.FOCUS + self.total_time = self.focus_mins * 60 + elif phase == "Short Break": + self.state = PomodoroState.SHORT_BREAK + self.total_time = self.short_break_mins * 60 + elif phase == "Long Break": + self.state = PomodoroState.LONG_BREAK + self.total_time = self.long_break_mins * 60 + self.time_left = self.total_time + self._update_display() + + def start_timer(self): + if self.state == PomodoroState.STOPPED: + self.state = PomodoroState.FOCUS + self.total_time = self.focus_mins * 60 + self.time_left = self.total_time + + self.is_running = True + self.start_btn.set_label("Pause") + + if self._timeout_id is None: + self._timeout_id = GLib.timeout_add_seconds(1, self._tick) + + def pause_timer(self): + self.is_running = False + self.start_btn.set_label("Start") + if self._timeout_id is not None: + GLib.source_remove(self._timeout_id) + self._timeout_id = None + + def reset_timer(self, *_): + self.pause_timer() + self.state = PomodoroState.STOPPED + self.current_cycle = 1 + self.total_time = self.focus_mins * 60 + self.time_left = self.total_time + self._update_display() + + def restart_session(self, *_): + self.pause_timer() + if self.state == PomodoroState.FOCUS: + self.total_time = self.focus_mins * 60 + elif self.state == PomodoroState.SHORT_BREAK: + self.total_time = self.short_break_mins * 60 + elif self.state == PomodoroState.LONG_BREAK: + self.total_time = self.long_break_mins * 60 + else: + self.total_time = self.focus_mins * 60 + self.state = PomodoroState.FOCUS + + self.time_left = self.total_time + self._update_display() + self.start_timer() + + def skip_session(self, *_): + self.pause_timer() + self._finish_session() + + def _tick(self): + if not self.is_running: + self._timeout_id = None + return False + + if self.time_left > 0: + self.time_left -= 1 + self._update_display() + return True + else: + self._timeout_id = None + self._finish_session() + return False + + def _finish_session(self): + if self.state == PomodoroState.FOCUS: + if self.current_cycle >= self.max_cycles: + self.state = PomodoroState.LONG_BREAK + self.total_time = self.long_break_mins * 60 + os.system( + 'notify-send "Pomodoro" "Focus session complete! Time for a long break." &' + ) + else: + self.state = PomodoroState.SHORT_BREAK + self.total_time = self.short_break_mins * 60 + os.system( + 'notify-send "Pomodoro" "Focus session complete! Time for a short break." &' + ) + else: + if self.state == PomodoroState.LONG_BREAK: + self.current_cycle = 1 + else: + self.current_cycle += 1 + + self.state = PomodoroState.FOCUS + self.total_time = self.focus_mins * 60 + os.system('notify-send "Pomodoro" "Break over! Time to focus." &') + + self.play_sound() + self.time_left = self.total_time + self._update_display() + + if self.auto_start: + self.start_timer() + else: + self.is_running = False + self.start_btn.set_label("Start") + self._timeout_id = None + + def play_sound(self): + sound_file = "" + base_dir = ( + Path(__file__).parent.parent.parent.parent + / "assets" + / "sounds" + / "pomodoro" + ) + + if self.state == PomodoroState.FOCUS: + sound_file = str(base_dir / "alert-work.mp3") + elif self.state == PomodoroState.SHORT_BREAK: + sound_file = str(base_dir / "alert-short-break.mp3") + elif self.state == PomodoroState.LONG_BREAK: + sound_file = str(base_dir / "alert-long-break.mp3") + + if os.path.exists(sound_file): + vol = float(self.volume) / 100.0 + os.system(f"pw-play --volume={vol} '{sound_file}' &") + + def _update_display(self): + mins = self.time_left // 60 + secs = self.time_left % 60 + self.time_label.set_label(f"{mins:02d}:{secs:02d}") + self.cycle_label.set_label(f"Cycle {self.current_cycle} / {self.max_cycles}") + + progress = self.time_left / self.total_time if self.total_time > 0 else 0 + self.progress_bar.value = progress + self.progress_bar.queue_draw() + + # Update phase button active states + active_name = { + PomodoroState.FOCUS: "Focus", + PomodoroState.SHORT_BREAK: "Short Break", + PomodoroState.LONG_BREAK: "Long Break", + }.get(self.state, "Focus") + + for name, btn in self.phase_buttons.items(): + if name == active_name: + btn.add_style_class("active") + else: + btn.remove_style_class("active") + + # Update progress bar color + if self.state == PomodoroState.SHORT_BREAK: + self.progress_bar.set_name("pomodoro-progress-short") + elif self.state == PomodoroState.LONG_BREAK: + self.progress_bar.set_name("pomodoro-progress-long") + else: + self.progress_bar.set_name("pomodoro-progress") diff --git a/src/window/todo/todo_widget.py b/src/window/panel/components/todo.py similarity index 91% rename from src/window/todo/todo_widget.py rename to src/window/panel/components/todo.py index 5fb2dc9..b0db585 100644 --- a/src/window/todo/todo_widget.py +++ b/src/window/panel/components/todo.py @@ -5,9 +5,9 @@ from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.entry import Entry from fabric.widgets.label import Label -from fabric.widgets.scrolledwindow import ScrolledWindow - +from shared.window.animated_scrollwindow import AnimatedScrollable from services.todo import get_todo_service + from shared.window.applet_window import AppletWindow from utils.utils import svg_file @@ -24,7 +24,6 @@ class TodoItem(Box): name="todo-item", orientation="h", spacing=8, - style_classes=["menu"], **kwargs, ) @@ -134,13 +133,13 @@ class TodoItem(Box): ], ) - self.children = [ + for child in [ self.checkbox, self.text_container, - # self.priority_button, self.edit_button, self.delete_button, - ] + ]: + self.add(child) def _toggle_completion(self, *_): """Toggle todo completion status""" @@ -149,7 +148,8 @@ class TodoItem(Box): def _cycle_priority(self, *_): """Cycle through priority levels""" priorities = ["low", "medium", "high"] - current_index = priorities.index(self.todo_data["priority"]) + current_priority = self.todo_data.get("priority", "medium") + current_index = priorities.index(current_priority) new_priority = priorities[(current_index + 1) % len(priorities)] get_todo_service().set_priority(self.todo_data["id"], new_priority) @@ -204,7 +204,10 @@ class TodoItem(Box): name="todo-checkbox-icon", size=20, ) - self.checkbox.set_child(new_checkbox_icon) + old_child = self.checkbox.get_child() + if old_child: + self.checkbox.remove(old_child) + self.checkbox.add(new_checkbox_icon) self.checkbox_icon = new_checkbox_icon # Update text and styling with markup @@ -299,7 +302,6 @@ class TodoListWidget(AppletWindow): name="todo-add-section", orientation="h", spacing=8, - style_classes=["menu"], children=[ self.new_todo_entry, self.add_button, @@ -311,17 +313,19 @@ class TodoListWidget(AppletWindow): name="todos-container", orientation="v", spacing=4, + v_expand=True, ) # Scrolled window for todos - self.scrolled = ScrolledWindow( + self.scrolled = AnimatedScrollable( name="todos-scrolled", - min_content_height=300, - max_content_height=500, - min_content_width=400, + min_content_size=(400, 300), + max_content_size=(-1, 500), child=self.todos_container, - policy="automatic", - v_expand=True, # Allow vertical expansion + h_scrollbar_policy="automatic", + v_scrollbar_policy="automatic", + propagate_height=False, + v_expand=True, ) # Clear completed button @@ -337,7 +341,6 @@ class TodoListWidget(AppletWindow): name="todo-main-container", orientation="v", spacing=8, - style_classes=["menu"], children=[ self.header, self.add_section, @@ -346,7 +349,7 @@ class TodoListWidget(AppletWindow): ], ) - self.children = [self.main_container] + self.add(self.main_container) def _add_todo(self, *_): """Add a new todo""" @@ -378,7 +381,8 @@ class TodoListWidget(AppletWindow): """Refresh the entire todo list""" # Clear existing items self.todo_items.clear() - self.todos_container.children = [] + for child in self.todos_container.get_children(): + self.todos_container.remove(child) # Get all todos todos = get_todo_service().todos @@ -388,19 +392,20 @@ class TodoListWidget(AppletWindow): priority_order = {"high": 0, "medium": 1, "low": 2} return ( todo["completed"], # False (incomplete) comes before True (completed) - priority_order.get(todo["priority"], 1), + priority_order.get(todo.get("priority", "medium"), 1), todo["created_at"], ) sorted_todos = sorted(todos, key=sort_key) - # Create todo item widgets + # Create todo item widgets and add them to the container for todo in sorted_todos: todo_item = TodoItem(todo, self) self.todo_items[todo["id"]] = todo_item + self.todos_container.add(todo_item) - # Update container children - self.todos_container.children = list(self.todo_items.values()) + # Ensure they are visible + self.todos_container.show_all() # Update stats self._update_stats() diff --git a/src/window/panel/main.py b/src/window/panel/main.py index 63d3b14..f12b448 100644 --- a/src/window/panel/main.py +++ b/src/window/panel/main.py @@ -1,5 +1,5 @@ from fabric.system_tray.widgets import SystemTray -from fabric.utils import logger +from fabric.utils import Gdk, logger from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox @@ -109,8 +109,16 @@ class Panel(Window): name="panel-button", child=DateTime(name="date-time", formatters=["%a %-d %b %I:%M %P"]), ) + self.datetime_btn.add_events( + Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK + ) setup_cursor_hover(self.datetime_btn, "pointer") + self._pomodoro_instance = None + self._todo_instance = None + self.datetime_btn.connect("clicked", self.on_datetime_clicked) + self.datetime_btn.connect("button-press-event", self.on_datetime_right_click) + self.custom_mods = CustomMods(parent_window=self) self.workspace_indicator = WorkspaceIndicator() @@ -178,6 +186,36 @@ class Panel(Window): def on_notification_icon_clicked(self, *_): self.notification_center.toggle() + def on_datetime_clicked(self, *_): + self.pomodoro.toggle() + + def on_datetime_right_click(self, widget, event): + logger.info(f"[Panel] Right click detected: button={event.button}") + if event.button == 3: # Right click + self.todo_widget.toggle() + return True + return False + + @property + def pomodoro(self): + if self._pomodoro_instance is None: + from window.panel.components.pomodoro import Pomodoro + + self._pomodoro_instance = Pomodoro( + parent=self, pointing_to=self.datetime_btn + ) + return self._pomodoro_instance + + @property + def todo_widget(self): + if self._todo_instance is None: + from window.panel.components.todo import TodoListWidget + + self._todo_instance = TodoListWidget( + parent=self, pointing_to=self.datetime_btn + ) + return self._todo_instance + @property def control_center(self): if self._control_center_instance is None: @@ -351,9 +389,15 @@ class Panel(Window): logger.error(f"An error occurred: {e}") # Destroy MouseCapture windows - for mc in [self.control_center, self.notification_center]: + for mc in [ + self.control_center, + self.notification_center, + self._pomodoro_instance, + self._todo_instance, + ]: try: - mc.destroy() + if mc: + mc.destroy() except Exception as e: logger.error(f"An error occurred: {e}") diff --git a/src/window/todo/__init__.py b/src/window/todo/__init__.py deleted file mode 100644 index ee6321b..0000000 --- a/src/window/todo/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Todo widget package