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