From 11dd90a7246c159793d3e38263fdafb763e457ad Mon Sep 17 00:00:00 2001 From: Andy Kopra Date: Mon, 27 Jul 2026 01:21:58 +0200 Subject: [PATCH] Table alignment for Emacs and Sublime Text (from dev 66442bdd4d5d) --- README.md | 2 +- doc/edit/emacs/README.md | 37 +- doc/edit/emacs/klammertext-align.el | 455 ++++++++++++++++++ doc/edit/sublime/Default.sublime-keymap | 7 + doc/edit/sublime/Klammertext_align.py | 426 ++++++++++++++++ .../sublime/Klammertext_in_Sublime_Text.md | 32 +- 6 files changed, 954 insertions(+), 5 deletions(-) create mode 100644 doc/edit/emacs/klammertext-align.el create mode 100644 doc/edit/sublime/Klammertext_align.py diff --git a/README.md b/README.md index 67cff52..639757c 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ are regenerated on each release — patches cannot be merged directly. Report problems (or send patches) to the author; accepted changes are applied to the development tree and appear in a following snapshot. -This snapshot was assembled from development commit `5d35f256476e`. +This snapshot was assembled from development commit `66442bdd4d5d`. ## License diff --git a/doc/edit/emacs/README.md b/doc/edit/emacs/README.md index bcc95ae..a9ce9be 100644 --- a/doc/edit/emacs/README.md +++ b/doc/edit/emacs/README.md @@ -13,12 +13,14 @@ and load the mode. Add to `~/.emacs.d/init.el`: (add-to-list 'load-path "full-pathname-of-the-emacs-directory") (require 'klammertext-mode) (require 'klammertext-indent) ; optional, experimental: TAB indentation +(require 'klammertext-align) ; optional, experimental: table alignment ``` Replace `full-pathname-of-the-emacs-directory` with the full path to the -directory that contains `klammertext-mode.el`. The second require loads the -experimental indentation support (see "Indentation" below); it is a separate -unit — comment the line out to disable indentation entirely. +directory that contains `klammertext-mode.el`. The last two requires load +the experimental indentation and table-alignment support (see their sections +below); each is a separate unit — comment its line out to disable it +entirely. The mode auto-activates for `.kt` and `.k` files. (The `.k` / `.kt` distinction is a filing convention, not a lexical one — the same mode serves both.) You can @@ -196,6 +198,35 @@ indentation happens only when you ask for it (TAB, `indent-region`). The offset is `klammertext-indent-offset` (default 2); all three variables are customizable in the `klammertext-indent` group. +## Table alignment (experimental) + +With `klammertext-align.el` loaded (the optional require above), **`C-c C-a`** +with point anywhere inside a `@table` span pads the cells of its rows so the +`|` separators line up: + +``` +@table + First item | Second | A third item that's longer || + Row 2 | Text | Not as long || +@ +``` + +A row is one line ending with the row delimiter `||` (the customary trailing +delimiter — the parser strips one trailing delimiter, and it keeps every row +uniform, which also suits program-generated tables); the last row may omit +it. Alignment is for small data items, so a row is left untouched — and +contributes nothing to the column widths — when any of its cells is longer +than `klammertext-align-cell-max` (30) characters or the row spans lines. +If the aligned rows would exceed `klammertext-align-row-max` (100) columns, +nothing changes and the reason is reported. + +The padding is semantically free: the SKS strips cell content, and no +whitespace is ever inserted inside a bar run (which would turn a `||` row +separator into an empty `| |` cell). Bars inside a nested klammer in a cell +(`@frac 1 | 2 @`) belong to that klammer, not the table, and are left alone. +Aligned rows adopt the leading whitespace of the first aligned row — run TAB +first if the rows disagree. + ## Literal klammers Inside a `literal` argument — for example the body of `@code ... code@` — `#` diff --git a/doc/edit/emacs/klammertext-align.el b/doc/edit/emacs/klammertext-align.el new file mode 100644 index 0000000..9193b92 --- /dev/null +++ b/doc/edit/emacs/klammertext-align.el @@ -0,0 +1,455 @@ +;;; klammertext-align.el --- Table alignment for Klammertext -*- lexical-binding: t; -*- + +;; EXPERIMENTAL. Aligns the columns of a table klammer: with point anywhere +;; inside a @table span, `klammertext-align-table' (bound C-c C-a) pads the +;; cells of its rows so the | separators line up vertically: +;; +;; @table +;; First item | Second | A third item that's longer || +;; Row 2 | Text | Not as long || +;; @ +;; +;; This file is a separate unit, loaded from the init file: +;; +;; (require 'klammertext-align) +;; +;; Comment that line out to disable alignment entirely. The Sublime Text +;; port doc/sublime/Klammertext_align.py implements the same algorithm — +;; keep the two in step. +;; +;; Alignment is for SMALL data items (2026-07-27): +;; +;; * A row is one line ending with the row delimiter || (the customary +;; trailing delimiter; the parser strips one trailing top-level +;; delimiter, and it keeps every row uniform). The last row may omit +;; the ||. +;; * A row is LEFT UNTOUCHED when any of its cells is longer than +;; `klammertext-align-cell-max' (30) characters, or when the row spans +;; lines (a cell with a newline). Untouched rows do not contribute to +;; the column widths. +;; * If the aligned rows would exceed `klammertext-align-row-max' (100) +;; columns, nothing is changed and the reason is reported — the general +;; case of long rows has no good answer, so the command declines rather +;; than guessing. +;; +;; Cell padding is semantically free: the SKS strips cell content, and no +;; whitespace is ever inserted inside a bar run (that would turn a || row +;; separator into an empty | | cell — the load-bearing-whitespace trap). +;; Bars inside a nested klammer (e.g. @frac 1 | 2 @ in a cell) belong to +;; that klammer, not the table: only bars at nesting depth 0 within the +;; table span count, the same depth rule the Klammermachine itself applies +;; to @cond. Aligned rows adopt the leading whitespace of the first +;; aligned row; run TAB / `indent-region' first if the rows disagree. +;; +;; SYNC: `klammertext-align-klammers' / `-cell-max' / `-row-max' are +;; mirrored as ALIGN_KLAMMERS / CELL_MAX / ROW_MAX in Klammertext_align.py +;; (a Sublime plugin cannot read these defcustoms). + +;;; Code: + +(require 'klammertext-mode) + +(defgroup klammertext-align nil + "Table alignment for Klammertext files." + :group 'klammertext) + +(defcustom klammertext-align-klammers '("table") + "Klammers whose rows `klammertext-align-table' aligns." + :type '(repeat string) + :group 'klammertext-align) + +(defcustom klammertext-align-cell-max 30 + "A row with a cell longer than this many characters is left untouched. +Alignment is for small data items." + :type 'integer + :group 'klammertext-align) + +(defcustom klammertext-align-row-max 100 + "Refuse to align when the aligned rows would exceed this many columns. +nil means no limit." + :type '(choice integer (const nil)) + :group 'klammertext-align) + +;; --- string helpers (the mode's helpers are buffer-based) ---------------- + +(defun klammertext-align--escaped-p (s pos) + "Non-nil if the char at POS in string S is escaped by an odd run of ^." + (let ((n 0) (i (1- pos))) + (while (and (>= i 0) (eq (aref s i) ?^)) + (setq n (1+ n) i (1- i))) + (= (mod n 2) 1))) + +(defun klammertext-align--block-end (s frm) + "Index just after the ]# closing a #[ block opened at FRM in string S. +Counts nested #[ ... ]#; (length S) if unclosed." + (let ((depth 1) (i frm) (n (length s))) + (while (> depth 0) + (let ((a (string-search "#[" s i)) + (b (string-search "]#" s i))) + (cond + ((and (null a) (null b)) + (setq i n depth 0)) + ((or (null b) (and a (< a b))) + (setq depth (1+ depth) i (+ a 2))) + (t + (setq depth (1- depth) i (+ b 2)))))) + i)) + +(defun klammertext-align--at-run-end (s pos) + "Index just after the run of @ that begins at POS in string S." + (let ((p pos) (n (length s))) + (while (and (< p n) (eq (aref s p) ?@)) + (setq p (1+ p))) + p)) + +(defun klammertext-align--name-end (s pos) + "Index just past the run of name chars starting at POS in string S." + (let ((k pos) (n (length s))) + (while (and (< k n) (klammertext--name-char-p (aref s k))) + (setq k (1+ k))) + k)) + +;; --- finding the enclosing table span (buffer-based) --------------------- + +(defun klammertext-align--enclosing-span (pos names) + "Innermost span of a klammer named in NAMES that contains POS. +Return (NAME CONTENT-START CONTENT-END) with CONTENT-START just after the +opening @NAME token and CONTENT-END at the start of the closing delimiter +token, or nil. Scans the buffer from the top with a position stack, +stepping over removed text, literal spans, escaped characters, and the +abbreviated @name-arg form." + (save-excursion + (goto-char (point-min)) + (let ((stack nil) (found nil) (go t)) + (while (and go (not found) + (re-search-forward "[@#]" nil t)) + (let ((hit (1- (point)))) + (cond + ((klammertext--escaped-p hit)) + ((eq (char-after hit) ?#) + (let ((next (char-after (1+ hit)))) + (cond + ((eq next ?#) (setq go nil)) + ((eq next ?\[) (goto-char (klammertext--block-end (+ hit 2)))) + ((memq next '(?+ ?/ ?-))) + (t (goto-char (line-end-position)))))) + (t + (let* ((run-end (klammertext--at-run-end hit)) + (len (- run-end hit))) + (if (klammertext--name-char-p (char-after run-end)) + (progn + (goto-char run-end) + (skip-chars-forward "A-Za-z0-9_") + (let ((name (buffer-substring-no-properties run-end (point)))) + (cond + ((and (= len 1) + (member name klammertext-literal-klammers)) + (unless (re-search-forward + (concat (regexp-quote name) "@") nil t) + (setq go nil))) + ((and (= len 1) (eq (char-after) ?-))) + (t (push (list name hit (point)) stack))))) + ;; a close: token starts at the preceding name run, if any + (let* ((ns (save-excursion + (goto-char hit) + (skip-chars-backward "A-Za-z0-9_") + (point))) + (tok-start (if (and (< ns hit) + (or (= ns (point-min)) + (not (eq (char-before ns) ?@)))) + ns + hit)) + (entry (pop stack))) + (when (and entry + (member (nth 0 entry) names) + (<= (nth 1 entry) pos) + (<= pos run-end)) + (setq found (list (nth 0 entry) (nth 2 entry) tok-start))) + (goto-char run-end)))))))) + found))) + +;; --- scanning the span content, line by line ----------------------------- +;; A line record is a vector: +;; [START END START-DEPTH END-DEPTH BARS BLOCKED COMMENT] +;; START/END are offsets into the content string (END excludes the newline); +;; BARS is a list of (POS . RUNLEN) for unescaped depth-0 bar runs, in order. + +(defun klammertext-align--line-index (starts p) + "Index of the line (in the STARTS vector) containing offset P." + (let ((lo 0) (hi (1- (length starts)))) + (while (< lo hi) + (let ((mid (/ (+ lo hi 1) 2))) + (if (<= (aref starts mid) p) + (setq lo mid) + (setq hi (1- mid))))) + lo)) + +(defun klammertext-align--block-lines (lines starts n a b) + "Mark every line record touched by [A, B) as blocked." + (let* ((last (max a (1- b))) + (k0 (klammertext-align--line-index starts a)) + (k1 (klammertext-align--line-index starts (min last (max 0 (1- n)))))) + (let ((k k0)) + (while (<= k k1) + (aset (aref lines k) 5 t) + (setq k (1+ k)))))) + +(defun klammertext-align--scan-lines (content) + "Scan CONTENT (a table span's text). Return the vector of line records." + (let* ((n (length content)) + (starts (let ((acc (list 0))) + (dotimes (idx n) + (when (eq (aref content idx) ?\n) + (push (1+ idx) acc))) + (vconcat (nreverse acc)))) + (nlines (length starts)) + (lines (make-vector nlines nil))) + (dotimes (k nlines) + (aset lines k (vector (aref starts k) + (if (< (1+ k) nlines) + (1- (aref starts (1+ k))) + n) + nil nil nil nil nil))) + (aset (aref lines 0) 2 0) + (let ((depth 0) (i 0) (go t)) + (while (and go (< i n)) + (let ((j i)) + (while (and (< j n) + (not (memq (aref content j) '(?@ ?# ?| ?\n)))) + (setq j (1+ j))) + (if (>= j n) + (setq go nil) + (let ((hit j) (c (aref content j))) + (setq i (1+ hit)) + (cond + ((eq c ?\n) + (let ((k (klammertext-align--line-index starts hit))) + (aset (aref lines k) 3 depth) + (when (< (1+ k) nlines) + (aset (aref lines (1+ k)) 2 depth)))) + ((klammertext-align--escaped-p content hit)) + ((eq c ?#) + (let ((next (and (< (1+ hit) n) (aref content (1+ hit))))) + (cond + ((eq next ?#) + (klammertext-align--block-lines lines starts n hit n) + (setq go nil)) + ((eq next ?\[) + (let ((e (klammertext-align--block-end content (+ hit 2)))) + (unless (= (klammertext-align--line-index starts (max hit (1- e))) + (klammertext-align--line-index starts hit)) + (klammertext-align--block-lines lines starts n hit e)) + (setq i e))) + ((memq next '(?+ ?/ ?-))) + (t + (aset (aref lines (klammertext-align--line-index starts hit)) 6 t) + (let ((eol (string-search "\n" content hit))) + (setq i (or eol n))))))) + ((eq c ?|) + (unless (and (> hit 0) (eq (aref content (1- hit)) ?|)) + (let ((k hit)) + (while (and (< k n) (eq (aref content k) ?|)) + (setq k (1+ k))) + (when (= depth 0) + (let ((rec (aref lines (klammertext-align--line-index starts hit)))) + (aset rec 4 (append (aref rec 4) + (list (cons hit (- k hit))))))) + (setq i k)))) + (t ; ?@ + (let* ((run-end (klammertext-align--at-run-end content hit)) + (run-len (- run-end hit)) + (after (and (< run-end n) (aref content run-end)))) + (if (klammertext--name-char-p after) + (let* ((k (klammertext-align--name-end content run-end)) + (name (substring content run-end k))) + (setq i k) + (cond + ((and (= run-len 1) + (member name klammertext-literal-klammers)) + (let* ((idx (string-search (concat name "@") content k)) + (e (if idx (+ idx (length name) 1) n))) + (unless (= (klammertext-align--line-index + starts (max hit (1- e))) + (klammertext-align--line-index starts hit)) + (klammertext-align--block-lines lines starts n hit e)) + (setq i e))) + ((and (= run-len 1) (< k n) (eq (aref content k) ?-))) + (t (setq depth (1+ depth))))) + (setq depth (max 0 (1- depth))) + (setq i run-end))))))))) + (dotimes (k nlines) + (let ((rec (aref lines k))) + (unless (aref rec 2) (aset rec 5 t)) + (unless (aref rec 3) (aset rec 3 depth))))) + lines)) + +;; --- the alignment ------------------------------------------------------- + +(defun klammertext-align--indent-width (content rec) + "Width of the leading whitespace of line REC in CONTENT." + (let ((i (aref rec 0)) (end (aref rec 1))) + (while (and (< i end) (memq (aref content i) '(?\s ?\t))) + (setq i (1+ i))) + (- i (aref rec 0)))) + +(defun klammertext-align--trailing-rowsep-p (content rec) + "Non-nil when line REC's last depth-0 bar run is a || at the end of the +line (only whitespace after it)." + (let ((bars (aref rec 4))) + (and bars + (let* ((run (car (last bars))) + (pos (car run))) + (and (= (cdr run) 2) + (string-blank-p (substring content (+ pos 2) (aref rec 1)))))))) + +(defun klammertext-align--parse-row (content rec text chain-ok last-content-p) + "If line REC is an alignable row, return (REC CELLS TRAILING-P); else nil. +TEXT is the line's text; see the file header for the rules." + (catch 'no + (when (or (aref rec 5) (aref rec 6) (not chain-ok)) + (throw 'no nil)) + (unless (and (eql (aref rec 2) 0) (eql (aref rec 3) 0)) + (throw 'no nil)) + (unless (aref rec 4) + (throw 'no nil)) + (let* ((trailing (klammertext-align--trailing-rowsep-p content rec)) + (bars (aref rec 4)) + (singles (if trailing (butlast bars) bars))) + (dolist (run singles) + (unless (= (cdr run) 1) + (throw 'no nil))) ; a mid-line || or ||| : not one row + (unless (or trailing last-content-p) + (throw 'no nil)) ; row continues onto the next line + (ignore text) + (let* ((cell-start (+ (aref rec 0) + (klammertext-align--indent-width content rec))) + (cell-end (if trailing (car (car (last bars))) (aref rec 1))) + (bounds (append (list cell-start) + (mapcar #'car singles) + (list cell-end))) + (cells nil) + (b-idx 0)) + (while (< b-idx (1- (length bounds))) + (let* ((a (+ (nth b-idx bounds) (if (> b-idx 0) 1 0))) + (cell (string-trim (substring content a (nth (1+ b-idx) bounds))))) + (when (> (length cell) klammertext-align-cell-max) + (throw 'no nil)) + (push cell cells) + (setq b-idx (1+ b-idx)))) + (list rec (nreverse cells) trailing))))) + +(defun klammertext-align--pad (cell width) + (concat cell (make-string (max 0 (- width (length cell))) ?\s))) + +(defun klammertext-align--edits (content) + "Compute the alignment edits for CONTENT (a table span's text). +Return (EDITS . MESSAGE): EDITS is a list of (START END NEW) triples +relative to CONTENT, ascending; MESSAGE is a status string (a reason when +EDITS is nil)." + (let* ((lines (klammertext-align--scan-lines content)) + (nlines (length lines)) + (last-content nil)) + (let ((k (1- nlines))) + (while (and (>= k 1) (null last-content)) + (let ((rec (aref lines k))) + (unless (string-blank-p (substring content (aref rec 0) (aref rec 1))) + (setq last-content k))) + (setq k (1- k)))) + (let ((rows nil) (chain-ok t)) + (let ((k 1)) + (while (< k nlines) + (let* ((rec (aref lines k)) + (text (substring content (aref rec 0) (aref rec 1))) + (stripped (string-trim text))) + (cond + ((string-empty-p stripped)) ; blank: chain unchanged + ((and (string-prefix-p ":" stripped) ; option line: unchanged + (null (aref rec 4)))) + (t + (let ((row (klammertext-align--parse-row + content rec text chain-ok (eql k last-content)))) + (setq chain-ok (klammertext-align--trailing-rowsep-p content rec)) + (when row (push row rows)))))) + (setq k (1+ k)))) + (setq rows (nreverse rows)) + (if (null rows) + (cons nil "no alignable rows found") + (let* ((ncols (apply #'max (mapcar (lambda (r) (length (nth 1 r))) rows))) + (widths (make-vector ncols 0))) + (dolist (r rows) + (let ((c-idx 0)) + (dolist (cell (nth 1 r)) + (aset widths c-idx (max (aref widths c-idx) (length cell))) + (setq c-idx (1+ c-idx))))) + (let* ((indent (make-string (klammertext-align--indent-width + content (nth 0 (car rows))) + ?\s)) + (longest 0)) + (dolist (r rows) + (let* ((m (length (nth 1 r))) + (w (+ (length indent) + (let ((sum 0) (c 0)) + (while (< c m) + (setq sum (+ sum (aref widths c)) c (1+ c))) + sum) + (* 3 (1- m)) + (if (nth 2 r) 3 0)))) + (setq longest (max longest w)))) + (if (and klammertext-align-row-max + (> longest klammertext-align-row-max)) + (cons nil (format "aligned rows would be %d characters (limit %d); not aligning" + longest klammertext-align-row-max)) + (let ((edits nil)) + (dolist (r rows) + (let* ((rec (nth 0 r)) + (cells (nth 1 r)) + (trailing (nth 2 r)) + (m (length cells)) + (parts nil) + (c-idx 0)) + (dolist (cell cells) + (push (if (and (= c-idx (1- m)) (not trailing)) + cell + (klammertext-align--pad cell (aref widths c-idx))) + parts) + (setq c-idx (1+ c-idx))) + (let ((new (concat indent + (mapconcat #'identity (nreverse parts) " | ") + (if trailing " ||" "")))) + (unless (string= new (substring content + (aref rec 0) (aref rec 1))) + (push (list (aref rec 0) (aref rec 1) new) edits))))) + (setq edits (nreverse edits)) + (cons edits + (if edits + (format "aligned %d rows" (length rows)) + "already aligned")))))))))) + +;; --- the command --------------------------------------------------------- + +(defun klammertext-align-table () + "Align the columns of the table klammer containing point. +Rows with a cell longer than `klammertext-align-cell-max' characters, or +spanning lines, are left untouched; see the file header for the full rules." + (interactive) + (let ((span (klammertext-align--enclosing-span (point) + klammertext-align-klammers))) + (unless span + (user-error "Point is not inside a table klammer (%s)" + (mapconcat (lambda (name) (concat "@" name)) + klammertext-align-klammers ", "))) + (let* ((beg (nth 1 span)) + (content (buffer-substring-no-properties beg (nth 2 span))) + (result (klammertext-align--edits content))) + (save-excursion + (dolist (e (sort (copy-sequence (car result)) + (lambda (a b) (> (nth 0 a) (nth 0 b))))) + (goto-char (+ beg (nth 0 e))) + (delete-region (+ beg (nth 0 e)) (+ beg (nth 1 e))) + (insert (nth 2 e)))) + (message "Klammertext: %s" (cdr result))))) + +(define-key klammertext-mode-map (kbd "C-c C-a") #'klammertext-align-table) + +(provide 'klammertext-align) +;;; klammertext-align.el ends here diff --git a/doc/edit/sublime/Default.sublime-keymap b/doc/edit/sublime/Default.sublime-keymap index e13a656..971653c 100644 --- a/doc/edit/sublime/Default.sublime-keymap +++ b/doc/edit/sublime/Default.sublime-keymap @@ -28,5 +28,12 @@ "context": [ { "key": "selector", "operator": "equal", "operand": "text.klammertext" } ] + }, + { + "keys": ["ctrl+alt+a"], + "command": "klammertext_align_table", + "context": [ + { "key": "selector", "operator": "equal", "operand": "text.klammertext" } + ] } ] diff --git a/doc/edit/sublime/Klammertext_align.py b/doc/edit/sublime/Klammertext_align.py new file mode 100644 index 0000000..f669ee7 --- /dev/null +++ b/doc/edit/sublime/Klammertext_align.py @@ -0,0 +1,426 @@ +# Klammertext_align.py +# +# EXPERIMENTAL. Table alignment for Klammertext files — pads the cells of a +# klammer's rows so the | separators line up vertically. Companion to +# doc/emacs/klammertext-align.el (the same algorithm; keep the two in step). +# This file is a separate unit: delete it (or move it out of the package +# folder) to disable alignment entirely. +# +# Command name (for keymaps / the command palette): klammertext_align_table +# Keybinding: Ctrl+Alt+A (in Default.sublime-keymap), scoped to Klammertext +# files. With the caret anywhere inside a @table span, the command aligns +# that table's rows: +# +# @table +# First item | Second | A third item that's longer || +# Row 2 | Text | Not as long || +# @ +# +# Alignment is for SMALL data items (2026-07-27): +# +# * A row is one line ending with the row delimiter || (the customary +# trailing delimiter; the parser strips one trailing top-level delimiter, +# and it keeps every row uniform). The last row may omit the ||. +# * A row is LEFT UNTOUCHED when any of its cells is longer than CELL_MAX +# (30) characters, or when the row spans lines (a cell with a newline). +# Untouched rows do not contribute to the column widths. +# * If the aligned rows would exceed ROW_MAX (100) columns, nothing is +# changed and the status bar says so — the general case of long rows has +# no good answer, so the command declines rather than guessing. +# +# Cell padding is semantically free: the SKS strips cell content, and no +# whitespace is ever inserted inside a bar run (that would turn a || row +# separator into an empty | | cell — the load-bearing-whitespace trap). +# Bars inside a nested klammer (e.g. @frac 1 | 2 @ in a cell) belong to that +# klammer, not the table: only bars at nesting depth 0 within the table span +# count, the same depth rule the Klammermachine itself applies to @cond. +# Aligned rows adopt the leading whitespace of the first aligned row; run +# the reindent command (Ctrl+Alt+I) first if the rows disagree. +# +# SYNC: ALIGN_KLAMMERS / CELL_MAX / ROW_MAX mirror the Emacs defcustoms +# klammertext-align-klammers / -cell-max / -row-max in klammertext-align.el. +# LITERAL_KLAMMERS is the same four-way synced list as everywhere else. The +# scanning helpers are duplicated from Klammertext.py so this file stays a +# deletable unit with no import coupling. + +try: + import sublime + import sublime_plugin + _IN_SUBLIME = True +except ImportError: # standalone testing outside Sublime Text + _IN_SUBLIME = False + +import bisect + +ALIGN_KLAMMERS = set(["table"]) +CELL_MAX = 30 +ROW_MAX = 100 +LITERAL_KLAMMERS = set(["code"]) + + +# --- pure helpers (duplicated from Klammertext.py; see SYNC note above) ----- + +def name_char_p(ch): + """True if CH can be part of a klammer name (letter, digit or _).""" + if ch is None: + return False + return (('a' <= ch <= 'z') or ('A' <= ch <= 'Z') + or ('0' <= ch <= '9') or ch == '_') + + +def escaped_p(s, pos): + """True if the char at POS is escaped by an odd run of ^ before it.""" + n = 0 + i = pos - 1 + while i >= 0 and s[i] == '^': + n += 1 + i -= 1 + return (n % 2) == 1 + + +def block_end(s, frm): + """Index just after the ]# that closes a #[ block opened at FROM (the index + just after the opening #[). Counts nested #[ ... ]#; len(s) if unclosed.""" + depth = 1 + i = frm + n = len(s) + while depth > 0: + a = s.find('#[', i) + b = s.find(']#', i) + if a == -1 and b == -1: + return n + if b == -1 or (a != -1 and a < b): + depth += 1 + i = a + 2 + else: + depth -= 1 + i = b + 2 + return i + + +def at_run_end(s, pos): + """Index just after the run of @ that begins at POS.""" + p = pos + n = len(s) + while p < n and s[p] == '@': + p += 1 + return p + + +# --- finding the enclosing table span --------------------------------------- + +def enclosing_span(s, pos, names): + """Innermost span of a klammer named in NAMES that contains POS. + Return (name, content_start, content_end) with content_start just after + the opening @name token and content_end at the start of the closing + delimiter token, or None. Scans S from the start with a position stack, + stepping over removed text, literal spans, escaped characters, and the + abbreviated @name-arg form.""" + stack = [] # (name, open_token_start, content_start) + n = len(s) + i = 0 + while i < n: + j = i + while j < n and s[j] != '@' and s[j] != '#': + j += 1 + if j >= n: + break + hit = j + i = hit + 1 + if escaped_p(s, hit): + continue + nxt = s[hit + 1] if hit + 1 < n else None + if s[hit] == '#': + if nxt == '#': + break # ## removes the rest of the buffer + elif nxt == '[': + i = block_end(s, hit + 2) + elif nxt in ('+', '/', '-'): + pass + else: + eol = s.find('\n', hit) + i = n if eol == -1 else eol + continue + run_end = at_run_end(s, hit) + run_len = run_end - hit + after = s[run_end] if run_end < n else None + if name_char_p(after): + k = run_end + while k < n and name_char_p(s[k]): + k += 1 + name = s[run_end:k] + i = k + if run_len == 1 and name in LITERAL_KLAMMERS: + idx = s.find(name + '@', k) + if idx == -1: + break + i = idx + len(name) + 1 + elif run_len == 1 and k < n and s[k] == '-': + pass # @name-arg : opens no span + else: + stack.append((name, hit, k)) + else: + # a close: the token starts at the preceding name run, if any + ns = hit + while ns > 0 and name_char_p(s[ns - 1]): + ns -= 1 + tok_start = ns if (ns < hit and (ns == 0 or s[ns - 1] != '@')) else hit + if stack: + name, open_start, content_start = stack.pop() + if name in names and open_start <= pos <= run_end: + return (name, content_start, tok_start) + i = run_end + return None + + +# --- scanning the span content, line by line -------------------------------- + +def scan_lines(content): + """Scan CONTENT (the text of a table span). Return a list of line + records, one per line: dicts with start, end (offsets into CONTENT, end + excludes the newline), start_depth, end_depth (klammer nesting relative + to the span), bars (list of (pos, runlen) for unescaped depth-0 bar + runs), blocked (inside removed/verbatim content), comment (a # removes + the rest of the line).""" + n = len(content) + line_starts = [0] + for idx, ch in enumerate(content): + if ch == '\n': + line_starts.append(idx + 1) + nlines = len(line_starts) + + def line_index(p): + return bisect.bisect_right(line_starts, p) - 1 + + lines = [{'start': line_starts[k], + 'end': (line_starts[k + 1] - 1 if k + 1 < nlines else n), + 'bars': [], 'blocked': False, 'comment': False, + 'start_depth': None, 'end_depth': None} + for k in range(nlines)] + lines[0]['start_depth'] = 0 + + def block_range(a, b): + """Mark every line touched by [a, b) as blocked.""" + last = max(a, b - 1) + for k in range(line_index(a), line_index(min(last, n - 1)) + 1): + lines[k]['blocked'] = True + + depth = 0 + i = 0 + while i < n: + j = i + while j < n and content[j] not in '@#|\n': + j += 1 + if j >= n: + break + hit = j + i = hit + 1 + c = content[hit] + if c == '\n': + k = line_index(hit) + lines[k]['end_depth'] = depth + if k + 1 < nlines: + lines[k + 1]['start_depth'] = depth + continue + if escaped_p(content, hit): + continue + nxt = content[hit + 1] if hit + 1 < n else None + if c == '#': + if nxt == '#': + block_range(hit, n) + break + elif nxt == '[': + e = block_end(content, hit + 2) + if line_index(max(hit, e - 1)) != line_index(hit): + block_range(hit, e) + i = e + elif nxt in ('+', '/', '-'): + pass + else: # # to end of line + lines[line_index(hit)]['comment'] = True + eol = content.find('\n', hit) + i = n if eol == -1 else eol + continue + if c == '|': + if hit > 0 and content[hit - 1] == '|': + continue # mid-run (after an escaped ^|) + k = hit + while k < n and content[k] == '|': + k += 1 + if depth == 0: + lines[line_index(hit)]['bars'].append((hit, k - hit)) + i = k + continue + # '@' + run_end = at_run_end(content, hit) + run_len = run_end - hit + after = content[run_end] if run_end < n else None + if name_char_p(after): + k = run_end + while k < n and name_char_p(content[k]): + k += 1 + name = content[run_end:k] + i = k + if run_len == 1 and name in LITERAL_KLAMMERS: + idx = content.find(name + '@', k) + e = n if idx == -1 else idx + len(name) + 1 + if line_index(max(hit, e - 1)) != line_index(hit): + block_range(hit, e) + i = e + elif run_len == 1 and k < n and content[k] == '-': + pass + else: + depth += 1 + else: + depth = max(0, depth - 1) + i = run_end + + for ln in lines: + if ln['start_depth'] is None: + ln['blocked'] = True + if ln['end_depth'] is None: + ln['end_depth'] = depth + return lines + + +# --- the alignment ---------------------------------------------------------- + +def compute_edits(content): + """Compute the alignment edits for CONTENT (a table span's text). + Return (edits, message): edits is a list of (start, end, new_text) + triples relative to CONTENT, ascending; message is a status string (a + reason when edits is empty).""" + lines = scan_lines(content) + n = len(content) + + # The last line holding actual content (a row there may omit its ||). + last_content = None + for k in range(len(lines) - 1, 0, -1): + if content[lines[k]['start']:lines[k]['end']].strip(): + last_content = k + break + + rows = [] # (line record, cells, trailing_p) + chain_ok = True # a row must START a row: the previous + for k in range(1, len(lines)): # content line ended with || (or was the + ln = lines[k] # opener / an option line / blank) + text = content[ln['start']:ln['end']] + stripped = text.strip() + if not stripped: + continue # blank line: chain unchanged + if stripped.startswith(':') and not ln['bars']: + continue # option line: chain unchanged + row = _parse_row(content, ln, text, chain_ok, k == last_content) + trailing = _trailing_rowsep(content, ln) + chain_ok = trailing + if row is not None: + rows.append(row) + + if not rows: + return ([], "no alignable rows found") + + widths = [] + for _ln, cells, _tr in rows: + for c_idx, cell in enumerate(cells): + if c_idx >= len(widths): + widths.append(0) + widths[c_idx] = max(widths[c_idx], len(cell)) + + indent = ' ' * _indent_width(content, rows[0][0]) + if ROW_MAX is not None: + longest = 0 + for _ln, cells, trailing in rows: + m = len(cells) + w = (len(indent) + sum(widths[:m]) + 3 * (m - 1) + + (3 if trailing else 0)) + longest = max(longest, w) + if longest > ROW_MAX: + return ([], "aligned rows would be %d characters (limit %d); " + "not aligning" % (longest, ROW_MAX)) + + edits = [] + for ln, cells, trailing in rows: + parts = [cells[c].ljust(widths[c]) for c in range(len(cells) - 1)] + last = cells[-1] + if trailing: + last = last.ljust(widths[len(cells) - 1]) + parts.append(last) + new = indent + ' | '.join(parts) + (' ||' if trailing else '') + if new != content[ln['start']:ln['end']]: + edits.append((ln['start'], ln['end'], new)) + msg = ("aligned %d rows" % len(rows)) if edits else "already aligned" + return (edits, msg) + + +def _indent_width(content, ln): + i = ln['start'] + while i < ln['end'] and content[i] in ' \t': + i += 1 + return i - ln['start'] + + +def _trailing_rowsep(content, ln): + """True when the line's LAST depth-0 bar run is a || sitting at the end of + the line (only whitespace after it).""" + if not ln['bars']: + return False + pos, runlen = ln['bars'][-1] + return (runlen == 2 + and content[pos + 2:ln['end']].strip() == '') + + +def _parse_row(content, ln, text, chain_ok, is_last_content): + """If the line is an alignable row, return (ln, cells, trailing_p); + else None. See the file header for the rules.""" + if ln['blocked'] or ln['comment'] or not chain_ok: + return None + if ln['start_depth'] != 0 or ln['end_depth'] != 0: + return None + if not ln['bars']: + return None + trailing = _trailing_rowsep(content, ln) + singles = ln['bars'][:-1] if trailing else ln['bars'] + for _pos, runlen in singles: + if runlen != 1: + return None # a mid-line || (or |||): not one row + if not trailing and not is_last_content: + return None # row continues onto the next line + cell_start = ln['start'] + _indent_width(content, ln) + cell_end = ln['bars'][-1][0] if trailing else ln['end'] + bounds = [cell_start] + [p for p, _r in singles] + [cell_end] + cells = [] + for b_idx in range(len(bounds) - 1): + a = bounds[b_idx] + (1 if b_idx > 0 else 0) # skip the | itself + cell = content[a:bounds[b_idx + 1]].strip() + if len(cell) > CELL_MAX: + return None + cells.append(cell) + return (ln, cells, trailing) + + +# --- the command ------------------------------------------------------------ + +if _IN_SUBLIME: + + class KlammertextAlignTableCommand(sublime_plugin.TextCommand): + """Align the columns of the table klammer containing the caret. + Bound to Ctrl+Alt+A.""" + + def run(self, edit): + view = self.view + s = view.substr(sublime.Region(0, view.size())) + pos = view.sel()[0].b if len(view.sel()) else 0 + span = enclosing_span(s, pos, ALIGN_KLAMMERS) + if span is None: + sublime.status_message( + "Klammertext: the caret is not inside a table klammer (%s)" + % ", ".join("@" + name for name in sorted(ALIGN_KLAMMERS))) + return + _name, cs, ce = span + edits, msg = compute_edits(s[cs:ce]) + for a, b, new in sorted(edits, reverse=True): + view.replace(edit, sublime.Region(cs + a, cs + b), new) + sublime.status_message("Klammertext: " + msg) + + def is_enabled(self): + return self.view.match_selector(0, "text.klammertext") diff --git a/doc/edit/sublime/Klammertext_in_Sublime_Text.md b/doc/edit/sublime/Klammertext_in_Sublime_Text.md index 0223b4e..1ed7dfe 100644 --- a/doc/edit/sublime/Klammertext_in_Sublime_Text.md +++ b/doc/edit/sublime/Klammertext_in_Sublime_Text.md @@ -12,7 +12,8 @@ Emacs mode closely; where the two intentionally differ, the file headers say so. | `Klammertext.sublime-syntax` | Syntax highlighting. Colors the text-removal constructs (`#`, `##`, `#[...]#`) and the three `@`-tiers — application `@`, definition `@@`, system `@@@` — each as an opening vs. a close, plus `^`-escapes and verbatim `@code ... code@` spans. | | `Klammertext.py` | Plugin with two features that share one context-sensitive matcher: jump between an opening and its close, and live highlighting of the matching delimiter as the caret moves (mismatched or unbalanced delimiters flag in red). | | `Klammertext_indent.py` | **Experimental.** Reindentation per the Klammertext convention (see below). A separate unit: delete this one file to disable indentation; nothing else is affected. | -| `Default.sublime-keymap` | Binds jump-to-match to **Ctrl+M** and reindent to **Ctrl+Alt+I**, scoped to Klammertext files. | +| `Klammertext_align.py` | **Experimental.** Table alignment (see below). Also a separate, deletable unit. | +| `Default.sublime-keymap` | Binds jump-to-match to **Ctrl+M**, reindent to **Ctrl+Alt+I**, and table alignment to **Ctrl+Alt+A**, scoped to Klammertext files. | | `Comments.tmPreferences` | Comment toggling: **Ctrl+/** inserts `# ` (line removal), **Ctrl+Shift+/** wraps in `#[ ... ]#` (block removal). | | `Breakers` / `Celeste` / `Mariana` / `Monokai` / `Sixteen` `.sublime-color-scheme` | Color overrides for Sublime's five built-in schemes — one hue system, full intensity on the dark schemes, scaled down on the light ones. Additive: they recolor only the Klammertext delimiters and leave the rest of each scheme unchanged. | | `Klammertext_in_Sublime_Text.md` | This file. | @@ -50,6 +51,7 @@ adaptive `region.*` scopes, which were added in ST4. | **Ctrl+/** | Toggle line comment (`#`) | | **Ctrl+Shift+/** | Toggle block comment (`#[ ... ]#`) | | **Ctrl+Alt+I** | Reindent the selected lines (the current line when there is just a caret) — experimental, see "Indentation" below | +| **Ctrl+Alt+A** | Align the columns of the table containing the caret — experimental, see "Table alignment" below | Ctrl+M is Sublime's own "go to matching bracket" key, reused here because the built-in cannot match Klammertext's context-dependent `@`. macOS users who @@ -91,6 +93,34 @@ ask for it. To disable the feature, delete `Klammertext_indent.py` and the Ctrl+Alt+I entry in `Default.sublime-keymap` (or just the keymap entry, to keep the command available from plugins). +## Table alignment (experimental) + +`Klammertext_align.py` ports the Emacs mode's table alignment +(`doc/emacs/klammertext-align.el`): **Ctrl+Alt+A** with the caret anywhere +inside a `@table` span pads the cells of its rows so the `|` separators line +up: + +``` +@table + First item | Second | A third item that's longer || + Row 2 | Text | Not as long || +@ +``` + +A row is one line ending with the row delimiter `||` (the customary trailing +delimiter; the last row may omit it). Alignment is for small data items, so +a row is left untouched — and contributes nothing to the column widths — +when any of its cells is longer than `CELL_MAX` (30) characters or the row +spans lines. If the aligned rows would exceed `ROW_MAX` (100) columns, +nothing changes and the status bar says so. The padding is semantically +free: the SKS strips cell content, no whitespace is ever inserted inside a +bar run (which would turn a `||` row separator into an empty `| |` cell), +and bars inside a nested klammer in a cell (`@frac 1 | 2 @`) belong to that +klammer, not the table. Aligned rows adopt the leading whitespace of the +first aligned row — run Ctrl+Alt+I first if the rows disagree. The limits +sit at the top of `Klammertext_align.py`, mirrored from the Emacs +defcustoms. + ## Colors Colors are installed automatically for all five of Sublime's built-in schemes.