diff options
| author | cashmere <cashmere@autistici.org> | 2026-08-01 16:10:52 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-08-01 16:10:52 +0200 |
| commit | 99457f838fc07d0c5c5fb43e692d67c39edde4c8 (patch) | |
| tree | af3c2d265425935a8d6a670bca8c84a1c112561a | |
| parent | 688dc0aac76b7d80e26a7e5c84d378c73f39697f (diff) | |
| download | emacs-99457f838fc07d0c5c5fb43e692d67c39edde4c8.tar.gz | |
add croc-ui
| -rw-r--r-- | config.org | 3 | ||||
| -rw-r--r-- | lisp/croc-ui.el | 1075 |
2 files changed, 1077 insertions, 1 deletions
@@ -4311,7 +4311,8 @@ workspace (e.g. *scratch*)." (general-def '(normal visual operator) 'override :predicate '(not (derived-mode-p 'mu4e-main-mode 'mu4e-headers-mode - 'mu4e-view-mode 'mu4e-compose-mode)) + 'mu4e-view-mode 'mu4e-compose-mode + 'croc-ui-mode)) "s" 'my/s-key-dispatch) diff --git a/lisp/croc-ui.el b/lisp/croc-ui.el new file mode 100644 index 0000000..c75d4b2 --- /dev/null +++ b/lisp/croc-ui.el @@ -0,0 +1,1075 @@ +;;; croc-ui.el --- -*- lexical-binding: t -*- + +;; Copyright (C) 2026 cashmere + +;; Author: cashmere +;; Version: 0.2.0 +;; Package-Requires: ((emacs "28.1")) +;; Keywords: comm, tools + +;;; Commentary: + +;; A keyboard-first, Rougier-style front-end for croc(1), the +;; peer-to-peer file transfer tool. The `*croc*' dashboard shows the +;; current share code as a hero, a link-farm of key hints, and a +;; hairline feed of transfers with live progress chips. Each +;; transfer is a real croc process; its raw log is one RET away. +;; +;; Archetype: hero + link farm + hairline feed (mu4e-dashboard / +;; mastodon-alt idioms). Faces are theme-agnostic: `link' = salient, +;; `shadow' = faded, `bold' = strong, `error' = critical, inverse +;; video = chips and selection. No color is ever computed at load +;; time, so the UI tracks live theme switches. +;; +;; When a send waits, its code also shows as a QR card the official +;; croc app can scan. The QR encoder is pure Elisp (no qrencode(1)) +;; and renders as half-block text, so it works identically in a GUI +;; and in `emacs -nw'. +;; +;; Keys (dashboard): +;; s send file(s) d send folder t send text r receive +;; RET log w copy code Q qr x kill +;; X clear done n/p next/prev g refresh q quit +;; +;; Evil: normal state gets j/k plus the same one-key actions. + +;;; Code: + +(require 'cl-lib) +(require 'ansi-color) + +;;; § 1 Faces (theme-agnostic, recipe 0) + +(defface croc-ui-strong '((t :inherit bold)) + "Structural text: title, file names." + :group 'croc-ui) + +(defface croc-ui-faded '((t :inherit shadow)) + "Secondary text: hints, metadata, placeholders." + :group 'croc-ui) + +(defface croc-ui-salient '((t :inherit link)) + "Attention: share codes, direction glyphs." + :group 'croc-ui) + +(defface croc-ui-critical '((t :inherit (error bold))) + "Failed transfers. Used scarcely." + :group 'croc-ui) + +(defface croc-ui-chip '((t :inherit bold :inverse-video t)) + "Active state chip." + :group 'croc-ui) + +(defface croc-ui-chip-i '((t :inherit shadow :inverse-video t)) + "Inactive state chip." + :group 'croc-ui) + +(defface croc-ui-chip-active '((t :inherit link :inverse-video t)) + "In-progress state chip: the salient accent, inverted." + :group 'croc-ui) + +(defface croc-ui-chip-critical '((t :inherit (error bold) :inverse-video t)) + "Failed state chip." + :group 'croc-ui) + +(defface croc-ui-selected '((t :inherit bold :inverse-video t)) + "Selected row (hl-line remap target). Pure inverse: always theme-fresh." + :group 'croc-ui) + +(defface croc-ui-hairline + '((t :inherit default :strike-through t :overline nil :underline nil + :height 1.0 :extend t)) + "Full-width rule drawn by a strike-through newline." + :group 'croc-ui) + +;;; § 2 Customs + +(defgroup croc-ui nil + "Dashboard for the croc file transfer CLI." + :group 'applications + :prefix "croc-ui-") + +(defcustom croc-ui-program "croc" + "Path to the croc executable." + :type 'string + :group 'croc-ui) + +(defcustom croc-ui-receive-directory + (let ((dl (expand-file-name "~/Downloads/"))) + (if (file-directory-p dl) dl (expand-file-name "~/"))) + "Default folder offered when receiving files." + :type 'directory + :group 'croc-ui) + +(defcustom croc-ui-extra-arguments nil + "Extra global flags appended to every croc invocation (e.g. relay options)." + :type '(repeat string) + :group 'croc-ui) + +(defcustom croc-ui-show-qr t + "When non-nil, show a QR card of the waiting share code in the dashboard." + :type 'boolean + :group 'croc-ui) + +;;; § 3 Data layer + +(cl-defstruct (croc-ui--transfer (:constructor croc-ui--transfer-create)) + id direction label code (state 'starting) (progress 0) + process buffer started) + +(defvar croc-ui--transfers nil + "Live list of `croc-ui--transfer' structs, newest first.") + +(defvar croc-ui--counter 0 + "Monotonic transfer id source.") + +(defconst croc-ui--buffer-name "*croc*" + "Dashboard buffer name.") + +(defconst croc-ui--symbols + '((send . ("^" . "↑")) + (recv . ("v" . "↓"))) + "Direction glyphs: (ASCII-fallback . preferred).") + +(defun croc-ui--symbol (name) + "Glyph for NAME, degrading to ASCII when undisplayable." + (let ((pair (alist-get name croc-ui--symbols))) + (if (and (cdr pair) (char-displayable-p (aref (cdr pair) 0))) + (cdr pair) + (car pair)))) + +(defun croc-ui--find (id) + "Transfer struct with ID." + (cl-find id croc-ui--transfers :key #'croc-ui--transfer-id)) + +(defun croc-ui--live-p (tr) + "Non-nil when TR's process is still running." + (and (croc-ui--transfer-process tr) + (process-live-p (croc-ui--transfer-process tr)))) + +(defun croc-ui--hero-transfer () + "The transfer whose code deserves the hero slot: newest waiting send." + (cl-find-if (lambda (tr) + (and (eq (croc-ui--transfer-direction tr) 'send) + (eq (croc-ui--transfer-state tr) 'waiting) + (croc-ui--transfer-code tr))) + croc-ui--transfers)) + +;;; § 4 Process layer + +(defvar-local croc-ui--log-transfer nil + "Back-reference from a log buffer to its transfer struct.") + +(define-derived-mode croc-ui-log-mode special-mode "croc-log" + "Mode for raw croc process logs." + (setq-local truncate-lines t) + (local-set-key (kbd "w") #'croc-ui-copy-code) + (local-set-key (kbd "x") #'croc-ui-kill)) + +(defun croc-ui--filter (proc chunk) + "Append CHUNK to PROC's log and fold its state into the transfer struct." + (let ((tr (process-get proc 'croc-ui-transfer))) + (when (buffer-live-p (process-buffer proc)) + (with-current-buffer (process-buffer proc) + (let ((inhibit-read-only t)) + (goto-char (point-max)) + (let ((beg (point))) + (insert chunk) + (ansi-color-apply-on-region beg (point-max)))))) + (when tr + (let ((state (croc-ui--transfer-state tr)) + refresh) + ;; Share code: scan the tail so a chunk split cannot hide it. + (when (and (not (croc-ui--transfer-code tr)) + (buffer-live-p (process-buffer proc))) + (with-current-buffer (process-buffer proc) + (save-excursion + (goto-char (point-max)) + (when (re-search-backward "Code is: \\([^ \t\n]+\\)" + (max (point-min) (- (point) 1024)) t) + (setf (croc-ui--transfer-code tr) (match-string 1)) + (setq refresh t))))) + (when (string-match "\\([0-9]+\\(?:\\.[0-9]+\\)?\\)%" chunk) + (let ((p (min 100 (truncate (string-to-number (match-string 1 chunk)))))) + (when (> p (croc-ui--transfer-progress tr)) + (setf (croc-ui--transfer-progress tr) p)) + (unless (eq state 'active) + (setf (croc-ui--transfer-state tr) 'active) + (setq refresh t)) + (croc-ui--update-chip tr))) + (when (and (eq (croc-ui--transfer-state tr) 'starting) + (croc-ui--transfer-code tr)) + (setf (croc-ui--transfer-state tr) 'waiting) + (setq refresh t)) + (when refresh (croc-ui-refresh)))))) + +(defun croc-ui--sentinel (proc _event) + "Fold PROC's exit into its transfer struct. +croc prints no completion message: success means exit 0 AND a 100% +progress line in the log. croc also exits 0 after printing mere usage +text (e.g. a rejected code argument) — that path has no progress line +and MUST read as failed, not done." + (when-let* ((tr (process-get proc 'croc-ui-transfer))) + (unless (memq (croc-ui--transfer-state tr) '(done failed cancelled)) + (setf (croc-ui--transfer-state tr) + (if (and (zerop (process-exit-status proc)) + (buffer-live-p (process-buffer proc)) + (with-current-buffer (process-buffer proc) + (save-excursion + (goto-char (point-min)) + (search-forward "100%" nil t)))) + (progn (setf (croc-ui--transfer-progress tr) 100) 'done) + 'failed))) + (croc-ui-refresh))) + +(defun croc-ui--spawn (direction label args &optional env) + "Start a croc transfer in DIRECTION (`send' or `recv') labelled LABEL. +ARGS are the croc command line (global flags must precede any subcommand). +ENV is a list of \"KEY=VALUE\" strings visible only to the child process +\(used for CROC_SECRET, so the code never appears in the process list). +Returns the new transfer struct." + (unless (executable-find croc-ui-program) + (user-error "croc executable not found (croc-ui-program)")) + (let* ((id (cl-incf croc-ui--counter)) + (buf (get-buffer-create (format "*croc#%d %s*" id label))) + (tr (croc-ui--transfer-create + :id id :direction direction :label label + :state (if (eq direction 'send) 'starting 'waiting) + :buffer buf :started (current-time)))) + (with-current-buffer buf + (let ((inhibit-read-only t)) (erase-buffer)) + (croc-ui-log-mode) + (setq-local croc-ui--log-transfer tr)) + (setf (croc-ui--transfer-process tr) + (let ((process-environment (append env process-environment))) + (make-process :name (format "croc#%d" id) + :buffer buf + :command (append (list croc-ui-program) + croc-ui-extra-arguments + args) + :filter #'croc-ui--filter + :sentinel #'croc-ui--sentinel + :noquery t))) + (process-put (croc-ui--transfer-process tr) 'croc-ui-transfer tr) + (push tr croc-ui--transfers) + (croc-ui-refresh) + tr)) + +;;; § 5 Layout primitives + +(defun croc-ui--width () + "Render width: the dashboard window when visible, else 80." + (if-let* ((win (get-buffer-window croc-ui--buffer-name))) + (window-width win) + 80)) + +(defun croc-ui--justify (left right) + "Join LEFT and RIGHT with elastic space; truncate LEFT with an ellipsis." + (let* ((width (croc-ui--width)) + (left (truncate-string-to-width + left (max 8 (- width (length right) 2)) nil nil "…")) + (pad (propertize " " 'display + `(space :align-to (- right ,(+ (length right) 1)))))) + (concat left pad right))) + +(defun croc-ui--hairline () + "A full-width rule that doubles as a section anchor." + (propertize "\n" 'face 'croc-ui-hairline 'croc-ui-hairline t)) + +(defun croc-ui--chip (tr) + "Fixed-width (6) state chip for TR." + (pcase (croc-ui--transfer-state tr) + ('starting (propertize " PREP " 'face 'croc-ui-chip-i)) + ('waiting (propertize " WAIT " 'face 'croc-ui-chip-i)) + ('active (propertize (format " %3d%% " (croc-ui--transfer-progress tr)) + 'face 'croc-ui-chip-active)) + ('done (propertize " DONE " 'face 'croc-ui-chip)) + ('failed (propertize " FAIL " 'face 'croc-ui-chip-critical)) + ('cancelled (propertize " STOP " 'face 'croc-ui-chip-i)) + (_ (propertize " ?? " 'face 'croc-ui-chip-i)))) + +(defun croc-ui--button (text action help) + "Clickable TEXT running ACTION, with HELP echo." + (propertize text 'pointer 'hand 'mouse-face 'highlight 'follow-link t + 'help-echo help + 'keymap (let ((map (make-sparse-keymap))) + (define-key map [mouse-2] action) + (define-key map (kbd "RET") action) + map))) + +;;; § 6 QR layer (pure Elisp, no dependencies) + +;; A self-contained QR encoder: byte mode, EC level L, versions 1-10 +;; (up to 271 bytes — a croc code is ~25). No qrencode(1), no C +;; module, so the card works identically in a GUI and in `emacs -nw'. +;; Rendering pairs two module rows per glyph with UPPER HALF BLOCK, +;; which makes every module a perfect square in any fixed-pitch font. +;; Colors are explicit black/white — the one place we do NOT inherit +;; the theme: a QR only scans with guaranteed contrast, whatever the +;; theme says. + +(defconst croc-ui--qr-capacity [17 32 53 78 106 134 154 192 230 271] + "Byte-mode payload (characters) for versions 1-10, EC level L.") + +(defconst croc-ui--qr-data-codewords [19 34 55 80 108 136 156 194 232 274] + "Data codewords for versions 1-10, EC level L.") + +(defconst croc-ui--qr-block-spec + '((7 (1 . 19)) ; v1 + (10 (1 . 34)) ; v2 + (15 (1 . 55)) ; v3 + (20 (1 . 80)) ; v4 + (26 (1 . 108)) ; v5 + (18 (2 . 68)) ; v6 + (20 (2 . 78)) ; v7 + (24 (2 . 97)) ; v8 + (30 (2 . 116)) ; v9 + (18 (2 . 68) (2 . 69))) ; v10 + "(EC-PER-BLOCK (BLOCKS . DATA-CW) [(BLOCKS . DATA-CW)]) per version 1-10, L.") + +(defconst croc-ui--qr-alignment + [nil nil (6 18) (6 22) (6 26) (6 30) (6 34) (6 22 38) (6 24 42) (6 26 46) (6 28 50)] + "Alignment pattern centers, indexed by version (v1 has none).") + +;; GF(2^8) arithmetic for Reed-Solomon, primitive polynomial x^8+x^4+x^3+x^2+1. + +(defvar croc-ui--qr-gf-exp (make-vector 512 0)) +(defvar croc-ui--qr-gf-log (make-vector 256 0)) + +(defun croc-ui--qr-gf-init () + "Fill the GF(2^8) log/exp tables." + (let ((x 1)) + (dotimes (i 255) + (aset croc-ui--qr-gf-exp i x) + (aset croc-ui--qr-gf-log x i) + (setq x (ash x 1)) + (when (>= x 256) + (setq x (logxor x #x11d)))) + (dotimes (i 255) + (aset croc-ui--qr-gf-exp (+ i 255) (aref croc-ui--qr-gf-exp i))))) +(croc-ui--qr-gf-init) + +(defun croc-ui--qr-gf-mul (a b) + "Multiply A and B in GF(2^8)." + (if (or (zerop a) (zerop b)) + 0 + (aref croc-ui--qr-gf-exp + (+ (aref croc-ui--qr-gf-log a) (aref croc-ui--qr-gf-log b))))) + +(defvar croc-ui--qr-rs-poly-cache nil + "Memoized Reed-Solomon generator polynomials, (DEGREE . COEFFS).") + +(defun croc-ui--qr-rs-poly (n) + "RS generator polynomial of degree N, coefficients highest-degree first." + (or (cdr (assq n croc-ui--qr-rs-poly-cache)) + (let ((poly (list 1))) + (dotimes (i n) + (let ((root (aref croc-ui--qr-gf-exp i)) + (next (make-list (1+ (length poly)) 0))) + (dotimes (j (length poly)) + (setf (nth j next) (logxor (nth j next) (nth j poly))) + (setf (nth (1+ j) next) + (logxor (nth (1+ j) next) + (croc-ui--qr-gf-mul (nth j poly) root)))) + (setq poly next))) + (push (cons n poly) croc-ui--qr-rs-poly-cache) + poly))) + +(defun croc-ui--qr-rs-ec (data n poly) + "N Reed-Solomon check bytes for DATA (list of bytes) using POLY." + (let ((res (append data (make-list n 0)))) + (dotimes (i (length data)) + (let ((coef (nth i res))) + (unless (zerop coef) + (dotimes (j (1+ n)) + (setf (nth (+ i j) res) + (logxor (nth (+ i j) res) + (croc-ui--qr-gf-mul (nth j poly) coef))))))) + (nthcdr (length data) res))) + +(defun croc-ui--qr-data-codewords (bytes version) + "Mode header + BYTES + terminator + padding, as a list of codewords." + (let* ((total (aref croc-ui--qr-data-codewords (1- version))) + (count-bits (if (>= version 10) 16 8)) + (bits nil)) + (cl-flet ((emit (value width) + (dotimes (i width) + (push (logand (ash value (- i (1- width))) 1) bits)))) + (emit 4 4) ; 0100 = byte mode + (emit (length bytes) count-bits) + (dolist (b bytes) (emit b 8))) + (setq bits (nreverse bits)) + (let ((capacity (* total 8))) + (setq bits (append bits (make-list (min 4 (- capacity (length bits))) 0))) + (let ((rem (% (length bits) 8))) + (unless (zerop rem) + (setq bits (append bits (make-list (- 8 rem) 0)))))) + (let ((out nil)) + (while bits + (let ((byte 0)) + (dotimes (_ 8) + (setq byte (logior (ash byte 1) (pop bits)))) + (push byte out))) + (setq out (nreverse out)) + (let ((pad #xec)) + (while (< (length out) total) + (setq out (append out (list pad))) + (setq pad (if (= pad #xec) #x11 #xec)))) + out))) + +(defun croc-ui--qr-interleave (data version) + "Split DATA into RS blocks, ECC each, interleave; return a vector." + (pcase-let ((`(,ec ,g1 ,g2) (nth (1- version) croc-ui--qr-block-spec))) + (let ((poly (croc-ui--qr-rs-poly ec)) + (blocks nil) + (offset 0)) + (dolist (group (if g2 (list g1 g2) (list g1))) + (dotimes (_ (car group)) + (let* ((len (cdr group)) + (chunk (cl-subseq data offset (+ offset len)))) + (push (cons chunk (croc-ui--qr-rs-ec chunk ec poly)) blocks) + (setq offset (+ offset len))))) + (setq blocks (nreverse blocks)) + (let ((out nil) + (maxlen (apply #'max (mapcar (lambda (b) (length (car b))) blocks)))) + (dotimes (i maxlen) + (dolist (b blocks) + (when (< i (length (car b))) + (push (nth i (car b)) out)))) + (dotimes (i ec) + (dolist (b blocks) + (push (nth i (cdr b)) out))) + (vconcat (nreverse out)))))) + +(defun croc-ui--qr-bch-digit (n) + "Bit length of N." + (let ((d 0)) + (while (> n 0) + (setq n (ash n -1)) + (cl-incf d)) + d)) + +(defun croc-ui--qr-format-bits (mask) + "15 format bits for EC level L and MASK: BCH(15,5) masked with 0x5412." + (let* ((data (logior (ash #b01 3) mask)) ; L = 01 + (rem (ash data 10)) + (div (croc-ui--qr-bch-digit #x537))) + (while (>= (- (croc-ui--qr-bch-digit rem) div) 0) + (setq rem (logxor rem (ash #x537 (- (croc-ui--qr-bch-digit rem) div))))) + (logxor (logior (ash data 10) rem) #x5412))) + +(defun croc-ui--qr-version-bits (version) + "18 version bits for VERSION (7+ only): BCH(18,6)." + (let ((rem (ash version 12)) + (div (croc-ui--qr-bch-digit #x1f25))) + (while (>= (- (croc-ui--qr-bch-digit rem) div) 0) + (setq rem (logxor rem (ash #x1f25 (- (croc-ui--qr-bch-digit rem) div))))) + (logior (ash version 12) rem))) + +(defun croc-ui--qr-mask-p (mask r c) + "Non-nil when MASK flips the module at row R, column C." + (pcase mask + (0 (zerop (% (+ r c) 2))) + (1 (zerop (% r 2))) + (2 (zerop (% c 3))) + (3 (zerop (% (+ r c) 3))) + (4 (zerop (% (+ (/ r 2) (/ c 3)) 2))) + (5 (zerop (+ (% (* r c) 2) (% (* r c) 3)))) + (6 (zerop (% (+ (% (* r c) 2) (% (* r c) 3)) 2))) + (7 (zerop (% (+ (% (+ r c) 2) (% (* r c) 3)) 2))))) + +(defun croc-ui--qr-reserve-format (fn) + "Flag the format cells in FN so data placement skips them." + (let ((size (length fn))) + (dotimes (i 9) + (unless (aref (aref fn 8) i) (aset (aref fn 8) i 'format)) + (unless (aref (aref fn i) 8) (aset (aref fn i) 8 'format))) + (dotimes (i 8) + (aset (aref fn 8) (- size i 1) 'format) + (aset (aref fn (- size i 1)) 8 'format)))) + +(defun croc-ui--qr-write-format (m bits) + "Write the 15 format BITS into M (both copies) plus the dark module." + (let ((size (length m))) + (dotimes (i 15) + (let ((bit (logand (ash bits (- i)) 1))) + (aset (aref m (cond ((< i 6) i) + ((< i 8) (1+ i)) + (t (+ size i -15)))) + 8 bit) + (aset (aref m 8) + (cond ((< i 8) (- size i 1)) + ((= i 8) 7) + (t (- 14 i))) + bit))) + (aset (aref m (- size 8)) 8 1))) + +(defun croc-ui--qr-build (version codewords mask) + "Assemble the module matrix for VERSION around CODEWORDS, applying MASK." + (let* ((size (+ 17 (* 4 version))) + (m (make-vector size nil)) + (fn (make-vector size nil))) + (dotimes (r size) + (aset m r (make-vector size 0)) + (aset fn r (make-vector size nil))) + ;; Finder patterns with their one-module separators. + (dolist (corner (list (list 0 0) (list 0 (- size 7)) (list (- size 7) 0))) + (pcase-let ((`(,r0 ,c0) corner)) + (dotimes (dr 9) + (dotimes (dc 9) + (let ((r (+ r0 dr -1)) (c (+ c0 dc -1)) + (off-r (1- dr)) (off-c (1- dc))) + (when (and (>= r 0) (< r size) (>= c 0) (< c size)) + (aset (aref m r) c + (if (and (<= 0 off-r 6) (<= 0 off-c 6) + (or (= off-r 0) (= off-r 6) (= off-c 0) (= off-c 6) + (and (<= 2 off-r 4) (<= 2 off-c 4)))) + 1 0)) + (aset (aref fn r) c t))))))) + ;; Alignment patterns, BEFORE timing: their centers legitimately sit + ;; on the timing row/column (v7+), so the only pairs to skip are the + ;; three that overlap a finder — exactly the cells already flagged. + (dolist (r (aref croc-ui--qr-alignment version)) + (dolist (c (aref croc-ui--qr-alignment version)) + (unless (aref (aref fn r) c) + (dotimes (dr 5) + (dotimes (dc 5) + (let ((dark (or (= dr 0) (= dr 4) (= dc 0) (= dc 4) + (and (= dr 2) (= dc 2))))) + (aset (aref m (+ r dr -2)) (+ c dc -2) (if dark 1 0)) + (aset (aref fn (+ r dr -2)) (+ c dc -2) t))))))) + ;; Timing patterns (filling only what finders/alignment left open). + (dotimes (i size) + (unless (aref (aref fn 6) i) + (aset (aref m 6) i (if (zerop (% i 2)) 1 0)) + (aset (aref fn 6) i t)) + (unless (aref (aref fn i) 6) + (aset (aref m i) 6 (if (zerop (% i 2)) 1 0)) + (aset (aref fn i) 6 t))) + ;; Version information (v7+ only). + (when (>= version 7) + (let ((bits (croc-ui--qr-version-bits version))) + (dotimes (i 18) + (let ((bit (logand (ash bits (- i)) 1))) + (aset (aref m (/ i 3)) (+ (% i 3) size -11) bit) + (aset (aref fn (/ i 3)) (+ (% i 3) size -11) t) + (aset (aref m (+ (% i 3) size -11)) (/ i 3) bit) + (aset (aref fn (+ (% i 3) size -11)) (/ i 3) t))))) + ;; Reserve format cells, then place the data in the zigzag sweep. + (croc-ui--qr-reserve-format fn) + (let ((col (1- size)) (row (1- size)) (inc -1) + (idx 0) (bit 7) (total (length codewords))) + (while (> col 0) + (when (= col 6) (cl-decf col)) + (let ((sweep-done nil)) + (while (not sweep-done) + (dotimes (k 2) + (unless (aref (aref fn row) (- col k)) + (let ((dark (and (< idx total) + (= 1 (logand (ash (aref codewords idx) (- bit)) 1))))) + (when (croc-ui--qr-mask-p mask row (- col k)) + (setq dark (not dark))) + (aset (aref m row) (- col k) (if dark 1 0)) + (if (= bit 0) + (setq bit 7 idx (1+ idx)) + (cl-decf bit))))) + (setq row (+ row inc)) + (when (or (< row 0) (>= row size)) + (setq row (- row inc) inc (- inc) sweep-done t)))) + (setq col (- col 2)))) + (croc-ui--qr-write-format m (croc-ui--qr-format-bits mask)) + m)) + +(defun croc-ui--qr-penalty (m) + "Spec N1-N4 penalty for matrix M; lower picks the more scannable mask." + (let* ((size (length m)) + (penalty 0)) + ;; N1: runs of 5+ same-color modules in rows and columns. + (dotimes (axis 2) + (dotimes (fixed size) + (let ((prev -1) (run 0)) + (dotimes (i size) + (let ((v (if (zerop axis) + (aref (aref m fixed) i) + (aref (aref m i) fixed)))) + (if (= v prev) + (cl-incf run) + (when (>= run 5) (cl-incf penalty (+ 3 run -5))) + (setq prev v run 1)))) + (when (>= run 5) (cl-incf penalty (+ 3 run -5)))))) + ;; N2: same-color 2x2 blocks. + (dotimes (r (1- size)) + (dotimes (c (1- size)) + (let ((v (aref (aref m r) c))) + (when (and (= v (aref (aref m r) (1+ c))) + (= v (aref (aref m (1+ r)) c)) + (= v (aref (aref m (1+ r)) (1+ c)))) + (cl-incf penalty 3))))) + ;; N3: finder-like 1011101 with a 4-module light guard on either side. + (dotimes (axis 2) + (dotimes (fixed size) + (dotimes (i size) + (cl-flet ((at (off) + (if (zerop axis) + (aref (aref m fixed) off) + (aref (aref m off) fixed)))) + (when (and (<= (+ i 6) (1- size)) + (= 1 (at i)) (= 0 (at (+ i 1))) + (= 1 (at (+ i 2))) (= 1 (at (+ i 3))) (= 1 (at (+ i 4))) + (= 0 (at (+ i 5))) (= 1 (at (+ i 6))) + (or (and (>= i 4) + (= 0 (at (- i 1))) (= 0 (at (- i 2))) + (= 0 (at (- i 3))) (= 0 (at (- i 4)))) + (and (<= (+ i 10) (1- size)) + (= 0 (at (+ i 7))) (= 0 (at (+ i 8))) + (= 0 (at (+ i 9))) (= 0 (at (+ i 10)))))) + (cl-incf penalty 40)))))) + ;; N4: deviation from a 50% dark ratio. + (let ((dark 0)) + (dotimes (r size) + (dotimes (c size) + (when (= 1 (aref (aref m r) c)) (cl-incf dark)))) + (cl-incf penalty (* 10 (floor (abs (- (/ (* 100.0 dark) (* size size)) 50)) 5)))) + penalty)) + +(defun croc-ui--qr-encode (text) + "Encode TEXT as a QR matrix: a vector of row vectors, 1 = dark, 0 = light. +Byte mode, EC level L, smallest version in 1-10 that fits." + (let* ((bytes (append (encode-coding-string text 'utf-8) nil)) + (n (length bytes)) + (version (cl-loop for v from 1 to 10 + when (<= n (aref croc-ui--qr-capacity (1- v))) + return v))) + (unless version + (error "Payload too long for QR (%d > %d bytes)" + n (aref croc-ui--qr-capacity 9))) + (let ((codewords (croc-ui--qr-interleave + (croc-ui--qr-data-codewords bytes version) version)) + (best nil) + (best-penalty most-positive-fixnum)) + (dotimes (mask 8) + (let* ((m (croc-ui--qr-build version codewords mask)) + (penalty (croc-ui--qr-penalty m))) + (when (< penalty best-penalty) + (setq best m best-penalty penalty)))) + best))) + +(defun croc-ui--qr-module (matrix size margin row col) + "Non-nil when the module at ROW COL (with MARGIN offset) is dark." + (let ((r (- row margin)) (c (- col margin))) + (and (>= r 0) (< r size) (>= c 0) (< c size) + (= 1 (aref (aref matrix r) c))))) + +(defun croc-ui--qr-render (matrix &optional margin) + "Render QR MATRIX as a list of propertized strings, one per text line. +Two module rows are combined per glyph via UPPER HALF BLOCK, so modules +stay square; MARGIN (default 4) modules of quiet zone wrap the code." + (let* ((size (length matrix)) + (margin (or margin 4)) + (total (+ size (* 2 margin)))) + (if (char-displayable-p ?▀) + (cl-loop for row below total by 2 + collect + (concat " " + (mapconcat + (lambda (col) + (propertize "▀" 'face + (list :foreground + (if (croc-ui--qr-module matrix size margin row col) + "black" "white") + :background + (if (croc-ui--qr-module matrix size margin (1+ row) col) + "black" "white")))) + (number-sequence 0 (1- total))))) + ;; No UPPER HALF BLOCK (non-UTF-8 terminal): two spaces per module. + (cl-loop for row below total + collect + (concat " " + (mapconcat + (lambda (col) + (propertize " " 'face + (list :background + (if (croc-ui--qr-module matrix size margin row col) + "black" "white")))) + (number-sequence 0 (1- total)))))))) + +(defvar-local croc-ui--qr-hidden nil + "Non-nil hides the QR card in this dashboard buffer.") + +(defvar croc-ui--qr-cache nil + "Alist of (CODE . rendered-lines), capped at 8 entries.") + +(defun croc-ui--qr-render-code (code) + "Cached QR lines for CODE, or nil when CODE cannot be encoded." + (or (cdr (assoc code croc-ui--qr-cache)) + (let ((lines (condition-case nil + (croc-ui--qr-render (croc-ui--qr-encode code)) + (error nil)))) + (push (cons code lines) croc-ui--qr-cache) + (when (> (length croc-ui--qr-cache) 8) + (setq croc-ui--qr-cache (cl-subseq croc-ui--qr-cache 0 8))) + lines))) + +(defun croc-ui--insert-qr () + "QR card for the hero code, so the croc app can scan to receive." + (when (and croc-ui-show-qr (not croc-ui--qr-hidden)) + (when-let* ((hero (croc-ui--hero-transfer)) + (code (croc-ui--transfer-code hero)) + (lines (croc-ui--qr-render-code code))) + (dolist (line lines) + (insert line "\n")) + (insert (propertize " scan with the croc app — Q hides" 'face 'croc-ui-faded) + "\n\n")))) + +(defun croc-ui-toggle-qr () + "Toggle the QR card in the dashboard." + (interactive) + (setq croc-ui--qr-hidden (not croc-ui--qr-hidden)) + (croc-ui-refresh) + (message "QR card %s" (if croc-ui--qr-hidden "hidden" "shown"))) + +;;; § 7 Dashboard rendering + +(defun croc-ui--insert-hero () + "Title line plus, when a send is waiting, the share code hero." + (insert (propertize " croc" 'face 'croc-ui-strong) + (propertize " peer-to-peer transfer" 'face 'croc-ui-faded) + "\n\n") + (when-let* ((hero (croc-ui--hero-transfer))) + (let ((code (croc-ui--transfer-code hero))) + (insert (propertize " share this code" 'face 'croc-ui-faded) "\n") + (insert " " + (croc-ui--button + (propertize code 'face + (if (display-graphic-p) + '(:inherit (croc-ui-strong croc-ui-salient) + :height 1.25) + '(:inherit (croc-ui-strong croc-ui-salient)))) + (lambda (&rest _) (interactive) + (kill-new code) + (message "copied: %s" code)) + "mouse-2 / RET: copy code") + "\n" + (propertize " waiting for the other side — w copies, x cancels" + 'face 'croc-ui-faded) + "\n\n")))) + +(defun croc-ui--hint (key label) + "One link-farm hint: bracketed KEY in faded, LABEL in default." + (concat (propertize "[" 'face 'croc-ui-faded) + (propertize key 'face 'croc-ui-strong) + (propertize "]" 'face 'croc-ui-faded) + " " label)) + +(defun croc-ui--insert-hints () + "The link farm: literal key hints, maintained with the keymap." + (insert " " + (mapconcat #'identity + (list (croc-ui--hint "s" "send file") + (croc-ui--hint "d" "send folder") + (croc-ui--hint "t" "send text") + (croc-ui--hint "r" "receive")) + " ") + "\n " + (mapconcat #'identity + (list (croc-ui--hint "RET" "log") + (croc-ui--hint "w" "copy code") + (croc-ui--hint "Q" "qr") + (croc-ui--hint "x" "kill") + (croc-ui--hint "X" "clear done") + (croc-ui--hint "g" "refresh") + (croc-ui--hint "q" "quit")) + " ") + "\n")) + +(defun croc-ui--insert-transfer (tr) + "One feed row for TR: glyph + label left, code + chip + time right." + (let* ((id (croc-ui--transfer-id tr)) + (done-p (memq (croc-ui--transfer-state tr) '(done failed cancelled))) + (glyph (propertize (croc-ui--symbol (croc-ui--transfer-direction tr)) + 'face (if done-p 'croc-ui-faded 'croc-ui-salient))) + (label (propertize (croc-ui--transfer-label tr) + 'face (if done-p 'default 'croc-ui-strong))) + (code-text (or (croc-ui--transfer-code tr) "…")) + (code (croc-ui--button + (propertize (format "%-28s" (truncate-string-to-width code-text 28)) + 'face (if done-p 'croc-ui-faded 'croc-ui-salient)) + (lambda (&rest _) (interactive) + (when-let* ((c (croc-ui--transfer-code tr))) + (kill-new c) + (message "copied: %s" c))) + "mouse-2 / RET: copy code")) + (chip (propertize (croc-ui--chip tr) 'croc-ui-chip id)) + (time (propertize (format-time-string "%H:%M" (croc-ui--transfer-started tr)) + 'face 'croc-ui-faded)) + (row (croc-ui--justify (concat glyph " " label) + (concat code " " chip " " time)))) + (insert (propertize (concat " " row) 'croc-ui-id id) "\n"))) + +(defun croc-ui--insert-transfers () + "The transfer feed, separated by hairlines." + (insert (croc-ui--hairline)) + (if (null croc-ui--transfers) + (insert (propertize " no transfers yet — s sends something" 'face 'croc-ui-faded) + "\n") + (dolist (tr croc-ui--transfers) + (croc-ui--insert-transfer tr)))) + +(defun croc-ui--header-line () + "Four-slot status bar: [ STATUS | name (primary) ... right ]." + (let* ((active (cl-count-if #'croc-ui--live-p croc-ui--transfers)) + (status (if (> active 0) + (propertize (format " %d " active) 'face 'croc-ui-chip) + (propertize " 0 " 'face 'croc-ui-chip-i))) + (name (propertize " croc " 'face 'croc-ui-strong)) + (prim (propertize (format "(%d transfer%s active)" + active (if (= active 1) "" "s")) + 'face 'croc-ui-faded)) + (right (propertize (format "%s total " (length croc-ui--transfers)) + 'face 'croc-ui-faded))) + (concat (propertize " " 'display '(raise 0.15)) + status name + (propertize " " 'display '(raise -0.20)) + prim + (propertize " " 'display `(space :align-to (- right ,(length right)))) + right))) + +(defun croc-ui-refresh () + "Re-render the dashboard, restoring point by transfer id." + (interactive) + (when-let* ((buf (get-buffer croc-ui--buffer-name))) + (with-current-buffer buf + (when (eq major-mode 'croc-ui-mode) + (let ((id (get-text-property (point) 'croc-ui-id)) + (inhibit-read-only t)) + (erase-buffer) + (croc-ui--insert-hero) + (croc-ui--insert-qr) + (croc-ui--insert-hints) + (croc-ui--insert-transfers) + (if-let* ((match (and id + (progn (goto-char (point-min)) + (text-property-search-forward + 'croc-ui-id id #'eq))))) + (goto-char (prop-match-beginning match)) + (goto-char (point-min)) + (croc-ui-next))))))) + +(defun croc-ui--update-chip (tr) + "In-place rewrite of TR's progress chip; the row never reflows." + (when-let* ((buf (get-buffer croc-ui--buffer-name))) + (when (get-buffer-window buf) + (with-current-buffer buf + (when (eq major-mode 'croc-ui-mode) + (save-excursion + (goto-char (point-min)) + (when-let* ((match (text-property-search-forward + 'croc-ui-chip (croc-ui--transfer-id tr) #'eq))) + (let ((inhibit-read-only t) + (chip (propertize + (format " %3d%% " (croc-ui--transfer-progress tr)) + 'face 'croc-ui-chip-active + 'croc-ui-chip (croc-ui--transfer-id tr)))) + (delete-region (prop-match-beginning match) (prop-match-end match)) + (goto-char (prop-match-beginning match)) + (insert chip))))))))) + +;;; § 8 Mode and navigation + +(defun croc-ui--transfer-at-point () + "Transfer struct for the row at point." + (when-let* ((id (get-text-property (point) 'croc-ui-id))) + (croc-ui--find id))) + +(defun croc-ui-next () + "Move to the next transfer row." + (interactive) + (let ((match (text-property-search-forward 'croc-ui-id nil nil t))) + (if match + (goto-char (prop-match-beginning match)) + (goto-char (point-min)) + (when-let* ((first (text-property-search-forward 'croc-ui-id nil nil))) + (goto-char (prop-match-beginning first)))))) + +(defun croc-ui-previous () + "Move to the previous transfer row." + (interactive) + (let* ((here (point)) + (starts (save-excursion + (goto-char (point-min)) + (cl-loop for m = (text-property-search-forward 'croc-ui-id nil nil) + while m + collect (prop-match-beginning m) + do (goto-char (prop-match-end m)))))) + (goto-char (or (car (last (cl-remove-if-not (lambda (p) (< p here)) starts))) + (car (last starts)) + here)))) + +(defun croc-ui-open-log () + "Pop the raw croc log for the transfer at point." + (interactive) + (if-let* ((tr (croc-ui--transfer-at-point))) + (pop-to-buffer (croc-ui--transfer-buffer tr)) + (user-error "No transfer on this row"))) + +(defun croc-ui-copy-code () + "Copy the code of the transfer at point (or in this log, or the hero)." + (interactive) + (when-let* ((tr (or (croc-ui--transfer-at-point) + croc-ui--log-transfer + (croc-ui--hero-transfer) + (car croc-ui--transfers))) + (code (croc-ui--transfer-code tr))) + (kill-new code) + (message "copied: %s" code))) + +(defun croc-ui-kill () + "Cancel the transfer at point (or this log's transfer)." + (interactive) + (if-let* ((tr (or (croc-ui--transfer-at-point) croc-ui--log-transfer))) + (if (croc-ui--live-p tr) + (progn + (setf (croc-ui--transfer-state tr) 'cancelled) + (delete-process (croc-ui--transfer-process tr)) + (croc-ui-refresh)) + (user-error "Transfer already finished")) + (user-error "No transfer here"))) + +(defun croc-ui-clear-done () + "Drop every finished transfer from the feed (logs are kept)." + (interactive) + (setq croc-ui--transfers + (cl-remove-if-not #'croc-ui--live-p croc-ui--transfers)) + (croc-ui-refresh)) + +(defvar croc-ui-mode-map + (let ((map (make-sparse-keymap))) + (define-key map (kbd "s") #'croc-ui-send-files) + (define-key map (kbd "d") #'croc-ui-send-directory) + (define-key map (kbd "t") #'croc-ui-send-text) + (define-key map (kbd "r") #'croc-ui-receive) + (define-key map (kbd "RET") #'croc-ui-open-log) + (define-key map (kbd "w") #'croc-ui-copy-code) + (define-key map (kbd "Q") #'croc-ui-toggle-qr) + (define-key map (kbd "x") #'croc-ui-kill) + (define-key map (kbd "X") #'croc-ui-clear-done) + (define-key map (kbd "g") #'croc-ui-refresh) + (define-key map (kbd "n") #'croc-ui-next) + (define-key map (kbd "p") #'croc-ui-previous) + (define-key map (kbd "q") #'quit-window) + map) + "Keymap for `croc-ui-mode'.") + +(define-derived-mode croc-ui-mode special-mode "croc" + "NΛNO-style dashboard for croc transfers." + (setq-local cursor-type nil + truncate-lines t + left-fringe-width 8 + right-fringe-width 8 + header-line-format '(:eval (croc-ui--header-line)) + mode-line-format "") + (face-remap-set-base 'hl-line 'croc-ui-selected) + (hl-line-mode 1)) + +(declare-function evil-set-initial-state "evil") +(declare-function evil-define-key* "evil-core") + +(with-eval-after-load 'evil + (evil-set-initial-state 'croc-ui-mode 'normal) + ;; `evil-define-key' is a macro: calling it from a file byte-compiled + ;; without evil loaded yields (invalid-function evil-define-key). + ;; `evil-define-key*' is the function underneath and is always safe. + (evil-define-key* 'normal croc-ui-mode-map + (kbd "j") #'croc-ui-next + (kbd "k") #'croc-ui-previous + (kbd "RET") #'croc-ui-open-log + (kbd "s") #'croc-ui-send-files + (kbd "d") #'croc-ui-send-directory + (kbd "t") #'croc-ui-send-text + (kbd "r") #'croc-ui-receive + (kbd "w") #'croc-ui-copy-code + (kbd "Q") #'croc-ui-toggle-qr + (kbd "x") #'croc-ui-kill + (kbd "X") #'croc-ui-clear-done + (kbd "g") #'croc-ui-refresh + (kbd "q") #'quit-window)) + +;;; § 9 Entry points + +;;;###autoload +(defun croc-ui () + "Pop the croc dashboard." + (interactive) + (pop-to-buffer (get-buffer-create croc-ui--buffer-name)) + (unless (eq major-mode 'croc-ui-mode) + (croc-ui-mode)) + (croc-ui-refresh) + ;; Point sits on the first row; keep the hero in view regardless. + (set-window-start (selected-window) (point-min))) + +(declare-function dired-get-marked-files "dired") + +;;;###autoload +(defun croc-ui-send-files (files) + "Send FILES with croc. In dired, sends the marked files." + (interactive + (list (if (derived-mode-p 'dired-mode) + (or (dired-get-marked-files) (user-error "No files marked")) + (list (read-file-name "Send file: " nil nil t))))) + (let ((label (if (= 1 (length files)) + (file-name-nondirectory (directory-file-name (car files))) + (format "%d files" (length files))))) + (croc-ui--spawn 'send label (append '("--ignore-stdin" "send") files)) + (croc-ui))) + +;;;###autoload +(defun croc-ui-send-directory (directory) + "Send DIRECTORY with croc." + (interactive "DSend folder: ") + (croc-ui--spawn 'send + (concat (file-name-nondirectory + (directory-file-name directory)) "/") + (list "--ignore-stdin" "send" directory)) + (croc-ui)) + +;;;###autoload +(defun croc-ui-send-text (text) + "Send TEXT (the region when active) as a croc text payload." + (interactive + (list (if (use-region-p) + (buffer-substring-no-properties (region-beginning) (region-end)) + (read-string "Send text: ")))) + (croc-ui--spawn 'send + (concat "“" (truncate-string-to-width + (string-trim text) 24 nil nil "…") "”") + (list "--ignore-stdin" "send" "--text" text)) + (croc-ui)) + +(defun croc-ui--code-from-kill-ring () + "Latest kill when it smells like a croc code, else nil." + (let ((top (ignore-errors (current-kill 0 t)))) + (when (and top (string-match-p "\\`[a-z0-9]+-[a-z0-9-]+\\'" top)) + top))) + +;;;###autoload +(defun croc-ui-receive (code directory) + "Receive whatever CODE points to into DIRECTORY." + (interactive + (list (read-string "Croc code: " nil nil (croc-ui--code-from-kill-ring)) + (read-directory-name "Save into: " croc-ui-receive-directory nil nil))) + (unless (file-directory-p directory) + (make-directory directory t)) + (let ((tr (croc-ui--spawn + 'recv (file-name-nondirectory (directory-file-name directory)) + (list "--yes" "--overwrite" "--ignore-stdin" + "--out" directory) + ;; New (secure) mode: the code travels in CROC_SECRET, + ;; never as a visible command-line argument. + (list (format "CROC_SECRET=%s" code))))) + ;; The receiver knows its code from the start (no "Code is:" line). + (setf (croc-ui--transfer-code tr) code) + (croc-ui-refresh)) + (croc-ui)) + +(provide 'croc-ui) +;;; croc-ui.el ends here |
