Skip to content

Commit ddaefbc

Browse files
committed
Emit focus events on window selection changes
Previously ghostel only reported focus via `after-focus-change-function', so child programs (Claude Code, btop, vim) that enable mode 1004 only saw focus-out when the entire Emacs frame blurred. Selecting a different window inside Emacs kept the terminal thinking it was focused. Compute logical focus per ghostel buffer as "frame is focused AND buffer is in frame's selected window" and hook into `window-selection-change-functions' and `window-buffer-change-functions' in addition to frame focus. A buffer-local `ghostel--focus-state' dedups so only actual transitions emit VT events. Minibuffer activation correctly drives focus-out because the ghostel window is no longer the frame's selected window. Also drop an unrelated bug in the sentinel: it called `remove-function' on every process exit, which removed the focus hook globally and broke focus reporting for all other live ghostel buffers. The handler's existing guards (`ghostel--term', `process-live-p') already make it a no-op after exit, so the removal was both wrong and unnecessary. Only dedup focus state when event actually emits When mode 1004 is disabled, `ghostel--focus-event' drops the write and returns nil. Previously we updated `ghostel--focus-state' unconditionally, so if a focus change ran before the child enabled 1004, the cached state would dedup away the first real event once 1004 was turned on. Update the state only when the event actually emitted. Close #140
1 parent 4f7b1cd commit ddaefbc

2 files changed

Lines changed: 239 additions & 8 deletions

File tree

ghostel.el

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2244,17 +2244,41 @@ Call this after changing the Emacs theme so terminals match."
22442244

22452245
;;; Focus events
22462246

2247-
(defun ghostel--focus-change ()
2248-
"Notify ghostel terminals in the selected frame about focus change.
2249-
Only send the event if the terminal has enabled focus reporting (mode 1004)."
2250-
(let ((focused (frame-focus-state)))
2251-
(dolist (buf (buffer-list))
2247+
(defvar-local ghostel--focus-state nil
2248+
"Last focus state actually reported to the terminal for this buffer.
2249+
Non-nil means a focus-in event was delivered. Only updated when
2250+
`ghostel--focus-event' actually emits (mode 1004 enabled), so that
2251+
enabling 1004 after a focus change still lets the next event fire.")
2252+
2253+
(defun ghostel--buffer-focused-p (buf)
2254+
"Return non-nil if BUF is logically focused.
2255+
BUF is focused when it is displayed in the selected window of a
2256+
frame whose focus state is t (i.e. the frame has keyboard focus
2257+
and the buffer is the active selection within it)."
2258+
(seq-some (lambda (win)
2259+
(let ((frame (window-frame win)))
2260+
(and (eq (frame-focus-state frame) t)
2261+
(eq win (frame-selected-window frame)))))
2262+
(get-buffer-window-list buf nil t)))
2263+
2264+
(defun ghostel--focus-change (&rest _)
2265+
"Update focus state for every live ghostel buffer.
2266+
Called from `after-focus-change-function',
2267+
`window-selection-change-functions', and
2268+
`window-buffer-change-functions'. Sends a focus event only when
2269+
the buffer's logical focus state transitions; `ghostel--focus-event'
2270+
further gates on terminal mode 1004."
2271+
(dolist (buf (buffer-list))
2272+
(when (buffer-live-p buf)
22522273
(with-current-buffer buf
22532274
(when (and (derived-mode-p 'ghostel-mode)
22542275
ghostel--term
22552276
ghostel--process
22562277
(process-live-p ghostel--process))
2257-
(ghostel--focus-event ghostel--term focused))))))
2278+
(let ((focused (and (ghostel--buffer-focused-p buf) t)))
2279+
(unless (eq focused ghostel--focus-state)
2280+
(when (ghostel--focus-event ghostel--term focused)
2281+
(setq ghostel--focus-state focused)))))))))
22582282

22592283
(defvar-local ghostel--pending-output nil
22602284
"Accumulated output chunks waiting to be fed to the terminal.
@@ -2319,7 +2343,6 @@ PROCESS is the shell process, EVENT describes the state change."
23192343
(when ghostel--input-timer
23202344
(cancel-timer ghostel--input-timer)
23212345
(setq ghostel--input-timer nil))
2322-
(remove-function after-focus-change-function #'ghostel--focus-change)
23232346
(run-hook-with-args 'ghostel-exit-functions buf event)
23242347
(if ghostel-kill-buffer-on-exit
23252348
(kill-buffer buf)
@@ -2978,6 +3001,8 @@ PROCESS is the shell process, WINDOWS is the list of windows."
29783001
(setq-local scroll-conservatively 101)
29793002
(setq-local line-spacing 0)
29803003
(add-function :after after-focus-change-function #'ghostel--focus-change)
3004+
(add-hook 'window-selection-change-functions #'ghostel--focus-change)
3005+
(add-hook 'window-buffer-change-functions #'ghostel--focus-change)
29813006
(ghostel--suppress-interfering-modes)
29823007
(setq ghostel--scroll-intercept-active t)
29833008
;; Let C-g reach the keymap instead of triggering keyboard-quit.

test/ghostel-test.el

Lines changed: 207 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1582,6 +1582,206 @@ the reply waits for the redraw timer."
15821582
(ghostel--write-input term "\e[?1004l")
15831583
(should (equal nil (ghostel--focus-event term t))))) ; focus ignored after reset
15841584

1585+
;; -----------------------------------------------------------------------
1586+
;; Test: window-level focus events (issue #140)
1587+
;; -----------------------------------------------------------------------
1588+
1589+
(defun ghostel-test--make-focus-buffer (name)
1590+
"Create a ghostel-mode buffer NAME with a fake term and live process.
1591+
Returns the buffer."
1592+
(let ((buf (generate-new-buffer name)))
1593+
(with-current-buffer buf
1594+
(ghostel-mode)
1595+
(setq ghostel--term (vector 'fake-term))
1596+
(setq ghostel--process
1597+
(start-process (concat "ghostel-test-focus-" name)
1598+
nil "cat"))
1599+
(set-process-query-on-exit-flag ghostel--process nil))
1600+
buf))
1601+
1602+
(defun ghostel-test--cleanup-focus-buffer (buf)
1603+
"Kill BUF and its fake process."
1604+
(when (buffer-live-p buf)
1605+
(with-current-buffer buf
1606+
(when (and ghostel--process (process-live-p ghostel--process))
1607+
(delete-process ghostel--process)))
1608+
(kill-buffer buf)))
1609+
1610+
(defmacro ghostel-test--with-focus-stub (events-var focus-fn &rest body)
1611+
"Run BODY with `ghostel--focus-event' and `frame-focus-state' stubbed.
1612+
EVENTS-VAR names a list that receives (BUFFER . FOCUSED) pairs.
1613+
FOCUS-FN is a zero-arg function returning the current `frame-focus-state'."
1614+
(declare (indent 2))
1615+
`(cl-letf (((symbol-function 'ghostel--focus-event)
1616+
(lambda (_term focused)
1617+
(push (cons (current-buffer) focused) ,events-var)
1618+
t))
1619+
((symbol-function 'frame-focus-state)
1620+
(lambda (&optional _frame) (funcall ,focus-fn))))
1621+
,@body))
1622+
1623+
(ert-deftest ghostel-test-focus-window-selection ()
1624+
"Window selection changes flip per-buffer focus state."
1625+
(let* ((events nil)
1626+
(focus-fn (lambda () t))
1627+
(buf (ghostel-test--make-focus-buffer " *ghostel-focus-1*"))
1628+
(other (generate-new-buffer " *other*"))
1629+
(saved-window-config (current-window-configuration)))
1630+
(unwind-protect
1631+
(ghostel-test--with-focus-stub events focus-fn
1632+
(delete-other-windows)
1633+
(switch-to-buffer buf)
1634+
(let ((other-win (split-window)))
1635+
(set-window-buffer other-win other)
1636+
;; ghostel window selected → focus-in
1637+
(ghostel--focus-change)
1638+
(should (equal (car events) (cons buf t)))
1639+
;; Select the other window → focus-out
1640+
(select-window other-win)
1641+
(setq events nil)
1642+
(ghostel--focus-change)
1643+
(should (equal (car events) (cons buf nil)))
1644+
;; Select ghostel window again → focus-in
1645+
(select-window (get-buffer-window buf))
1646+
(setq events nil)
1647+
(ghostel--focus-change)
1648+
(should (equal (car events) (cons buf t)))))
1649+
(set-window-configuration saved-window-config)
1650+
(ghostel-test--cleanup-focus-buffer buf)
1651+
(kill-buffer other))))
1652+
1653+
(ert-deftest ghostel-test-focus-dedup ()
1654+
"Repeat calls with unchanged state do not re-send focus events."
1655+
(let* ((events nil)
1656+
(frame-focused t)
1657+
(focus-fn (lambda () frame-focused))
1658+
(buf (ghostel-test--make-focus-buffer " *ghostel-focus-dedup*"))
1659+
(saved-window-config (current-window-configuration)))
1660+
(unwind-protect
1661+
(ghostel-test--with-focus-stub events focus-fn
1662+
(delete-other-windows)
1663+
(switch-to-buffer buf)
1664+
(ghostel--focus-change) ; focus-in
1665+
(ghostel--focus-change) ; no-op (dedup)
1666+
(ghostel--focus-change) ; no-op (dedup)
1667+
(should (equal events (list (cons buf t))))
1668+
;; Transition to focus-out, then confirm further calls dedup.
1669+
(setq frame-focused nil)
1670+
(ghostel--focus-change) ; focus-out
1671+
(ghostel--focus-change) ; no-op (dedup)
1672+
(should (equal events (list (cons buf nil) (cons buf t)))))
1673+
(set-window-configuration saved-window-config)
1674+
(ghostel-test--cleanup-focus-buffer buf))))
1675+
1676+
(ert-deftest ghostel-test-focus-two-ghostel-buffers ()
1677+
"Only the ghostel buffer in the selected window is focused."
1678+
(let* ((events nil)
1679+
(focus-fn (lambda () t))
1680+
(buf-a (ghostel-test--make-focus-buffer " *ghostel-focus-a*"))
1681+
(buf-b (ghostel-test--make-focus-buffer " *ghostel-focus-b*"))
1682+
(saved-window-config (current-window-configuration)))
1683+
(unwind-protect
1684+
(ghostel-test--with-focus-stub events focus-fn
1685+
(delete-other-windows)
1686+
(switch-to-buffer buf-a)
1687+
(let ((win-b (split-window)))
1688+
(set-window-buffer win-b buf-b)
1689+
;; A selected: A transitions nil→t, B stays nil (dedup).
1690+
(ghostel--focus-change)
1691+
(should (equal events (list (cons buf-a t))))
1692+
;; Select B: A transitions t→nil, B transitions nil→t.
1693+
(select-window win-b)
1694+
(setq events nil)
1695+
(ghostel--focus-change)
1696+
(should (= (length events) 2))
1697+
(should (member (cons buf-a nil) events))
1698+
(should (member (cons buf-b t) events))
1699+
;; Back to A: inverse transitions.
1700+
(select-window (get-buffer-window buf-a))
1701+
(setq events nil)
1702+
(ghostel--focus-change)
1703+
(should (= (length events) 2))
1704+
(should (member (cons buf-a t) events))
1705+
(should (member (cons buf-b nil) events))))
1706+
(set-window-configuration saved-window-config)
1707+
(ghostel-test--cleanup-focus-buffer buf-a)
1708+
(ghostel-test--cleanup-focus-buffer buf-b))))
1709+
1710+
(ert-deftest ghostel-test-focus-frame-blur ()
1711+
"Frame losing focus drives the ghostel buffer to focus-out."
1712+
(let* ((events nil)
1713+
(frame-focused t)
1714+
(focus-fn (lambda () frame-focused))
1715+
(buf (ghostel-test--make-focus-buffer " *ghostel-focus-blur*"))
1716+
(saved-window-config (current-window-configuration)))
1717+
(unwind-protect
1718+
(ghostel-test--with-focus-stub events focus-fn
1719+
(delete-other-windows)
1720+
(switch-to-buffer buf)
1721+
(ghostel--focus-change) ; focus-in
1722+
(should (equal (car events) (cons buf t)))
1723+
(setq frame-focused nil) ; simulate app blur
1724+
(setq events nil)
1725+
(ghostel--focus-change)
1726+
(should (equal (car events) (cons buf nil)))
1727+
(setq frame-focused t) ; refocus
1728+
(setq events nil)
1729+
(ghostel--focus-change)
1730+
(should (equal (car events) (cons buf t))))
1731+
(set-window-configuration saved-window-config)
1732+
(ghostel-test--cleanup-focus-buffer buf))))
1733+
1734+
(ert-deftest ghostel-test-focus-skips-state-update-when-1004-off ()
1735+
"Dropped events (mode 1004 off) do not update cached focus state.
1736+
Otherwise, enabling 1004 after a focus change would dedup away the
1737+
first real focus event."
1738+
(let* ((events nil)
1739+
(emit-p nil)
1740+
(buf (ghostel-test--make-focus-buffer " *ghostel-focus-gated*"))
1741+
(saved-window-config (current-window-configuration)))
1742+
(unwind-protect
1743+
(cl-letf (((symbol-function 'ghostel--focus-event)
1744+
(lambda (_term focused)
1745+
(when emit-p
1746+
(push (cons (current-buffer) focused) events))
1747+
emit-p))
1748+
((symbol-function 'frame-focus-state)
1749+
(lambda (&optional _frame) t)))
1750+
(delete-other-windows)
1751+
(switch-to-buffer buf)
1752+
;; Mode 1004 off: event is dropped, state must remain nil.
1753+
(ghostel--focus-change)
1754+
(should (null events))
1755+
(with-current-buffer buf
1756+
(should (null ghostel--focus-state)))
1757+
;; Child now enables mode 1004. Next focus-change must emit.
1758+
(setq emit-p t)
1759+
(ghostel--focus-change)
1760+
(should (equal events (list (cons buf t)))))
1761+
(set-window-configuration saved-window-config)
1762+
(ghostel-test--cleanup-focus-buffer buf))))
1763+
1764+
(ert-deftest ghostel-test-focus-minibuffer ()
1765+
"Activating the minibuffer triggers focus-out on the ghostel buffer."
1766+
(let* ((events nil)
1767+
(focus-fn (lambda () t))
1768+
(buf (ghostel-test--make-focus-buffer " *ghostel-focus-mini*"))
1769+
(saved-window-config (current-window-configuration)))
1770+
(unwind-protect
1771+
(ghostel-test--with-focus-stub events focus-fn
1772+
(delete-other-windows)
1773+
(switch-to-buffer buf)
1774+
(ghostel--focus-change)
1775+
(should (equal (car events) (cons buf t)))
1776+
;; Simulate minibuffer activation by selecting the minibuffer window.
1777+
(let ((mb-win (minibuffer-window)))
1778+
(select-window mb-win)
1779+
(setq events nil)
1780+
(ghostel--focus-change)
1781+
(should (equal (car events) (cons buf nil)))))
1782+
(set-window-configuration saved-window-config)
1783+
(ghostel-test--cleanup-focus-buffer buf))))
1784+
15851785
;; -----------------------------------------------------------------------
15861786
;; Test: incremental (partial) redraw
15871787
;; -----------------------------------------------------------------------
@@ -5942,7 +6142,13 @@ while :; do sleep 0.1; done'\n")
59426142

59436143

59446144
(defconst ghostel-test--elisp-tests
5945-
'(ghostel-test-raw-key-sequences
6145+
'(ghostel-test-focus-window-selection
6146+
ghostel-test-focus-dedup
6147+
ghostel-test-focus-two-ghostel-buffers
6148+
ghostel-test-focus-frame-blur
6149+
ghostel-test-focus-skips-state-update-when-1004-off
6150+
ghostel-test-focus-minibuffer
6151+
ghostel-test-raw-key-sequences
59466152
ghostel-test-modifier-number
59476153
ghostel-test-send-event
59486154
ghostel-test-raw-key-modified-specials

0 commit comments

Comments
 (0)