;;; company-childframe.el --- Graphical popup frontend for Company  -*- lexical-binding: t -*-

;; Copyright (C) 2026  Free Software Foundation, Inc.

;; GNU Emacs 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.

;; GNU Emacs 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 GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.


;;; Commentary:
;;
;; Tooltip completion menu frontend for Company that uses a child frame.
;;
;; A lot of the code here was imported from the package `company-posframe',
;; credit to Clément Pit-Claudel, Feng Shu and others.

;;; Code:

(require 'company)
(require 'posframe)

(defgroup company-childframe nil
  "Group group group"
  :group 'company)

(defcustom company-childframe-font nil
  "The font used by company-childframe's frame.
Using current frame's font if it is nil."
  :type 'face)

(defcustom company-childframe-border-width 1
  "The width of the popup's border, in graphical frames.

Users of HiDPI screens might like to set it to 2."
  :type 'integer)

(defvar company-childframe-buffer " *company-childframe-buffer*"
  "company-childframe's buffer which used by posframe.")

(defvar company-childframe--frame nil)

(defvar company-childframe-show-params nil
  "List of extra parameters passed to `posframe-show' in
  `company-childframe-show'.")

(defvar company-childframe-last-status nil)

(defvar company-childframe-buffer-map
  (let ((keymap (make-sparse-keymap)))
    (set-keymap-parent keymap company-active-map)
    (define-key keymap [wheel-down] 'company-childframe-wheel-up)
    (define-key keymap [wheel-up] 'company-childframe-wheel-down)
    keymap)
  "Keymap for the child frame's popup/buffer.")

(defun company-childframe-wheel-up ()
  "Scroll up the displayed candidates."
  (interactive)
  (company-childframe--wheel-scroll 3))

(defun company-childframe-wheel-down ()
  "Scroll up the displayed candidates."
  (interactive)
  (company-childframe--wheel-scroll -3))

(defun company-childframe--wheel-scroll (amount)
  (let ((parent-frame (frame-parameter nil 'parent-frame))
        (parent-buffer (frame-parameter nil 'posframe-parent-buffer)))
    (when (and parent-frame
               parent-buffer)
      (select-frame parent-frame)
      (select-window (get-buffer-window (cdr parent-buffer)))
      (company-select-next amount))))

(defvar company-childframe-poshandler
  #'company-childframe-show-at-prefix
  "Poshandler for the completion dialog.")

(defvar company-childframe--shown-above nil)

(defun company-childframe-show-at-prefix (info)
  "Poshandler showing `company-childframe' at `company-prefix'."
  (let* ((parent-window (plist-get info :parent-window))
         (point (- (plist-get info :position)
                   (plist-get info :company-prefix-length)))
         (after-string-width
          (with-current-buffer (window-buffer parent-window)
            (thread-last
              (and (= point (point-max))
                   (overlays-in point point))
              (mapcar (lambda (o) (company--string-pixel-width
                              (overlay-get o 'after-string))))
              (cl-reduce #'+))))
         (posn (posn-at-point point parent-window))
         ;; TODO: Strictly speaking, if company-childframe-font is not nil, that
         ;; should be used to find the default width...
         (expected-margin-width (* (plist-get info :company-margin) (default-font-width)))
         (border-width (if (display-graphic-p)
                           company-childframe-border-width
                         0))
         (min-bottom-distance (+ (* company-tooltip-minimum
                                    (+ (or (default-value 'line-spacing) 0)
                                       (plist-get info :font-height)))
                                 (* 2 border-width)))
         (xy (posn-x-y posn))
         handler-res)
    (setcar xy (- (car xy) expected-margin-width
                  border-width
                  ;; Might bite us if the posn-at-point behavior changes
                  ;; someday, but the odds seem low.
                  after-string-width))
    (when (< (plist-get info :posframe-height) min-bottom-distance)
      (cl-decf (cadr (plist-member info :parent-frame-height))
               (- min-bottom-distance (plist-get info :posframe-height))))
    (setq handler-res
          (posframe-poshandler-point-bottom-left-corner
           (plist-put info :position posn)))
    (setq company-childframe--shown-above
          (> (+
              (nth 1 (window-body-pixel-edges parent-window))
              (cdr (posn-x-y posn)))
             (cdr handler-res)))
    handler-res))

(defun company-childframe-show ()
  "Show company-childframe candidate menu."
  (defvar x-wait-for-event-timeout)
  (defvar x-fast-protocol-requests)
  (let* ((x-wait-for-event-timeout (and (>= emacs-major-version 31)
                                        ;; debbugs#80662
                                        (bound-and-true-p
                                         x-wait-for-event-timeout)))
         (before-make-frame-hook)
         (after-make-frame-functions)
         (x-fast-protocol-requests t)
         (height (min company-tooltip-limit
                      (if company-search-mode
                          (1+ company-candidates-length)
                        company-candidates-length)))
         (company-lines (company--create-lines company-selection height))
         (margin (car company-lines))
         (lines (cdr company-lines))
         (width (length (car lines)))
         (contents (mapconcat #'identity lines "\n"))
         (buffer (get-buffer-create company-childframe-buffer)))
    (when (and (eq (frame-live-p company-childframe--frame) 'x)
               (not (eq (car (frame-list)) company-childframe--frame)))
      ;; Make sure it's the first in the list, to avoid premature sync when some
      ;; other frame is redisplayed first.  Again, non-atomic updated on X11.
      ;; https://debbugs.gnu.org/80662#185
      (delete-frame company-childframe--frame))
    (apply #'posframe-show buffer
           :string contents
           :height height
           :width (if (or (<= company-candidates-length
                              height)
                          (not (display-graphic-p)))
                      width
                    (1- width))
           :font company-childframe-font
           :background-color (face-attribute 'company-tooltip :background)
           :lines-truncate t
           :override-parameters '((inhibit-double-buffering . t))
           :border-width (and (display-graphic-p) company-childframe-border-width)
           ;; :border-color "light salmon"
           ;; :border-color "light steel blue"
           ;; We'll probably want a separate face for it.
           :border-color (face-attribute 'company-tooltip-scrollbar-track :background)
           :poshandler company-childframe-poshandler
           :poshandler-extra-info
           (list :company-margin margin
                 :company-prefix-length (length (car (company--boundaries))))
           company-childframe-show-params)
    (when (and company-childframe--shown-above
               company-tooltip-flip-when-above)
      (with-current-buffer buffer
        (erase-buffer)
        (insert (mapconcat #'identity (reverse lines) "\n"))))
    (with-current-buffer buffer
      (use-local-map company-childframe-buffer-map)
      (setq company-childframe--frame posframe--frame)
      ;; FIXME: Does not honor remappings by minor modes in the parent buffer,
      ;; e.g. the special behavior of C-d with parent-mode, etc.
      (add-hook 'pre-command-hook
                #'company-childframe--pre-command
                nil t))))

(defun company-childframe-hide ()
  "Hide company-childframe candidate menu."
  (when (and (frame-live-p company-childframe--frame)
             (frame-visible-p company-childframe--frame))
    ;; PGTK/NS/W32 protocols can update the display atomically.
    (when (and (eq window-system 'x)
               ;; https://debbugs.gnu.org/80961
               (< emacs-major-version 32))
      ;; Seems to help avoid the final flicker - probably by keeping the parent's
      ;; display matrix up to date (so it can repaint on Expose immediately).
      (redisplay))
    (make-frame-invisible company-childframe--frame)))

;;;###autoload
(defun company-childframe-frontend (command)
  "`company-mode' frontend using childframe.
For COMMAND refer to `company-frontends'."
  (setq company-childframe-last-status
        (list (selected-window)
              (current-buffer)))
  (cl-case command
    (pre-command
     (when (not (posframe-workable-p))
       (user-error "Child frames not supported")))
    (show (setq company--tooltip-current-width 0))
    (hide
     (company-childframe-hide))
    (post-command
     (when (equal (window-buffer (selected-window))
                  (current-buffer))
       (company-childframe-show)))
    (select-mouse
     (company-childframe--select-mouse))))

(defun company-childframe--select-mouse ()
  (let ((event-col-row (company--event-col-row company-mouse-event))
        (event-window (posn-window (event-start company-mouse-event))))
    (cond ((and event-window
                (equal (buffer-name (window-buffer event-window))
                       company-childframe-buffer))
           (company-set-selection (+ (cdr event-col-row)
                                     company-tooltip-offset
                                     (if (and (eq company-tooltip-offset-display 'lines)
                                              (not (zerop company-tooltip-offset)))
                                         -1 0)))
           t))))

(defun company-childframe--pre-command ()
  (let ((parent-frame (frame-parameter nil 'parent-frame))
        (parent-buffer (cdr (frame-parameter nil 'posframe-parent-buffer))))
    (when (and
           (not (memq this-command
                      '(company-childframe-wheel-up
                        company-childframe-wheel-down)))
           parent-frame parent-buffer)
      (select-frame parent-frame)
      (select-window (get-buffer-window parent-buffer)))))

;;;###autoload
(defun company-childframe-unless-just-one-frontend (command)
  "`company-childframe-frontend', but not shown for single candidates."
  (if (company--show-inline-p)
      (and (member command '(post-command hide))
           (company-childframe-hide))
    (and (memq command '(post-command show unhide hide select-mouse))
         (company-childframe-frontend command))))

(defun company-childframe-window-change ()
  "Hide posframe on window change."
  (when (posframe-workable-p)
    (unless (or (equal (buffer-name) company-childframe-buffer)
                (equal company-childframe-last-status
                       (list (selected-window)
                             (current-buffer))))
      (company-childframe-hide))))

(add-hook 'window-configuration-change-hook
          #'company-childframe-window-change)

(provide 'company-childframe)
;;; company-childframe.el ends here
