;;; xdg-appmenu.el --- Run XDG desktop applications -*- lexical-binding: t; -*-

;; Copyright (C) 2023 Akib Azmain Turja.

;; Author: Akib Azmain Turja <akib@disroot.org>
;; Created: 2023-07-05
;; Version: 0.1
;; Package-Requires: ((emacs "28.1"))
;; Keywords: convenience processes terminals
;; Homepage: https://codeberg.org/akib/emacs-xdg-appmenu

;; This file is not part of GNU Emacs.

;; This file 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, or (at your option)
;; any later version.

;; This program 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.

;; For a full copy of the GNU General Public License
;; see <https://www.gnu.org/licenses/>.

;;; Commentary:

;; XDG Appmenu allows you to run XDG desktop application right from
;; your Emacs.  To run an application, just do `M-x xdg-appmenu'.

;;; Code:

(require 'subr-x)
(require 'cl-lib)
(require 'xdg)

(defgroup xdg-appmenu nil
  "Run XDG desktop applications."
  :group 'convenience
  :group 'processes
  :group 'terminals
  :link '(url-link "https://codeberg.org/akib/emacs-xdg-appmenu")
  :prefix "xdg-appmenu-")

(defcustom xdg-appmenu-desktop-directories
  (mapcar (apply-partially #'expand-file-name "applications")
          (cons (xdg-data-home) (xdg-data-dirs)))
  "Directories where XDG desktop files of applications can be found."
  :type '(repeat directory))

(defcustom xdg-appmenu-terminal-runner
  #'xdg-appmenu-terminal-runner-term
  "Function to run XDG desktop application commands in a terminal.

The function should accept two arguments, the application name and
the shell command to run, and it should return the terminal buffer,
without displaying it."
  :type '(choice
          (const :tag "Term runner" xdg-appmenu-terminal-runner-term)
          (const :tag "Eat runner" xdg-appmenu-terminal-runner-eat)
          (function :tag "Custom runner function")))

(defvar xdg-appmenu--list 'uninitialized
  "List of data parsed from desktop files.")

(defvar xdg-appmenu--max-name-length 0
  "Maximum length of an app name.")

(defun xdg-appmenu--parse-desktop-file (file)
  "Parse XDG desktop file FILE."
  (with-temp-buffer
    (insert-file-contents file)
    (let* ((begin (re-search-forward
                   (rx line-start "[Desktop Entry]" (zero-or-more " ")
                       line-end)
                   nil t))
           (end (if (re-search-forward (rx line-start "[") nil t)
                    (match-beginning 0)
                  (point-max)))
           (display-p t)
           (name nil)
           (icon nil)
           (command nil)
           (terminal-p nil)
           (comment nil))
      (when begin
        (cl-flet ((get-key (key)
                    (goto-char begin)
                    (when (re-search-forward
                           (rx-to-string
                            `(and line-start ,key (zero-or-more " ")
                                  "=" (zero-or-more " ")
                                  (group-n 10 (zero-or-more
                                               not-newline))
                                  line-end))
                           end t)
                      (match-string 10))))
          (when (or (string= (get-key "Hidden") "true")
                    (string= (get-key "NoDisplay") "true"))
            (setq display-p nil))
          (when display-p
            (when-let* ((tryexec (get-key "TryExec"))
                        ((not (if (file-name-absolute-p tryexec)
                                  (file-executable-p tryexec)
                                (locate-file tryexec exec-path nil
                                             #'file-executable-p)))))
              (setq display-p nil)))
          (when display-p
            (setq name (get-key "Name"))
            (setq icon (get-key "Icon"))
            (setq command (get-key "Exec"))
            (setq terminal-p (string= (get-key "Terminal") "true"))
            (setq comment (get-key "Comment")))
          (when (and display-p name command)
            (list name icon command terminal-p comment file)))))))

(defun xdg-appmenu--format-command (spec)
  "Return the command to run application as specified in SPEC."
  (cl-destructuring-bind (name icon command _terminal-p _comment file)
      spec
    (format-spec command
                 `((?f . "")
                   (?F . "")
                   (?u . "")
                   (?U . "")
                   (?d . "")
                   (?D . "")
                   (?n . "")
                   (?N . "")
                   (?i . ,(if (and icon (not (string-empty-p icon)))
                              (format "--icon %s" icon)
                            ""))
                   (?c . ,name)
                   (?k . ,file)))))

(defun xdg-appmenu--parse-all ()
  "Parse all desktop files on path and cache the parsed data."
  (setq xdg-appmenu--list nil)
  (setq xdg-appmenu--max-name-length 0)
  (let ((success nil)
        (hash (make-hash-table :test 'equal)))
    (unwind-protect
        (progn
          (dolist (dir xdg-appmenu-desktop-directories)
            (when (file-directory-p dir)
              (dolist (file (directory-files-recursively
                             dir "\\.desktop\\'"))
                (let ((id (string-replace
                           "/" "-"
                           (string-remove-suffix
                            ".desktop"
                            (string-remove-prefix
                             (file-name-as-directory dir) file)))))
                  (unless (gethash id hash)
                    (puthash id nil hash)
                    (when-let* ((data (xdg-appmenu--parse-desktop-file
                                       file))
                                ((not (assoc (car data)
                                             xdg-appmenu--list))))
                      (push data xdg-appmenu--list)
                      (setq xdg-appmenu--max-name-length
                            (max xdg-appmenu--max-name-length
                                 (length (car data))))))))))
          (setq success t))
      (unless success
        (setq xdg-appmenu--list 'uninitialized)))))

(defun xdg-appmenu--annotate (cand)
  "Annotate candidate CAND with its comment."
  (let ((spec (assoc cand xdg-appmenu--list)))
    (if (or (not spec) (not (nth 4 spec))
            (string-empty-p (nth 4 spec)))
        ""
      (concat
       (make-string
        (+ (- xdg-appmenu--max-name-length (length cand)) 2) ?\s)
       (truncate-string-to-width (nth 4 spec) 80 nil nil t)))))

;;;###autoload
(defun xdg-appmenu (app)
  "Run XDG desktop application APP.

If prefix argument is given, refresh completions."
  (interactive
   (progn
     (when (or (eq xdg-appmenu--list 'uninitialized)
               current-prefix-arg)
       (xdg-appmenu--parse-all))
     (list (completing-read
            "Run application: "
            (lambda (string pred action)
              (if (eq action 'metadata)
                  `(metadata
                    (category . xdg-app)
                    (annotation-function . xdg-appmenu--annotate))
                (complete-with-action action xdg-appmenu--list
                                      string pred)))
            nil t nil 'xdg-appmenu-history))))
  (let ((spec (assoc app xdg-appmenu--list)))
    (unless spec
      (error "Unknown XDG desktop application: %s" app))
    (let ((buffer (funcall xdg-appmenu-terminal-runner app
                           (xdg-appmenu--format-command spec))))
      (when (nth 3 spec)
        (pop-to-buffer-same-window buffer)))))

(defun xdg-appmenu-terminal-runner-term (app command)
  "Run shell command COMMAND in a Term buffer.

APP is the name of the application."
  (require 'term)
  (let ((name (generate-new-buffer-name
               (format "*xdg-app: %s*" app))))
    (string-match (rx "*" (group-n 10 (zero-or-more anything)) "*"
                      (group-n 20 (opt "<" (one-or-more digit) ">")
                               string-end))
                  name)
    (setq name (concat (match-string 10 name) (match-string 20 name)))
    (make-term name "sh" nil "-c" command)))

(declare-function eat-make "eat")
(declare-function eat-emacs-mode "eat")

(defun xdg-appmenu-terminal-runner-eat (app command)
  "Run shell command COMMAND in an Eat buffer.

APP is the name of the application."
  (require 'eat)
  (let ((name (generate-new-buffer-name
               (format "*xdg-app: %s*" app))))
    (string-match (rx "*" (group-n 10 (zero-or-more anything)) "*"
                      (group-n 20 (opt "<" (one-or-more digit) ">")
                               string-end))
                  name)
    (setq name (concat (match-string 10 name) (match-string 20 name)))
    (with-current-buffer (eat-make name "sh" nil "-c" command)
      (eat-emacs-mode)
      (current-buffer))))

(provide 'xdg-appmenu)
;;; xdg-appmenu.el ends here
