feat(src/window/pomodoro): implement Pomodoro timer widget and integrate it into the panel
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.
This commit is contained in:
5 files changed
+524
-26
No files matched your search
@@ -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")
|
||||
@@ -0,0 +1,433 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fabric.utils import GLib
|
||||
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 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
|
||||
|
||||
|
||||
class TodoItem(Box):
|
||||
"""Individual todo item widget"""
|
||||
|
||||
def __init__(self, todo_data, todo_list_widget, **kwargs):
|
||||
self.todo_data = todo_data
|
||||
self.todo_list_widget = todo_list_widget
|
||||
self.editing = False
|
||||
|
||||
super().__init__(
|
||||
name="todo-item",
|
||||
orientation="h",
|
||||
spacing=8,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
"""Build the todo item UI"""
|
||||
# Checkbox for completion - using SVG icons
|
||||
checkbox_icon = (
|
||||
"checkbox-check.svg"
|
||||
if self.todo_data["completed"]
|
||||
else "checkbox-uncheck.svg"
|
||||
)
|
||||
self.checkbox_icon = svg_file(
|
||||
"todo/" + checkbox_icon,
|
||||
name="todo-checkbox-icon",
|
||||
size=24,
|
||||
)
|
||||
self.checkbox = Button(
|
||||
name="todo-checkbox",
|
||||
child=self.checkbox_icon,
|
||||
on_clicked=self._toggle_completion,
|
||||
)
|
||||
|
||||
# Todo text (can be converted to entry for editing)
|
||||
text_content = self.todo_data["text"]
|
||||
if self.todo_data["completed"]:
|
||||
text_content = f"<s>{text_content}</s>"
|
||||
|
||||
self.text_label = Label(
|
||||
markup=text_content,
|
||||
name="todo-text",
|
||||
h_align="start",
|
||||
h_expand=True,
|
||||
line_wrap="word-char",
|
||||
style_classes=(
|
||||
["title-widget"]
|
||||
if not self.todo_data["completed"]
|
||||
else ["status-label"]
|
||||
),
|
||||
)
|
||||
|
||||
# Date/time label
|
||||
created_at = datetime.fromisoformat(self.todo_data["created_at"])
|
||||
date_text = created_at.strftime("%b %d, %Y at %I:%M %p")
|
||||
|
||||
self.date_label = Label(
|
||||
label=date_text,
|
||||
name="todo-date",
|
||||
h_align="start",
|
||||
style_classes=["todo-date-text"],
|
||||
)
|
||||
|
||||
self.text_entry = Entry(
|
||||
name="todo-text-entry",
|
||||
text=self.todo_data["text"],
|
||||
h_expand=True,
|
||||
visible=False,
|
||||
)
|
||||
self.text_entry.connect("activate", self._save_edit)
|
||||
|
||||
# Priority indicator
|
||||
# priority_symbols = {"high": "🔴", "medium": "🟡", "low": "🟢"}
|
||||
#
|
||||
# self.priority_label = Label(
|
||||
# label=priority_symbols.get(self.todo_data["priority"], "🟡"),
|
||||
# name="todo-priority-label",
|
||||
# )
|
||||
# self.priority_button = Button(
|
||||
# name="todo-priority",
|
||||
# size=(20, 20),
|
||||
# child=self.priority_label,
|
||||
# on_clicked=self._cycle_priority,
|
||||
# )
|
||||
#
|
||||
# Edit button - using SVG icon
|
||||
self.edit_icon = svg_file(
|
||||
"todo/edit.svg",
|
||||
name="todo-edit-icon",
|
||||
size=12,
|
||||
)
|
||||
self.edit_button = Button(
|
||||
name="todo-edit",
|
||||
child=self.edit_icon,
|
||||
on_clicked=self._start_edit,
|
||||
)
|
||||
|
||||
# Delete button - using SVG icon
|
||||
self.delete_icon = svg_file(
|
||||
"todo/delete-symbolic.svg",
|
||||
name="todo-delete-icon",
|
||||
size=12,
|
||||
)
|
||||
self.delete_button = Button(
|
||||
name="todo-delete",
|
||||
child=self.delete_icon,
|
||||
on_clicked=self._delete_todo,
|
||||
)
|
||||
|
||||
# Text container that switches between label and entry
|
||||
self.text_container = Box(
|
||||
orientation="v",
|
||||
h_expand=True,
|
||||
children=[
|
||||
Box(orientation="h", children=[self.text_label]),
|
||||
self.date_label,
|
||||
],
|
||||
)
|
||||
|
||||
for child in [
|
||||
self.checkbox,
|
||||
self.text_container,
|
||||
self.edit_button,
|
||||
self.delete_button,
|
||||
]:
|
||||
self.add(child)
|
||||
|
||||
def _toggle_completion(self, *_):
|
||||
"""Toggle todo completion status"""
|
||||
get_todo_service().toggle_todo(self.todo_data["id"])
|
||||
|
||||
def _cycle_priority(self, *_):
|
||||
"""Cycle through priority levels"""
|
||||
priorities = ["low", "medium", "high"]
|
||||
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)
|
||||
|
||||
def _start_edit(self, *_):
|
||||
"""Start editing the todo text"""
|
||||
if self.editing:
|
||||
return
|
||||
|
||||
self.editing = True
|
||||
self.text_container.children = [
|
||||
Box(orientation="h", children=[self.text_entry]),
|
||||
self.date_label,
|
||||
]
|
||||
self.text_entry.set_visible(True)
|
||||
self.text_entry.grab_focus()
|
||||
self.text_entry.set_position(-1) # Move cursor to end
|
||||
|
||||
def _save_edit(self, *_):
|
||||
"""Save the edited todo text"""
|
||||
if not self.editing:
|
||||
return
|
||||
|
||||
new_text = self.text_entry.get_text().strip()
|
||||
if new_text:
|
||||
get_todo_service().edit_todo(self.todo_data["id"], new_text)
|
||||
|
||||
self._cancel_edit()
|
||||
|
||||
def _cancel_edit(self):
|
||||
"""Cancel editing and revert to label"""
|
||||
self.editing = False
|
||||
self.text_container.children = [
|
||||
Box(orientation="h", children=[self.text_label]),
|
||||
self.date_label,
|
||||
]
|
||||
self.text_entry.set_visible(False)
|
||||
|
||||
def _delete_todo(self, *_):
|
||||
"""Delete this todo"""
|
||||
get_todo_service().delete_todo(self.todo_data["id"])
|
||||
|
||||
def update_from_data(self, todo_data):
|
||||
"""Update the widget from new todo data"""
|
||||
self.todo_data = todo_data
|
||||
|
||||
# Update checkbox icon by recreating it
|
||||
checkbox_icon = (
|
||||
"checkbox-check.svg" if todo_data["completed"] else "checkbox-uncheck.svg"
|
||||
)
|
||||
new_checkbox_icon = svg_file(
|
||||
"todo/" + checkbox_icon,
|
||||
name="todo-checkbox-icon",
|
||||
size=20,
|
||||
)
|
||||
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
|
||||
text_content = todo_data["text"]
|
||||
if todo_data["completed"]:
|
||||
text_content = f"<s>{text_content}</s>"
|
||||
|
||||
self.text_label.set_markup(text_content)
|
||||
self.text_label.style_classes = (
|
||||
["title-widget"] if not todo_data["completed"] else ["status-label"]
|
||||
)
|
||||
|
||||
# Update date/time
|
||||
created_at = datetime.fromisoformat(todo_data["created_at"])
|
||||
date_text = created_at.strftime("%b %d, %Y at %I:%M %p")
|
||||
self.date_label.set_label(date_text)
|
||||
|
||||
|
||||
class TodoListWidget(AppletWindow):
|
||||
"""Main todo list window"""
|
||||
|
||||
def __init__(self, parent=None, pointing_to=None, **kwargs):
|
||||
super().__init__(
|
||||
parent=parent,
|
||||
pointing_to=pointing_to,
|
||||
layer="top",
|
||||
title="modus-todo",
|
||||
anchor="top right",
|
||||
margin="2px 10px 0px 0px",
|
||||
exclusivity="auto",
|
||||
name="todo-list-menu",
|
||||
visible=False,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.todo_items = {} # Maps todo IDs to TodoItem widgets
|
||||
|
||||
# Register callback with todo service
|
||||
get_todo_service().add_callback(self._on_todo_event)
|
||||
|
||||
self._build_ui()
|
||||
self._refresh_todos()
|
||||
|
||||
# Add keybinding for escape
|
||||
self.add_keybinding("Escape", self.hide_todo_list)
|
||||
|
||||
def _build_ui(self):
|
||||
"""Build the main UI"""
|
||||
# Header with title and stats
|
||||
self.stats_label = Label(
|
||||
label="",
|
||||
name="todo-stats",
|
||||
style_classes=["status-label"],
|
||||
h_align="start",
|
||||
)
|
||||
|
||||
self.header = Box(
|
||||
name="todo-header",
|
||||
orientation="v",
|
||||
children=[
|
||||
Label(
|
||||
label="Todo List",
|
||||
name="todo-title",
|
||||
style_classes=["title"],
|
||||
h_align="start",
|
||||
),
|
||||
self.stats_label,
|
||||
],
|
||||
)
|
||||
|
||||
# Add new todo section
|
||||
self.new_todo_entry = Entry(
|
||||
name="new-todo-entry",
|
||||
placeholder_text="Add a new task...",
|
||||
h_expand=True,
|
||||
)
|
||||
self.new_todo_entry.connect("activate", self._add_todo)
|
||||
|
||||
# Add button - using SVG icon
|
||||
self.add_icon = svg_file(
|
||||
"todo/plus-symbolic.svg",
|
||||
name="add-todo-icon",
|
||||
size=12,
|
||||
)
|
||||
self.add_button = Button(
|
||||
name="add-todo-button",
|
||||
child=self.add_icon,
|
||||
on_clicked=self._add_todo,
|
||||
)
|
||||
|
||||
self.add_section = Box(
|
||||
name="todo-add-section",
|
||||
orientation="h",
|
||||
spacing=8,
|
||||
children=[
|
||||
self.new_todo_entry,
|
||||
self.add_button,
|
||||
],
|
||||
)
|
||||
|
||||
# Todo items container
|
||||
self.todos_container = Box(
|
||||
name="todos-container",
|
||||
orientation="v",
|
||||
spacing=4,
|
||||
v_expand=True,
|
||||
)
|
||||
|
||||
# Scrolled window for todos
|
||||
self.scrolled = AnimatedScrollable(
|
||||
name="todos-scrolled",
|
||||
min_content_size=(400, 300),
|
||||
max_content_size=(-1, 500),
|
||||
child=self.todos_container,
|
||||
h_scrollbar_policy="automatic",
|
||||
v_scrollbar_policy="automatic",
|
||||
propagate_height=False,
|
||||
v_expand=True,
|
||||
)
|
||||
|
||||
# Clear completed button
|
||||
self.clear_button = Button(
|
||||
name="clear-completed-button",
|
||||
label="Clear Completed",
|
||||
style_classes=["status-label"],
|
||||
on_clicked=self._clear_completed,
|
||||
)
|
||||
|
||||
# Main container
|
||||
self.main_container = Box(
|
||||
name="todo-main-container",
|
||||
orientation="v",
|
||||
spacing=8,
|
||||
children=[
|
||||
self.header,
|
||||
self.add_section,
|
||||
self.scrolled,
|
||||
self.clear_button,
|
||||
],
|
||||
)
|
||||
|
||||
self.add(self.main_container)
|
||||
|
||||
def _add_todo(self, *_):
|
||||
"""Add a new todo"""
|
||||
text = self.new_todo_entry.get_text().strip()
|
||||
if text:
|
||||
get_todo_service().add_todo(text)
|
||||
self.new_todo_entry.set_text("")
|
||||
|
||||
def _clear_completed(self, *_):
|
||||
"""Clear all completed todos"""
|
||||
get_todo_service().clear_completed()
|
||||
|
||||
def _on_todo_event(self, event_type, data=None):
|
||||
"""Handle todo service events via callback"""
|
||||
if event_type == "todos-changed":
|
||||
GLib.idle_add(self._refresh_todos)
|
||||
elif event_type == "todo-added":
|
||||
GLib.idle_add(self._refresh_todos)
|
||||
elif event_type == "todo-deleted":
|
||||
GLib.idle_add(self._refresh_todos)
|
||||
elif event_type in ["todo-toggled", "todo-edited", "todo-priority-changed"]:
|
||||
if data and data["id"] in self.todo_items:
|
||||
GLib.idle_add(
|
||||
lambda: self.todo_items[data["id"]].update_from_data(data)
|
||||
)
|
||||
GLib.idle_add(self._update_stats)
|
||||
|
||||
def _refresh_todos(self, *_):
|
||||
"""Refresh the entire todo list"""
|
||||
# Clear existing items
|
||||
self.todo_items.clear()
|
||||
for child in self.todos_container.get_children():
|
||||
self.todos_container.remove(child)
|
||||
|
||||
# Get all todos
|
||||
todos = get_todo_service().todos
|
||||
|
||||
# Sort todos: incomplete first, then by priority, then by creation date
|
||||
def sort_key(todo):
|
||||
priority_order = {"high": 0, "medium": 1, "low": 2}
|
||||
return (
|
||||
todo["completed"], # False (incomplete) comes before True (completed)
|
||||
priority_order.get(todo.get("priority", "medium"), 1),
|
||||
todo["created_at"],
|
||||
)
|
||||
|
||||
sorted_todos = sorted(todos, key=sort_key)
|
||||
|
||||
# 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)
|
||||
|
||||
# Ensure they are visible
|
||||
self.todos_container.show_all()
|
||||
|
||||
# Update stats
|
||||
self._update_stats()
|
||||
|
||||
def _update_stats(self):
|
||||
"""Update the statistics display"""
|
||||
stats = get_todo_service().get_stats()
|
||||
stats_text = f"{stats['pending']} pending, {stats['completed']} completed"
|
||||
self.stats_label.set_label(stats_text)
|
||||
|
||||
def set_visible(self, visible):
|
||||
"""Override set_visible for debugging"""
|
||||
super().set_visible(visible)
|
||||
|
||||
def hide_todo_list(self, *_):
|
||||
"""Hide the todo list"""
|
||||
self.hide()
|
||||
self.hide()
|
||||
|
||||
def destroy(self):
|
||||
"""Clean up when destroyed"""
|
||||
# Remove callback from todo service
|
||||
get_todo_service().remove_callback(self._on_todo_event)
|
||||
|
||||
super().destroy()
|
||||
@@ -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}")
|
||||
|
||||
|
||||
Reference in new issue
Block a user