summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--claude/claude-input.el473
1 files changed, 473 insertions, 0 deletions
diff --git a/claude/claude-input.el b/claude/claude-input.el
new file mode 100644
index 0000000..ae7ff1f
--- /dev/null
+++ b/claude/claude-input.el
@@ -0,0 +1,473 @@
+;;; claude-input.el --- Slot-based, buffered Zhuyin input for chinese-zozy -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Jean Sévère Durand
+
+;; Keywords: i18n, convenience
+
+;; This program is free software; you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Improvements over the `chinese-zozy' Quail package (ZOZY.el), which
+;; is left untouched. Everything is done by advising Quail.
+;;
+;; 1. Slots. A syllable has four slots: initial, medial, final and
+;; tone. Typing a symbol whose slot is already taken replaces the
+;; old symbol, so the order of typing does not matter. A tone
+;; finishes the syllable (its candidates are shown); typing any
+;; non-tone symbol afterwards starts a *new* character and leaves
+;; the finished one alone.
+;;
+;; 2. Buffering. All characters of one composition are kept as
+;; "cells". Move between cells with the keys in
+;; `claude-input-backward-keys' / `claude-input-forward-keys' and
+;; edit or delete them. The composition ends with a key in
+;; `claude-input-commit-keys' (RET by default).
+;;
+;; Keys are classified through `quail-keyboard-translate', so the code
+;; works with any `quail-keyboard-layout-type'. Slots store the raw
+;; keys that the user typed, because `quail-current-key' is raw as
+;; well.
+;;
+;; Word-level selection (stage 2) is not implemented yet.
+;;
+;; Usage: (require 'claude-input), then select `chinese-zozy'.
+
+;;; Code:
+
+(require 'quail)
+(require 'subr-x)
+
+(defgroup claude-input nil
+ "Slot-based, buffered Zhuyin input."
+ :group 'quail)
+
+(defcustom claude-input-package "chinese-zozy"
+ "Name of the Quail package that this file improves."
+ :type 'string)
+
+(defcustom claude-input-backward-keys (list [left] (kbd "M-b"))
+ "Key sequences moving to the previous cell inside a composition."
+ :type '(repeat sexp))
+
+(defcustom claude-input-forward-keys (list [right] (kbd "M-f"))
+ "Key sequences moving to the next cell inside a composition."
+ :type '(repeat sexp))
+
+(defcustom claude-input-commit-keys (list (kbd "RET") (kbd "C-j"))
+ "Key sequences that finish the whole composition."
+ :type '(repeat sexp))
+
+;;;; Slots
+
+(defconst claude-input--slot-table
+ (let ((table (make-hash-table :test #'eq)))
+ ;; Keys are in the standard (QWERTY) layout, as used by ZOZY.el.
+ (dolist (spec '((0 . "1q2wazsxedcrfv5tgbyhn") ; initials
+ (1 . "ujm") ; medials
+ (2 . "8ik,9ol.0p;/-") ; finals
+ (3 . " 6347"))) ; tones
+ (seq-doseq (c (cdr spec))
+ (puthash c (car spec) table)))
+ table)
+ "Map a standard-layout key to its slot index (0 to 3).")
+
+(defun claude-input--slot-of (char)
+ "Return the slot index of the raw key CHAR, or nil if it is no symbol.
+CHAR is first translated to the standard layout."
+ (and (characterp char)
+ (gethash (quail-keyboard-translate char) claude-input--slot-table)))
+
+(defun claude-input--key (slots)
+ "Return the normalised raw key string built from SLOTS."
+ (apply #'string (delq nil (append slots nil))))
+
+(defun claude-input--slots-from-key (key)
+ "Return a fresh slot vector filled with the symbols in KEY."
+ (let ((slots (make-vector 4 nil)))
+ (seq-doseq (c key)
+ (when-let* ((i (claude-input--slot-of c)))
+ (aset slots i c)))
+ slots))
+
+;;;; Cells
+
+;; A cell is a vector [SLOTS STR BEG END INDEX]. STR is the text of
+;; the cell, BEG and END are markers around that text in the buffer,
+;; and INDEX is the selected candidate. The text of the current cell
+;; is owned by Quail (`quail-overlay'); its markers are only refreshed
+;; when we leave the cell.
+
+(defvar claude-input--cells nil
+ "Vector of cells of the composition in progress, or nil when inactive.")
+
+(defvar claude-input--index 0
+ "Index in `claude-input--cells' of the cell being edited.")
+
+(defvar claude-input--slots (make-vector 4 nil)
+ "Slots of the syllable being edited.")
+
+(defvar claude-input--last-tone-p nil
+ "Non-nil if the last key typed was a tone.")
+
+(defvar claude-input--overlay nil
+ "Overlay underlining the whole composition.")
+
+(defsubst claude-input--active-p ()
+ "Return non-nil if a buffered composition is in progress."
+ (and claude-input--cells t))
+
+(defsubst claude-input--cell-slots (cell) (aref cell 0))
+(defsubst claude-input--cell-str (cell) (aref cell 1))
+(defsubst claude-input--cell-beg (cell) (aref cell 2))
+(defsubst claude-input--cell-end (cell) (aref cell 3))
+(defsubst claude-input--cell-choice (cell) (aref cell 4))
+
+(defun claude-input--make-cell (&optional slots str beg end choice)
+ "Make a cell. Markers are created if BEG and END are nil."
+ (let ((b (make-marker)) (e (make-marker)))
+ ;; BEG advances over text inserted at its position, END does not.
+ ;; Together this keeps adjacent cells separated when the text of
+ ;; one of them is replaced.
+ (set-marker-insertion-type b t)
+ (set-marker-insertion-type e nil)
+ (when beg (set-marker b beg))
+ (when end (set-marker e end))
+ (vector (or slots (make-vector 4 nil)) (or str "") b e (or choice 0))))
+
+(defun claude-input--current ()
+ "Return the cell being edited."
+ (aref claude-input--cells claude-input--index))
+
+(defun claude-input--current-choice ()
+ "Return the selected candidate index of the current translations."
+ (if quail-current-translations
+ (or (car-safe (car-safe quail-current-translations)) 0)
+ 0))
+
+(defun claude-input--save-current ()
+ "Save the state of the current cell, from Quail's, into the cell."
+ (let ((cell (claude-input--current))
+ (beg (overlay-start quail-overlay))
+ (end (overlay-end quail-overlay)))
+ (aset cell 0 (copy-sequence claude-input--slots))
+ (aset cell 1 (or quail-current-str ""))
+ (aset cell 4 (claude-input--current-choice))
+ (when beg
+ (set-marker (claude-input--cell-beg cell) beg)
+ (set-marker (claude-input--cell-end cell) end))
+ ;; Make Quail forget the text so that it stays in the buffer.
+ (when end (move-overlay quail-overlay end end))
+ (claude-input--normalize-cells cell)))
+
+(defun claude-input--normalize-cells (current)
+ "Order `claude-input--cells' by buffer position and track CURRENT.
+Ties (empty cells share a position with their neighbours) are broken by
+the old order, so the sort is stable. `claude-input--index' is set to
+the index of the cell CURRENT, found by identity. This must be called
+after the markers of CURRENT have been refreshed."
+ (let ((i -1) (last-pos 0) keyed)
+ (seq-doseq (cell claude-input--cells)
+ (let ((pos (or (marker-position (claude-input--cell-beg cell)) last-pos)))
+ (setq last-pos pos)
+ (push (list pos (setq i (1+ i)) cell) keyed)))
+ (setq claude-input--cells
+ (vconcat
+ (mapcar #'caddr
+ (sort (nreverse keyed)
+ (lambda (a b)
+ (or (< (car a) (car b))
+ (and (= (car a) (car b))
+ (< (cadr a) (cadr b)))))))))
+ (setq claude-input--index
+ (or (seq-position claude-input--cells current #'eq) 0))))
+
+(defun claude-input--release-cell (cell)
+ "Release the markers of CELL."
+ (set-marker (claude-input--cell-beg cell) nil)
+ (set-marker (claude-input--cell-end cell) nil))
+
+(defun claude-input--empty-cell-p (cell)
+ "Return non-nil if CELL has no symbols at all."
+ (not (seq-some #'identity (claude-input--cell-slots cell))))
+
+(defun claude-input--enter (index)
+ "Make the cell at INDEX the one being edited.
+The previous current cell must already have been saved. The text of
+cell INDEX is handed over to Quail, and the candidates are rebuilt."
+ (setq claude-input--index index)
+ (let* ((cell (aref claude-input--cells index))
+ (beg (marker-position (claude-input--cell-beg cell)))
+ (end (marker-position (claude-input--cell-end cell))))
+ (setq claude-input--slots (copy-sequence (claude-input--cell-slots cell))
+ claude-input--last-tone-p nil
+ quail-current-key (claude-input--key claude-input--slots))
+ (cond
+ ((and beg end)
+ (move-overlay quail-overlay beg end)
+ (goto-char end))
+ (t (move-overlay quail-overlay (point) (point))))
+ (setq quail-current-str (claude-input--cell-str cell))
+ (if (string-empty-p quail-current-key)
+ (quail-delete-region)
+ (let ((choice (claude-input--cell-choice cell)))
+ (quail-update-translation (quail-translate-key))
+ ;; Restore the candidate that was selected earlier.
+ (when (and quail-current-translations (> choice 0))
+ (setcar (car quail-current-translations)
+ (min choice (1- (length (cdr quail-current-translations)))))
+ (quail-update-current-translations)
+ (quail-update-translation nil))))
+ (claude-input--refresh-overlay)))
+
+(defun claude-input--move (delta)
+ "Save the current cell and edit the cell DELTA cells away from it.
+Empty cells are dropped. Return non-nil if we moved."
+ (let ((target (aref claude-input--cells
+ (max 0 (min (+ claude-input--index delta)
+ (1- (length claude-input--cells)))))))
+ (unless (eq target (claude-input--current))
+ (let ((cur (claude-input--current)))
+ (claude-input--save-current)
+ (when (claude-input--empty-cell-p cur)
+ (claude-input--release-cell cur)
+ (setq claude-input--cells (vconcat (delq cur (append claude-input--cells nil)))))
+ (claude-input--enter (seq-position claude-input--cells target #'eq))
+ t))))
+
+(defun claude-input--insert-cell-after-current ()
+ "Save the current cell and open a new empty cell right after it."
+ (claude-input--save-current)
+ (let ((pos (or (overlay-end quail-overlay) (point)))
+ (idx (1+ claude-input--index)))
+ (setq claude-input--cells
+ (vconcat (seq-subseq claude-input--cells 0 idx)
+ (vector (claude-input--make-cell nil "" pos pos))
+ (seq-subseq claude-input--cells idx)))
+ (setq claude-input--index idx
+ claude-input--slots (make-vector 4 nil)
+ claude-input--last-tone-p nil
+ quail-current-key ""
+ quail-current-str "")
+ (goto-char pos)
+ (move-overlay quail-overlay pos pos)))
+
+;;;; Overlay
+
+(defun claude-input--refresh-overlay ()
+ "Underline all text of the composition."
+ (when (claude-input--active-p)
+ (let ((lo nil) (hi nil))
+ (dotimes (i (length claude-input--cells))
+ (let* ((cell (aref claude-input--cells i))
+ (cur (= i claude-input--index))
+ (b (if cur (overlay-start quail-overlay)
+ (marker-position (claude-input--cell-beg cell))))
+ (e (if cur (overlay-end quail-overlay)
+ (marker-position (claude-input--cell-end cell)))))
+ (when (and b e)
+ (setq lo (if lo (min lo b) b)
+ hi (if hi (max hi e) e)))))
+ (when lo
+ (unless (overlayp claude-input--overlay)
+ (setq claude-input--overlay (make-overlay lo hi nil nil t))
+ (overlay-put claude-input--overlay 'face 'underline)
+ (overlay-put claude-input--overlay 'priority -1))
+ (move-overlay claude-input--overlay lo hi)))))
+
+(defun claude-input--delete-overlay ()
+ "Delete the composition overlay."
+ (when (overlayp claude-input--overlay)
+ (delete-overlay claude-input--overlay))
+ (setq claude-input--overlay nil))
+
+;;;; Translation advice
+
+(defun claude-input--applies-p ()
+ "Return non-nil if our advice should act in the current Quail package."
+ (and (claude-input--active-p)
+ (equal (quail-name) claude-input-package)))
+
+(defun claude-input--translate-advice (orig &rest args)
+ "Around advice for `quail-translate-key' (ORIG, ARGS).
+Implements the slots; see the Commentary."
+ (let ((char last-command-event))
+ (cond
+ ((not (claude-input--applies-p))
+ (apply orig args))
+ ;; A symbol key typed by the user.
+ ((and (eq this-command 'quail-self-insert-command)
+ (claude-input--slot-of char))
+ (let* ((slot (claude-input--slot-of char))
+ (tone-p (= slot 3))
+ (finished (aref claude-input--slots 3)))
+ ;; A finished syllable followed by a non-tone symbol (or by a
+ ;; tone after a non-tone edit) starts a new character. Several
+ ;; tones in a row only replace the tone.
+ (when (and finished (not (and tone-p claude-input--last-tone-p)))
+ (claude-input--insert-cell-after-current))
+ (setq claude-input--last-tone-p tone-p)
+ (aset claude-input--slots slot char)
+ (setq quail-current-key (claude-input--key claude-input--slots))
+ (let ((res (apply orig args)))
+ (when (numberp res)
+ ;; No translation for this combination: keep what we had,
+ ;; show the symbols typed so far.
+ (setq quail-current-str
+ (or quail-current-str (quail-keyseq-translate quail-current-key))))
+ (claude-input--refresh-overlay)
+ ;; Only the commit keys finish the composition.
+ nil)))
+ ;; Anything else: a digit selecting a candidate, a backspace, or
+ ;; a stray key that ends the composition.
+ (t
+ (let ((res (apply orig args)))
+ (setq claude-input--slots
+ (claude-input--slots-from-key quail-current-key))
+ (claude-input--refresh-overlay)
+ (cond ((eq res t) nil) ; a selection is not the end
+ (t res)))))))
+
+;;;; Commands bound in the Quail translation keymap
+
+(defun claude-input-backward ()
+ "Edit the previous character of the composition."
+ (interactive)
+ (if (claude-input--active-p)
+ (or (claude-input--move -1)
+ (message "Beginning of composition"))
+ (quail-error "Not composing")))
+
+(defun claude-input-forward ()
+ "Edit the next character of the composition."
+ (interactive)
+ (if (claude-input--active-p)
+ (or (claude-input--move 1)
+ (message "End of composition"))
+ (quail-error "Not composing")))
+
+(defun claude-input-delete-backward ()
+ "Delete the last symbol of the current syllable.
+When the syllable is already empty, delete the character itself and
+move to a neighbour."
+ (interactive)
+ (cond
+ ((not (claude-input--active-p)) (quail-delete-last-char))
+ ((> (length quail-current-key) 1)
+ (quail-delete-last-char))
+ (t
+ ;; One symbol left: remove the cell.
+ (let ((cur claude-input--index)
+ (n (length claude-input--cells)))
+ (cond
+ ((= n 1) (quail-abort-translation))
+ (t
+ (quail-delete-region)
+ (claude-input--release-cell (claude-input--current))
+ (setq claude-input--cells
+ (vconcat (seq-subseq claude-input--cells 0 cur)
+ (seq-subseq claude-input--cells (1+ cur))))
+ (setq claude-input--index (min cur (1- (length claude-input--cells))))
+ ;; Previous cell if there is one, else the next.
+ (when (> cur 0) (setq claude-input--index (1- cur)))
+ (claude-input--enter claude-input--index)))))))
+
+(defun claude-input-commit ()
+ "Finish the whole composition and keep the text."
+ (interactive)
+ (when (claude-input--active-p)
+ ;; Make the last cell the current one so that point ends up after
+ ;; all of the text.
+ (claude-input--move (length claude-input--cells)))
+ (quail-terminate-translation))
+
+(defun claude-input--bind-keys ()
+ "Install our commands in the translation keymap of the package."
+ (when-let* ((map (quail-translation-keymap)))
+ ;; Quail binds ESC itself, which blocks meta keys such as M-b.
+ ;; Shadow it with a prefix map.
+ (unless (keymapp (lookup-key map [27]))
+ (define-key map [27] (make-sparse-keymap)))
+ (dolist (k claude-input-backward-keys)
+ (define-key map k #'claude-input-backward))
+ (dolist (k claude-input-forward-keys)
+ (define-key map k #'claude-input-forward))
+ (dolist (k claude-input-commit-keys)
+ (define-key map k #'claude-input-commit))
+ (define-key map "\177" #'claude-input-delete-backward)
+ (define-key map [backspace] #'claude-input-delete-backward)))
+
+;;;; Session management
+
+(defun claude-input--start ()
+ "Begin a composition."
+ (setq claude-input--cells (vector (claude-input--make-cell))
+ claude-input--index 0
+ claude-input--slots (make-vector 4 nil)
+ claude-input--last-tone-p nil))
+
+(defun claude-input--end ()
+ "Release everything."
+ (when claude-input--cells
+ (seq-doseq (cell claude-input--cells)
+ (claude-input--release-cell cell)))
+ (claude-input--delete-overlay)
+ (setq claude-input--cells nil
+ claude-input--index 0
+ claude-input--slots (make-vector 4 nil)
+ claude-input--last-tone-p nil))
+
+(defun claude-input--start-translation-advice (orig key)
+ "Around advice for `quail-start-translation' (ORIG, KEY).
+Wrap the whole composition so that state is set up and cleaned."
+ (if (not (equal (quail-name) claude-input-package))
+ (funcall orig key)
+ (claude-input--bind-keys)
+ (claude-input--start)
+ (unwind-protect
+ (let* ((res (funcall orig key))
+ (last (and claude-input--cells
+ (1- (length claude-input--cells)))))
+ ;; If the loop ended while editing a cell in the middle,
+ ;; put its text back ourselves and move after the last cell.
+ (if (and last (/= claude-input--index last)
+ (stringp res) (> (length res) 0))
+ (progn
+ (insert res)
+ (let ((e (marker-position
+ (claude-input--cell-end
+ (aref claude-input--cells last)))))
+ (when e (goto-char e)))
+ "")
+ res))
+ (claude-input--end))))
+
+;;;###autoload
+(define-minor-mode claude-input-mode
+ "Globally enable slot-based, buffered input for `chinese-zozy'."
+ :global t
+ (cond
+ (claude-input-mode
+ (advice-add 'quail-translate-key :around #'claude-input--translate-advice)
+ (advice-add 'quail-start-translation :around
+ #'claude-input--start-translation-advice))
+ (t
+ (advice-remove 'quail-translate-key #'claude-input--translate-advice)
+ (advice-remove 'quail-start-translation
+ #'claude-input--start-translation-advice))))
+
+;; (claude-input-mode 1)
+
+(provide 'claude-input)
+;;; claude-input.el ends here