diff options
author | Sean Allred <code@seanallred.com> | 2014-11-14 09:34:14 -0500 |
---|---|---|
committer | Sean Allred <code@seanallred.com> | 2014-11-14 09:34:14 -0500 |
commit | 77e519de7b1219767a25399b727c2aece6189e6c (patch) | |
tree | 5c99eb09549561a549e93c19aaa788cd81a16073 | |
parent | 525a304f9aca38e1101a1d02ade26ec3b8e91b23 (diff) | |
parent | 8d14a6b425d27b7b942f3922d583877ec8b00690 (diff) |
Merge pull request #46 from vermiculus/sx-question-mode
Question Mode
-rw-r--r-- | Cask | 1 | ||||
-rw-r--r-- | sx-encoding.el | 35 | ||||
-rw-r--r-- | sx-question-list.el | 30 | ||||
-rw-r--r-- | sx-question-mode.el | 436 | ||||
-rw-r--r-- | sx-question.el | 76 | ||||
-rw-r--r-- | sx-request.el | 2 | ||||
-rw-r--r-- | sx-time.el | 22 |
7 files changed, 542 insertions, 60 deletions
@@ -8,6 +8,7 @@ (depends-on "json" "1.4") (depends-on "url") (depends-on "cl-lib") +(depends-on "markdown-mode") (development (depends-on "ert")) diff --git a/sx-encoding.el b/sx-encoding.el index 0b72365..9d48e60 100644 --- a/sx-encoding.el +++ b/sx-encoding.el @@ -23,6 +23,8 @@ ;;; Code: +(require 'cl-lib) + (defcustom sx-encoding-html-entities-plist '(Aacute "Á" aacute "á" Acirc "Â" acirc "â" acute "´" AElig "Æ" aelig "æ" Agrave "À" agrave "à" alefsym "ℵ" Alpha "Α" alpha "α" amp "&" and "∧" @@ -74,6 +76,39 @@ (substring ss 1)))))))) (replace-regexp-in-string "&[^; ]*;" get-function string))) +(defun sx-encoding-normalize-line-endings (string) + "Normalize the line endings for STRING" + (delete ?\r string)) + +(defun sx-encoding-clean-content (string) + "Cleans STRING for display. +Applies `sx-encoding-normalize-line-endings' and +`sx-encoding-decode-entities'." + (sx-encoding-decode-entities + (sx-encoding-normalize-line-endings + string))) + +(defun sx-encoding-clean-content-deep (data) + "Clean DATA recursively where necessary. + +See `sx-encoding-clean-content'." + (if (consp data) + ;; If we're looking at a cons cell, test to see if is a list. If + ;; it is, map ourselves over the entire list. If it is not, + ;; reconstruct the cons cell using a cleaned cdr. + (if (listp (cdr data)) + (cl-map #'list #'sx-encoding-clean-content-deep data) + (cons (car data) (sx-encoding-clean-content-deep (cdr data)))) + ;; If we're looking at an atom, clean and return if we're looking + ;; at a string, map if we're looking at a vector, and just return + ;; if we aren't looking at either. + (cond + ((stringp data) + (sx-encoding-clean-content data)) + ((vectorp data) + (cl-map #'vector #'sx-encoding-clean-content-deep data)) + (t data)))) + (defun sx-encoding-gzipped-p (data) "Checks for magic bytes in DATA. diff --git a/sx-question-list.el b/sx-question-list.el index bdbc2f1..ff1bdaa 100644 --- a/sx-question-list.el +++ b/sx-question-list.el @@ -26,6 +26,7 @@ (require 'sx) (require 'sx-time) (require 'sx-question) +(require 'sx-question-mode) ;;; Customization @@ -69,7 +70,7 @@ :group 'sx-question-list-faces) (defface sx-question-list-tags - '((t :inherit font-lock-function-name-face)) + '((t :inherit sx-question-mode-tags)) "" :group 'sx-question-list-faces) @@ -179,6 +180,9 @@ Letters do not insert themselves; instead, they are commands. (defvar sx-question-list--current-site "emacs" "Site being displayed in the *question-list* buffer.") +(defvar sx-question-list--current-dataset nil + "") + (defun sx-question-list-refresh (&optional redisplay no-update) "Update the list of questions. If REDISPLAY is non-nil, also call `tabulated-list-print'. @@ -187,8 +191,12 @@ a new list before redisplaying." (interactive "pP") ;; Reset the mode-line unread count (we rebuild it here). (setq sx-question-list--unread-count 0) - (let ((question-list (sx-question-get-questions - sx-question-list--current-site))) + (let ((question-list + (if (and no-update sx-question-list--current-dataset) + sx-question-list--current-dataset + (sx-question-get-questions + sx-question-list--current-site)))) + (setq sx-question-list--current-dataset question-list) ;; Print the result. (setq tabulated-list-entries (mapcar #'sx-question-list--print-info question-list))) @@ -233,7 +241,8 @@ Used in the questions list to indicate a question was updated \"4d ago\"." (propertize (concat (sx-time-since .last_activity_date) sx-question-list-ago-string) 'face 'sx-question-list-date) - (propertize (concat " [" (mapconcat #'identity .tags "] [") "]") + " " + (propertize (mapconcat #'sx-question--tag-format .tags " ") 'face 'sx-question-list-tags) (propertize " " 'display "\n")))))) @@ -270,8 +279,9 @@ focus the relevant window." (when (sx-question--read-p data) (cl-decf sx-question-list--unread-count) (sx-question--mark-read data)) - (unless (window-live-p sx-question--window) - (setq sx-question--window + (unless (and (window-live-p sx-question-mode--window) + (null (equal sx-question-mode--window (selected-window)))) + (setq sx-question-mode--window (condition-case er (split-window-below sx-question-list-height) (error @@ -281,11 +291,11 @@ focus the relevant window." (car (cdr-safe er))) nil (error (cdr er))))))) - (sx-question--display data sx-question--window) + (sx-question-mode--display data sx-question-mode--window) (when focus - (if sx-question--window - (select-window sx-question--window) - (switch-to-buffer sx-question--buffer)))) + (if sx-question-mode--window + (select-window sx-question-mode--window) + (switch-to-buffer sx-question-mode--buffer)))) (defvar sx-question-list--buffer nil "Buffer where the list of questions is displayed.") diff --git a/sx-question-mode.el b/sx-question-mode.el new file mode 100644 index 0000000..20d3035 --- /dev/null +++ b/sx-question-mode.el @@ -0,0 +1,436 @@ +;;; sx-question-mode.el --- Creating the buffer that displays questions -*- lexical-binding: t; -*- + +;; Copyright (C) 2014 Artur Malabarba + +;; Author: Artur Malabarba <bruce.connor.am@gmail.com> + +;; This program 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. + +;; 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. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; + + +;;; Code: +(require 'markdown-mode) + +(require 'sx) +(require 'sx-question) + +(defgroup sx-question-mode nil + "Customization group for sx-question-mode." + :prefix "sx-question-mode-" + :group 'sx) + +(defgroup sx-question-mode-faces nil + "Customization group for the faces of `sx-question-mode'." + :prefix "sx-question-mode-" + :group 'sx-question-mode) + + +;;; Displaying a question +(defvar sx-question-mode--window nil + "Window where the content of questions is displayed.") + +(defvar sx-question-mode--buffer nil + "Buffer being used to display questions.") + +(defvar sx-question-mode--data nil + "The data of the question being displayed.") + +(defun sx-question-mode--display (data &optional window) + "Display question given by DATA on WINDOW. +If WINDOW is nil, use selected one. +Returns the question buffer." + (let ((inhibit-read-only t)) + (with-current-buffer + (sx-question-mode--display-buffer window) + (erase-buffer) + (sx-question-mode) + (sx-question-mode--print-question data) + (current-buffer)))) + +(defun sx-question-mode--display-buffer (window) + "Display and return the buffer used for displaying a question. +Create the buffer if necessary. +If WINDOW is given, use that to display the buffer." + ;; Create the buffer if necessary. + (unless (buffer-live-p sx-question-mode--buffer) + (setq sx-question-mode--buffer + (generate-new-buffer "*stack-question*"))) + (cond + ;; Window was given, use it. + ((window-live-p window) + (set-window-buffer window sx-question-mode--buffer)) + ;; No window, but the buffer is already being displayed somewhere. + ((get-buffer-window sx-question-mode--buffer 'visible)) + ;; Neither, so we create the window. + (t (switch-to-buffer sx-question-mode--buffer))) + sx-question-mode--buffer) + + +;;; Printing a question's content +;;;; Faces and Variables +(defvar sx-question-mode--overlays nil + "") +(make-variable-buffer-local 'sx-question-mode--overlays) + +(defface sx-question-mode-header + '((t :inherit font-lock-variable-name-face)) + "Face used on the question headers in the question buffer." + :group 'sx-question-mode-faces) + +(defface sx-question-mode-title + '((t :height 1.3 :weight bold :inherit default)) + "Face used on the question title in the question buffer." + :group 'sx-question-mode-faces) + +(defface sx-question-mode-title-comments + '((t :height 1.1 :inherit sx-question-mode-title)) + "Face used on the question title in the question buffer." + :group 'sx-question-mode-faces) + +(defcustom sx-question-mode-header-title "\n" + "String used before the question title at the header." + :type 'string + :group 'sx-question-mode) + +(defface sx-question-mode-author + '((t :inherit font-lock-string-face)) + "Face used on the question author in the question buffer." + :group 'sx-question-mode-faces) + +(defcustom sx-question-mode-header-author "\nAuthor: " + "String used before the question author at the header." + :type 'string + :group 'sx-question-mode) + +(defface sx-question-mode-date + '((t :inherit font-lock-string-face)) + "Face used on the question date in the question buffer." + :group 'sx-question-mode-faces) + +(defcustom sx-question-mode-header-date "\nAsked on: " + "String used before the question date at the header." + :type 'string + :group 'sx-question-mode) + +(defface sx-question-mode-tags + '((t :underline nil :inherit font-lock-function-name-face)) + "Face used on the question tags in the question buffer." + :group 'sx-question-mode-faces) + +(defface sx-question-mode-author + '((t :inherit font-lock-variable-name-face)) + "Face used for author names in the question buffer." + :group 'sx-question-mode-faces) + +(defcustom sx-question-mode-header-tags "\nTags: " + "String used before the question tags at the header." + :type 'string + :group 'sx-question-mode) + +(defface sx-question-mode-content-face + '((((background dark)) :background "#090909") + (((background light)) :background "#f4f4f4")) + "Face used on the question body in the question buffer. +Shouldn't have a foreground, or this will interfere with +font-locking." + :group 'sx-question-mode-faces) + +(defcustom sx-question-mode-last-edit-format " (edited %s ago by %s)" + "Format used to describe last edit date in the header. +First %s is replaced with the date, and the second %s with the +editor's name." + :type 'string + :group 'sx-question-mode) + +(defcustom sx-question-mode-separator + (concat "\n" (make-string 80 ?_) "\n") + "Separator used between header and body." + :type 'string + :group 'sx-question-mode) + +(defcustom sx-question-mode-answer-title "Answer" + "Title used at the start of \"Answer\" sections." + :type 'string + :group 'sx-question-mode) + +(defcustom sx-question-mode-comments-title " Comments" + "Title used at the start of \"Comments\" sections." + :type 'string + :group 'sx-question-mode) + +(defcustom sx-question-mode-comments-format "%s: %s\n" + "Format used to display comments. +First \"%s\" is replaced with user name. +Second \"%s\" is replaced with the comment." + :type 'string + :group 'sx-question-mode) + + +;;; Printing a question's content +;;;; Functions +;; This is where most of the work is still left to be done! Need to +;; insert more data from QUESTION. +(defun sx-question-mode--print-question (question) + "Print a buffer describing QUESTION. +QUESTION must be a data structure returned by `json-read'." + ;; Clear the overlays + (mapc #'delete-overlay sx-question-mode--overlays) + (setq sx-question-mode--overlays nil) + ;; Print everything + (sx-question-mode--print-section question) + (sx-assoc-let question + (mapc #'sx-question-mode--print-section .answers)) + (goto-char (point-min)) + (with-selected-window sx-question-mode--window + (sx-question-mode-next-section))) + +(defun sx-question-mode--print-section (data) + "Print a section corresponding to DATA. +DATA can represent a question or an answer." + (sx-assoc-let data + (insert sx-question-mode-header-title + (if .title + ;; Questions have title + (propertize + .title + 'font-lock-face 'sx-question-mode-title + 'sx-question-mode--section 1) + ;; Answers don't + (propertize + sx-question-mode-answer-title + 'font-lock-face 'sx-question-mode-title + 'sx-question-mode--section 2))) + ;; Sections can be hidden with overlays + (sx-question-mode--wrap-in-overlay + '(sx-question-mode--section-content t) + (sx-question-mode--insert-header + ;; Author + sx-question-mode-header-author + (sx-question-mode--propertized-display-name .owner) + 'sx-question-mode-author + ;; Date + sx-question-mode-header-date + (concat + (sx-time-seconds-to-date .creation_date) + (when .last_edit_date + (format sx-question-mode-last-edit-format + (sx-time-since .last_edit_date) + (sx-question-mode--propertized-display-name .last_editor)))) + 'sx-question-mode-date) + (when .title + ;; Tags + (sx-question-mode--insert-header + sx-question-mode-header-tags + (mapconcat #'sx-question--tag-format .tags " ") + 'sx-question-mode-tags)) + ;; Body + (insert (propertize sx-question-mode-separator + 'face 'sx-question-mode-header)) + (sx-question-mode--wrap-in-overlay + '(face sx-question-mode-content-face) + (insert "\n" + (sx-question-mode--fill-string + .body_markdown) + (propertize sx-question-mode-separator + 'face 'sx-question-mode-header)))) + ;; Comments + (when .comments + (insert + "\n" + (propertize + sx-question-mode-comments-title + 'font-lock-face 'sx-question-mode-title-comments + 'sx-question-mode--section 3)) + (sx-question-mode--wrap-in-overlay + '(sx-question-mode--section-content t) + (insert "\n") + (sx-question-mode--wrap-in-overlay + '(face sx-question-mode-content-face) + (mapc #'sx-question-mode--print-comment .comments)))))) + +(defun sx-question-mode--fill-string (text) + "Fill TEXT according to `markdown-mode' and return it." + (with-temp-buffer + (insert text) + (markdown-mode) + (goto-char (point-min)) + ;; ;; Do something here + ;; (while (null (eobp)) + ;; (skip-chars-forward "\r\n[:blank:]") + ;; (markdown-pre-region)) + (buffer-string))) + +(defun sx-question-mode--propertized-display-name (author) + "Return display_name of AUTHOR with `sx-question-mode-author' face." + (sx-assoc-let author + (propertize .display_name + 'font-lock-face 'sx-question-mode-author))) + +(defun sx-question-mode--print-comment (data) + "Print the comment described by alist DATA." + (sx-assoc-let data + (insert + (format + sx-question-mode-comments-format + (sx-question-mode--propertized-display-name .owner) + (substring + ;; We fill with three spaces at the start, so the comment is + ;; slightly indented. + (sx-question-mode--fill-string + (concat " " .body_markdown)) + ;; Then we remove the spaces from the first line, since we'll + ;; add the username there anyway. + 3))))) + +(defmacro sx-question-mode--wrap-in-overlay (properties &rest body) + "Execute BODY and wrap any inserted text in an overlay. +Overlay is pushed on `sx-question-mode--overlays' and given PROPERTIES. +Return the result of BODY." + (declare (indent 1) + (debug t)) + `(let ((p (point-marker)) + (result (progn ,@body))) + (let ((ov (make-overlay p (point))) + (props ,properties)) + (while props + (overlay-put ov (pop props) (pop props))) + (push ov sx-question-mode--overlays)) + result)) + +(defun sx-question-mode--insert-header (&rest args) + "Insert HEADER and VALUE. +HEADER is given `sx-question-mode-header' face, and value is given FACE. +\(fn header value face [header value face] [header value face] ...)" + (while args + (insert + (propertize (pop args) 'font-lock-face 'sx-question-mode-header) + (propertize (pop args) 'font-lock-face (pop args))))) + + +;;; Movement commands +;; Sections are headers placed above a question's content or an +;; answer's content, or above the list of comments. They are +;; identified with the `sx-question-mode--section' text property. +;; To move between sections, just search for the property. The value +;; of the text-property is the depth of the section (1 for contents, 2 +;; for comments). +(defcustom sx-question-mode-recenter-line 1 + "Screen line to which we recenter after moving between sections. +This is used as an argument to `recenter', only used if the end +of section is outside the window. +If nil, no recentering is performed." + :type '(choice (const :tag "Don't recenter" nil) + integer) + :group 'sx-question-mode) + +(defun sx-question-mode-next-section (&optional n) + "Move down to next section (question or answer) of this buffer. +Prefix argument N moves N sections down or up." + (interactive "p") + (unless n (setq n 1)) + (dotimes (_ (abs n)) + ;; This will either move us to the next section, or move out of + ;; the current one. + (unless (sx-question-mode--goto-propety-change 'section n) + ;; If all we did was move out the current one, then move again + ;; and we're guaranteed to reach the next section. + (sx-question-mode--goto-propety-change 'section n))) + (when sx-question-mode-recenter-line + (let ((ov (car-safe (sx-question-mode--section-overlays-at (line-end-position))))) + (when (and (overlayp ov) (> (overlay-end ov) (window-end))) + (recenter sx-question-mode-recenter-line))))) + +(defun sx-question-mode-previous-section (&optional n) + "Move down to previous section (question or answer) of this buffer. +Prefix argument N moves N sections up or down." + (interactive "p") + (sx-question-mode-next-section (- (or n 1)))) + +(defun sx-question-mode--goto-propety-change (prop &optional direction) + "Move forward until the value of text-property `sx-question-mode--PROP' changes. +Return the new value of PROP at point. +If DIRECTION is negative, move backwards instead." + (let ((prop (intern (format "sx-question-mode--%s" prop))) + (func (if (and (numberp direction) + (< direction 0)) + #'previous-single-property-change + #'next-single-property-change)) + (limit (if (and (numberp direction) + (< direction 0)) + (point-min) (point-max)))) + (goto-char (funcall func (point) prop nil limit)) + (get-text-property (point) prop))) + + +(defun sx-question-mode-hide-show-section () + "Hide or show section under point." + (interactive) + (let ((ov (car (or (sx-question-mode--section-overlays-at (point)) + (sx-question-mode--section-overlays-at + (line-end-position)))))) + (goto-char (overlay-start ov)) + (forward-line 0) + (overlay-put + ov 'invisible + (null (overlay-get ov 'invisible))))) + +(defun sx-question-mode--section-overlays-at (pos) + "Return a list of `sx-question-mode--section-content' overlays at POS." + (cl-remove-if (lambda (x) (null (overlay-get x 'sx-question-mode--section-content))) + (overlays-at pos))) + + +;;; Major-mode +(define-derived-mode sx-question-mode markdown-mode "Question" + "Major mode for a question and its answers. +Letters do not insert themselves; instead, they are commands. +\\<sx-question-mode> +\\{sx-question-mode}" + (remove-hook 'after-change-functions 'markdown-check-change-for-wiki-link t) + (remove-hook 'window-configuration-change-hook + 'markdown-fontify-buffer-wiki-links t) + (read-only-mode)) + +(mapc + (lambda (x) (define-key sx-question-mode-map + (car x) (cadr x))) + `(("n" sx-question-mode-next-section) + ("p" sx-question-mode-previous-section) + ("j" sx-question-mode-next-section) + ("k" sx-question-mode-previous-section) + ("g" sx-question-mode-refresh) + ("q" quit-window) + (" " scroll-up-command) + (,(kbd "S-SPC") scroll-down-command) + ([backspace] scroll-down-command) + ([tab] sx-question-mode-hide-show-section))) + +(defun sx-question-mode-refresh () + "Refresh currently displayed question. +Queries the API for any changes to the question or its answers or +comments, and redisplays it." + (interactive) + (unless (derived-mode-p 'sx-question-mode) + (error "Not in `sx-question-mode'")) + (sx-assoc-let sx-question-mode--data + (sx-question-mode--display + (sx-question-get-question + sx-question-list--current-site .question_id) + (selected-window)))) + +(provide 'sx-question-mode) +;;; sx-question-mode.el ends here diff --git a/sx-question.el b/sx-question.el index 20a71cc..d15cc80 100644 --- a/sx-question.el +++ b/sx-question.el @@ -30,7 +30,19 @@ (require 'sx-method) (defvar sx-question-browse-filter - '(nil (user.profile_image shallow_user.profile_image))) + '((question.body_markdown + question.comments + question.answers + question.last_editor + user.display_name + comment.owner + comment.body_markdown + comment.body + answer.last_editor + answer.owner + answer.body_markdown + answer.comments) + (user.profile_image shallow_user.profile_image))) (defun sx-question-get-questions (site &optional page) "Get the page PAGE of questions from SITE." @@ -40,6 +52,16 @@ (page . ,page)) sx-question-browse-filter)) +(defun sx-question-get-question (site id) + "Get the question ID from SITE." + (let ((res (sx-method-call + (format "questions/%s" id) + `((site . ,site)) + sx-question-browse-filter))) + (if (vectorp res) + (elt res 0) + (error "Couldn't find question %S in %S" id site)))) + ;;; Question Properties (defun sx-question--read-p (question) @@ -62,54 +84,10 @@ With optional argument predicate, use it instead of `<'." (funcall (or pred #'<) (cdr (assoc property x)) (cdr (assoc property y)))) - -;;; Displaying a question -(defvar sx-question--window nil - "Window where the content of questions is displayed.") - -(defvar sx-question--buffer nil - "Buffer being used to display questions.") - -(defcustom sx-question-use-html t - "If nil, markdown is used for the body." - :type 'boolean - :group 'sx-question) - -(defun sx-question--display (data &optional window) - "Display question given by DATA on WINDOW. -If WINDOW is nil, use selected one." - (let ((sx-lto--body-src-block - (if sx-question-use-html nil - sx-lto--body-src-block)) - (inhibit-read-only t)) - (with-current-buffer - (sx-question--display-buffer window) - (erase-buffer) - (insert - (org-element-interpret-data - (sx-lto--question data))) - (org-mode) - (show-all) - (view-mode) - (current-buffer)))) - -(defun sx-question--display-buffer (window) - "Display and return the buffer used for displaying a question. -Create the buffer if necessary. -If WINDOW is given, use that to display the buffer." - ;; Create the buffer if necessary. - (unless (buffer-live-p sx-question--buffer) - (setq sx-question--buffer - (generate-new-buffer "*sx-question*"))) - (cond - ;; Window was given, use it. - ((window-live-p window) - (set-window-buffer window sx-question--buffer)) - ;; No window, but the buffer is already being displayed somewhere. - ((get-buffer-window sx-question--buffer 'visible)) - ;; Neither, so we create the window. - (t (switch-to-buffer sx-question--buffer))) - sx-question--buffer) + +(defun sx-question--tag-format (tag) + "Formats TAG for display" + (concat "[" tag "]")) (provide 'sx-question) ;;; sx-question.el ends here diff --git a/sx-request.el b/sx-request.el index dd98ead..b16fd9a 100644 --- a/sx-request.el +++ b/sx-request.el @@ -115,7 +115,7 @@ number of requests left every time it finishes a call.") sx-request-remaining-api-requests-message-threshold) (sx-message "%d API requests reamining" sx-request-remaining-api-requests)) - .items))))))) + (sx-encoding-clean-content-deep .items)))))))) ;;; Support Functions @@ -49,5 +49,27 @@ (concat (format "%.0f" (/ delay (car (cddr here)))) (cadr here))))))) +(defcustom sx-time-date-format-year "%H:%M %e %b %Y" + "Format used for dates on a past year. +See also `sx-time-date-format'." + :type 'string + :group 'sx-time) + +(defcustom sx-time-date-format "%H:%M - %d %b" + "Format used for dates on this year. +See also `sx-time-date-format-year'." + :type 'string + :group 'sx-time) + +(defun sx-time-seconds-to-date (seconds) + "Return the integer SECONDS as a date string." + (let ((time (seconds-to-time seconds))) + (format-time-string + (if (string= (format-time-string "%Y") + (format-time-string "%Y" time)) + sx-time-date-format + sx-time-date-format-year) + time))) + (provide 'sx-time) ;;; sx-time.el ends here |