Files
2026-07-12 18:56:10 +05:45

534 lines
17 KiB
Python

from collections.abc import Iterator
from fabric.utils import Gdk, idle_add, remove_handler
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from modules.launcher.base import LauncherPlugin
from services import TodoService
class TodoPlugin(LauncherPlugin):
@property
def name(self) -> str:
return "todo"
@property
def icon(self) -> str:
return "view-list-symbolic"
@property
def keywords(self) -> list[str]:
return ["todo"]
def __init__(self, handler):
super().__init__(handler)
self.todo_service = TodoService()
self._query_handler: int = 0
self.current_slots: list = []
self.todos: list = []
def on_search(self, text: str) -> None:
self.query_todos(text)
def stop(self):
if self._query_handler:
remove_handler(self._query_handler)
self._query_handler = 0
self.current_slots.clear()
def query_todos(self, text: str) -> None:
self.handler.start("todos")
self.current_slots.clear()
text_stripped = text.strip()
commands = {
"add": self._handle_add_command,
"done": self._handle_done_command,
"remove": self._handle_remove_command,
"edit": self._handle_edit_command,
"clear": self._handle_clear_command,
"help": lambda t: self._show_command_help(),
}
for prefix, handler in commands.items():
if text_stripped.startswith(prefix):
handler(text)
return
self._load_and_display_todos(text)
def _load_and_display_todos(self, text: str) -> None:
result = self.todo_service.list_todos()
if not result["success"]:
self._show_error_message(f"Failed to load todos: {result['error']}")
return
self.todos = result["data"]
if not self.todos:
self._show_no_todos()
return
filtered_todos = self._filter_todos(text)
# Convert to list to check if empty, as we need to know if we should show "no results"
filtered_list = list(filtered_todos)
if not filtered_list:
self._show_no_results()
return
self._query_handler = idle_add(
self._bake_next_todo_slot, iter(filtered_list), pin=True
)
def _filter_todos(self, text: str) -> Iterator[dict]:
if not text.strip():
return iter(self.todos)
text_lower = text.lower()
return iter(
todo
for todo in self.todos
if (
text_lower in todo.get("title", "").lower()
or text_lower in todo.get("description", "").lower()
or any(text_lower in tag.lower() for tag in todo.get("tags", []))
)
)
def _show_no_todos(self) -> None:
self.handler.slot_ready(
Button(
style_classes="app-slot todo-no-todos",
child=Box(
orientation="h",
spacing=12,
children=[
Label(
label="No todos found",
style_classes="todo-no-todos-text",
h_align="center",
),
],
),
tooltip_text="Use 'add title description' to add todos",
),
"todos",
)
self.handler.done()
def _show_no_results(self) -> None:
self.handler.slot_ready(
Button(
style_classes="app-slot todo-no-results",
child=Box(
orientation="h",
spacing=12,
children=[
Label(
label="No todos match your search",
style_classes="todo-no-results-text",
h_align="center",
),
],
),
),
"todos",
)
self.handler.done()
def _bake_next_todo_slot(self, iterator: Iterator[dict]) -> bool:
try:
todo = next(iterator)
except StopIteration:
idle_add(self.handler.done)
return False
self._create_todo_slot(todo)
return True
def _create_todo_slot(self, todo: dict) -> None:
todo_id = todo.get("id")
title = todo.get("title", "")
description = todo.get("description", "")
status = todo.get("status", "pending")
priority = todo.get("priority", "medium")
tags = todo.get("tags", [])
status_icon = "✓" if status == "completed" else "○"
status_color = "completed" if status == "completed" else "pending"
priority_icons = {"low": "↓", "medium": "→", "high": "↑"}
priority_icon = priority_icons.get(priority, "→")
title_label = Label(
label=f"{status_icon} {title}",
style_classes=f"todo-title todo-{status_color}",
h_align="start",
max_chars_width=30,
ellipsization="end",
)
description_label = Label(
label=description if description else "No description",
style_classes="todo-description",
h_align="start",
max_chars_width=40,
ellipsization="end",
)
tags_label = Label(
label=f"{priority_icon} {' '.join(f'#{tag}' for tag in tags[:3])}"
if tags
else f"{priority_icon} {priority}",
style_classes="todo-tags",
h_align="start",
max_chars_width=35,
ellipsization="end",
)
slot_widget = Button(
style_classes=f"app-slot todo-slot todo-{status_color}",
child=Box(
orientation="v",
spacing=4,
children=[
title_label,
description_label,
tags_label,
],
),
tooltip_text=f"Click to toggle completion\nShift+Enter to toggle completion\nAlt+Enter to remove todo\nPriority: {priority}\nTags: {', '.join(tags) if tags else 'None'}",
on_clicked=lambda *_: self._toggle_todo_completion(todo_id),
)
slot_widget.connect(
"key-press-event",
lambda widget, event, tid=todo_id: self._on_slot_key_press(
widget, event, tid
),
)
slot_info = {
"widget": slot_widget,
"title_label": title_label,
"description_label": description_label,
"tags_label": tags_label,
"todo_id": todo_id,
"todo": todo,
}
self.current_slots.append(slot_info)
self.handler.slot_ready(slot_widget, "todos")
def _toggle_todo_completion(self, todo_id: int) -> None:
result = self.todo_service.get_todo(todo_id)
if not result["success"]:
self._show_error_message(f"Failed to get todo: {result['error']}")
return
todo = result["data"]
current_status = todo.get("status", "pending")
result = (
self.todo_service.mark_pending(todo_id)
if current_status == "completed"
else self.todo_service.mark_done(todo_id)
)
if not result["success"]:
self._show_error_message(f"Failed to update todo: {result['error']}")
return
self._refresh_todo_list()
def _remove_todo(self, todo_id: int) -> None:
result = self.todo_service.remove_todo(todo_id)
if not result["success"]:
self._show_error_message(f"Failed to remove todo: {result['error']}")
return
self._refresh_todo_list()
def _on_slot_key_press(self, widget, event, todo_id: int) -> bool:
if not event:
return False
# Shift+Enter: Toggle completion
if (
event.keyval == Gdk.KEY_Return or event.keyval == Gdk.KEY_KP_Enter
) and event.state & Gdk.ModifierType.SHIFT_MASK:
return self._toggle_todo_completion(todo_id) or True
# Alt+Enter: Remove todo
if (
event.keyval == Gdk.KEY_Return or event.keyval == Gdk.KEY_KP_Enter
) and event.state & Gdk.ModifierType.MOD1_MASK:
return self._remove_todo(todo_id) or True
return False
def _handle_add_command(self, text: str) -> None:
if not text.strip().endswith(";"):
self._show_error_message(
"Todo command must end with ';'\n\nUsage: add title [-d description] [-p low|medium|high];"
)
return
command_text = text.strip()[:-1]
parts = command_text.split()
if len(parts) < 2:
self._show_command_help("add")
return
title = parts[1]
description = ""
priority = "medium" # Default priority
i = 2
while i < len(parts):
if parts[i] == "-d":
desc_parts = []
i += 1
while i < len(parts) and not parts[i].startswith("-"):
desc_parts.append(parts[i])
i += 1
description = " ".join(desc_parts)
elif parts[i] == "-p":
if i + 1 < len(parts) and parts[i + 1] in ["low", "medium", "high"]:
priority = parts[i + 1]
i += 2
else:
self._show_error_message(
"Invalid priority. Use: low, medium, or high"
)
return
else:
if not description:
desc_parts = []
while i < len(parts) and not parts[i].startswith("-"):
desc_parts.append(parts[i])
i += 1
description = " ".join(desc_parts)
else:
i += 1
result = self.todo_service.add_todo(title, description, priority=priority)
if not result["success"]:
self._show_error_message(f"Error adding todo: {result['error']}")
return
if hasattr(self.handler, "clear_input"):
self.handler.clear_input()
self._refresh_todo_list()
def _handle_done_command(self, text: str) -> None:
if text.strip() == "done":
self._load_and_display_todos("")
return
if not text.strip().endswith(";"):
self._show_error_message(
"Todo command must end with ';'\n\nUsage: done todo_title;"
)
return
command_text = text.strip()[:-1]
parts = command_text.split()
if len(parts) < 2:
self._load_and_display_todos("")
return
todo_title = " ".join(parts[1:])
self._execute_todo_command("mark_done", todo_title)
def _handle_remove_command(self, text: str) -> None:
if text.strip() == "remove":
self._load_and_display_todos("")
return
if not text.strip().endswith(";"):
self._show_error_message(
"Todo command must end with ';'\n\nUsage: remove todo_title;"
)
return
command_text = text.strip()[:-1]
parts = command_text.split()
if len(parts) < 2:
self._load_and_display_todos("")
return
todo_title = " ".join(parts[1:])
self._execute_todo_command("remove_todo", todo_title)
def _handle_edit_command(self, text: str) -> None:
if text.strip() == "edit":
self._load_and_display_todos("")
return
if not text.strip().endswith(";"):
self._show_error_message(
"Todo command must end with ';'\n\nUsage: edit todo_title field new_value;"
)
return
command_text = text.strip()[:-1]
parts = command_text.split()
if len(parts) < 4:
self._load_and_display_todos("")
return
field = parts[-2]
new_value = parts[-1]
todo_title = " ".join(parts[1:-2])
if not todo_title:
self._show_error_message("Todo title cannot be empty")
return
if field not in ["title", "description", "priority", "status"]:
self._show_error_message(
"Invalid field. Use: title, description, priority, or status"
)
return
result = self.todo_service.edit_todo(
self._find_todo_by_title(todo_title), **{field: new_value}
)
if not result["success"]:
self._show_error_message(f"Error editing todo: {result['error']}")
return
if hasattr(self.handler, "clear_input"):
self.handler.clear_input()
self._refresh_todo_list()
def _handle_clear_command(self, text: str) -> None:
result = self.todo_service.clear_completed()
if not result["success"]:
self._show_error_message(
f"Error clearing completed todos: {result['error']}"
)
return
if hasattr(self.handler, "clear_input"):
self.handler.clear_input()
self._refresh_todo_list()
def _execute_todo_command(self, command: str, todo_title: str) -> None:
todo_id = self._find_todo_by_title(todo_title)
if not todo_id:
return
result = getattr(self.todo_service, command)(todo_id)
if not result["success"]:
self._show_error_message(f"Error executing command: {result['error']}")
return
if hasattr(self.handler, "clear_input"):
self.handler.clear_input()
self._refresh_todo_list()
def _find_todo_by_title(self, todo_title: str) -> int:
todos = self.todo_service.list_todos()["data"]
matching_todos = [
todo
for todo in todos
if todo.get("title", "").lower() == todo_title.lower()
]
if not matching_todos:
self._show_error_message(f"No todo found with title: '{todo_title}'")
return None
if len(matching_todos) > 1:
self._show_error_message(
f"Multiple todos found with title: '{todo_title}'\nPlease be more specific"
)
return None
return matching_todos[0]["id"]
def _show_command_help(self, command: str = None) -> None:
help_texts = {
"add": "Usage: add title [-d description] [-p low|medium|high];\nExample: add Buy groceries -d Get milk and bread -p high;\nExample: add Call mom -p low;\nExample: add Fix bug -d Critical issue;",
"done": "Usage: done todo_title;\nExample: done Buy groceries;",
"remove": "Usage: remove todo_title;\nExample: remove Buy groceries;",
"edit": "Usage: edit todo_title field new_value;\nFields: title, description, priority, status\nExample: edit Buy groceries title Buy organic groceries;",
"clear": "Usage: clear\nRemoves all completed todos",
}
help_text = help_texts.get(
command,
"""Available commands:
• add title [-d description] [-p low|medium|high]; - Add new todo
• done todo_title; - Mark todo as completed
• remove todo_title; - Remove todo
• edit todo_title field new_value; - Edit todo
• clear - Remove all completed todos
• help - Show this help
Note: Commands with arguments must end with semicolon (;)
Flags: -d for description, -p for priority (low|medium|high)
Click on todo or Shift+Enter to toggle completion
Alt+Enter to remove todo""",
)
self.handler.slot_ready(
Button(
style_classes="app-slot todo-help",
child=Box(
orientation="h",
spacing=12,
children=[
Label(
label=help_text,
style_classes="todo-help-text",
h_align="center",
),
],
),
),
"todos",
)
self.handler.done()
def _show_error_message(self, message: str) -> None:
self.handler.slot_ready(
Button(
style_classes="app-slot todo-error",
child=Box(
orientation="h",
spacing=12,
children=[
Label(
label=message,
style_classes="todo-error-text",
h_align="center",
),
],
),
on_clicked=lambda *_: self.handler.launched(),
),
"todos",
)
self.handler.done()
def _refresh_todo_list(self) -> None:
self.current_slots.clear()
if hasattr(self.handler, "clear_viewport"):
self.handler.clear_viewport()
idle_add(self._show_todo_list)
def _show_todo_list(self) -> None:
self.handler.start("todos")
self._load_and_display_todos("")
def handle_external(self, command: str, args: str) -> None:
# Map external commands if needed
pass