main
  1;;; org-kanban.el --- Kanban board view for org-mode TODOs -*- lexical-binding: t; -*-
  2
  3;; Copyright (C) 2026 Vincent Demeester
  4
  5;; Author: Vincent Demeester <vincent@sbr.pm>
  6;; Keywords: org, kanban, productivity
  7;; Version: 0.1.0
  8;; Package-Requires: ((emacs "29.1") (org-ql "0.8"))
  9
 10;;; Commentary:
 11
 12;; Interactive kanban board that renders org-mode TODOs as cards in
 13;; columns based on their TODO state.  Cards can be moved between
 14;; columns (changing their state) with simple keybindings.
 15;;
 16;; Uses org-ql for querying and org-mode API for state changes.
 17;; Data source is your existing todos.org file.
 18;;
 19;; Keybindings in org-kanban buffers:
 20;;   j / n / ↓ - next card down in column
 21;;   k / p / ↑ - previous card up in column
 22;;   l / →     - next column
 23;;   h / ←     - previous column
 24;;   > / L     - move card to next state
 25;;   < / H     - move card to previous state
 26;;   RET       - jump to heading in org file
 27;;   v / SPC   - preview card in side window (toggle)
 28;;   f         - filter by section
 29;;   F         - clear filters
 30;;   D         - toggle DONE/CANX visibility
 31;;   g         - refresh board
 32;;   q         - quit
 33;;   TAB       - cycle card detail
 34;;
 35;; Interactive commands:
 36;;   M-x org-kanban       - open kanban board
 37;;   M-x org-kanban-work  - board filtered to Work section
 38
 39;;; Code:
 40
 41(require 'org)
 42(require 'org-ql)
 43(require 'cl-lib)
 44
 45;;; Customization
 46
 47(defgroup org-kanban nil
 48  "Kanban board view for org-mode TODOs."
 49  :group 'org
 50  :prefix "org-kanban-")
 51
 52(defcustom org-kanban-file (expand-file-name "~/desktop/org/todos.org")
 53  "Path to the org file to display as a kanban board."
 54  :type 'file
 55  :group 'org-kanban)
 56
 57(defcustom org-kanban-columns '("TODO" "NEXT" "STRT" "WAIT")
 58  "TODO states to display as columns (left to right).
 59DONE and CANX are toggled with `D'."
 60  :type '(repeat string)
 61  :group 'org-kanban)
 62
 63(defcustom org-kanban-done-columns '("DONE" "CANX")
 64  "Completed states, hidden by default.  Toggle with `D'."
 65  :type '(repeat string)
 66  :group 'org-kanban)
 67
 68(defcustom org-kanban-state-order '("TODO" "NEXT" "STRT" "WAIT" "DONE" "CANX")
 69  "Order of states for moving cards left/right."
 70  :type '(repeat string)
 71  :group 'org-kanban)
 72
 73(defcustom org-kanban-column-width 30
 74  "Width of each column in characters."
 75  :type 'integer
 76  :group 'org-kanban)
 77
 78(defcustom org-kanban-max-cards nil
 79  "Maximum number of cards per column.  Nil means no limit."
 80  :type '(choice (const :tag "No limit" nil) integer)
 81  :group 'org-kanban)
 82
 83(defcustom org-kanban-level 2
 84  "Org heading level to display as cards.
 852 means direct children of top-level sections."
 86  :type 'integer
 87  :group 'org-kanban)
 88
 89(defcustom org-kanban-open-in-tab t
 90  "If non-nil, RET opens the heading in a new tab.
 91If nil, opens in a new window via `pop-to-buffer'."
 92  :type 'boolean
 93  :group 'org-kanban)
 94
 95(defcustom org-kanban-sort 'priority
 96  "Default sort order for cards within each column."
 97  :type '(choice (const :tag "Priority (highest first)" priority)
 98                 (const :tag "Scheduled date (earliest first)" scheduled)
 99                 (const :tag "Deadline (earliest first)" deadline)
100                 (const :tag "Alphabetical" alpha)
101                 (const :tag "No sorting (file order)" none))
102  :group 'org-kanban)
103
104;;; Faces
105
106(defface org-kanban-column-header
107  '((t :inherit fixed-pitch :weight bold :underline t))
108  "Face for column headers."
109  :group 'org-kanban)
110
111(defface org-kanban-card
112  '((t :inherit default))
113  "Face for card text."
114  :group 'org-kanban)
115
116(defface org-kanban-card-selected
117  '((((class color) (background dark))
118     :background "#44475a" :extend t)
119    (((class color) (background light))
120     :background "#dde4ff" :extend t)
121    (t :inverse-video t :extend t))
122  "Face for the currently selected card."
123  :group 'org-kanban)
124
125(defface org-kanban-indicator
126  '((((background dark))
127     :foreground "#ff79c6" :weight bold)
128    (((background light))
129     :foreground "#a626a4" :weight bold))
130  "Face for the active card indicator."
131  :group 'org-kanban)
132
133(defface org-kanban-priority-1
134  '((t :foreground "#ff5555" :weight bold))
135  "Face for priority 1 (highest)."
136  :group 'org-kanban)
137
138(defface org-kanban-priority-2
139  '((t :foreground "#ffb86c" :weight bold))
140  "Face for priority 2."
141  :group 'org-kanban)
142
143(defface org-kanban-priority-3
144  '((t :foreground "#f1fa8c"))
145  "Face for priority 3."
146  :group 'org-kanban)
147
148(defface org-kanban-state-todo
149  '((t :foreground "#8be9fd"))
150  "Face for TODO state."
151  :group 'org-kanban)
152
153(defface org-kanban-state-next
154  '((t :foreground "#50fa7b" :weight bold))
155  "Face for NEXT state."
156  :group 'org-kanban)
157
158(defface org-kanban-state-strt
159  '((t :foreground "#ff79c6" :weight bold))
160  "Face for STRT state."
161  :group 'org-kanban)
162
163(defface org-kanban-state-wait
164  '((t :foreground "#ffb86c"))
165  "Face for WAIT state."
166  :group 'org-kanban)
167
168(defface org-kanban-state-done
169  '((t :foreground "#6272a4"))
170  "Face for DONE state."
171  :group 'org-kanban)
172
173(defface org-kanban-state-canx
174  '((t :foreground "#6272a4" :strike-through t))
175  "Face for CANX state."
176  :group 'org-kanban)
177
178(defface org-kanban-tag
179  '((t :inherit org-tag))
180  "Face for tags on cards."
181  :group 'org-kanban)
182
183(defface org-kanban-date
184  '((t :inherit org-date))
185  "Face for dates on cards."
186  :group 'org-kanban)
187
188(defface org-kanban-section-filter
189  '((t :inherit font-lock-keyword-face))
190  "Face for the active section filter indicator."
191  :group 'org-kanban)
192
193;;; Internal variables
194
195(defvar-local org-kanban--cards nil
196  "Alist of (STATE . cards) for current board.")
197
198(defvar-local org-kanban--show-done nil
199  "Whether to show DONE/CANX columns.")
200
201(defvar-local org-kanban--section-filter nil
202  "Current section filter, or nil for all.")
203
204(defvar-local org-kanban--selected-card nil
205  "Plist of the currently selected card (:heading :state :col :row).")
206
207(defvar-local org-kanban--card-positions nil
208  "Hash table mapping (col . row) to card plist.")
209
210(defvar-local org-kanban--selected-col nil
211  "Column index of the currently selected card.")
212
213(defvar-local org-kanban--selected-row nil
214  "Row index of the currently selected card.")
215
216(defvar-local org-kanban--sort nil
217  "Current sort order. Nil means use `org-kanban-sort' default.")
218
219;;; Data fetching
220
221(defun org-kanban--fetch-cards ()
222  "Fetch all TODO items from `org-kanban-file' using org-ql.
223Returns an alist of (STATE . list-of-card-plists)."
224  (let ((all-states (append org-kanban-columns
225                            (when org-kanban--show-done
226                              org-kanban-done-columns)))
227        (results '()))
228    (dolist (state all-states)
229      (let ((cards (org-ql-select org-kanban-file
230                     `(and (todo ,state)
231                           (level ,org-kanban-level)
232                           ,@(when org-kanban--section-filter
233                               `((ancestors
234                                  (and (level 1)
235                                       (heading ,org-kanban--section-filter))))))
236                     :action (lambda ()
237                               (let* ((element (org-element-at-point))
238                                      (priority-raw (org-element-property :priority element))
239                                      (priority-num (when priority-raw
240                                                      (if (< priority-raw 10)
241                                                          priority-raw       ;; already a number (1-5)
242                                                        (- priority-raw 48)))) ;; ASCII char (?1=49 -> 1)
243                                      (tags (mapcar #'substring-no-properties (org-get-tags nil t)))
244                                      (scheduled (org-entry-get nil "SCHEDULED"))
245                                      (deadline (org-entry-get nil "DEADLINE"))
246                                      (section (save-excursion
247                                                 (while (> (org-current-level) 1)
248                                                   (org-up-heading-safe))
249                                                 (substring-no-properties (org-get-heading t t t t)))))
250                                 (list :heading (org-kanban--render-links
251                                                  (substring-no-properties (org-get-heading t t t t)))
252                                       :state state
253                                       :priority priority-num
254                                       :tags tags
255                                       :scheduled scheduled
256                                       :deadline deadline
257                                       :section section
258                                       :marker (point-marker)))))))
259        (push (cons state (org-kanban--sort-cards cards)) results)))
260    (nreverse results)))
261
262(defun org-kanban--sort-cards (cards)
263  "Sort CARDS according to the current sort order."
264  (let ((order (or org-kanban--sort org-kanban-sort)))
265    (pcase order
266      ('none cards)
267      ('priority
268       (sort cards
269             (lambda (a b)
270               (let ((pa (or (plist-get a :priority) 99))
271                     (pb (or (plist-get b :priority) 99)))
272                 (< pa pb)))))
273      ('scheduled
274       (sort cards
275             (lambda (a b)
276               (let ((sa (or (plist-get a :scheduled) "9999"))
277                     (sb (or (plist-get b :scheduled) "9999")))
278                 (string< sa sb)))))
279      ('deadline
280       (sort cards
281             (lambda (a b)
282               (let ((da (or (plist-get a :deadline) "9999"))
283                     (db (or (plist-get b :deadline) "9999")))
284                 (string< da db)))))
285      ('alpha
286       (sort cards
287             (lambda (a b)
288               (string< (plist-get a :heading) (plist-get b :heading)))))
289      (_ cards))))
290
291;;; Text processing
292
293(defun org-kanban--render-links (str)
294  "Replace org links in STR with their description.
295\=[[url][desc]] becomes desc, [[url]] becomes url."
296  (let ((result str))
297    ;; [[target][description]] → description
298    (setq result (replace-regexp-in-string
299                  "\\[\\[\\([^]]*\\)\\]\\[\\([^]]*\\)\\]\\]"
300                  "\\2" result))
301    ;; [[target]] → target
302    (setq result (replace-regexp-in-string
303                  "\\[\\[\\([^]]*\\)\\]\\]"
304                  "\\1" result))
305    result))
306
307;;; Rendering
308
309(defun org-kanban--truncate (str width)
310  "Truncate STR to WIDTH display columns, adding ellipsis if needed."
311  (if (> (string-width str) width)
312      (concat (truncate-string-to-width str (- width 1)) "")
313    str))
314
315(defun org-kanban--pad (str width)
316  "Pad STR to WIDTH display columns with spaces."
317  (let ((truncated (org-kanban--truncate str width)))
318    (concat truncated (make-string (max 0 (- width (string-width truncated))) ?\s))))
319
320(defun org-kanban--state-face (state)
321  "Return the face for STATE."
322  (pcase state
323    ("TODO" 'org-kanban-state-todo)
324    ("NEXT" 'org-kanban-state-next)
325    ("STRT" 'org-kanban-state-strt)
326    ("WAIT" 'org-kanban-state-wait)
327    ("DONE" 'org-kanban-state-done)
328    ("CANX" 'org-kanban-state-canx)
329    (_ 'default)))
330
331(defun org-kanban--priority-face (priority)
332  "Return the face for PRIORITY number."
333  (pcase priority
334    (1 'org-kanban-priority-1)
335    (2 'org-kanban-priority-2)
336    (3 'org-kanban-priority-3)
337    (_ nil)))
338
339(defun org-kanban--format-card-line1 (card width &optional selected)
340  "Format first line of CARD (heading) to fit WIDTH.
341If SELECTED is non-nil, prepend a visible indicator."
342  (let* ((indicator (if selected
343                       (propertize "" 'face 'org-kanban-indicator)
344                     "  "))
345         (inner-width (- width 2))  ;; reserve 2 chars for indicator
346         (heading (plist-get card :heading))
347         (priority (plist-get card :priority))
348         (prefix (if priority (format "[#%d] " priority) ""))
349         (avail (- inner-width (string-width prefix)))
350         (truncated (org-kanban--truncate heading avail))
351         (line (concat prefix truncated))
352         (result (org-kanban--pad line inner-width)))
353    ;; Apply priority face to prefix
354    (when (and priority (org-kanban--priority-face priority))
355      (put-text-property 0 (min (length prefix) (length result)) 'face
356                         (org-kanban--priority-face priority) result))
357    (concat indicator result)))
358
359(defun org-kanban--format-card-line2 (card width &optional selected)
360  "Format second line of CARD (metadata) to fit WIDTH.
361If SELECTED is non-nil, prepend a continuation indicator."
362  (let* ((tags (plist-get card :tags))
363         (scheduled (plist-get card :scheduled))
364         (deadline (plist-get card :deadline))
365         (section (plist-get card :section))
366         (parts '()))
367    ;; Add date info
368    (when deadline
369      (push (propertize (format "⚑%s" (org-kanban--short-date deadline))
370                        'face 'org-kanban-date)
371            parts))
372    (when scheduled
373      (push (propertize (format "▸%s" (org-kanban--short-date scheduled))
374                        'face 'org-kanban-date)
375            parts))
376    ;; Add section
377    (when (and section (not org-kanban--section-filter))
378      (push (propertize (org-kanban--truncate section 10)
379                        'face 'org-kanban-section-filter)
380            parts))
381    ;; Add first tag
382    (when tags
383      (push (propertize (format ":%s:" (car tags))
384                        'face 'org-kanban-tag)
385            parts))
386    (let* ((indicator (if selected
387                         (propertize "" 'face 'org-kanban-indicator)
388                       "  "))
389           (inner-width (- width 2))
390           (line (string-join (nreverse parts) " ")))
391      (concat indicator (org-kanban--pad line inner-width)))))
392
393(defun org-kanban--short-date (date-str)
394  "Extract short date from DATE-STR like '<2026-04-07 Tue>'."
395  (if (and date-str (string-match "\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)" date-str))
396      (let ((date (match-string 1 date-str)))
397        (format "%s/%s" (substring date 5 7) (substring date 8 10)))
398    ""))
399
400(defun org-kanban--render ()
401  "Render the kanban board into the current buffer."
402  (let* ((inhibit-read-only t)
403         (columns (append org-kanban-columns
404                          (when org-kanban--show-done
405                            org-kanban-done-columns)))
406         (col-width org-kanban-column-width)
407         (separator "")
408         (card-positions (make-hash-table :test 'equal))
409         ;; Calculate max rows
410         (max-rows (apply #'max 0
411                          (mapcar (lambda (state)
412                                    (length (alist-get state org-kanban--cards
413                                                       nil nil #'string=)))
414                                  columns))))
415    (erase-buffer)
416
417    ;; Title line
418    (insert (propertize "  Org Kanban" 'face '(:inherit fixed-pitch :weight bold :height 1.3)))
419    (when org-kanban--section-filter
420      (insert "  "
421              (propertize (format "⟨%s⟩" org-kanban--section-filter)
422                          'face 'org-kanban-section-filter)))
423    ;; Show current sort
424    (let ((sort-name (or org-kanban--sort org-kanban-sort)))
425      (unless (eq sort-name 'none)
426        (insert "  "
427                (propertize (format "↕%s" sort-name) 'face 'shadow))))
428    (unless org-kanban--show-done
429      (insert "  "
430              (propertize "(D to show done)" 'face 'shadow)))
431    (insert "\n\n")
432
433    ;; Column headers
434    (insert "  ")
435    (dotimes (col-idx (length columns))
436      (let* ((state (nth col-idx columns))
437             (count (length (alist-get state org-kanban--cards nil nil #'string=)))
438             (header (format "%s (%d)" state count)))
439        (insert (propertize (org-kanban--pad header col-width)
440                            'face (list 'org-kanban-column-header
441                                        (org-kanban--state-face state))))
442        (when (< col-idx (1- (length columns)))
443          (insert " " separator " "))))
444    (insert "\n")
445
446    ;; Separator line
447    (insert "  ")
448    (dotimes (col-idx (length columns))
449      (insert (make-string col-width ?─))
450      (when (< col-idx (1- (length columns)))
451        (insert "─┼─")))
452    (insert "\n")
453
454    ;; Card rows
455    (dotimes (row max-rows)
456      ;; Line 1: heading
457      (insert "  ")
458      (dotimes (col-idx (length columns))
459        (let* ((state (nth col-idx columns))
460               (cards (alist-get state org-kanban--cards nil nil #'string=))
461               (card (nth row cards)))
462          (if card
463              (let* ((sel (and (eql col-idx org-kanban--selected-col)
464                              (eql row org-kanban--selected-row)))
465                     (start (point)))
466                (insert (org-kanban--format-card-line1 card col-width sel))
467                ;; Store card position
468                (puthash (cons col-idx row) card card-positions)
469                ;; Add text properties for navigation
470                (put-text-property start (point) 'org-kanban-card card)
471                (put-text-property start (point) 'org-kanban-col col-idx)
472                (put-text-property start (point) 'org-kanban-row row))
473            (insert (make-string col-width ?\s)))
474          (when (< col-idx (1- (length columns)))
475            (insert " " separator " "))))
476      (insert "\n")
477
478      ;; Line 2: metadata
479      (insert "  ")
480      (dotimes (col-idx (length columns))
481        (let* ((state (nth col-idx columns))
482               (cards (alist-get state org-kanban--cards nil nil #'string=))
483               (card (nth row cards)))
484          (if card
485              (let* ((sel (and (eql col-idx org-kanban--selected-col)
486                              (eql row org-kanban--selected-row)))
487                     (start (point)))
488                (insert (org-kanban--format-card-line2 card col-width sel))
489                (put-text-property start (point) 'org-kanban-card card)
490                (put-text-property start (point) 'org-kanban-col col-idx)
491                (put-text-property start (point) 'org-kanban-row row))
492            (insert (make-string col-width ?\s)))
493          (when (< col-idx (1- (length columns)))
494            (insert " " separator " "))))
495      (insert "\n")
496
497      ;; Blank line between cards
498      (insert "  ")
499      (dotimes (col-idx (length columns))
500        (insert (make-string col-width ?\s))
501        (when (< col-idx (1- (length columns)))
502          (insert " " separator " ")))
503      (insert "\n"))
504
505    (setq org-kanban--card-positions card-positions)
506
507    ;; Footer with keybinding help
508    (insert "\n")
509    (insert (propertize "  hjkl/←↑↓→" 'face 'font-lock-keyword-face) " nav  "
510            (propertize "</H" 'face 'font-lock-keyword-face) " state←  "
511            (propertize ">/L" 'face 'font-lock-keyword-face) " state→  "
512            (propertize "RET" 'face 'font-lock-keyword-face) " goto  "
513            (propertize "v/SPC" 'face 'font-lock-keyword-face) " preview  "
514            (propertize "f" 'face 'font-lock-keyword-face) " filter  "
515            (propertize "s" 'face 'font-lock-keyword-face) " sort  "
516            (propertize "D" 'face 'font-lock-keyword-face) " done  "
517            (propertize "g" 'face 'font-lock-keyword-face) " refresh  "
518            (propertize "q" 'face 'font-lock-keyword-face) " quit")))
519
520;;; Selection and preview
521
522(defun org-kanban--update-preview (card)
523  "Update the preview window with CARD content, if visible."
524  (let ((preview-win (get-buffer-window "*org-kanban-preview*")))
525    (when preview-win
526      (let ((marker (plist-get card :marker)))
527        (when (and marker (marker-buffer marker))
528          (let ((content (with-current-buffer (marker-buffer marker)
529                           (save-excursion
530                             (goto-char marker)
531                             (org-fold-show-entry)
532                             (buffer-substring
533                              (point)
534                              (save-excursion (org-end-of-subtree t t) (point)))))))
535            (with-current-buffer (get-buffer-create "*org-kanban-preview*")
536              (let ((inhibit-read-only t))
537                (erase-buffer)
538                (insert content)
539                (org-mode)
540                (goto-char (point-min))
541                (org-fold-show-all)
542                (org-cycle-hide-drawers 'all)
543                (read-only-mode 1)))))))))
544
545;;; Navigation
546
547(defun org-kanban--goto-card (col row)
548  "Select the card at COL and ROW.
549Re-renders the board to update the indicator, then moves point."
550  (when (gethash (cons col row) org-kanban--card-positions)
551    (setq org-kanban--selected-col col
552          org-kanban--selected-row row)
553    ;; Re-render to show the indicator (uses cached card data)
554    (org-kanban--render)
555    ;; Move point to the selected card
556    (goto-char (point-min))
557    (let ((found nil))
558      (while (and (not found) (not (eobp)))
559        (when (and (equal (get-text-property (point) 'org-kanban-col) col)
560                   (equal (get-text-property (point) 'org-kanban-row) row))
561          (setq found t))
562        (unless found
563          (goto-char (next-single-property-change (point) 'org-kanban-card nil (point-max))))))
564    ;; Update selected card and preview
565    (let ((card (gethash (cons col row) org-kanban--card-positions)))
566      (when card
567        (setq org-kanban--selected-card card)
568        (org-kanban--update-preview card)))))
569
570(defun org-kanban-next-card ()
571  "Move to the next card (down or next column)."
572  (interactive)
573  (let ((col (get-text-property (point) 'org-kanban-col))
574        (row (get-text-property (point) 'org-kanban-row)))
575    (cond
576     ;; Try next row in same column
577     ((and col row (gethash (cons col (1+ row)) org-kanban--card-positions))
578      (org-kanban--goto-card col (1+ row)))
579     ;; Try first card in next column
580     ((and col (gethash (cons (1+ col) 0) org-kanban--card-positions))
581      (org-kanban--goto-card (1+ col) 0))
582     ;; Wrap to first card
583     ((gethash (cons 0 0) org-kanban--card-positions)
584      (org-kanban--goto-card 0 0))
585     ;; No card at point, find first card
586     (t (org-kanban--goto-card 0 0)))))
587
588(defun org-kanban-prev-card ()
589  "Move to the previous card (up or previous column)."
590  (interactive)
591  (let ((col (get-text-property (point) 'org-kanban-col))
592        (row (get-text-property (point) 'org-kanban-row)))
593    (cond
594     ;; Try previous row in same column
595     ((and col row (> row 0)
596           (gethash (cons col (1- row)) org-kanban--card-positions))
597      (org-kanban--goto-card col (1- row)))
598     ;; Try last card in previous column
599     ((and col (> col 0))
600      (let ((prev-col (1- col))
601            (r 0))
602        (while (gethash (cons prev-col (1+ r)) org-kanban--card-positions)
603          (cl-incf r))
604        (org-kanban--goto-card prev-col r)))
605     ;; No card at point, find first card
606     (t (org-kanban--goto-card 0 0)))))
607
608(defun org-kanban--col-count (col)
609  "Return the number of cards in column COL."
610  (let ((count 0))
611    (while (gethash (cons col count) org-kanban--card-positions)
612      (cl-incf count))
613    count))
614
615(defun org-kanban-next-column ()
616  "Move to the next column (right), keeping the same row or nearest card."
617  (interactive)
618  (let ((col (or org-kanban--selected-col 0))
619        (row (or org-kanban--selected-row 0))
620        (ncols (length (append org-kanban-columns
621                               (when org-kanban--show-done org-kanban-done-columns)))))
622    (cl-loop for c from (1+ col) below ncols
623             for cnt = (org-kanban--col-count c)
624             when (> cnt 0)
625             do (org-kanban--goto-card c (min row (1- cnt)))
626             and return nil
627             finally
628             ;; Wrap around from the start
629             (cl-loop for c from 0 below col
630                      for cnt = (org-kanban--col-count c)
631                      when (> cnt 0)
632                      do (org-kanban--goto-card c (min row (1- cnt)))
633                      and return nil))))
634
635(defun org-kanban-prev-column ()
636  "Move to the previous column (left), keeping the same row or nearest card."
637  (interactive)
638  (let ((col (or org-kanban--selected-col 0))
639        (row (or org-kanban--selected-row 0))
640        (ncols (length (append org-kanban-columns
641                               (when org-kanban--show-done org-kanban-done-columns)))))
642    (cl-loop for c downfrom (1- col) to 0
643             for cnt = (org-kanban--col-count c)
644             when (> cnt 0)
645             do (org-kanban--goto-card c (min row (1- cnt)))
646             and return nil
647             finally
648             ;; Wrap around from the end
649             (cl-loop for c downfrom (1- ncols) above col
650                      for cnt = (org-kanban--col-count c)
651                      when (> cnt 0)
652                      do (org-kanban--goto-card c (min row (1- cnt)))
653                      and return nil))))
654
655;;; Card operations
656
657(defun org-kanban--move-card (direction)
658  "Move the card at point in DIRECTION (:next or :prev) in the state order."
659  (let ((card (get-text-property (point) 'org-kanban-card)))
660    (when card
661      (let* ((current-state (plist-get card :state))
662             (marker (plist-get card :marker))
663             (heading (plist-get card :heading))
664             (idx (cl-position current-state org-kanban-state-order :test #'string=))
665             (new-idx (pcase direction
666                        (:next (min (1- (length org-kanban-state-order)) (1+ idx)))
667                        (:prev (max 0 (1- idx)))))
668             (new-state (nth new-idx org-kanban-state-order)))
669        (unless (string= current-state new-state)
670          ;; Change the state in the org file
671          (with-current-buffer (marker-buffer marker)
672            (save-excursion
673              (goto-char marker)
674              (org-todo new-state)))
675          (message "Moved \"%s\" → %s" heading new-state)
676          ;; Refresh board, try to stay near current position
677          (let ((col (get-text-property (point) 'org-kanban-col))
678                (row (get-text-property (point) 'org-kanban-row)))
679            (org-kanban-refresh)
680            ;; Try to navigate back to a reasonable position
681            (or (ignore-errors (org-kanban--goto-card col row) t)
682                (ignore-errors (org-kanban--goto-card col (max 0 (1- row))) t)
683                (org-kanban--goto-card 0 0))))))))
684
685(defun org-kanban-move-right ()
686  "Move the card at point to the next state."
687  (interactive)
688  (org-kanban--move-card :next))
689
690(defun org-kanban-move-left ()
691  "Move the card at point to the previous state."
692  (interactive)
693  (org-kanban--move-card :prev))
694
695(defun org-kanban-goto-heading ()
696  "Jump to the org heading for the card at point.
697Opens in a new tab if `org-kanban-open-in-tab' is non-nil."
698  (interactive)
699  (let ((card (get-text-property (point) 'org-kanban-card)))
700    (when card
701      (let ((marker (plist-get card :marker)))
702        (when (and marker (marker-buffer marker))
703          (if org-kanban-open-in-tab
704              (progn
705                (tab-bar-new-tab)
706                (switch-to-buffer (marker-buffer marker)))
707            (pop-to-buffer (marker-buffer marker)))
708          (goto-char marker)
709          (org-reveal)
710          (org-fold-show-entry))))))
711
712(defun org-kanban-preview ()
713  "Preview the card at point in a side window (toggle)."
714  (interactive)
715  (let ((win (get-buffer-window "*org-kanban-preview*")))
716    (if win
717        (quit-window nil win)
718      (let* ((card (get-text-property (point) 'org-kanban-card))
719             (marker (and card (plist-get card :marker))))
720        (when (and marker (marker-buffer marker))
721          (let ((content
722                 (with-current-buffer (marker-buffer marker)
723                   (save-excursion
724                     (goto-char marker)
725                     (org-fold-show-entry)
726                     (buffer-substring
727                      (point)
728                      (save-excursion
729                        (org-end-of-subtree t t)
730                        (point)))))))
731            (with-current-buffer (get-buffer-create "*org-kanban-preview*")
732              (let ((inhibit-read-only t))
733                (erase-buffer)
734                (insert content)
735                (org-mode)
736                (goto-char (point-min))
737                (org-fold-show-all)
738                (org-cycle-hide-drawers 'all)
739                (read-only-mode 1)))
740            (display-buffer "*org-kanban-preview*"
741                            '((display-buffer-in-side-window)
742                              (side . bottom)
743                              (window-height . 0.35)))))))))
744
745;;; Filtering
746
747(defun org-kanban-filter-section ()
748  "Filter the board to show only items from a specific section."
749  (interactive)
750  (let* ((sections (org-ql-select org-kanban-file
751                     '(level 1)
752                     :action (lambda () (substring-no-properties (org-get-heading t t t t)))))
753         (choice (completing-read "Filter by section (empty to clear): "
754                                  sections nil nil)))
755    (setq org-kanban--section-filter (if (string-empty-p choice) nil choice))
756    (org-kanban-refresh)))
757
758(defun org-kanban-clear-filter ()
759  "Clear the section filter."
760  (interactive)
761  (setq org-kanban--section-filter nil)
762  (org-kanban-refresh))
763
764(defun org-kanban-cycle-sort ()
765  "Cycle through sort orders: priority → scheduled → deadline → alpha → none."
766  (interactive)
767  (let* ((current (or org-kanban--sort org-kanban-sort))
768         (order '(priority scheduled deadline alpha none))
769         (idx (cl-position current order))
770         (next (nth (mod (1+ (or idx 0)) (length order)) order)))
771    (setq org-kanban--sort next)
772    (setq org-kanban--cards (org-kanban--fetch-cards))
773    (setq org-kanban--selected-col nil
774          org-kanban--selected-row nil)
775    (org-kanban--render)
776    (goto-char (point-min))
777    (when (gethash (cons 0 0) org-kanban--card-positions)
778      (org-kanban--goto-card 0 0))
779    (message "Sort: %s" next)))
780
781(defun org-kanban-toggle-done ()
782  "Toggle visibility of DONE/CANX columns."
783  (interactive)
784  (setq org-kanban--show-done (not org-kanban--show-done))
785  (org-kanban-refresh))
786
787;;; Refresh
788
789(defun org-kanban-refresh ()
790  "Refresh the kanban board."
791  (interactive)
792  (setq org-kanban--cards (org-kanban--fetch-cards))
793  (setq org-kanban--selected-col nil
794        org-kanban--selected-row nil)
795  (org-kanban--render)
796  (goto-char (point-min))
797  ;; Navigate to first card
798  (when (gethash (cons 0 0) org-kanban--card-positions)
799    (org-kanban--goto-card 0 0)))
800
801;;; Major mode
802
803(defvar org-kanban-mode-map
804  (let ((map (make-sparse-keymap)))
805    ;; Navigation
806    (define-key map (kbd "j") #'org-kanban-next-card)
807    (define-key map (kbd "n") #'org-kanban-next-card)
808    (define-key map (kbd "<down>") #'org-kanban-next-card)
809    (define-key map (kbd "k") #'org-kanban-prev-card)
810    (define-key map (kbd "p") #'org-kanban-prev-card)
811    (define-key map (kbd "<up>") #'org-kanban-prev-card)
812    ;; Column navigation
813    (define-key map (kbd "l") #'org-kanban-next-column)
814    (define-key map (kbd "<right>") #'org-kanban-next-column)
815    (define-key map (kbd "h") #'org-kanban-prev-column)
816    (define-key map (kbd "<left>") #'org-kanban-prev-column)
817    ;; Move cards (change state)
818    (define-key map (kbd ">") #'org-kanban-move-right)
819    (define-key map (kbd "L") #'org-kanban-move-right)
820    (define-key map (kbd "<") #'org-kanban-move-left)
821    (define-key map (kbd "H") #'org-kanban-move-left)
822    ;; Actions
823    (define-key map (kbd "RET") #'org-kanban-goto-heading)
824    (define-key map (kbd "v") #'org-kanban-preview)
825    (define-key map (kbd "SPC") #'org-kanban-preview)
826    (define-key map (kbd "f") #'org-kanban-filter-section)
827    (define-key map (kbd "F") #'org-kanban-clear-filter)
828    (define-key map (kbd "s") #'org-kanban-cycle-sort)
829    (define-key map (kbd "D") #'org-kanban-toggle-done)
830    (define-key map (kbd "g") #'org-kanban-refresh)
831    (define-key map (kbd "q") #'quit-window)
832    map)
833  "Keymap for `org-kanban-mode'.")
834
835(define-derived-mode org-kanban-mode special-mode "OrgKanban"
836  "Major mode for the org-mode kanban board.
837\\{org-kanban-mode-map}"
838  (setq-local buffer-read-only t)
839  (setq-local truncate-lines t)
840  (setq-local cursor-type nil)
841  ;; Force monospace fixed-pitch font at uniform height to fix alignment
842  ;; with mixed-fonts themes (modus-themes-mixed-fonts, variable-pitch headings)
843  (face-remap-add-relative 'default :inherit 'fixed-pitch :height 1.0)
844)
845
846;;; Entry points
847
848;;;###autoload
849(defun org-kanban (&optional file)
850  "Open the kanban board for FILE (defaults to `org-kanban-file')."
851  (interactive)
852  (let ((buf (get-buffer-create "*org-kanban*")))
853    (with-current-buffer buf
854      (org-kanban-mode)
855      (when file
856        (setq-local org-kanban-file file))
857      (org-kanban-refresh))
858    (pop-to-buffer buf)))
859
860;;;###autoload
861(defun org-kanban-work ()
862  "Open the kanban board filtered to the Work section."
863  (interactive)
864  (let ((buf (get-buffer-create "*org-kanban*")))
865    (with-current-buffer buf
866      (org-kanban-mode)
867      (setq org-kanban--section-filter "Work")
868      (org-kanban-refresh))
869    (pop-to-buffer buf)))
870
871;;;###autoload
872(defun org-kanban-projects ()
873  "Open the kanban board filtered to the Projects section."
874  (interactive)
875  (let ((buf (get-buffer-create "*org-kanban*")))
876    (with-current-buffer buf
877      (org-kanban-mode)
878      (setq org-kanban--section-filter "Projects")
879      (org-kanban-refresh))
880    (pop-to-buffer buf)))
881
882;;;###autoload
883(defun org-kanban-systems ()
884  "Open the kanban board filtered to the Systems section."
885  (interactive)
886  (let ((buf (get-buffer-create "*org-kanban*")))
887    (with-current-buffer buf
888      (org-kanban-mode)
889      (setq org-kanban--section-filter "Systems")
890      (org-kanban-refresh))
891    (pop-to-buffer buf)))
892
893(provide 'org-kanban)
894;;; org-kanban.el ends here