|
| 1 | +;;; pushbullet-ui.el --- Pushbullet UI -*- lexical-binding: t; -*- |
| 2 | + |
| 3 | +;; Copyright (C) 2025 Savio Sena <savio.sena@gmail.com> |
| 4 | + |
| 5 | +;; Author: Savio Sena <savio.sena@gmail.com> |
| 6 | +;; Version: 1.0.0 |
| 7 | +;; Package-Requires: ((emacs "29.1") (all-the-icons "5.0.0")) |
| 8 | +;; Keywords: pushbullet, client, tool, internet |
| 9 | +;; URL: https://github.com/sav/emacs-pushbullet |
| 10 | + |
| 11 | +;; This file is not part of GNU Emacs. |
| 12 | + |
| 13 | +;; This program is free software: you can redistribute it and/or modify |
| 14 | +;; it under the terms of the GNU General Public License as published by |
| 15 | +;; the Free Software Foundation, either version 3 of the License, or |
| 16 | +;; (at your option) any later version. |
| 17 | + |
| 18 | +;; This program is distributed in the hope that it will be useful, |
| 19 | +;; but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 20 | +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 21 | +;; GNU General Public License for more details. |
| 22 | + |
| 23 | +;; You should have received a copy of the GNU General Public License |
| 24 | +;; along with this program. If not, see <https://www.gnu.org/licenses/>. |
| 25 | + |
| 26 | +;;; Commentary: |
| 27 | +;;; This package provides the user interface components for the Emacs Pushbullet |
| 28 | +;;; client. It includes functions for rendering pushes, handling user |
| 29 | +;;; interactions, and managing the display of Pushbullet data within Emacs. |
| 30 | +;;; |
| 31 | + |
| 32 | +;;; Code: |
| 33 | + |
| 34 | +(require 'cl-lib) |
| 35 | +(require 'button) |
| 36 | +(require 'widget) |
| 37 | +(require 'wid-edit) |
| 38 | +(require 'all-the-icons) |
| 39 | + |
| 40 | +(defgroup pushbullet-ui nil |
| 41 | + "User interface for the Pushbullet client." |
| 42 | + :group 'extensions) |
| 43 | + |
| 44 | +(defcustom pushbullet-ui-columns 70 |
| 45 | + "Maximum number of columns for wrapping lines in the Pushbullet UI buffer." |
| 46 | + :type 'integer |
| 47 | + :group 'pushbullet-ui) |
| 48 | + |
| 49 | +(defcustom pushbullet-ui-left-alignment 8 |
| 50 | + "The size of the left alignment padding in the Pushbullet UI." |
| 51 | + :type 'integer |
| 52 | + :group 'pushbullet-ui) |
| 53 | + |
| 54 | +(defcustom pushbullet-ui-textfield-width |
| 55 | + (truncate |
| 56 | + (* (- pushbullet-ui-columns pushbullet-ui-left-alignment) 0.90)) |
| 57 | + "The calculated width for editable text fields within the Pushbullet UI." |
| 58 | + :type 'integer |
| 59 | + :group 'pushbullet-ui) |
| 60 | + |
| 61 | +(defcustom pushbullet-ui-debug nil |
| 62 | + "Enable verbose logging for Pushbullet UI operations. |
| 63 | +When non-nil, additional debug messages will be printed to the *Messages* buffer." |
| 64 | + :type 'boolean |
| 65 | + :group 'pushbullet-ui) |
| 66 | + |
| 67 | +(defcustom pushbullet-ui-show-send-form t |
| 68 | + "Whether to display the send form in the Pushbullet UI." |
| 69 | + :type 'boolean |
| 70 | + :group 'pushbullet-ui) |
| 71 | + |
| 72 | +(defvar pushbullet-ui--buffer nil |
| 73 | + "The buffer currently used for rendering the Pushbullet UI. This is a |
| 74 | + buffer-local variable.") |
| 75 | + |
| 76 | +(defvar pushbullet-ui--title nil |
| 77 | + "The title string displayed at the top of the Pushbullet UI buffer. |
| 78 | + This is a buffer-local variable.") |
| 79 | + |
| 80 | +(defvar pushbullet-ui--pushes nil |
| 81 | + "A buffer-local list of Pushbullet pushes currently displayed in the |
| 82 | + UI, where each push is an alist.") |
| 83 | + |
| 84 | +(defvar pushbullet-ui--mode-map nil |
| 85 | + "The keymap active within the Pushbullet UI buffer.") |
| 86 | + |
| 87 | +(defvar pushbullet-ui--api nil |
| 88 | + "A buffer-local alist of callback functions for Pushbullet API |
| 89 | + interactions. See `pushbullet-api'.") |
| 90 | + |
| 91 | +(defmacro pushbullet-ui--log (fmt &rest args) |
| 92 | + "Logs a debug message with FMT and ARGS if `pushbullet-ui-debug' is |
| 93 | + enabled. |
| 94 | +The message is prefixed with '[pushbullet-ui]' for easy identification |
| 95 | +in the `*Messages*' buffer." |
| 96 | + `(when pushbullet-ui-debug |
| 97 | + (message (concat "[pushbullet-ui] " ,fmt) ,@args))) |
| 98 | + |
| 99 | +(defun pushbullet-ui--align-right (max str) |
| 100 | + "Inserts spaces to right-align STR within a field of MAX width in the |
| 101 | + current buffer." |
| 102 | + (let ((len (length str))) |
| 103 | + (when (>= max len) |
| 104 | + (widget-insert (make-string (- max (length str)) ?\s))))) |
| 105 | + |
| 106 | +(defun pushbullet-ui--insert-aligned (str) |
| 107 | + "Inserts a newline and then the string STR, right-aligned by |
| 108 | + `pushbullet-ui-left-alignment'." |
| 109 | + (widget-insert "\n") |
| 110 | + (pushbullet-ui--align-right pushbullet-ui-left-alignment str) |
| 111 | + (widget-insert str)) |
| 112 | + |
| 113 | +(defun pushbullet-ui--list-filter (pushes) |
| 114 | + "Filters a list of PUSHES, returning only those that are active and |
| 115 | + have at least a title, URL, or body." |
| 116 | + (let ((is-active (alist-get 'active pushbullet-ui--api))) |
| 117 | + (seq-filter (lambda (push) (funcall is-active push)) pushes))) |
| 118 | + |
| 119 | +(defun pushbullet-ui--list-remove (list push) |
| 120 | + "Removes PUSH from LIST where elements in LIST match PUSH |
| 121 | + based on the `'iden' key-value pairs." |
| 122 | + (let ((iden (alist-get 'iden push))) |
| 123 | + (seq-remove (lambda (item) (equal (alist-get 'iden item) iden)) list))) |
| 124 | + |
| 125 | +(defun pushbullet-ui--send (title body url) |
| 126 | + (let ((send (alist-get 'send pushbullet-ui--api))) |
| 127 | + (funcall send title body url) |
| 128 | + (pushbullet-ui--log "Pushed: (%S, %S, %S)" title body url) |
| 129 | + (pushbullet-ui--load-more 1))) |
| 130 | + |
| 131 | +(defun pushbullet-ui--load-more (&optional limit) |
| 132 | + "Fetches additional pushes from the Pushbullet server using the `fetch' |
| 133 | + callback from `pushbullet-ui--api', and then re-renders the UI." |
| 134 | + (let ((fetch (alist-get 'fetch pushbullet-ui--api))) |
| 135 | + (funcall fetch |
| 136 | + #'(lambda (pushes) |
| 137 | + (setq pushbullet-ui--pushes |
| 138 | + (pushbullet-ui--list-filter |
| 139 | + (append pushbullet-ui--pushes pushes))) |
| 140 | + (pushbullet-ui--log "Loaded more %S pushes. Total: %S" |
| 141 | + (length pushes) (length pushbullet-ui--pushes)) |
| 142 | + (pushbullet-ui--render) |
| 143 | + ;; Move cursor back to its original position when called from |
| 144 | + ;; "Load More" button. |
| 145 | + (when (not limit) |
| 146 | + (goto-char (point-max)) |
| 147 | + (search-backward "Load More"))) |
| 148 | + limit)) |
| 149 | + nil) |
| 150 | + |
| 151 | +(defun pushbullet-ui--export-all () |
| 152 | + "Exports all currently loaded pushes to an Org-mode buffer using the |
| 153 | + `export' callback from `pushbullet-ui--api'." |
| 154 | + (let* ((export (alist-get 'export pushbullet-ui--api))) |
| 155 | + (funcall export pushbullet-ui--pushes))) |
| 156 | + |
| 157 | +(defun pushbullet-ui--delete-all (&rest args) |
| 158 | + "Deletes all pushes currently displayed in the UI from the Pushbullet |
| 159 | + server using the `delete' callback from `pushbullet-ui--api', then |
| 160 | + re-renders the UI." |
| 161 | + (let* ((del (alist-get 'del pushbullet-ui--api))) |
| 162 | + (dolist (push pushbullet-ui--pushes) |
| 163 | + (funcall del push))) |
| 164 | + (setq pushbullet-ui--pushes nil) |
| 165 | + (pushbullet-ui--render)) |
| 166 | + |
| 167 | +(defun pushbullet-ui--delete-row (push) |
| 168 | + "Deletes a single PUSH from the `pushbullet-ui--pushes' list, invokes |
| 169 | + the `delete' callback from `pushbullet-ui--api', and then re-renders |
| 170 | + the UI." |
| 171 | + (setq pushbullet-ui--pushes |
| 172 | + (pushbullet-ui--list-remove pushbullet-ui--pushes push)) |
| 173 | + (let* ((del (alist-get 'del pushbullet-ui--api))) |
| 174 | + (funcall del push)) |
| 175 | + (pushbullet-ui--log "Row deleted") |
| 176 | + (pushbullet-ui--render)) |
| 177 | + |
| 178 | +(defun pushbullet-ui--render-top (title) |
| 179 | + "Renders the top section of the Pushbullet UI, displaying the |
| 180 | + provided TITLE as a banner." |
| 181 | + (let ((len (length title))) |
| 182 | + (widget-insert |
| 183 | + (propertize |
| 184 | + (concat "══ " title " " (make-string (- pushbullet-ui-columns 4 len) ?═) "\n") |
| 185 | + 'face 'bold)))) |
| 186 | + |
| 187 | +(defun pushbullet-ui--render-bottom () |
| 188 | + "Renders the bottom section of the Pushbullet UI, including action |
| 189 | + buttons such as 'Load More', 'Export', 'Delete All', and 'Close'." |
| 190 | + (widget-insert "\n" (make-string pushbullet-ui-columns ?═) "\n") |
| 191 | + (pushbullet-ui--align-right |
| 192 | + pushbullet-ui-columns |
| 193 | + " [Load More] [Export] [Delete All] [Close]") |
| 194 | + (widget-create 'push-button |
| 195 | + :notify (lambda (&rest _) (pushbullet-ui--load-more)) |
| 196 | + "Load More") |
| 197 | + (widget-insert " ") |
| 198 | + (widget-create 'push-button |
| 199 | + :notify (lambda (&rest _) (pushbullet-ui--export-all)) |
| 200 | + "Export") |
| 201 | + (widget-insert " ") |
| 202 | + (widget-create 'push-button |
| 203 | + :notify (lambda (&rest _) (pushbullet-ui--delete-all)) |
| 204 | + "Delete All") |
| 205 | + (widget-insert " ") |
| 206 | + (widget-create 'push-button |
| 207 | + :notify (lambda (&rest _) (kill-buffer)) |
| 208 | + "Close") |
| 209 | + (widget-insert "\n")) |
| 210 | + |
| 211 | +(defun pushbullet-ui--render-push (push) |
| 212 | + "Renders a single PUSH (an alist) as a set of editable widgets in the UI. |
| 213 | +This includes displaying its creation datetime, editable fields for |
| 214 | +title, URL, and body, and 'Delete' buttons." |
| 215 | + (let* ((created (seconds-to-time (alist-get 'created push))) |
| 216 | + (datetime (propertize |
| 217 | + (format "%s %s %s %s" |
| 218 | + (all-the-icons-faicon "calendar") |
| 219 | + (format-time-string "%Y-%b-%d" created) |
| 220 | + (all-the-icons-faicon "clock-o") |
| 221 | + (format-time-string "%H:%M" created)) |
| 222 | + 'face 'shadow)) |
| 223 | + (_ (widget-insert (format "\n ── %s " datetime))) |
| 224 | + (_ (widget-insert (make-string |
| 225 | + (- pushbullet-ui-columns |
| 226 | + (+ 7 (length datetime))) ?─) "\n")) |
| 227 | + (_ (pushbullet-ui--insert-aligned "Title: ")) |
| 228 | + (title-w (widget-create 'editable-field |
| 229 | + :size pushbullet-ui-textfield-width |
| 230 | + :format "%v" |
| 231 | + :value (or (alist-get 'title push) ""))) |
| 232 | + (_ (pushbullet-ui--insert-aligned "URL: ")) |
| 233 | + (url-w (widget-create 'editable-field |
| 234 | + :size pushbullet-ui-textfield-width |
| 235 | + :format "%v" |
| 236 | + :value (or (alist-get 'url push) ""))) |
| 237 | + (_ (pushbullet-ui--insert-aligned "Body: ")) |
| 238 | + (body-w (widget-create 'text |
| 239 | + :size pushbullet-ui-textfield-width |
| 240 | + :format "%v" |
| 241 | + :value (or (alist-get 'body push) "")))) |
| 242 | + (widget-insert "\n\n") |
| 243 | + (pushbullet-ui--align-right pushbullet-ui-columns "[Delete]") |
| 244 | + (widget-create 'push-button |
| 245 | + :notify |
| 246 | + (lambda (&rest _) |
| 247 | + (pushbullet-ui--delete-row push)) |
| 248 | + "Delete") |
| 249 | + (widget-insert "\n"))) |
| 250 | + |
| 251 | +(defun pushbullet-ui--render-pushes () |
| 252 | + "Render the list of pushes in the UI (`pushbullet-ai--pushes')." |
| 253 | + (dolist (push pushbullet-ui--pushes) |
| 254 | + (let* ((is-active (alist-get 'active pushbullet-ui--api))) |
| 255 | + (when (funcall is-active push) |
| 256 | + (pushbullet-ui--render-push push))))) |
| 257 | + |
| 258 | +(defun pushbullet-ui--render-form () |
| 259 | + "Renders the 'New Push' form, allowing users to input a title, URL, |
| 260 | + and body for a new Pushbullet push. |
| 261 | +Includes a 'Push' button to submit the form via the `send' callback from |
| 262 | +`pushbullet-ui--api'." |
| 263 | + (widget-insert |
| 264 | + (propertize |
| 265 | + (concat "\n\n\n══ New Push " |
| 266 | + (make-string (- pushbullet-ui-columns 12) ?═) " \n") |
| 267 | + 'face 'bold)) |
| 268 | + (pushbullet-ui--insert-aligned "Title: ") |
| 269 | + (let* ((new-title (widget-create |
| 270 | + 'editable-field |
| 271 | + :size pushbullet-ui-textfield-width |
| 272 | + :value "")) |
| 273 | + (_ (pushbullet-ui--insert-aligned "URL: ")) |
| 274 | + (new-url (widget-create |
| 275 | + 'editable-field |
| 276 | + :size pushbullet-ui-textfield-width |
| 277 | + :value "")) |
| 278 | + (_ (pushbullet-ui--insert-aligned "Body: ")) |
| 279 | + (new-body (widget-create |
| 280 | + 'editable-field |
| 281 | + :size pushbullet-ui-textfield-width |
| 282 | + :value ""))) |
| 283 | + |
| 284 | + (widget-insert "\n\n") |
| 285 | + (widget-insert (make-string pushbullet-ui-columns ?═) "\n") |
| 286 | + (pushbullet-ui--align-right pushbullet-ui-columns " Push ") |
| 287 | + (widget-create 'push-button |
| 288 | + :notify (lambda (&rest _) |
| 289 | + (pushbullet-ui--send |
| 290 | + (widget-value new-title) |
| 291 | + (widget-value new-body) |
| 292 | + (widget-value new-url))) |
| 293 | + "Push") |
| 294 | + (widget-insert "\n"))) |
| 295 | + |
| 296 | +(defun pushbullet-ui--render () |
| 297 | + "Renders the complete Pushbullet UI in the buffer specified by |
| 298 | + `pushbullet-ui--buffer'. |
| 299 | +This involves rendering the top banner, iterating through |
| 300 | +`pushbullet-ui--pushes' to display each push, rendering the bottom |
| 301 | +action buttons, and optionally rendering the 'New Push' form if |
| 302 | +`pushbullet-ui-show-send-form' is non-nil." |
| 303 | + (with-current-buffer pushbullet-ui--buffer |
| 304 | + (let* ((inhibit-read-only t) |
| 305 | + (inhibit-modification-hooks t)) |
| 306 | + (remove-overlays) |
| 307 | + (erase-buffer) |
| 308 | + (goto-address-mode 1) |
| 309 | + (pushbullet-ui--render-top pushbullet-ui--title) |
| 310 | + ;; cleanup inactive pushes |
| 311 | + (setq pushbullet-ui--pushes |
| 312 | + (pushbullet-ui--list-filter pushbullet-ui--pushes)) |
| 313 | + (pushbullet-ui--render-pushes) |
| 314 | + (pushbullet-ui--render-bottom) |
| 315 | + (when pushbullet-ui-show-send-form |
| 316 | + (pushbullet-ui--render-form)) |
| 317 | + (widget-setup) |
| 318 | + (use-local-map |
| 319 | + (make-composed-keymap |
| 320 | + pushbullet-ui--mode-map widget-keymap))))) |
| 321 | + |
| 322 | +(defun pushbullet-ui (title buffer api mode-map) |
| 323 | + "Initializes and displays the Pushbullet UI in the specified BUFFER. |
| 324 | +
|
| 325 | +This function sets up buffer-local variables, including the UI TITLE, |
| 326 | +the API CALLBACKS alist for interaction, and the MODE-MAP for |
| 327 | +keybindings. It then triggers the initial fetch of pushes and renders |
| 328 | +the complete UI. |
| 329 | +
|
| 330 | +BUFFER is the buffer to create or use. |
| 331 | +TITLE is a string to be displayed as the main title of the UI. |
| 332 | +API is an alist of callback functions for Pushbullet API operations. |
| 333 | +MODE-MAP is the keymap to use in the UI buffer." |
| 334 | + (setq pushbullet-ui--title title |
| 335 | + pushbullet-ui--buffer buffer |
| 336 | + pushbullet-ui--api api |
| 337 | + pushbullet-ui--pushes nil |
| 338 | + pushbullet-ui--mode-map mode-map) |
| 339 | + (pushbullet-ui--load-more) |
| 340 | + nil) |
| 341 | + |
| 342 | +(provide 'pushbullet-ui) |
| 343 | + |
| 344 | +;;; pushbullet-ui.el ends here |
0 commit comments