From 4637799e113daeab16c2a2b050e64860273ec479 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Sun, 12 Jul 2026 09:32:47 +0545 Subject: [PATCH] feat(src/window/pomodoro): implement Pomodoro timer widget and integrate it into the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Pomodoro component with UI controls and state management, modify Panel to instantiate and cache Pomodoro and TodoList widgets, hook datetime button to toggle Pomodoro and handle right‑click on todo list, extend destroy logic to cleanly close Pomodoro and Todo instances. --- src/window/controlcenter/main.py | 1 + src/window/panel/components/pomodoro.py | 449 ++++++++++++++++++ .../components/todo.py} | 49 +- src/window/panel/main.py | 50 +- src/window/todo/__init__.py | 1 - 5 files changed, 524 insertions(+), 26 deletions(-) create mode 100644 src/window/panel/components/pomodoro.py rename src/window/{todo/todo_widget.py => panel/components/todo.py} (91%) delete mode 100644 src/window/todo/__init__.py 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