about summary refs log tree commit diff
path: root/thogai.el
diff options
context:
space:
mode:
authorArun Isaac2022-06-12 16:53:24 +0530
committerArun Isaac2022-06-12 16:59:09 +0530
commit22bc8cf3bcbae78df49e8b1c5cb92d4ee3f4e41f (patch)
tree63329bac815804dddfc1da1b42aa387e2b5c792d /thogai.el
downloadthogai-22bc8cf3bcbae78df49e8b1c5cb92d4ee3f4e41f.tar.gz
thogai-22bc8cf3bcbae78df49e8b1c5cb92d4ee3f4e41f.tar.lz
thogai-22bc8cf3bcbae78df49e8b1c5cb92d4ee3f4e41f.zip
Initial commit
Diffstat (limited to 'thogai.el')
-rw-r--r--thogai.el425
1 files changed, 425 insertions, 0 deletions
diff --git a/thogai.el b/thogai.el
new file mode 100644
index 0000000..7933ed1
--- /dev/null
+++ b/thogai.el
@@ -0,0 +1,425 @@
+;;; thogai.el --- Stenotyping software -*- lexical-binding: t -*-
+
+;; Stenotyping software for Emacs
+;; Copyright (C) 2022 Arun Isaac
+;;
+;; Author: Arun Isaac <arunisaac@systemreboot.net>
+;; Version: 0.1.0
+;; Keywords: abbrev, convenience, hardware
+;; Homepage: https://git.systemreboot.net/thogai
+;; Package-Requires: ((emacs "27.1"))
+
+;; This file is part of thogai.
+
+;; thogai 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.
+
+;; thogai 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 thogai.  If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+;;
+;; thogai is stenotyping software.  It interfaces with steno machines
+;; using the Gemini protocol, and translates strokes using a steno
+;; dictionary in the Plover dictionary format.
+;;
+;; Usage:
+;;
+;; Connect to your steno machine using the Gemini protocol with
+;;
+;; M-x thogai-connect
+;; 
+;; Chord away!
+
+;;; Code:
+
+(require 'map)
+(require 'pcase)
+(require 'seq)
+(require 'subr-x)
+(require 'term)
+
+(defvar thogai-dictionary-files
+  (list "~/.config/plover/main.json"
+        "~/.config/plover/commands.json")
+  "List of dictionary files in order of increasing precedence.
+
+All files should be in the Plover dictionary format, and are read
+and loaded into memory when `thogai-load-dictionaries' is run.")
+
+(defvar thogai-user-dictionary-alist
+  nil
+  "Additional dictionary entries.
+
+This is an association list specifying additional translations
+not present in `thogai-dictionary-files'.  For example,
+
+(setq thogai-user-dictionary-alist
+      '((\"PER\" . \"perfect\")
+        (\"THO/TKPWAOEU\" . \"thogai\")))")
+
+(defvar thogai-dictionary
+  (make-hash-table :test 'equal)
+  "Hash table used as the steno dictionary by thogai.
+
+This variable is loaded from files listed in
+`thogai-dictionary-files' on running `thogai-load-dictionaries'.")
+
+(defvar thogai-stroke-ring-size
+  1000
+  "Size of `thogai-stroke-ring'.
+
+This is the number of past strokes thogai keeps track of.  Among
+other things, it affects the number of strokes one can undo back
+to.")
+
+(defvar thogai-stroke-ring
+  (make-ring thogai-stroke-ring-size)
+  "Ring tracking all past strokes.")
+
+(defvar thogai-attach-next
+  nil
+  "Flag indicating whether to attach the next translation.
+
+If non-nil, the next part of the translation is attached without
+a space, and the variable is reset to nil.")
+
+(defvar thogai-capitalize-next-word
+  nil
+  "Flag indicating whether to capitalize the next word.
+
+If non-nil, the first letter of the next word is capitalized and
+the variable is reset to nil.")
+
+(defvar thogai-uncapitalize-next-word
+  nil
+  "Flag indicating whether to uncapitalize the next word.
+
+If non-nil, the first letter of the next word is uncapitalized
+and the variable is reset to nil.")
+
+(defun thogai-load-dictionaries ()
+  "Load dictionary files in THOGAI-DICTIONARY-FILES."
+  (interactive)
+  ;; Reset hash table.
+  (clrhash thogai-dictionary)
+  (let ((thogai-dictionary-longest-key 0))
+    (dolist (dictionary-file thogai-dictionary-files)
+      (maphash (lambda (key value)
+                 ;; Load key, value pair into hash table.
+                 (puthash key value thogai-dictionary)
+                 ;; Update length of longest key.
+                 (setq thogai-dictionary-longest-key
+                       (max thogai-dictionary-longest-key
+                            (length (split-string key "/")))))
+               ;; Parse JSON dictionary file.
+               (with-temp-buffer
+                 (insert-file-contents dictionary-file)
+                 (json-parse-buffer))))
+    ;; Enlarge `thogai-stroke-ring' if it is too small.
+    (when (< (ring-size thogai-stroke-ring)
+             thogai-dictionary-longest-key)
+      (ring-resize thogai-stroke-ring thogai-dictionary-longest-key)))
+  (message "Loaded %d dictionary entries"
+           (hash-table-count thogai-dictionary)))
+
+(defun thogai-lookup (stroke)
+  "Lookup STROKE in dictionary.
+
+STROKE is looked up in `thogai-user-dictionary-alist' and
+`thogai-dictionary' with `thogai-user-dictionary-alist' having
+higher precedence.  If STROKE is in neither, nil is returned."
+  (or (map-elt thogai-user-dictionary-alist stroke)
+      (map-elt thogai-dictionary stroke)))
+
+(defun thogai-longest-match (state)
+  "Return the longest sublist of STATE that maps to a stroke.
+
+STATE is a list of strokes, oldest first.  STATE should typically
+be the list of elements in `thogai-stroke-ring', or a subset
+thereof."
+  (if (thogai-lookup (string-join state "/"))
+      state
+    (pcase state
+      (`(,_ . ,tail)
+       (thogai-longest-match tail)))))
+
+(defun thogai-delete (string)
+  "Delete STRING before point, if it is present.
+
+If STRING is not present before point, do nothing."
+  (let ((start (- (point) (length string)))
+        (end (point)))
+    (when (and (>= start (point-min))
+               (string= (buffer-substring start end)
+                        string))
+      (delete-region start end))))
+
+(defun thogai-compose (&rest functions)
+  "Return a composition of FUNCTIONS.
+
+The last function in FUNCTIONS is applied first."
+  (lambda (arg)
+    (seq-reduce (lambda (result function)
+                  (funcall function result))
+                functions
+                arg)))
+
+(defun thogai-insert-translation (translation &optional subtranslation)
+  "Insert TRANSLATION at point.
+
+TRANSLATION is a string mapped to in a steno dictionary.  If
+SUBTRANSLATION is non-nil, this is a recursive call.  External
+callers should never pass non-nil SUBTRANSLATION."
+  (pcase (save-match-data
+           (string-match (rx string-start (or (one-or-more (not ?{))
+                                              (sequence ?{ (one-or-more (not ?{)) ?})))
+                         translation)
+           (list (match-string 0 translation)
+                 (substring translation (match-end 0))))
+    (`(,translation ,rest-of-translation)
+     (pcase translation
+       ;; Attach operator
+       ("{^}"
+        (setq thogai-attach-next t))
+       ;; Prefix
+       ((pred (lambda (str)
+                (string-suffix-p "^}" str)))
+        (insert (string-remove-prefix "{" (string-remove-suffix "^}" translation)))
+        (setq thogai-attach-next t))
+       ;; Capitalize next word.
+       ("{-|}"
+        (setq thogai-capitalize-next-word t))
+       ;; Capitalize previous word.
+       ("{*-|}"
+        (save-excursion
+          (left-word)
+          (upcase-region (point) (1+ (point)))))
+       ;; Uncapitalize next word.
+       ("{>}"
+        (setq thogai-uncapitalize-next-word t))
+       ;; Uncapitalize previous word.
+       ("{*>}"
+        (save-excursion
+          (left-word)
+          (downcase-region (point) (1+ (point)))))
+       ;; Retroactively delete space.
+       ("{*!}"
+        (save-excursion
+          (left-word)
+          (delete-char -1)))
+       ;; Simple literal translation
+       (_
+        ;; Insert a space unless at the beginning of line.
+        (unless (or subtranslation
+                    (= (line-beginning-position)
+                       (point))
+                    thogai-attach-next)
+          (insert " "))
+        ;; Reset `thogai-attach-next'.
+        (setq thogai-attach-next nil)
+        (insert (funcall (thogai-compose
+                          ;; Capitalize first letter.
+                          (if thogai-capitalize-next-word
+                              (lambda (str)
+                                (setq thogai-capitalize-next-word nil)
+                                (concat (upcase (substring str 0 1))
+                                        (substring str 1)))
+                            'identity)
+                          ;; Uncapitalize first letter.
+                          (if thogai-uncapitalize-next-word
+                              (lambda (str)
+                                (setq thogai-uncapitalize-next-word nil)
+                                (concat (downcase (substring str 0 1))
+                                        (substring str 1)))
+                            'identity))
+                         translation))))
+     ;; Recurse for rest of translation.
+     (unless (string= rest-of-translation "")
+       (thogai-insert-translation rest-of-translation t)))))
+
+(defun thogai-reverse-strokes (strokes)
+  "Reverse the effects of STROKES.
+
+STROKES is a list of strokes, oldest first.  `thogai-stroke-ring'
+is unaltered."
+  (thogai-delete
+   (with-temp-buffer
+     (thogai-inject-strokes strokes (make-ring (length strokes)))
+     (buffer-string))))
+
+(defun thogai-inject-strokes (strokes &optional stroke-ring)
+  "Inject STROKES into STROKE-RING.
+
+Inject STROKES, a list of strokes, into STROKE-RING as though
+they were really stroked on the steno machine.  If unspecified,
+STROKE-RING is `thogai-stroke-ring'.  The first element of
+STROKES is injected first."
+  (let ((stroke-ring (or stroke-ring
+                         thogai-stroke-ring)))
+    ;; Inject strokes one by one.
+    (dolist (stroke strokes)
+      (ring-insert stroke-ring stroke)
+      (thogai-insert-stroke stroke-ring))))
+
+(defun thogai-insert-stroke (stroke-ring)
+  "Translate the most recent stroke and insert it at point.
+
+STROKE-RING is the ring representing the current state, and
+should typically be `thogai-stroke-ring'."
+  (if (string= (ring-ref stroke-ring 0) "*")
+      ;; Special case to handle the undo operation
+      (progn
+        ;; Drop the "*" stroke from the state ring.
+        (ring-remove stroke-ring 0)
+        (let ((matched-strokes
+               (or (thogai-longest-match (reverse (ring-elements stroke-ring)))
+                   (list (ring-ref stroke-ring 0)))))
+          ;; Reverse the previous group of strokes.
+          (thogai-reverse-strokes matched-strokes)
+          ;; Delete space before strokes unless we are at the
+          ;; beginning of the line.
+          (unless (= (line-beginning-position)
+                     (point))
+            (delete-char -1))
+          ;; Re-insert the previous group of strokes except for the
+          ;; newest stroke.
+          (pcase (reverse matched-strokes)
+            (`(,_ . ,other-strokes)
+             (thogai-inject-strokes (reverse other-strokes)
+                                    (make-ring (length other-strokes))))))
+        ;; Remove the newest stroke from the state ring.
+        (ring-remove stroke-ring 0))
+    (let ((matched-strokes
+           (thogai-longest-match (reverse (ring-elements stroke-ring)))))
+      ;; Delete translation for earlier strokes that are captured by
+      ;; this translation. If no strokes were matched, skip this step.
+      (unless (null matched-strokes)
+        (thogai-reverse-strokes (butlast matched-strokes)))
+      ;; Insert literal stroke if it is not in the dictionary. Else,
+      ;; insert the actual translation.
+      (if (null matched-strokes)
+          (thogai-insert-translation (ring-ref stroke-ring 0))
+        (thogai-insert-translation (thogai-lookup (string-join matched-strokes "/")))))))
+
+(defun thogai-byte-to-bits (byte)
+  "Convert BYTE to a list of 8 bits.
+
+Each element of the returned list is a boolean, and the first
+element is the most significant bit."
+  (seq-map (lambda (i)
+             (not (zerop (logand byte (lsh 1 i)))))
+           (number-sequence 7 0 -1)))
+
+(defun thogai-filter-mapn (function &rest sequences)
+  "Map FUNCTION over SEQUENCES and return only non-nil results.
+
+Map FUNCTION over all SEQUENCES using `seq-mapn', but return a
+list of only the non-nil results."
+  (seq-filter 'identity
+              (apply 'seq-mapn function sequences)))
+
+(defun thogai-mapcatn (function &rest sequences)
+  "Map FUNCTION over SEQUENCES and concatenate the results.
+
+Map FUNCTION over all SEQUENCES using `seq-mapn', and concatenate
+the results into a list."
+  (apply 'seq-concatenate 'list
+         (apply 'seq-mapn function sequences)))
+
+;; See
+;; https://github.com/openstenoproject/plover/blob/master/plover/machine/geminipr.py
+;; for protocol documentation.
+(defun thogai-gemini-decode-stroke (bytes)
+  "Decode stroke represented by BYTES from the Gemini protocol.
+
+BYTES is a list of 6 bytes in the Gemini protocol."
+  (let* ((keys '(("Fn" "#1" "#2" "#3" "#4" "#5" "#6")
+                 ("S1-" "S2-" "T-" "K-" "P-" "W-" "H-")
+                 ("R-" "A-" "O-" "*1" "*2" "res1" "res2")
+                 ("pwr" "*3" "*4" "-E" "-U" "-F" "-R")
+                 ("-P" "-B" "-L" "-G" "-T" "-S" "-D")
+                 ("#7" "#8" "#9" "#A" "#B" "#C" "-Z")))
+         (pressed-keys
+          (thogai-mapcatn (lambda (byte keys)
+                            (thogai-filter-mapn (lambda (selectp key)
+                                                  (and selectp key))
+                                                (seq-drop (thogai-byte-to-bits byte)
+                                                          1)
+                                                keys))
+                          bytes
+                          keys)))
+    ;; NOTE: We recognize only a subset of keys supported by the
+    ;; Gemini protocol. For example, we recognize only a single S, not
+    ;; S1 and S2.
+    (let ((left (seq-mapcat (lambda (key)
+                              (string-trim-right key (rx (one-or-more (or ?1 ?2 ?-)))))
+                            (seq-filter (lambda (key)
+                                          (or (string= key "#1")
+                                              (and (not (member key (list "A-" "O-")))
+                                                   (string-suffix-p "-" key))))
+                                        pressed-keys)
+                            'string))
+          (vowel-or-star (seq-mapcat (lambda (key)
+                                       (string-trim key "-" (rx (or ?- ?1))))
+                                     (seq-filter (lambda (key)
+                                                   (member key (list "A-" "O-" "*1" "-E" "-U")))
+                                                 pressed-keys)
+                                     'string))
+          (right (seq-mapcat (lambda (key)
+                               (string-remove-prefix "-" key))
+                             (seq-filter (lambda (key)
+                                           (and (not (member key (list "-E" "-U")))
+                                                (string-prefix-p "-" key)))
+                                         pressed-keys)
+                             'string)))
+      (concat left
+              vowel-or-star
+              ;; Add hyphen if there is no vowel but there are
+              ;; right-side keys.
+              (if (and (string= vowel-or-star "")
+                       (not (string= right "")))
+                  "-"
+                "")
+              right))))
+
+(let ((bytes nil))
+  (defun thogai-gemini-protocol-filter (process output)
+    "Process filter for the Gemini serial protocol."
+    (let ((bytes-per-stroke 6))
+      (dolist (byte (string-to-list output))
+        (push byte bytes)
+        ;; The Gemini protocol represents each stroke by 6
+        ;; bytes. Decode stroke once 6 bytes are received.
+        (when (= (length bytes) bytes-per-stroke)
+          (let ((stroke (thogai-gemini-decode-stroke (reverse bytes))))
+            ;; Log to paper tape.
+            (with-current-buffer (process-buffer process)
+              (insert stroke)
+              (insert "\n"))
+            ;; Insert into stroke ring, and process translation.
+            (ring-insert thogai-stroke-ring stroke)
+            (thogai-insert-stroke thogai-stroke-ring))
+          (setq bytes nil))))))
+
+(defun thogai-connect (port speed)
+  "Connect to steno machine at serial PORT with SPEED."
+  (interactive (list (serial-read-name)
+                     (serial-read-speed)))
+  (make-serial-process :port port
+                       :buffer "*thogai-paper-tape*"
+                       :speed speed
+                       :filter 'thogai-gemini-protocol-filter)
+  (message "Connected to steno machine at %s via the Gemini protocol"
+           port))
+
+(provide 'thogai)
+
+;;; thogai.el ends here