534 lines
17 KiB
Python
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
|