Fix void `defhydra' error by add hydra package as dependency.
[amread-mode.git] / amread-mode.el
blobe912d94c1bc23ecaa43fcd714d82fd6d1540a5d8
1 ;;; amread-mode.el --- A minor mode helper user speed-reading -*- lexical-binding: t; -*-
3 ;;; Time-stamp: <2020-06-23 23:44:49 stardiviner>
5 ;; Authors: stardiviner <numbchild@gmail.com>
6 ;; Package-Requires: ((emacs "24.3") (cl-lib "0.6.1") (pyim "5.2.8") (hydra "0.15.0"))
7 ;; Package-Version: 0.1
8 ;; Keywords: wp
9 ;; homepage: https://repo.or.cz/amread-mode.git
11 ;; amread-mode is free software; you can redistribute it and/or modify it
12 ;; under the terms of the GNU General Public License as published by
13 ;; the Free Software Foundation; either version 3, or (at your option)
14 ;; any later version.
16 ;; amread-mode is distributed in the hope that it will be useful, but WITHOUT
17 ;; ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
18 ;; or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
19 ;; License for more details.
22 ;; You should have received a copy of the GNU General Public License
23 ;; along with GNU Emacs. If not, see <https://www.gnu.org/licenses/>.
25 ;;; Commentary:
26 ;;;
27 ;;; Usage
28 ;;;
29 ;;; 1. Launch amread-mode with command `amread-mode'.
30 ;;; 2. Stop amread-mode by pressing [q].
32 ;;; Code:
33 (require 'cl-lib)
34 (require 'pyim)
35 (require 'hydra)
38 (defcustom amread-word-speed 3.0
39 "Read words per second."
40 :type 'float
41 :safe #'floatp
42 :group 'amread-mode)
44 (defcustom amread-line-speed 4.0
45 "Read one line using N seconds in average."
46 :type 'float
47 :safe #'floatp
48 :group 'amread-mode)
50 (defcustom amread-scroll-style nil
51 "Set amread auto scroll style by word or line."
52 :type '(choice (const :tag "scroll by word" word)
53 (const :tag "scroll by line" line))
54 :safe #'symbolp
55 :group 'amread-mode)
57 (defcustom amread-voice-reader-enabled nil
58 "The initial state of voice reader."
59 :type 'boolean
60 :safe #'booleanp
61 :group 'amread-mode)
63 (defcustom amread-voice-reader-command
64 (cl-case system-type
65 (darwin "say")
66 (gnu/linux (or (executable-find "espeak") (executable-find "festival")))
67 (windows-nt )) ; TODO
68 "The command for reading text."
69 :type 'string
70 :safe #'stringp
71 :group 'amread-mode)
73 (defcustom amread-voice-reader-command-options ""
74 "Specify options for voice reader command."
75 :type 'string
76 :safe #'stringp
77 :group 'amread-mode)
79 (defcustom amread-voice-reader-language 'chinese
80 "Specifiy default language for voice reader."
81 :type 'symbol
82 :safe #'symbolp
83 :group 'amread-mode)
85 (defface amread-highlight-face
86 '((t :foreground "black" :background "ForestGreen"))
87 "Face for amread-mode highlight."
88 :group 'amread-mode)
90 (defvar amread--timer nil)
91 (defvar amread--current-position nil)
92 (defvar amread--overlay nil)
94 (defvar amread--voice-reader-proc-finished nil
95 "A process status variable indicate whether voice reader finished reading.
96 It has three status values:
97 - \='not-started :: process not started
98 - \='running :: process still running
99 - \='finished :: process finished")
101 (defmacro amread--voice-reader-status-wrapper (body)
102 "A wrapper macro for detecting voice reader process status and execute BODY."
103 `(if amread-voice-reader-enabled
104 ;; wait for process finished, then jump to next word.
105 (cl-case amread--voice-reader-proc-finished
106 (not-started ,body)
107 (running (ignore))
108 (finished ,body)
109 (t (setq amread--voice-reader-proc-finished 'not-started)))
110 ,body))
112 ;; (macroexpand-1
113 ;; '(amread--voice-reader-status-wrapper (amread--word-update)))
115 (defun amread--voice-reader-read-text (text)
116 "Read TEXT with voice command-line tool."
117 (when (and amread-voice-reader-enabled
118 (not (null text))
119 (not (string-empty-p text)))
120 (setq amread--voice-reader-proc-finished 'running)
121 (cl-case system-type
122 (darwin
123 (or (amread--voice-reader-read-text-with-tts text)
124 (amread--voice-reader-read-text-with-say text)))
125 (t (amread--voice-reader-read-text-with-tts text)))))
127 (defun amread--voice-reader-run-python-code-to-string (&rest python-code-lines)
128 "Run PYTHON-CODE-LINES through Python interpreter result to string."
129 (let ((python-interpreter (or (executable-find "python3") python-interpreter)))
130 (shell-command-to-string
131 (format "%s %s"
132 python-interpreter
133 (concat " -c "
134 ;; solve double quote character issue.
135 "\"" (string-replace "\"" "\\\"" (string-join python-code-lines "\n")) "\"")))))
137 ;; (print
138 ;; (amread--voice-reader-run-python-code-to-string
139 ;; "import numpy as np"
140 ;; "print(np.arange(6))"
141 ;; "print(\"blah blah\")"
142 ;; "print('{}'.format(3))"))
144 (defun amread--voice-reader-run-python-file-to-string (python-code-file)
145 "Run PYTHON-CODE-FILE through Python interpreter result to string."
146 (let ((python-interpreter (or (executable-find "python3") python-interpreter)))
147 (shell-command-to-string
148 (format "%s %s" python-interpreter python-code-file))))
150 (defun amread--voice-reader-run-python-code-in-repl (&rest python-code-lines)
151 "Run PYTHON-CODE-LINES through Python REPL process result to strings list."
152 (let ((python-interpreter (or (executable-find "python3") python-interpreter)))
153 (run-python)
154 ;; `process-send-string' alias `send-string'
155 ;; `python-shell-send-string', `python-shell-internal-send-string', `python-shell-send-string-no-output'
156 (dolist (line python-code-lines
157 result)
158 ;; assign last eval result to `dolist' binding `result'.
159 (setf result (python-shell-send-string-no-output line)))))
161 ;; (amread--voice-reader-run-python-code-in-repl
162 ;; "import sys"
163 ;; "print(sys.path)"
164 ;; "sys.path")
166 (defvar amread--voice-reader-engine-initialized nil)
168 ;; invoke cross-platform TTS Speech API through Python library "pyttsx3".
169 (defun amread--voice-reader-read-text-with-tts (text)
170 "Read TEXT with cross-platform TTS Speech API through Python library \"pyttsx3\"."
171 ;; keep instance of engine to avoid repeat creating performance issue.
172 (unless (string-equal amread--voice-reader-engine-initialized "True")
173 (setq amread--voice-reader-engine-initialized
174 (amread--voice-reader-run-python-code-in-repl
175 "import pyttsx3"
176 "engine = pyttsx3.init()"
177 "True")))
178 (amread--voice-reader-run-python-code-in-repl
179 (format "engine.say(\"%s\")" text)
180 "engine.runAndWait()"))
182 ;; (amread--voice-reader-read-text-with-tts "happy")
184 (defun amread--voice-reader-read-text-with-say (text)
185 "Read TEXT with macOS command `say'."
186 ;; detect language and switch language/voice.
187 (let ((language (amread-voice-reader-switch-language-voice)))
188 (cl-case language
189 (chinese
190 (setq amread-voice-reader-command-options "--voice=Ting-Ting")
191 (message "[amread] voice reader switched to Chinese language/voice."))
192 (english
193 (setq amread-voice-reader-command-options "--voice=Ava")
194 (message "[amread] voice reader switched to English language/voice."))
196 (let ((voice (completing-read "[amread] Select language/voice: " '("Ting-Ting" "Ava"))))
197 (setq amread-voice-reader-command-options (format "--voice=%s" voice))
198 (message "[amread] voice reader switched to language/voice <%s>." voice)))))
200 ;; Synchronous Processes
201 ;; (call-process-shell-command
202 ;; amread-voice-reader-command
203 ;; nil nil nil
204 ;; amread-voice-reader-command-options
205 ;; (shell-quote-argument text))
207 ;; Async Process
208 (make-process
209 :name "amread-voice-reader"
210 :command (list amread-voice-reader-command amread-voice-reader-command-options text)
211 :sentinel (lambda (_proc event)
212 (if (string= event "finished\n")
213 (setq amread--voice-reader-proc-finished 'finished)))
214 :buffer " *amread-voice-reader*"
215 :stderr " *amread-voice-reader*"))
217 (defun amread--word-update ()
218 "Scroll forward by word as step."
219 (let* ((begin (point))
220 ;; move point forward. NOTE This forwarding must be here before moving overlay forward.
221 (_length (+ (skip-chars-forward "^\s\t\n—") (skip-chars-forward "—")))
222 (end (point))
223 (word (buffer-substring-no-properties begin end)))
224 (if (eobp)
225 (progn
226 (amread-mode -1)
227 (setq amread--current-position nil))
228 ;; create the overlay if does not exist
229 (unless amread--overlay
230 (setq amread--overlay (make-overlay begin end)))
231 ;; move overlay forward
232 (when amread--overlay
233 (move-overlay amread--overlay begin end))
234 (setq amread--current-position (point))
235 (overlay-put amread--overlay 'face 'amread-highlight-face)
236 ;; read word text
237 (amread--voice-reader-read-text word)
238 (skip-chars-forward "\s\t\n—")
239 ;; when in nov.el ebook, auto navigate to next page.
240 (when (and (eobp) (eq major-mode 'nov-mode))
241 (nov-next-document)
242 (message "[amread] nov.el next page.")))))
244 (defun amread--line-update ()
245 "Scroll forward by line as step."
246 (let* ((line-begin (line-beginning-position))
247 (line-end (line-end-position))
248 (line-text (buffer-substring-no-properties line-begin line-end)))
249 (if (eobp) ; reached end of buffer.
250 (progn
251 (amread-mode -1)
252 (setq amread--current-position nil))
253 ;; create line overlay to highlight current reading line.
254 (unless amread--overlay
255 (setq amread--overlay (make-overlay line-begin line-end)))
256 ;; scroll down line
257 (when amread--overlay
258 (move-overlay amread--overlay line-begin line-end))
259 (overlay-put amread--overlay 'face 'amread-highlight-face)
260 ;; read line text
261 (amread--voice-reader-read-text line-text)
262 (forward-line 1)
263 ;; when in nov.el ebook, auto navigate to next page.
264 (when (and (eobp) (eq major-mode 'nov-mode))
265 (nov-next-document)
266 (message "[amread] nov.el next page.")))))
268 (defun amread--update ()
269 "Update and scroll forward under Emacs timer."
270 (cl-case amread-scroll-style
271 (word
272 (amread--voice-reader-status-wrapper (amread--word-update)))
273 (line
274 (amread--voice-reader-status-wrapper (amread--line-update))
275 ;; Auto modify the running timer REPEAT seconds based on next line words length.
276 (let* ((next-line-words (amread--get-next-line-words)) ; for English
277 ;; TODO: Add Chinese text logic.
278 ;; (next-line-length (amread--get-next-line-length)) ; for Chinese
279 (amread--next-line-pause-secs (truncate (/ next-line-words amread-word-speed))))
280 (when (> amread--next-line-pause-secs 0)
281 (setf (timer--repeat-delay amread--timer) amread--next-line-pause-secs))))
282 (t (user-error "Seems amread-mode is not normally started or not running."))))
284 (defun amread--scroll-style-ask ()
285 "Ask which scroll style to use."
286 (let ((style (intern (completing-read "amread-mode scroll style: " '("word" "line")))))
287 (setq amread-scroll-style style)
288 style))
290 (defun amread--get-line-words (&optional pos)
291 "Get the line words of position."
292 (save-excursion
293 (and pos (goto-char pos))
294 (beginning-of-line)
295 (count-words (line-end-position) (line-beginning-position))))
297 (defun amread--get-next-line-words ()
298 "Get the next line words."
299 (amread--get-line-words (save-excursion (forward-line) (point))))
301 (defun amread--get-line-length (&optional pos)
302 "Get the line length of position."
303 (save-excursion
304 (and pos (goto-char pos))
305 (- (line-end-position) (line-beginning-position))))
307 (defun amread--get-next-line-length ()
308 "Get the next line length."
309 (amread--get-line-words (save-excursion (forward-line) (point))))
311 ;;;###autoload
312 (defun amread-start ()
313 "Start / resume amread."
314 (interactive)
315 (read-only-mode 1)
316 ;; if quit `amread--scroll-style-ask', then don't enable `amread-mode'.
317 (or amread-scroll-style (amread--scroll-style-ask))
318 (setq amread--voice-reader-proc-finished 'not-started)
319 ;; select language
320 (setq amread-voice-reader-language
321 (intern
322 (completing-read "[amread] Select language: " '("chinese" "english"))))
323 ;; select scroll style
324 (if (null amread-scroll-style)
325 (user-error "User quited entering amread-mode.")
326 ;; resume from paused position
327 (cl-case amread-scroll-style
328 (word
329 (when amread--current-position
330 (goto-char amread--current-position))
331 (setq amread--timer
332 (run-with-timer 0 (/ 1.0 amread-word-speed) #'amread--update)))
333 (line
334 (when amread--current-position
335 (goto-char (point-min))
336 (forward-line amread--current-position))
337 (setq amread--timer
338 (run-with-timer 1 amread-line-speed #'amread--update)))
339 (t (user-error "Seems amread-mode is not normally started because of not selecting scroll style OR just not running.")))
340 ;; enable hydra
341 (amread-hydra/body)
342 (message "[amread] start reading...")))
344 ;;;###autoload
345 (defun amread-stop ()
346 "Stop amread."
347 (interactive)
348 (when amread--timer
349 (cancel-timer amread--timer)
350 (setq amread--timer nil)
351 (when amread--overlay
352 (delete-overlay amread--overlay)))
353 (setq amread-scroll-style nil)
354 (read-only-mode -1)
355 (hydra-keyboard-quit)
356 (message "[amread] stopped."))
358 ;;;###autoload
359 (defun amread-pause-or-resume ()
360 "Pause or resume amread."
361 (interactive)
362 (if amread--timer
363 (amread-stop)
364 (amread-start)))
366 ;;;###autoload
367 (defun amread-mode-quit ()
368 "Disable `amread-mode'."
369 (interactive)
370 (amread-mode -1)
371 (hydra-keyboard-quit))
373 ;;;###autoload
374 (defun amread-speed-up ()
375 "Speed up `amread-mode'."
376 (interactive)
377 (setq amread-word-speed (cl-incf amread-word-speed 0.2))
378 (message "[amread] word speed increased -> %s" amread-word-speed))
380 ;;;###autoload
381 (defun amread-speed-down ()
382 "Speed down `amread-mode'."
383 (interactive)
384 (setq amread-word-speed (cl-decf amread-word-speed 0.2))
385 (message "[amread] word speed decreased -> %s" amread-word-speed))
387 ;;;###autoload
388 (defun amread-voice-reader-toggle ()
389 "Toggle text voice reading."
390 (interactive)
391 (if amread-voice-reader-enabled
392 (progn
393 (setq amread-voice-reader-enabled nil)
394 (message "[amread] voice reader disabled."))
395 (setq amread-voice-reader-enabled t)
396 (message "[amread] voice reader enabled.")))
398 (defun amread--voice-reader-detect-language (&optional string)
399 "Detect text language."
400 ;; Return t if STRING is a Chinese string.
401 (if-let ((string (or string (word-at-point))))
402 (cond
403 ;; `pyim-probe-auto-english'
404 ((null (pyim-probe-dynamic-english))
405 'chinese)
406 ((pyim-probe-dynamic-english)
407 'english)
408 ((string-match (format "\\cC\\{%s\\}" (length string)) string)
409 'chinese))
410 nil))
412 ;; (amread--voice-reader-detect-language "测试")
413 ;; (amread--voice-reader-detect-language "测试test")
415 ;;;###autoload
416 (defun amread-voice-reader-switch-language-voice (&optional language)
417 "Switch voice reader LANGUAGE or voice."
418 (interactive "P")
419 (let ((language (or language
420 (when (called-interactively-p 'interactive)
421 (intern (completing-read "[amread] Select language: " '("chinese" "english"))))
422 amread-voice-reader-language
423 (amread--voice-reader-detect-language))))
424 language))
426 ;;;###autoload
427 (defun amread-voice-reader-read-buffer ()
428 "Read current buffer text without timer highlight updating."
429 (interactive)
430 ;; loop over all lines of buffer.
431 (save-excursion
432 (save-restriction
433 (widen)
434 (goto-char (point-min))
435 (while (not (eobp))
436 (let* ((line-begin (line-beginning-position))
437 (line-end (line-end-position))
438 (line-text (buffer-substring-no-properties line-begin line-end)))
439 ;; line processiqng
440 (let ((amread-voice-reader-enabled t))
441 (amread--voice-reader-status-wrapper
442 (amread--voice-reader-read-text line-text)))
443 (forward-line 1))))))
445 (defvar amread-mode-map
446 (let ((map (make-sparse-keymap)))
447 (define-key map (kbd "q") #'amread-mode-quit)
448 (define-key map (kbd "SPC") #'amread-pause-or-resume)
449 (define-key map [remap keyboard-quit] #'amread-mode-quit)
450 (define-key map (kbd "+") #'amread-speed-up)
451 (define-key map (kbd "-") #'amread-speed-down)
452 (define-key map (kbd "v") #'amread-voice-reader-toggle)
453 (define-key map (kbd "L") #'amread-voice-reader-switch-language-voice)
454 (define-key map (kbd ".") #'amread-hydra/body)
455 map)
456 "Keymap for `amread-mode' buffers.")
458 ;;;###autoload
459 (defhydra amread-hydra (:color green :hint nil :exit nil)
461 ^Control^ ^Adjust When Reading^
462 ^------------------^ ^-------------------------^
463 _SPC_: pause/resume _+_: speed up
464 _q_: quit _-_: speed down
465 ^ ^ _v_: toggle voice reader
466 ^ ^ _L_: switch language/voice
468 ("SPC" amread-pause-or-resume :color blue)
469 ("q" amread-mode-quit :color red)
470 ("+" amread-speed-up :color blue)
471 ("-" amread-speed-down :color blue)
472 ("v" amread-voice-reader-toggle :color pink)
473 ("L" amread-voice-reader-switch-language-voice :color pink))
475 ;;;###autoload
476 (define-minor-mode amread-mode
477 "I'm reading mode."
478 :init nil
479 :lighter " amread"
480 :keymap amread-mode-map
481 (if amread-mode
482 (amread-start)
483 (amread-stop)))
487 (provide 'amread-mode)
489 ;;; amread-mode.el ends here