branch: elpa/jabber
commit ac15ff345bc09089dc97c9211d87951f954079d9
Author: Thanos Apollo <[email protected]>
Commit: Thanos Apollo <[email protected]>
chatbuffer: Add all-window scroll-to-bottom policy
---
lisp/jabber-chat.el | 9 +-
lisp/jabber-chatbuffer.el | 71 +++++++++++++--
lisp/jabber-muc.el | 92 +++++++++++---------
tests/jabber-test-chatbuffer.el | 188 ++++++++++++++++++++++++++++++++++++++++
4 files changed, 308 insertions(+), 52 deletions(-)
diff --git a/lisp/jabber-chat.el b/lisp/jabber-chat.el
index db95e0fe19..21e94a0331 100644
--- a/lisp/jabber-chat.el
+++ b/lisp/jabber-chat.el
@@ -660,10 +660,11 @@ _XML-DATA is reserved for future use by OMEMO."
(self-p (string= (jabber-jid-user from)
(jabber-connection-bare-jid jc))))
(with-current-buffer chat-buffer
- (jabber-chatstates--clear-typing)
- (jabber-maybe-print-rare-time
- (jabber-chat-ewoc-enter
- (list (if error-p :error :foreign) msg-plist)))
+ (jabber-chat-buffer-with-scrolltobottom
+ (jabber-chatstates--clear-typing)
+ (jabber-maybe-print-rare-time
+ (jabber-chat-ewoc-enter
+ (list (if error-p :error :foreign) msg-plist))))
(when (and (not error-p) (not self-p))
(let ((inhibit-message jabber-chat-mam-syncing))
(dolist (hook '(jabber-message-hooks jabber-alert-message-hooks))
diff --git a/lisp/jabber-chatbuffer.el b/lisp/jabber-chatbuffer.el
index 5983335200..41e9cbfd8a 100644
--- a/lisp/jabber-chatbuffer.el
+++ b/lisp/jabber-chatbuffer.el
@@ -34,6 +34,15 @@
(defvar jabber-point-insert nil
"Position where the message being composed starts.")
+(defcustom jabber-scrolltobottom-all nil
+ "Non-nil means keep the input area at the bottom in all chat windows.
+This recenters all visible windows displaying the current chat buffer
+whose window point is in the input area, at or after
+`jabber-point-insert'. The default nil preserves the current behavior
+of recentering only one visible window."
+ :type 'boolean
+ :group 'jabber-chat)
+
(defun jabber-chat-buffer--recenter-input-p (window)
"Return non-nil when WINDOW should recenter to the input area."
(and (window-live-p window)
@@ -43,14 +52,66 @@
(window-buffer window))
(>= (window-point window) jabber-point-insert)))))
-(defun jabber-chat-buffer-recenter-input ()
- "Recenter the visible current buffer window to the input area."
- (when-let* ((window (get-buffer-window (current-buffer))))
- (when (jabber-chat-buffer--recenter-input-p window)
- (with-selected-window window
+(defun jabber-chat-buffer--recenter-input-window (window)
+ "Recenter WINDOW so the input area is at the bottom."
+ (with-selected-window window
+ (let ((resize-mini-windows nil))
+ (save-excursion
(goto-char jabber-point-insert)
(recenter -1)))))
+(defun jabber-chat-buffer--recenter-input-all ()
+ "Recenter all visible `current-buffer' windows following the input area."
+ (dolist (window (get-buffer-window-list (current-buffer) nil 'visible))
+ (when (jabber-chat-buffer--recenter-input-p window)
+ (jabber-chat-buffer--recenter-input-window window))))
+
+(defun jabber-chat-buffer-recenter-input ()
+ "Recenter visible `current-buffer' window(s) to the input area."
+ (if jabber-scrolltobottom-all
+ (jabber-chat-buffer--recenter-input-all)
+ (when-let* ((window (get-buffer-window (current-buffer))))
+ (when (jabber-chat-buffer--recenter-input-p window)
+ (jabber-chat-buffer--recenter-input-window window)))))
+
+(defvar-local jabber-chat-buffer--scrolltobottom-window-info nil
+ "Pre-insert scroll-to-bottom state for visible chat windows.
+Each entry is (WINDOW . FOLLOWING-P), where FOLLOWING-P records
+whether WINDOW was following the input area before message insertion.")
+
+(defun jabber-chat-buffer--scrolltobottom-windows ()
+ "Return windows eligible for pre-insert scroll-to-bottom state."
+ (if jabber-scrolltobottom-all
+ (get-buffer-window-list (current-buffer) nil 'visible)
+ (when-let* ((window (get-buffer-window (current-buffer))))
+ (list window))))
+
+(defun jabber-chat-buffer--scrolltobottom-before-insert ()
+ "Record pre-insert follow state for eligible `current-buffer' windows."
+ (setq jabber-chat-buffer--scrolltobottom-window-info
+ (mapcar (lambda (window)
+ (cons window
+ (and (jabber-chat-buffer--recenter-input-p window) t)))
+ (jabber-chat-buffer--scrolltobottom-windows))))
+
+(defun jabber-chat-buffer--scrolltobottom-after-insert ()
+ "Recenter windows that followed before insertion, then clear state."
+ (unwind-protect
+ (dolist (entry jabber-chat-buffer--scrolltobottom-window-info)
+ (when (and (cdr entry)
+ (window-live-p (car entry)))
+ (jabber-chat-buffer--recenter-input-window (car entry))))
+ (setq jabber-chat-buffer--scrolltobottom-window-info nil)))
+
+(defmacro jabber-chat-buffer-with-scrolltobottom (&rest body)
+ "Run BODY with ERC-like pre/post scroll-to-bottom state."
+ (declare (indent 0) (debug t))
+ `(progn
+ (jabber-chat-buffer--scrolltobottom-before-insert)
+ (unwind-protect
+ (progn ,@body)
+ (jabber-chat-buffer--scrolltobottom-after-insert))))
+
(defvar jabber-send-function nil
"Function for sending a message from a chat buffer.")
diff --git a/lisp/jabber-muc.el b/lisp/jabber-muc.el
index 7db2d2b91b..837eb79ece 100644
--- a/lisp/jabber-muc.el
+++ b/lisp/jabber-muc.el
@@ -1705,8 +1705,9 @@ messages."
printers))
(not (and (jabber-muc--history-message-p xml-data)
jabber-chat-earliest-backlog)))
- (jabber-maybe-print-rare-time
- (jabber-chat-ewoc-enter (list type msg-plist))))))
+ (jabber-chat-buffer-with-scrolltobottom
+ (jabber-maybe-print-rare-time
+ (jabber-chat-ewoc-enter (list type msg-plist)))))))
;; Alert hooks run regardless of buffer existence, but not for
;; history messages.
(unless (jabber-muc--history-message-p xml-data)
@@ -1785,13 +1786,14 @@ STATUS-CODES, ERROR-NODE, ACTOR and REASON come from
the stanza."
(let ((buffer (get-buffer (jabber-muc-get-buffer group jc))))
(if buffer
(with-current-buffer buffer
- (jabber-maybe-print-rare-time
- (jabber-chat-ewoc-enter
- (list (if (string= type "error")
- :muc-error
- :muc-notice)
- message
- :time (current-time)))))
+ (jabber-chat-buffer-with-scrolltobottom
+ (jabber-maybe-print-rare-time
+ (jabber-chat-ewoc-enter
+ (list (if (string= type "error")
+ :muc-error
+ :muc-notice)
+ message
+ :time (current-time))))))
(message "%s: %s" (jabber-jid-displayname group) message)))
;; Stagger: skip failed room and try the next one.
;; Defer via timer so Emacs can redisplay between joins.
@@ -1813,28 +1815,29 @@ come from the stanza."
(jabber-muc-remove-participant group nickname)
(when-let* ((buffer (jabber-muc-find-buffer group)))
(with-current-buffer buffer
- (when (and (fboundp 'jabber-chatstates--muc-remove-nick)
- (fboundp 'jabber-chatstates--delete-typing-node))
- (jabber-chatstates--muc-remove-nick nickname)
- (jabber-chatstates--delete-typing-node))
- (jabber-maybe-print-rare-time
- (jabber-chat-ewoc-enter
- (list :muc-notice
- (cond
- ((member jabber-muc-status-banned status-codes)
- (concat name " has been banned"
- (jabber-muc--format-actor-reason actor reason)))
- ((member jabber-muc-status-kicked status-codes)
- (concat name " has been kicked"
- (jabber-muc--format-actor-reason actor reason)))
- ((member jabber-muc-status-nick-changed status-codes)
- (concat name " changes nickname to "
- (jabber-xml-get-attribute item 'nick)))
- (t
- (concat name " has left the chatroom")))
- :time (current-time))))
- (when (fboundp 'jabber-chatstates--muc-reinsert-typing)
- (jabber-chatstates--muc-reinsert-typing))))))
+ (jabber-chat-buffer-with-scrolltobottom
+ (when (and (fboundp 'jabber-chatstates--muc-remove-nick)
+ (fboundp 'jabber-chatstates--delete-typing-node))
+ (jabber-chatstates--muc-remove-nick nickname)
+ (jabber-chatstates--delete-typing-node))
+ (jabber-maybe-print-rare-time
+ (jabber-chat-ewoc-enter
+ (list :muc-notice
+ (cond
+ ((member jabber-muc-status-banned status-codes)
+ (concat name " has been banned"
+ (jabber-muc--format-actor-reason actor reason)))
+ ((member jabber-muc-status-kicked status-codes)
+ (concat name " has been kicked"
+ (jabber-muc--format-actor-reason actor reason)))
+ ((member jabber-muc-status-nick-changed status-codes)
+ (concat name " changes nickname to "
+ (jabber-xml-get-attribute item 'nick)))
+ (t
+ (concat name " has left the chatroom")))
+ :time (current-time))))
+ (when (fboundp 'jabber-chatstates--muc-reinsert-typing)
+ (jabber-chatstates--muc-reinsert-typing)))))))
(defun jabber-muc--room-created-message ()
"Return a string with buttons for configuring a newly created room."
@@ -1855,19 +1858,21 @@ come from the stanza."
"Insert extra ewoc notices for STATUS-CODES into the current MUC buffer.
NICKNAME is the entering user. Assumes `jabber-chat-ewoc' is current."
(when (member jabber-muc-status-nick-modified status-codes)
- (jabber-chat-ewoc-enter
- (list :muc-notice
- (concat "Your nick was changed to " nickname " by the server")
- :time (current-time))))
+ (jabber-chat-buffer-with-scrolltobottom
+ (jabber-chat-ewoc-enter
+ (list :muc-notice
+ (concat "Your nick was changed to " nickname " by the server")
+ :time (current-time)))))
(when (member jabber-muc-status-room-created status-codes)
(if jabber-muc--auto-configure
(progn
(setq jabber-muc--auto-configure nil)
(jabber-muc-get-config jabber-buffer-connection jabber-group))
- (jabber-chat-ewoc-enter
- (list :muc-notice
- (jabber-muc--room-created-message)
- :time (current-time))))))
+ (jabber-chat-buffer-with-scrolltobottom
+ (jabber-chat-ewoc-enter
+ (list :muc-notice
+ (jabber-muc--room-created-message)
+ :time (current-time)))))))
(defun jabber-muc--query-affiliations (jc group)
"On JC, query member, admin, and owner affiliation lists for GROUP.
@@ -1942,10 +1947,11 @@ X-MUC, ACTOR, REASON and OUR-NICKNAME come from the
stanza."
reason actor)))
(when report
(with-current-buffer buffer
- (jabber-maybe-print-rare-time
- (jabber-chat-ewoc-enter
- (list :muc-notice report
- :time (current-time)))))))
+ (jabber-chat-buffer-with-scrolltobottom
+ (jabber-maybe-print-rare-time
+ (jabber-chat-ewoc-enter
+ (list :muc-notice report
+ :time (current-time))))))))
;; Extra notices (status 201/210) fire for self-presence regardless
;; of whether there was an affiliation delta report.
(when self-p
diff --git a/tests/jabber-test-chatbuffer.el b/tests/jabber-test-chatbuffer.el
index 4e8ab6fb31..f329c522e7 100644
--- a/tests/jabber-test-chatbuffer.el
+++ b/tests/jabber-test-chatbuffer.el
@@ -16,6 +16,16 @@
(defvar jabber-muc-xmlns-user "http://jabber.org/protocol/muc#user")
(defvar jabber-group nil)
(defvar jabber-muc-participants nil)
+(defvar jabber-scrolltobottom-all nil)
+(defvar jabber-chat-buffer--scrolltobottom-window-info nil)
+
+(declare-function jabber-chat-buffer-recenter-input "jabber-chatbuffer" ())
+(declare-function jabber-chat-buffer--recenter-input-window
+ "jabber-chatbuffer" (window))
+(declare-function jabber-chat-buffer--scrolltobottom-after-insert
+ "jabber-chatbuffer" ())
+(declare-function jabber-chat-buffer--scrolltobottom-before-insert
+ "jabber-chatbuffer" ())
;;; Test helpers
@@ -548,6 +558,184 @@ again, and the ewoc created on the first call must
survive."
(jabber-chat-buffer-refresh)
(should-not events)))))
+;;; Group 12: scroll-to-bottom window policy
+
+(ert-deftest jabber-test-chatbuffer-recenter-input-default-uses-one-window ()
+ "Default scroll-to-bottom behavior recenters only one visible window."
+ (with-temp-buffer
+ (let ((jabber-scrolltobottom-all nil)
+ (seen-buffers nil)
+ (checked nil)
+ (recentered nil))
+ (cl-letf (((symbol-function 'get-buffer-window)
+ (lambda (buffer &optional _all-frames)
+ (push buffer seen-buffers)
+ 'win-a))
+ ((symbol-function 'get-buffer-window-list)
+ (lambda (&rest _)
+ (error "get-buffer-window-list should not be called")))
+ ((symbol-function 'jabber-chat-buffer--recenter-input-p)
+ (lambda (window)
+ (push window checked)
+ t))
+ ((symbol-function 'window-live-p)
+ (lambda (_window) t))
+ ((symbol-function 'jabber-chat-buffer--recenter-input-window)
+ (lambda (window)
+ (push window recentered))))
+ (jabber-chat-buffer-recenter-input)
+ (should (equal (list (current-buffer)) seen-buffers))
+ (should (equal '(win-a) checked))
+ (should (equal '(win-a) recentered))))))
+
+(ert-deftest jabber-test-chatbuffer-recenter-input-all-uses-visible-windows ()
+ "All-window scroll-to-bottom checks every visible chat buffer window."
+ (with-temp-buffer
+ (let ((jabber-scrolltobottom-all t)
+ (seen-buffers nil)
+ (checked nil)
+ (recentered nil))
+ (cl-letf (((symbol-function 'get-buffer-window)
+ (lambda (&rest _)
+ (error "get-buffer-window should not be called")))
+ ((symbol-function 'get-buffer-window-list)
+ (lambda (buffer &optional _minibuf _all-frames)
+ (push buffer seen-buffers)
+ '(win-a win-b win-c)))
+ ((symbol-function 'jabber-chat-buffer--recenter-input-p)
+ (lambda (window)
+ (push window checked)
+ (memq window '(win-a win-c))))
+ ((symbol-function 'jabber-chat-buffer--recenter-input-window)
+ (lambda (window)
+ (push window recentered))))
+ (jabber-chat-buffer-recenter-input)
+ (should (equal (list (current-buffer)) seen-buffers))
+ (should (equal '(win-c win-b win-a) checked))
+ (should (equal '(win-c win-a) recentered))))))
+
+(ert-deftest jabber-test-chatbuffer-pre-post-recenters-following-window ()
+ "Post-insert nil mode recenters only the single selected chat window."
+ (with-temp-buffer
+ (let ((jabber-scrolltobottom-all nil)
+ (seen-buffers nil)
+ (checked nil)
+ (recentered nil))
+ (cl-letf (((symbol-function 'get-buffer-window)
+ (lambda (buffer &optional _all-frames)
+ (push buffer seen-buffers)
+ 'win-a))
+ ((symbol-function 'get-buffer-window-list)
+ (lambda (&rest _)
+ (error "get-buffer-window-list should not be called")))
+ ((symbol-function 'jabber-chat-buffer--recenter-input-p)
+ (lambda (window)
+ (push window checked)
+ t))
+ ((symbol-function 'window-live-p)
+ (lambda (_window) t))
+ ((symbol-function 'jabber-chat-buffer--recenter-input-window)
+ (lambda (window)
+ (push window recentered))))
+ (jabber-chat-buffer--scrolltobottom-before-insert)
+ (should (equal (list (current-buffer)) seen-buffers))
+ (should (equal '((win-a . t))
+ jabber-chat-buffer--scrolltobottom-window-info))
+ (should (equal '(win-a) checked))
+ (jabber-chat-buffer--scrolltobottom-after-insert)
+ (should (equal '(win-a) recentered))
+ (should-not jabber-chat-buffer--scrolltobottom-window-info)))))
+
+(ert-deftest jabber-test-chatbuffer-pre-post-all-records-visible-windows ()
+ "Post-insert all mode records and checks every visible chat window."
+ (with-temp-buffer
+ (let ((jabber-scrolltobottom-all t)
+ (seen-buffers nil)
+ (checked nil)
+ (recentered nil))
+ (cl-letf (((symbol-function 'get-buffer-window)
+ (lambda (&rest _)
+ (error "get-buffer-window should not be called")))
+ ((symbol-function 'get-buffer-window-list)
+ (lambda (buffer &optional _minibuf _all-frames)
+ (push buffer seen-buffers)
+ '(win-a win-b win-c)))
+ ((symbol-function 'jabber-chat-buffer--recenter-input-p)
+ (lambda (window)
+ (push window checked)
+ (memq window '(win-a win-c))))
+ ((symbol-function 'window-live-p)
+ (lambda (_window) t))
+ ((symbol-function 'jabber-chat-buffer--recenter-input-window)
+ (lambda (window)
+ (push window recentered))))
+ (jabber-chat-buffer--scrolltobottom-before-insert)
+ (should (equal (list (current-buffer)) seen-buffers))
+ (should (equal '((win-a . t) (win-b) (win-c . t))
+ jabber-chat-buffer--scrolltobottom-window-info))
+ (should (equal '(win-c win-b win-a) checked))
+ (jabber-chat-buffer--scrolltobottom-after-insert)
+ (should (equal '(win-c win-a) recentered))
+ (should-not jabber-chat-buffer--scrolltobottom-window-info)))))
+
+(ert-deftest jabber-test-chatbuffer-pre-post-preserves-history-window ()
+ "Post-insert does not move a window reading history before insertion."
+ (with-temp-buffer
+ (let ((jabber-scrolltobottom-all nil)
+ (recentered nil))
+ (cl-letf (((symbol-function 'get-buffer-window)
+ (lambda (&rest _) 'win-a))
+ ((symbol-function 'get-buffer-window-list)
+ (lambda (&rest _)
+ (error "get-buffer-window-list should not be called")))
+ ((symbol-function 'jabber-chat-buffer--recenter-input-p)
+ (lambda (_window) nil))
+ ((symbol-function 'jabber-chat-buffer--recenter-input-window)
+ (lambda (window)
+ (push window recentered))))
+ (jabber-chat-buffer--scrolltobottom-before-insert)
+ (jabber-chat-buffer--scrolltobottom-after-insert)
+ (should-not recentered)
+ (should-not jabber-chat-buffer--scrolltobottom-window-info)))))
+
+(ert-deftest jabber-test-chatbuffer-recenter-input-window-preserves-point ()
+ "Recentering moves temporarily to the input marker and restores point."
+ (let ((buffer (generate-new-buffer " *test-chat-recenter*"))
+ (previous-buffer (window-buffer (selected-window)))
+ (recenter-point nil)
+ typed-point)
+ (unwind-protect
+ (progn
+ (switch-to-buffer buffer)
+ (insert "history\n")
+ (setq-local jabber-point-insert (point-marker))
+ (insert "typed input")
+ (setq typed-point (point))
+ (cl-letf (((symbol-function 'recenter)
+ (lambda (&rest _)
+ (setq recenter-point (point)))))
+ (jabber-chat-buffer--recenter-input-window (selected-window)))
+ (should (= recenter-point jabber-point-insert))
+ (should (= (point) typed-point)))
+ (set-window-buffer (selected-window) previous-buffer)
+ (kill-buffer buffer))))
+
+(ert-deftest jabber-test-chatbuffer-after-insert-skips-dead-windows ()
+ "Post-insert does not recenter windows that are no longer live."
+ (with-temp-buffer
+ (let ((jabber-chat-buffer--scrolltobottom-window-info
+ '((win-live . t) (win-dead . t)))
+ (recentered nil))
+ (cl-letf (((symbol-function 'window-live-p)
+ (lambda (window)
+ (eq window 'win-live)))
+ ((symbol-function 'jabber-chat-buffer--recenter-input-window)
+ (lambda (window)
+ (push window recentered))))
+ (jabber-chat-buffer--scrolltobottom-after-insert)
+ (should (equal '(win-live) recentered))
+ (should-not jabber-chat-buffer--scrolltobottom-window-info)))))
+
(provide 'jabber-test-chatbuffer)
;;; jabber-test-chatbuffer.el ends here