Files
Modus/modules/launcher/plugins/kanban.py
T

951 lines
33 KiB
Python

"""
Kanban Plugin for Modus Launcher
A maintainable kanban board implementation with the following features:
- Three columns: To Do, In Progress, Done
- Drag and drop between columns
- Inline editing of notes
- Keyboard navigation and shortcuts
- Persistent state storage
- Command-based interaction (add, move, done)
Architecture:
- KanbanEditor: Handles inline text editing
- KanbanNote: Individual note widget with editing and drag/drop
- KanbanColumn: Column container with notes and drag/drop handling
- KanbanBoard: Main board widget with keyboard handling and state management
- KanbanPlugin: Plugin interface and command processing
"""
import json
import os
from pathlib import Path
from typing import List, Optional, Tuple
import cairo
import gi
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
from gi.repository import Gdk, GLib, GObject, Gtk
import utils.icons as icons
from modules.launcher.plugin_base import PluginBase
from modules.launcher.result import Result
gi.require_version("Gtk", "3.0")
# Constants
KANBAN_STATE_FILE = Path(os.path.expanduser("~/.kanban.json"))
BOARD_HEIGHT = 500
COLUMN_HEIGHT = 150
EDITOR_MIN_HEIGHT = 50
def create_drag_surface(widget: Gtk.Widget) -> cairo.ImageSurface:
"""Create a cairo surface from a widget for drag operations."""
alloc = widget.get_allocation()
surface = cairo.ImageSurface(cairo.Format.ARGB32, alloc.width, alloc.height)
cr = cairo.Context(surface)
cr.set_source_rgba(0, 0, 0, 0)
cr.rectangle(0, 0, alloc.width, alloc.height)
cr.fill()
widget.draw(cr)
return surface
class KanbanEditor(Gtk.Box):
"""Inline text editor for kanban notes with confirm/cancel functionality."""
__gsignals__ = {
"confirmed": (GObject.SignalFlags.RUN_LAST, None, (str,)),
"canceled": (GObject.SignalFlags.RUN_LAST, None, ()),
}
def __init__(self, initial_text=""):
super().__init__(name="inline-editor", spacing=4)
self._setup_text_view(initial_text)
self._setup_buttons()
self._setup_layout()
self.show_all()
def _setup_text_view(self, initial_text: str):
"""Setup the text view component."""
self.text_view = Gtk.TextView()
self.text_view.set_wrap_mode(Gtk.WrapMode.WORD)
self.text_view.connect("key-press-event", self._on_key_press)
buffer = self.text_view.get_buffer()
buffer.set_text(initial_text)
def _setup_buttons(self):
"""Setup confirm and cancel buttons."""
# Confirm button
confirm_label = Label(name="kanban-btn-label", markup=icons.accept)
self.confirm_btn = Gtk.Button(name="kanban-btn")
self.confirm_btn.add(confirm_label)
self.confirm_btn.connect("clicked", self._on_confirm_clicked)
self.confirm_btn.get_style_context().add_class("flat")
# Cancel button
cancel_label = Label(name="kanban-btn-neg", markup=icons.cancel)
self.cancel_btn = Gtk.Button(name="kanban-btn")
self.cancel_btn.add(cancel_label)
self.cancel_btn.connect("clicked", self._on_cancel_clicked)
self.cancel_btn.get_style_context().add_class("flat")
def _setup_layout(self):
"""Setup the widget layout."""
# Scrolled window for text view
scrolled_window = Gtk.ScrolledWindow(name="scrolled-window")
scrolled_window.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
scrolled_window.set_min_content_height(EDITOR_MIN_HEIGHT)
scrolled_window.add(self.text_view)
# Button container
button_box = Gtk.Box(spacing=4)
button_box.pack_start(self.confirm_btn, False, False, 0)
button_box.pack_start(self.cancel_btn, False, False, 0)
# Center the buttons
center_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
center_box.set_halign(Gtk.Align.CENTER)
center_box.pack_start(button_box, False, False, 0)
# Main layout
self.pack_start(scrolled_window, True, True, 0)
self.pack_start(center_box, False, False, 0)
def _get_text(self) -> str:
"""Get the current text from the editor."""
buffer = self.text_view.get_buffer()
start, end = buffer.get_bounds()
return buffer.get_text(start, end, True).strip()
def _on_confirm_clicked(self, _widget):
"""Handle confirm button click."""
text = self._get_text()
if text:
self.emit("confirmed", text)
else:
self.emit("canceled")
def _on_cancel_clicked(self, _widget):
"""Handle cancel button click."""
self.emit("canceled")
def _on_key_press(self, _widget, event):
"""Handle keyboard events."""
if event.keyval == Gdk.KEY_Escape:
self.emit("canceled")
return True
if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
if event.get_state() & Gdk.ModifierType.SHIFT_MASK:
# Shift+Enter: Insert newline
buffer = self.text_view.get_buffer()
cursor_iter = buffer.get_iter_at_mark(buffer.get_insert())
buffer.insert(cursor_iter, "\n")
return True
else:
# Enter: Confirm
self._on_confirm_clicked(None)
return True
return False
class KanbanNote(Gtk.EventBox):
"""Individual kanban note widget with editing, deletion, and drag/drop support."""
__gsignals__ = {
"changed": (GObject.SignalFlags.RUN_LAST, None, ()),
}
def __init__(self, text: str):
super().__init__()
self.text = text
self.set_can_focus(True) # Enable keyboard events
self._setup_ui()
self._setup_drag_and_drop()
self._connect_signals()
self.show_all()
def _setup_ui(self):
"""Setup the note's user interface."""
self.box = Gtk.Box(name="kanban-note", spacing=4)
# Text label
self.label = Gtk.Label(label=self.text)
self.label.set_line_wrap(True)
self.label.set_line_wrap_mode(Gtk.WrapMode.WORD)
# Delete button
delete_label = Label(name="kanban-btn-neg", markup=icons.trash)
self.delete_btn = Gtk.Button(name="kanban-btn", child=delete_label)
self.delete_btn.connect("clicked", self._on_delete_clicked)
# Layout
button_container = CenterBox(orientation="v", start_children=[self.delete_btn])
self.box.pack_start(self.label, True, True, 0)
self.box.pack_start(button_container, False, False, 0)
self.add(self.box)
def _setup_drag_and_drop(self):
"""Setup drag and drop functionality."""
self.drag_source_set(
Gdk.ModifierType.BUTTON1_MASK,
[Gtk.TargetEntry.new("UTF8_STRING", Gtk.TargetFlags.SAME_APP, 0)],
Gdk.DragAction.MOVE,
)
def _connect_signals(self):
"""Connect all signal handlers."""
self.connect("button-press-event", self._on_button_press)
self.connect("key-press-event", self._on_key_press)
self.connect("drag-data-get", self._on_drag_data_get)
self.connect("drag-data-delete", self._on_drag_data_delete)
self.connect("drag-begin", self._on_drag_begin)
def _on_button_press(self, _widget, event):
"""Handle mouse button press events."""
if event.type == Gdk.EventType._2BUTTON_PRESS:
self.start_edit()
return False
return True
def _on_key_press(self, _widget, event):
"""Handle keyboard events for kanban notes."""
keyval = event.keyval
# Enter or F2 - start editing
if keyval in (Gdk.KEY_Return, Gdk.KEY_F2):
self.start_edit()
return True
# Delete - remove note
if keyval == Gdk.KEY_Delete:
self._delete_note()
return True
return False
def _on_drag_begin(self, _widget, context):
"""Handle drag begin event."""
surface = create_drag_surface(self)
Gtk.drag_set_icon_surface(context, surface)
def _on_drag_data_get(self, _widget, _drag_context, data, _info, _time):
"""Provide data for drag operation."""
data.set_text(self.label.get_text(), -1)
def _on_drag_data_delete(self, _widget, _drag_context):
"""Handle drag data deletion."""
self._delete_note()
def _on_delete_clicked(self, _button):
"""Handle delete button click."""
self._delete_note()
def _delete_note(self):
"""Delete this note from its parent."""
parent = self.get_parent()
if parent:
parent.destroy()
def start_edit(self):
"""Start inline editing of this note."""
row = self.get_parent()
if not row:
return
editor = KanbanEditor(self.label.get_text())
editor.connect("confirmed", self._on_edit_confirmed)
editor.connect("canceled", self._on_edit_canceled)
# Replace self with editor
row.remove(self)
row.add(editor)
row.show_all()
# Focus the editor after a short delay
GLib.timeout_add(50, lambda: (editor.text_view.grab_focus(), False))
def _on_edit_confirmed(self, editor, text):
"""Handle edit confirmation."""
self.label.set_text(text)
self._replace_editor_with_self(editor)
self.emit("changed")
def _on_edit_canceled(self, editor):
"""Handle edit cancellation."""
self._replace_editor_with_self(editor)
def _replace_editor_with_self(self, editor):
"""Replace the editor with this note widget."""
row = editor.get_parent()
if row:
row.remove(editor)
row.add(self)
row.show_all()
class KanbanColumn(Gtk.Frame):
"""A kanban column containing notes with drag/drop support."""
__gsignals__ = {
"changed": (GObject.SignalFlags.RUN_LAST, None, ()),
}
def __init__(self, title: str):
super().__init__(name="kanban-column")
self.title = title
self._setup_ui()
self._setup_drag_and_drop()
self._configure_size()
self.show_all()
def _configure_size(self):
"""Configure size constraints for launcher compatibility."""
self.set_hexpand(True)
self.set_vexpand(True)
self.set_size_request(-1, COLUMN_HEIGHT)
def _setup_ui(self):
"""Setup the column's user interface."""
# Main container
self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
# Notes list
self.listbox = Gtk.ListBox()
self.listbox.set_selection_mode(Gtk.SelectionMode.NONE)
# Add button
add_label = Label(name="kanban-btn-label", markup=icons.add)
self.add_btn = Gtk.Button(name="kanban-btn-add", child=add_label)
self.add_btn.connect("clicked", self._on_add_clicked)
# Header with title and add button
title_label = Label(name="column-header", label=self.title)
header = CenterBox(
name="kanban-header",
center_children=[title_label],
end_children=[self.add_btn],
)
# Scrolled window for notes
self.scroller = ScrolledWindow(
name="scrolled-window",
propagate_height=False,
propagate_width=False
)
self.scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
self.scroller.add(self.listbox)
self.scroller.set_vexpand(True)
# Layout
self.box.pack_start(header, False, False, 0)
self.box.pack_start(self.scroller, True, True, 0)
self.box.pack_start(self.add_btn, False, False, 0)
self.add(self.box)
def _setup_drag_and_drop(self):
"""Setup drag and drop functionality."""
self.listbox.drag_dest_set(
Gtk.DestDefaults.ALL,
[Gtk.TargetEntry.new("UTF8_STRING", Gtk.TargetFlags.SAME_APP, 0)],
Gdk.DragAction.MOVE,
)
# Connect drag and drop signals
self.listbox.connect("drag-data-received", self._on_drag_data_received)
self.listbox.connect("drag-motion", self._on_drag_motion)
self.listbox.connect("drag-leave", self._on_drag_leave)
def _on_add_clicked(self, _button):
"""Handle add button click to create a new note."""
editor = KanbanEditor()
row = Gtk.ListBoxRow(name="kanban-row")
row.add(editor)
self.listbox.add(row)
self.listbox.show_all()
editor.text_view.grab_focus()
# Connect editor signals
editor.connect("confirmed", self._on_editor_confirmed)
editor.connect("canceled", self._on_editor_canceled)
# Scroll to bottom after row is loaded
GLib.idle_add(self._scroll_to_bottom)
def _on_editor_confirmed(self, editor, text):
"""Handle editor confirmation for new note."""
note = KanbanNote(text)
note.connect("changed", lambda _: self.emit("changed"))
# Replace editor with note
row = editor.get_parent()
if row:
row.remove(editor)
row.add(note)
self.listbox.show_all()
self.emit("changed")
def _on_editor_canceled(self, editor):
"""Handle editor cancellation for new note."""
row = editor.get_parent()
if row:
row.destroy()
def _scroll_to_bottom(self):
"""Scroll the column to the bottom."""
adj = self.scroller.get_vadjustment()
adj.set_value(adj.get_upper())
return False # Don't repeat
def add_note(self, text: str, suppress_signal: bool = False):
"""Add a new note to this column."""
note = KanbanNote(text)
note.connect("changed", lambda _: self.emit("changed"))
row = Gtk.ListBoxRow(name="kanban-row")
row.add(note)
row.connect("destroy", lambda _: self.emit("changed"))
self.listbox.add(row)
self.listbox.show_all()
if not suppress_signal:
self.emit("changed")
def get_notes(self) -> List[str]:
"""Get all note texts in this column."""
return [
row.get_children()[0].label.get_text()
for row in self.listbox.get_children()
if row.get_children() and isinstance(row.get_children()[0], KanbanNote)
]
def clear_notes(self, suppress_signal: bool = False):
"""Clear all notes from this column."""
for row in self.listbox.get_children():
row.destroy()
if not suppress_signal:
self.emit("changed")
def _on_drag_data_received(self, _widget, drag_context, _x, y, data, _info, time):
"""Handle drag data received event."""
text = data.get_text()
if not text:
return
# Find insertion point
row = self.listbox.get_row_at_y(y)
# Create new note
new_note = KanbanNote(text)
new_note.connect("changed", lambda _: self.emit("changed"))
new_row = Gtk.ListBoxRow(name="kanban-row")
new_row.add(new_note)
new_row.connect("destroy", lambda _: self.emit("changed"))
# Insert at appropriate position
if row:
self.listbox.insert(new_row, row.get_index())
else:
self.listbox.add(new_row)
self.listbox.show_all()
drag_context.finish(True, False, time)
self.emit("changed")
def _on_drag_motion(self, _widget, drag_context, _x, _y, time):
"""Handle drag motion event."""
Gdk.drag_status(drag_context, Gdk.DragAction.MOVE, time)
return True
def _on_drag_leave(self, widget, _drag_context, _time):
"""Handle drag leave event."""
widget.get_parent().get_parent().drag_unhighlight()
class KanbanBoard(Gtk.Box):
"""Main kanban board widget with three columns and command handling."""
__gsignals__ = {
"todo-added": (GObject.SignalFlags.RUN_LAST, None, ()),
}
def __init__(self):
super().__init__(name="kanban", orientation=Gtk.Orientation.VERTICAL)
# Command state
self.pending_add_text = None
self.pending_command = None
self._setup_ui()
self._setup_columns()
self._load_state()
self.show_all()
def _setup_ui(self):
"""Setup the board's user interface."""
# Configure size for launcher compatibility
self.set_size_request(-1, BOARD_HEIGHT)
self.set_vexpand(False)
self.set_hexpand(True)
self.set_can_focus(True)
# Connect keyboard events
self.connect("key-press-event", self._on_key_press)
# Hidden entry for launcher Enter key handling
from fabric.widgets.entry import Entry
self.hidden_entry = Entry(name="kanban-hidden-entry")
self.hidden_entry.set_size_request(1, 1)
self.hidden_entry.set_opacity(0.0)
self.hidden_entry.connect("activate", self._on_hidden_entry_activate)
self.add(self.hidden_entry)
# Grid for columns
self.grid = Gtk.Grid(
column_spacing=4,
column_homogeneous=True,
row_spacing=4,
row_homogeneous=True,
)
self.grid.set_vexpand(True)
self.grid.set_hexpand(True)
self.add(self.grid)
def _setup_columns(self):
"""Setup the kanban columns."""
self.columns = [
KanbanColumn("To Do"),
KanbanColumn("In Progress"),
KanbanColumn("Done"),
]
# Arrange columns vertically (stacked)
for i, column in enumerate(self.columns):
self.grid.attach(column, 0, i, 1, 1)
column.connect("changed", lambda _: self._save_state())
def _load_state(self):
"""Load kanban state from file."""
try:
with open(KANBAN_STATE_FILE, "r") as f:
state = json.load(f)
for col_data in state["columns"]:
for column in self.columns:
if column.title == col_data["title"]:
column.clear_notes(suppress_signal=True)
for note_text in col_data["notes"]:
column.add_note(note_text, suppress_signal=True)
break
except FileNotFoundError:
pass # No state file yet
except Exception as e:
print(f"Error loading kanban state: {e}")
def _save_state(self):
"""Save kanban state to file."""
state = {
"columns": [
{"title": col.title, "notes": col.get_notes()}
for col in self.columns
]
}
try:
with open(KANBAN_STATE_FILE, "w") as f:
json.dump(state, f, indent=2)
except Exception as e:
print(f"Error saving kanban state: {e}")
def _on_key_press(self, _widget, event):
"""Handle key press events for the kanban widget."""
keyval = event.keyval
# Enter - handle pending commands
if keyval == Gdk.KEY_Return:
return self._handle_enter_key()
# Ctrl+N - add new note to first column
if keyval == Gdk.KEY_n and event.state & Gdk.ModifierType.CONTROL_MASK:
self.columns[0]._on_add_clicked(None)
return True
# Ctrl+1, Ctrl+2, Ctrl+3 - add note to specific column
if event.state & Gdk.ModifierType.CONTROL_MASK:
column_shortcuts = {
Gdk.KEY_1: 0,
Gdk.KEY_2: 1,
Gdk.KEY_3: 2
}
if keyval in column_shortcuts:
column_idx = column_shortcuts[keyval]
if column_idx < len(self.columns):
self.columns[column_idx]._on_add_clicked(None)
return True
return False
def _handle_enter_key(self) -> bool:
"""Handle Enter key press for command execution."""
if self.pending_command:
command, text = self.pending_command
success = False
if command == "add":
self.columns[0].add_note(text)
success = True
elif command == "move":
success = self._execute_move_command(text)
elif command == "done":
success = self._execute_done_command(text)
elif command == "remove":
success = self._execute_remove_command(text)
elif command == "clear":
success = self._execute_clear_command()
if success:
self._save_state()
# Clear pending command and notify plugin to reset
self._clear_pending_commands()
self.emit("todo-added") # Signal for plugin reset
return True
elif self.pending_add_text:
# Fallback for old add system
self.columns[0].add_note(self.pending_add_text)
self._save_state()
self._clear_pending_commands()
self.emit("todo-added")
return True
return False
def _on_hidden_entry_activate(self, _entry):
"""Handle activation of the hidden entry (Enter key from launcher)."""
self._handle_enter_key()
def _clear_pending_commands(self):
"""Clear all pending command state."""
self.pending_command = None
self.pending_add_text = None
def set_pending_add_text(self, text: str):
"""Set the pending add text for Enter key handling."""
self.pending_add_text = text
def _execute_move_command(self, todo_text: str) -> bool:
"""Execute move command to advance todo to next state."""
search_text = todo_text.lower().strip()
# Find todo across all columns
for col_idx, column in enumerate(self.columns):
notes = column.get_notes()
for note_text in notes:
if search_text in note_text.lower():
return self._move_note_to_next_column(column, note_text, col_idx)
return False
def _execute_done_command(self, todo_text: str) -> bool:
"""Execute done command to move todo directly to Done column."""
search_text = todo_text.lower().strip()
# Find todo in To Do and In Progress columns only
for column in self.columns[:2]:
notes = column.get_notes()
for note_text in notes:
if search_text in note_text.lower():
return self._move_note_to_done_column(column, note_text)
# Check if already in Done column
done_notes = self.columns[2].get_notes()
return not any(search_text in note.lower() for note in done_notes)
def _move_note_to_next_column(self, current_column: KanbanColumn, note_text: str, col_idx: int) -> bool:
"""Move a note to the next column in sequence."""
# Determine next column
if col_idx == 0: # To Do -> In Progress
next_col_idx = 1
elif col_idx == 1: # In Progress -> Done
next_col_idx = 2
else: # Already in Done
return False
# Remove from current column and add to next
self._remove_note_from_column(current_column, note_text)
self.columns[next_col_idx].add_note(note_text)
return True
def _move_note_to_done_column(self, current_column: KanbanColumn, note_text: str) -> bool:
"""Move a note directly to the Done column."""
self._remove_note_from_column(current_column, note_text)
self.columns[2].add_note(note_text) # Done column
return True
def _execute_remove_command(self, todo_text: str) -> bool:
"""Execute remove command to delete a specific todo."""
search_text = todo_text.lower().strip()
# Find todo across all columns
for column in self.columns:
notes = column.get_notes()
for note_text in notes:
if search_text in note_text.lower():
self._remove_note_from_column(column, note_text)
return True
return False
def _execute_clear_command(self) -> bool:
"""Execute clear command to remove all todos from all columns."""
for column in self.columns:
column.clear_notes(suppress_signal=True)
return True
def _remove_note_from_column(self, column: KanbanColumn, note_text: str):
"""Remove a specific note from a column."""
for row in column.listbox.get_children():
if row.get_children():
note_widget = row.get_children()[0]
if (isinstance(note_widget, KanbanNote) and
note_widget.label.get_text() == note_text):
row.destroy()
break
class KanbanPlugin(PluginBase):
"""Kanban board plugin for the launcher providing task management interface."""
def __init__(self):
super().__init__()
self.display_name = "Kanban"
self.description = "Kanban board for task management"
self._current_widget = None
self._launcher_instance = None
def initialize(self):
"""Initialize the Kanban plugin."""
self.set_triggers(["kanban"])
self.description = (
"Kanban board for task management. "
"Commands: 'add <text>', 'move <text>', 'done <text>', 'remove <text>', 'clear'. "
"Keyboard: Enter/F2 to edit, Del to delete, Ctrl+N to add note"
)
self._setup_launcher_hooks()
def cleanup(self):
"""Cleanup the Kanban plugin."""
if self._current_widget:
self._destroy_current_widget()
self._cleanup_launcher_hooks()
def _destroy_current_widget(self):
"""Safely destroy the current widget."""
if self._current_widget.get_parent():
self._current_widget.get_parent().remove(self._current_widget)
self._current_widget.destroy()
self._current_widget = None
def _setup_launcher_hooks(self):
"""Setup hooks to monitor launcher state."""
try:
import gc
# Find the launcher instance using garbage collection
for obj in gc.get_objects():
if (hasattr(obj, "__class__") and
obj.__class__.__name__ == "Launcher" and
hasattr(obj, "close_launcher")):
self._launcher_instance = obj
break
except Exception as e:
print(f"Warning: Could not setup launcher hooks: {e}")
def _cleanup_launcher_hooks(self):
"""Cleanup launcher hooks."""
self._launcher_instance = None
def _reset_to_trigger(self):
"""Reset launcher to trigger word and refresh."""
if not (self._launcher_instance and
hasattr(self._launcher_instance, "search_entry")):
return
trigger = "kanban "
try:
def reset_and_refresh():
self._launcher_instance.search_entry.set_text(trigger)
self._launcher_instance.search_entry.set_position(-1)
self._launcher_instance._perform_search(trigger)
return False
GLib.timeout_add(50, reset_and_refresh)
except Exception as e:
print(f"Could not reset to trigger: {e}")
def _delayed_reset_for_add(self):
"""Reset to trigger with minimal delay for add operations."""
try:
GLib.timeout_add(50, lambda: (self._reset_to_trigger(), False))
except ImportError:
self._reset_to_trigger()
def _find_todo_in_columns(self, search_text: str) -> Tuple[Optional[int], Optional[str]]:
"""Find a todo by partial text match across all columns."""
if not self._current_widget:
return None, None
search_text = search_text.lower().strip()
for col_idx, column in enumerate(self._current_widget.columns):
notes = column.get_notes()
for note_text in notes:
if search_text in note_text.lower():
return col_idx, note_text
return None, None
def _move_todo_to_next_state(self, todo_text: str) -> Tuple[bool, str]:
"""Move a todo to the next state (To Do -> In Progress -> Done)."""
if not self._current_widget:
return False, "Kanban board not initialized"
col_idx, found_text = self._find_todo_in_columns(todo_text)
if col_idx is None:
return False, f"Todo '{todo_text}' not found"
# Determine next column
state_transitions = {
0: (1, "In Progress"), # To Do -> In Progress
1: (2, "Done"), # In Progress -> Done
}
if col_idx not in state_transitions:
return False, f"Todo '{found_text}' is already completed"
next_col_idx, next_state = state_transitions[col_idx]
# Move the note
self._current_widget._remove_note_from_column(
self._current_widget.columns[col_idx], found_text
)
self._current_widget.columns[next_col_idx].add_note(found_text)
self._current_widget._save_state()
return True, f"Moved '{found_text}' to {next_state}"
def _execute_complete_action(self, todo_text: str):
"""Execute the complete action (move directly to Done) and reset to trigger."""
if not self._current_widget:
return
col_idx, found_text = self._find_todo_in_columns(todo_text)
if col_idx is None or col_idx == 2: # Not found or already in Done
return
# Move to Done column
self._current_widget._remove_note_from_column(
self._current_widget.columns[col_idx], found_text
)
self._current_widget.columns[2].add_note(found_text)
self._current_widget._save_state()
self._delayed_reset_for_add()
def _execute_add_action(self, todo_text: str):
"""Execute the add action and reset to trigger."""
if not self._current_widget:
return
self._current_widget.columns[0].add_note(todo_text)
self._current_widget._save_state()
self._delayed_reset_for_add()
def _execute_remove_action(self, todo_text: str):
"""Execute the remove action and reset to trigger."""
if not self._current_widget:
return
success = self._current_widget._execute_remove_command(todo_text)
if success:
self._current_widget._save_state()
self._delayed_reset_for_add()
def _execute_clear_action(self):
"""Execute the clear action and reset to trigger."""
if not self._current_widget:
return
self._current_widget._execute_clear_command()
self._current_widget._save_state()
self._delayed_reset_for_add()
def query(self, query_string: str) -> List[Result]:
"""Process Kanban queries and return results."""
query = query_string.strip()
# Get or create kanban widget
kanban_widget = self._get_or_create_widget()
# Process command and set pending state
self._process_command(kanban_widget, query)
# Return kanban board result
return [self._create_kanban_result(kanban_widget)]
def _get_or_create_widget(self) -> KanbanBoard:
"""Get existing widget or create a new one."""
if not self._current_widget:
self._current_widget = KanbanBoard()
self._current_widget.connect("todo-added", lambda _: self._delayed_reset_for_add())
return self._current_widget
def _process_command(self, widget: KanbanBoard, query: str):
"""Process the query command and set pending state."""
# Clear previous state
widget.set_pending_add_text(None)
widget.pending_command = None
# Parse commands
if query.startswith("add "):
note_text = query[4:].strip()
if note_text:
widget.set_pending_add_text(note_text)
widget.pending_command = ("add", note_text)
elif query.startswith(("move ", "progress ")):
todo_text = self._extract_command_text(query)
if todo_text:
widget.pending_command = ("move", todo_text)
elif query.startswith(("done ", "complete ")):
todo_text = self._extract_command_text(query)
if todo_text:
widget.pending_command = ("done", todo_text)
elif query.startswith("remove "):
todo_text = self._extract_command_text(query)
if todo_text:
widget.pending_command = ("remove", todo_text)
elif query.strip() == "clear":
widget.pending_command = ("clear", "")
def _extract_command_text(self, query: str) -> Optional[str]:
"""Extract text from command query."""
parts = query.split(" ", 1)
return parts[1].strip() if len(parts) > 1 else None
def _create_kanban_result(self, widget: KanbanBoard) -> Result:
"""Create the kanban board result."""
return Result(
title="Kanban Board",
subtitle="Task management with kanban-style columns",
icon_markup=icons.kanban,
action=lambda: None, # Widget handles Enter key
relevance=1.0,
plugin_name=self.display_name,
custom_widget=widget,
data={"type": "kanban_board", "keep_launcher_open": True},
)