;;; whisper-posframe.el --- Record speech into a posframe -*- lexical-binding: t; -*- (require 'whisper) (require 'posframe) (defgroup whisper-posframe nil "Record speech into a temporary posframe buffer." :group 'whisper) (defcustom whisper-posframe-buffer-name "*whisper-capture*" "Name of the temporary Whisper capture buffer." :type 'string :group 'whisper-posframe) (defcustom whisper-posframe-width 80 "Width of the Whisper posframe in columns." :type 'integer :group 'whisper-posframe) (defcustom whisper-posframe-height 12 "Height of the Whisper posframe in lines." :type 'integer :group 'whisper-posframe) (defvar whisper-posframe--parent-frame nil) (defvar whisper-posframe-mode-map (let ((map (make-sparse-keymap))) ;; Finish recording and begin transcription. (define-key map (kbd "C-c C-c") #'whisper-posframe-finish) ;; Cancel recording/transcription without copying anything. (define-key map (kbd "C-c C-k") #'whisper-posframe-cancel) ;; Make C-g and Escape dismiss the capture. (define-key map (kbd "C-g") #'whisper-posframe-cancel) (define-key map (kbd "") #'whisper-posframe-cancel) map) "Keymap for `whisper-posframe-mode'.") (defvar-local whisper-posframe--status-timer nil) (defun whisper-posframe--recording-p () "Return non-nil when Whisper is recording." (process-live-p whisper--recording-process)) (defun whisper-posframe--transcribing-p () "Return non-nil when Whisper is transcribing." (process-live-p whisper--transcribing-process)) (defvar whisper-posframe--capture-buffer nil) (defun whisper-posframe--set-status (status) "Display STATUS at the beginning of the capture buffer." (when (derived-mode-p 'whisper-posframe-mode) (let ((inhibit-read-only t)) (save-excursion (goto-char (point-min)) ;; Remove the previous status line. (when (looking-at "^\\[Whisper:.*\\]\n") (delete-region (point) (line-beginning-position 2))) ;; Insert the new status line as actual displayed buffer text. (insert (propertize (format "[Whisper: %s]\n" status) 'face 'font-lock-keyword-face 'read-only t 'rear-nonsticky '(read-only face))))))) (defun whisper-posframe--update-status () "Update the status line according to Whisper's current process." (when (buffer-live-p (current-buffer)) (cond ((whisper-posframe--recording-p) (whisper-posframe--set-status "recording — C-c C-c to finish, C-g or Esc to cancel")) ((whisper-posframe--transcribing-p) (whisper-posframe--set-status "transcribing…")) (t ;; This can occur briefly while whisper.el is compiling, checking ;; its model, or transitioning between recording and transcription. (whisper-posframe--set-status "preparing Whisper…"))))) (defun whisper-posframe--stop-status-timer () "Stop the status update timer." (when (timerp whisper-posframe--status-timer) (cancel-timer whisper-posframe--status-timer) (setq whisper-posframe--status-timer nil))) (defun whisper-posframe--copy-and-close () "Copy only the transcription and close the capture posframe." (let ((buffer whisper-posframe--capture-buffer)) (when (buffer-live-p buffer) (with-current-buffer buffer (whisper-posframe--stop-status-timer) (let ((inhibit-read-only t) text) ;; Remove the status line from the actual capture buffer. (save-excursion (goto-char (point-min)) (when (looking-at "^\\[Whisper:.*\\]\n") (delete-region (point) (line-beginning-position 2)))) ;; Extract only the remaining transcription. (setq text (string-trim (buffer-substring-no-properties (point-min) (point-max)))) (when (> (length text) 0) (kill-new text) (message "Whisper transcription copied to the kill ring"))))) ;; Clear Whisper's asynchronous insertion target. (set-marker whisper--marker nil) (setq whisper--point-buffer nil) ;; Delete the posframe and the known capture buffer. (ignore-errors (posframe-delete-frame whisper-posframe-buffer-name)) (when (buffer-live-p buffer) (kill-buffer buffer)) (setq whisper-posframe--capture-buffer nil))) (define-derived-mode whisper-posframe-mode text-mode "Whisper" "Major mode for a temporary Whisper capture buffer." (use-local-map whisper-posframe-mode-map) (setq-local whisper-insert-text-at-point t) (setq-local whisper-display-transcription-buffer nil) (whisper-posframe--set-status "preparing Whisper…") ;; This hook runs after whisper.el has inserted the final transcription. (add-hook 'whisper-after-insert-hook #'whisper-posframe--copy-and-close nil t)) (defun whisper-posframe--delete () "Delete the Whisper posframe, if it exists." (when (buffer-live-p (get-buffer whisper-posframe-buffer-name)) (ignore-errors (posframe-delete-frame whisper-posframe-buffer-name)))) (defun whisper-posframe-finish () "Stop recording and begin Whisper transcription." (interactive) (cond ((whisper-posframe--recording-p) (whisper-posframe--set-status "transcribing…") ;; whisper-run toggles recording off and starts transcription. (whisper-run)) ((whisper-posframe--transcribing-p) (message "Whisper is already transcribing")) (t (message "Whisper is not currently recording")))) (defun whisper-posframe-cancel () "Cancel the active Whisper operation and close the posframe." (interactive) (when (or (whisper-recording-p) (whisper-transcribing-p)) ;; `whisper-run' interrupts recording or cancels transcription, ;; depending on Whisper's current state. (whisper-run)) (whisper-posframe--stop-status-timer) (set-marker whisper--marker nil) (setq whisper--point-buffer nil) (whisper-posframe--delete) (let ((buffer (current-buffer))) (when (buffer-live-p buffer) (kill-buffer buffer))) (message "Whisper recording canceled")) (defun whisper-posframe--focus (buffer frame) "Focus FRAME and select its window displaying BUFFER." (when (frame-live-p frame) (select-frame-set-input-focus frame) (when-let ((window (get-buffer-window buffer frame))) (select-window window)) (with-current-buffer buffer (goto-char (point-max))))) (defun whisper-posframe--start-whisper (buffer) "Start Whisper with BUFFER as its explicit insertion target." (with-current-buffer buffer (setq-local whisper-insert-text-at-point t) ;; These are the variables whisper.el uses asynchronously when ;; inserting the completed transcription. (setq whisper--point-buffer buffer) (set-marker whisper--marker (point) buffer) (whisper-run))) ;;;###autoload (defun whisper-posframe-record () "Open a posframe and start recording with Whisper." (interactive) (require 'whisper) (require 'posframe) (when (or (whisper-recording-p) (whisper-transcribing-p)) (user-error "Whisper is already busy")) (let ((buffer (get-buffer-create whisper-posframe-buffer-name))) (setq whisper-posframe--capture-buffer buffer) (setq whisper-posframe--parent-frame (selected-frame)) (with-current-buffer buffer (erase-buffer) (whisper-posframe-mode) ;; Refresh the visible status every 200 ms. (setq-local whisper-posframe--status-timer (run-at-time 0 0.2 (lambda (buffer) (when (buffer-live-p buffer) (with-current-buffer buffer (whisper-posframe--update-status)))) buffer))) ;; `:accept-focus t' ensures that C-g, Escape, and the other local ;; keybindings are received by the posframe. (let ((posframe (posframe-show buffer :parent-frame (selected-frame) :position (point) :poshandler #'posframe-poshandler-frame-center :width whisper-posframe-width :height whisper-posframe-height :internal-border-color "gray50" :border-width 2 :accept-focus t))) ;; Focus after posframe-show has created and displayed the child frame. (run-at-time 0 nil (lambda () (whisper-posframe--focus buffer posframe)))) ;; Start recording. `whisper-run' records into the current buffer. (whisper-posframe--start-whisper buffer))) (provide 'whisper-posframe) ;;; whisper-posframe.el ends here