import gi import json import os from pathlib import Path import cairo # For rendering the drag preview from fabric.widgets.label import Label from fabric.widgets.box import Box from fabric.widgets.centerbox import CenterBox import utils.icons as icons gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gdk, GObject, GLib def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface: alloc = widget.get_allocation() surface = cairo.ImageSurface(cairo.Format.ARGB32, alloc.width, alloc.height) cr = cairo.Context(surface) # Use a transparent background. cr.set_source_rgba(0, 0, 0, 0) cr.rectangle(0, 0, alloc.width, alloc.height) cr.fill() widget.draw(cr) return surface class InlineEditor(Gtk.Box): __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) # Replace Gtk.Entry with a Gtk.TextView for multiline editing. self.text_view = Gtk.TextView() self.text_view.set_wrap_mode(Gtk.WrapMode.WORD) buffer = self.text_view.get_buffer() buffer.set_text(initial_text) # Connect key press events to handle Return and SHIFT+Return. self.text_view.connect("key-press-event", self.on_key_press) confirm_btn = Gtk.Button( name="kanban-btn", child=Label(name="kanban-btn-label", markup=icons.accept) ) confirm_btn.connect("clicked", self.on_confirm) confirm_btn.get_style_context().add_class("flat") cancel_btn = Gtk.Button( name="kanban-btn", child=Label(name="kanban-btn-neg", markup=icons.cancel) ) cancel_btn.connect("clicked", self.on_cancel) cancel_btn.get_style_context().add_class("flat") # Pack the TextView inside a ScrolledWindow for better appearance. sw = Gtk.ScrolledWindow() sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) sw.set_min_content_height(50) sw.add(self.text_view) self.button_box = Box(children=[confirm_btn, cancel_btn], spacing=4) self.center_box = CenterBox(center_children=[self.button_box], orientation="v") self.pack_start(sw, True, True, 0) self.pack_start(self.center_box, False, False, 0) self.show_all() def on_confirm(self, widget): buffer = self.text_view.get_buffer() start, end = buffer.get_bounds() text = buffer.get_text(start, end, True).strip() if text: self.emit("confirmed", text) else: self.emit("canceled") def on_cancel(self, widget): self.emit("canceled") def on_key_press(self, widget, event): # Check for Escape to cancel. if event.keyval == Gdk.KEY_Escape: self.emit("canceled") return True # If Return is pressed... if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): state = event.get_state() if state & Gdk.ModifierType.SHIFT_MASK: # SHIFT+Return: insert a newline. buffer = self.text_view.get_buffer() cursor_iter = buffer.get_iter_at_mark(buffer.get_insert()) buffer.insert(cursor_iter, "\n") return True # Prevent further handling. else: # Plain Return: confirm the edit. self.on_confirm(widget) return True return False class KanbanNote(Gtk.EventBox): __gsignals__ = { "changed": (GObject.SignalFlags.RUN_LAST, None, ()), } def __init__(self, text): super().__init__() self.text = text # Variables to store the click offset for drag preview. self.setup_ui() self.setup_dnd() self.connect("button-press-event", self.on_button_press) def setup_ui(self): self.box = Gtk.Box(name="kanban-note", spacing=4) self.label = Gtk.Label(label=self.text) self.label.set_line_wrap(True) # Wrap long lines. self.label.set_line_wrap_mode(Gtk.WrapMode.WORD) self.delete_btn = Gtk.Button( name="kanban-btn", child=Label(name="kanban-btn-neg", markup=icons.trash) ) self.delete_btn.connect("clicked", self.on_delete_clicked) self.center_btn = CenterBox(orientation="v", start_children=[self.delete_btn]) self.box.pack_start(self.label, True, True, 0) self.box.pack_start(self.center_btn, False, False, 0) self.add(self.box) self.show_all() def setup_dnd(self): self.drag_source_set( Gdk.ModifierType.BUTTON1_MASK, [Gtk.TargetEntry.new("UTF8_STRING", Gtk.TargetFlags.SAME_APP, 0)], Gdk.DragAction.MOVE, ) self.connect("drag-data-get", self.on_drag_data_get) self.connect("drag-data-delete", self.on_drag_data_delete) # Set up drag-begin to display a preview of the card. self.connect("drag-begin", self.on_drag_begin) def on_button_press(self, widget, event): if event.type != Gdk.EventType._2BUTTON_PRESS: return True self.start_edit() return False def on_drag_begin(self, widget, context): surface = createSurfaceFromWidget(self) Gtk.drag_set_icon_surface(context, surface) def on_drag_data_get(self, widget, drag_context, data, info, time): data.set_text(self.label.get_text(), -1) def on_drag_data_delete(self, widget, drag_context): self.get_parent().destroy() def on_delete_clicked(self, button): self.get_parent().destroy() def start_edit(self): row = self.get_parent() editor = InlineEditor(self.label.get_text()) def on_confirmed(editor, text): self.label.set_text(text) row.remove(editor) row.add(self) row.show_all() self.emit("changed") def on_canceled(editor): row.remove(editor) row.add(self) row.show_all() editor.connect("confirmed", on_confirmed) editor.connect("canceled", on_canceled) row.remove(self) row.add(editor) row.show_all() # Usamos un retardo de 50 milisegundos: GLib.timeout_add(50, lambda: (editor.text_view.grab_focus(), False)) class KanbanColumn(Gtk.Frame): __gsignals__ = { "changed": (GObject.SignalFlags.RUN_LAST, None, ()), } def __init__(self, title): super().__init__(name="kanban-column") self.title = title self.setup_ui() self.setup_dnd() self.set_hexpand(True) self.set_vexpand(True) def setup_ui(self): self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) self.listbox = Gtk.ListBox() self.listbox.set_selection_mode(Gtk.SelectionMode.NONE) self.add_btn = Gtk.Button( name="kanban-btn-add", child=Label(name="kanban-btn-label", markup=icons.add), ) header = CenterBox( name="kanban-header", center_children=[Label(name="column-header", label=self.title)], end_children=[self.add_btn], ) self.box.pack_start(header, False, False, 0) self.add_btn.connect("clicked", self.on_add_clicked) scrolled = Gtk.ScrolledWindow(name="kanban-scroll") scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) scrolled.add(self.listbox) scrolled.set_vexpand(True) self.box.pack_start(scrolled, True, True, 0) self.box.pack_start(self.add_btn, False, False, 0) self.add(self.box) self.show_all() def setup_dnd(self): self.listbox.drag_dest_set( Gtk.DestDefaults.ALL, [Gtk.TargetEntry.new("UTF8_STRING", Gtk.TargetFlags.SAME_APP, 0)], Gdk.DragAction.MOVE, ) 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): editor = InlineEditor() row = Gtk.ListBoxRow(name="kanban-row") row.add(editor) self.listbox.add(row) self.listbox.show_all() editor.text_view.grab_focus() def on_confirmed(editor, text): note = KanbanNote(text) note.connect("changed", lambda x: self.emit("changed")) row.remove(editor) row.add(note) self.listbox.show_all() self.emit("changed") # Emit on add def on_canceled(editor): row.destroy() editor.connect("confirmed", on_confirmed) editor.connect("canceled", on_canceled) def add_note(self, text, suppress_signal=False): note = KanbanNote(text) note.connect("changed", lambda x: self.emit("changed")) row = Gtk.ListBoxRow(name="kanban-row") row.add(note) row.connect("destroy", lambda x: self.emit("changed")) self.listbox.add(row) self.listbox.show_all() if not suppress_signal: self.emit("changed") def get_notes(self): return [ row.get_children()[0].label.get_text() for row in self.listbox.get_children() if isinstance(row.get_children()[0], KanbanNote) ] def clear_notes(self, suppress_signal=False): 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): text = data.get_text() if text: row = self.listbox.get_row_at_y(y) new_note = KanbanNote(text) new_note.connect("changed", lambda x: self.emit("changed")) new_row = Gtk.ListBoxRow(name="kanban-row") new_row.add(new_note) new_row.connect("destroy", lambda x: self.emit("changed")) 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") # Emit on move def on_drag_motion(self, widget, drag_context, x, y, time): Gdk.drag_status(drag_context, Gdk.DragAction.MOVE, time) return True def on_drag_leave(self, widget, drag_context, time): widget.get_parent().get_parent().drag_unhighlight() class Kanban(Gtk.Box): STATE_FILE = Path(os.path.expanduser("~/.local/state/modus/kanban.json")) def __init__(self): super().__init__(name="kanban") self.grid = Gtk.Grid(column_spacing=4, column_homogeneous=True) self.grid.set_vexpand(True) self.add(self.grid) self.columns = [ KanbanColumn("To Do"), KanbanColumn("In Progress"), KanbanColumn("Done"), ] for i, column in enumerate(self.columns): self.grid.attach(column, i, 0, 1, 1) column.connect("changed", lambda x: self.save_state()) self.load_state() self.show_all() def save_state(self): state = { "columns": [ {"title": col.title, "notes": col.get_notes()} for col in self.columns ] } try: with open(self.STATE_FILE, "w") as f: json.dump(state, f, indent=2) except Exception as e: print(f"Error saving state: {e}") def load_state(self): try: with open(self.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 except Exception as e: print(f"Error loading state: {e}")