diff --git a/AUTHORS b/AUTHORS
index eef69f5..4c908a0 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -3,6 +3,9 @@ hackernews.el Hacker News client for GNU Emacs. To show our
appreciation for their public spirit, we list here in alphabetical
order a condensed list of their contributions.
+Andros Fenollosa: changed hackernews.el to add widget-based UI with
+ visual-fill-column support and modern design
+
Basil L. Contovounesios: wrote .dir-locals.el
.github/workflows/build.yml
and added AUTHORS
diff --git a/README.md b/README.md
index 2defbbc..0822c60 100644
--- a/README.md
+++ b/README.md
@@ -10,31 +10,35 @@ News](https://news.ycombinator.com/). It uses a HTTP
## Interface
-Version 0.7.1 of the `hackernews` package is able to fetch stories
+Version 0.8.0 of the `hackernews` package is able to fetch stories
from six different Hacker News feeds, namely top, new, best, ask, show
and job stories. The default feed is top stories, which corresponds
to the Hacker News homepage.
-The score, title, and comments count of each story is presented on a
-line of its own (see screenshot below), though this format is
-customizable. Both the title and comments count strings are
-hyperlinked to the Hacker News page for the item (the one with the
-comments), unless the story links to an external page, in which case
-the title is hyperlinked to that instead.
-
-Clicking or typing RET on a link opens it with the command
+The interface features a modern, widget-based design. Each story is
+displayed with a clickable title widget,
+followed by metadata (score, comments count, and author) in styled
+text with color coding. Interactive buttons for accessing comments
+and external links are provided for each story, and stories are
+separated by horizontal dividers for easy reading.
+
+The header includes clickable navigation buttons for switching between
+feeds (Top, New, Best, Ask, Show) and refreshing the current feed.
+Content is centered and formatted to a configurable width (default 80
+characters) for optimal readability. If the
+[`visual-fill-column`](https://github.com/joostkremers/visual-fill-column)
+package is installed, it will be used to center the content
+automatically.
+
+Clicking or typing RET on a widget opens it with the
+command
[`browse-url`](https://gnu.org/software/emacs/manual/html_node/emacs/Browse_002dURL.html),
which selects a browser based on the user option
`browse-url-browser-function`. This defaults to the system's default
-browser.
-
-Typing t on a link first tries to open it in
-[`eww`](https://gnu.org/software/emacs/manual/html_node/eww/index.html),
-if available, and otherwise passes it to the command
-`browse-url-text-emacs`, which consults the user option
-`browse-url-text-browser`. This defaults to running `lynx` within
-Emacs. Keep in mind that some websites do not render well in text
-mode.
+browser. Comment buttons use the user option
+`hackernews-internal-browser-function`, which defaults to
+[`eww`](https://gnu.org/software/emacs/manual/html_node/eww/index.html)
+for in-Emacs browsing.
A future `hackernews` version may support upvoting and interacting
with comments.
@@ -43,14 +47,14 @@ with comments.
| Key | Description |
|------------------|----------------------------------------------|
-| RET | Open link in default (external) browser |
+| RET | Activate widget at point (open link/button) |
| t | Open link in text-based browser within Emacs |
| r | Mark link as visited |
| R | Mark link as unvisited |
-| n | Move to next title link |
-| p | Move to previous title link |
-| TAB | Move to next comments count link |
-| S-TAB | Move to previous comments count link |
+| n | Move to next story |
+| p | Move to previous story |
+| TAB | Move to next widget (buttons, links, etc.) |
+| S-TAB | Move to previous widget |
| m | Load more stories |
| g | Reload stories |
| f | Prompt user for a feed to switch to |
@@ -120,6 +124,39 @@ slows down startup):
(require 'hackernews)
```
+## Configuration Examples
+
+### Classic Mode (Default)
+
+The classic mode provides a minimal, text-based interface with no additional configuration needed:
+
+```el
+(use-package hackernews
+ :ensure t)
+```
+
+### Modern Mode with Visual Enhancements
+
+The modern mode offers an enhanced interface with widgets, colors, and centered content:
+
+```el
+;; Install visual-fill-column for centered display
+(use-package visual-fill-column
+ :ensure t)
+
+;; Configure hackernews with modern UI
+(use-package hackernews
+ :ensure t
+ :config
+ ;; Use modern UI with enhanced visual elements
+ (setq hackernews-ui-style 'modern)
+ ;; Enable emoji icons in the interface
+ (setq hackernews-enable-emojis t)
+ ;; Optional: customize display width (default 80)
+ ;; (setq hackernews-display-width 100)
+ )
+```
+
## Usage
Just run M-x`hackernews`RET. This reads the
@@ -141,27 +178,26 @@ by adding the following to your `user-init-file`:
You can list and modify all custom faces and variables by typing
M-x`customize-group`RET`hackernews`RET.
-All `hackernews` buffers are displayed using the `pop-to-buffer`
-function for increased compatibility and customizability in how
-windows and frames are re/used. This function displays buffers in a
-new window by default. The simplest way to instead reuse the current
-window for `hackernews` buffers is to customize one of the user
-options `same-window-buffer-names`, `same-window-regexp` or in Emacs
-24 and subsequent versions, `display-buffer-alist` via
-M-x`customize-group`RET`windows`RET.
+Key customization options:
-If you prefer to roll out your own Elisp, you could add to your
-`user-init-file` something as simple as:
+- `hackernews-ui-style` (default `'classic`): Choose between
+ `'classic` (minimal text-based interface) or `'modern` (enhanced
+ widget-based interface with colors and visual separators).
-```el
-(push '("\\`\\*hackernews .*\\*\\'" display-buffer-same-window)
- display-buffer-alist)
+- `hackernews-display-width` (default 80): Maximum width for
+ displaying content in modern mode. Content is automatically
+ centered when
+ [`visual-fill-column`](https://github.com/joostkremers/visual-fill-column)
+ is installed.
-;; ...or equivalently, starting with Emacs 30:
+- `hackernews-enable-emojis` (default nil): Whether to display emojis
+ in the modern interface. When non-nil, feed navigation buttons
+ (Top, New, Best, Ask, Show) and comment counts will include emoji
+ icons for visual enhancement.
-(push '((category . hackernews) display-buffer-same-window)
- display-buffer-alist)
-```
+In modern mode, buffers are displayed using `display-buffer-same-window`
+for a full-screen experience. Classic mode uses the traditional display
+behavior.
### Troubleshooting
diff --git a/Screenshot.png b/Screenshot.png
index a2901a1..8927a4f 100644
Binary files a/Screenshot.png and b/Screenshot.png differ
diff --git a/hackernews.el b/hackernews.el
index d8fefaf..e2b1f2d 100644
--- a/hackernews.el
+++ b/hackernews.el
@@ -1,11 +1,12 @@
-;;; hackernews.el --- Hacker News Client for Emacs -*- lexical-binding: t -*-
+;;; hackernews.el --- Hacker News Client -*- lexical-binding: t -*-
;; Copyright (C) 2012-2025 The Hackernews.el Authors
;; Author: Lincoln de Sousa
;; Maintainer: Basil L. Contovounesios
;; Keywords: comm hypermedia news
-;; Version: 0.7.1
+;; Version: 0.8.0
+;; Package-Requires: ((emacs "24.3") (visual-fill-column "2.2"))
;; URL: https://github.com/clarete/hackernews.el
;; This program is free software; you can redistribute it and/or modify
@@ -33,6 +34,9 @@
(require 'cus-edit)
(require 'format-spec)
(require 'url)
+(require 'widget)
+(require 'wid-edit)
+(require 'cl-lib)
(eval-when-compile
;; - 24.3 started complaining about unknown `declare' props.
@@ -81,6 +85,44 @@
'((t :inherit default))
"Face used for the score of a story."
:package-version '(hackernews . "0.4.0"))
+
+;; Faces for modern UI style
+
+(defface hackernews-logo
+ '((t :foreground "#ff6600" :height 1.5))
+ "Face used for the \"Y\" in the Hacker News logo.
+Only used when `hackernews-ui-style' is \\='modern."
+ :package-version '(hackernews . "0.8.0"))
+
+(defface hackernews-title-text
+ '((t :foreground "#ff6600" :height 1.3))
+ "Face used for the \"Hacker News\" title text.
+Only used when `hackernews-ui-style' is \\='modern."
+ :package-version '(hackernews . "0.8.0"))
+
+(defface hackernews-separator
+ '((t :foreground "#666666"))
+ "Face used for horizontal separator lines.
+Only used when `hackernews-ui-style' is \\='modern."
+ :package-version '(hackernews . "0.8.0"))
+
+(defface hackernews-score-modern
+ '((t :foreground "#ff6600"))
+ "Face used for story scores in modern UI.
+Only used when `hackernews-ui-style' is \\='modern."
+ :package-version '(hackernews . "0.8.0"))
+
+(defface hackernews-author
+ '((t :foreground "#0066cc"))
+ "Face used for author names.
+Only used when `hackernews-ui-style' is \\='modern."
+ :package-version '(hackernews . "0.8.0"))
+
+(defface hackernews-feed-indicator
+ '((t :foreground "#ff6600"))
+ "Face used for the current feed indicator.
+Only used when `hackernews-ui-style' is \\='modern."
+ :package-version '(hackernews . "0.8.0"))
;;;; User options
@@ -204,6 +246,31 @@ face is changed to `hackernews-link-visited'."
When nil, visited links are not persisted across sessions."
:package-version '(hackernews . "0.5.0")
:type '(choice file (const :tag "None" nil)))
+
+(defcustom hackernews-ui-style 'classic
+ "Display style for the Hacker News interface.
+\\='classic - Minimal text-based interface using format strings.
+ This is the traditional interface, backward compatible
+ with all existing configurations.
+\\='modern - Enhanced interface with interactive widgets, colors,
+ and visual separators for improved readability."
+ :package-version '(hackernews . "0.8.0")
+ :type '(choice (const :tag "Classic minimal interface" classic)
+ (const :tag "Modern enhanced interface" modern)))
+
+(defcustom hackernews-display-width 80
+ "Maximum width for displaying hackernews content.
+Only used when `hackernews-ui-style' is \\='modern."
+ :package-version '(hackernews . "0.8.0")
+ :type 'integer)
+
+(defcustom hackernews-enable-emojis nil
+ "Whether to display emojis in the modern interface.
+When non-nil and `hackernews-ui-style' is \\='modern, feed navigation
+buttons and comment counts will include emoji icons for visual
+enhancement."
+ :package-version '(hackernews . "0.8.0")
+ :type 'boolean)
;;;; Internal definitions
@@ -326,6 +393,95 @@ This is intended as an :annotation-function in
(let ((name (hackernews--feed-name feed)))
(and name (concat " - " name))))
+
+;;;; UI Helpers for modern style
+
+(defconst hackernews--separator-char ?-
+ "Character used for horizontal separators in modern UI.")
+
+(defun hackernews--string-separator ()
+ "Return a string with the separator character."
+ (make-string hackernews-display-width hackernews--separator-char))
+
+(defun hackernews--insert-separator ()
+ "Insert a horizontal separator line using modern UI style."
+ (insert "\n")
+ (insert (propertize (hackernews--string-separator)
+ 'face 'hackernews-separator))
+ (insert "\n\n"))
+
+(defun hackernews--insert-logo ()
+ "Insert the Hacker News logo/header in modern UI style."
+ (insert "\n")
+ (insert (propertize "Y " 'face 'hackernews-logo))
+ (insert (propertize "Hacker News" 'face 'hackernews-title-text))
+ (insert "\n\n"))
+
+
+(defun hackernews--insert-header (feed-name)
+ "Insert the header for FEED-NAME in modern UI style."
+ (hackernews--insert-logo)
+
+ ;; Feed navigation buttons
+ (widget-create 'push-button
+ :notify (lambda (&rest _)
+ (hackernews-top-stories))
+ :help-echo "View top stories"
+ (format " %sTop " (if hackernews-enable-emojis "🔥 " "")))
+
+ (insert " ")
+
+ (widget-create 'push-button
+ :notify (lambda (&rest _)
+ (hackernews-new-stories))
+ :help-echo "View new stories"
+ (format " %sNew " (if hackernews-enable-emojis "🆕 " "")))
+
+ (insert " ")
+
+ (widget-create 'push-button
+ :notify (lambda (&rest _)
+ (hackernews-best-stories))
+ :help-echo "View best stories"
+ (format " %sBest " (if hackernews-enable-emojis "⭐ " "")))
+
+ (insert " ")
+
+ (widget-create 'push-button
+ :notify (lambda (&rest _)
+ (hackernews-ask-stories))
+ :help-echo "View ask stories"
+ (format " %sAsk " (if hackernews-enable-emojis "❓ " "")))
+
+ (insert " ")
+
+ (widget-create 'push-button
+ :notify (lambda (&rest _)
+ (hackernews-show-stories))
+ :help-echo "View show stories"
+ (format " %sShow " (if hackernews-enable-emojis "📺 " "")))
+
+ (insert " ")
+
+ (widget-create 'push-button
+ :notify (lambda (&rest _)
+ (hackernews-reload))
+ :help-echo "Refresh current feed"
+ " ↻ Refresh ")
+
+ ;; Current feed indicator
+ (insert "\n\n")
+ (insert (propertize (format "Showing: %s\n" feed-name)
+ 'face 'hackernews-feed-indicator))
+
+ ;; Keyboard shortcuts help
+ (insert "Keyboard: (n) Next | (p) Previous | (g) Refresh | (q) Quit\n")
+
+ (hackernews--insert-separator))
+
+
+
+
;;;; Motion
(defun hackernews--forward-button (n type)
@@ -346,18 +502,53 @@ This is intended as an :annotation-function in
(when msg (message "%s" msg))))
(defun hackernews-next-item (&optional n)
- "Move to Nth next story link (previous if N is negative).
+ "Move to Nth next story (previous if N is negative).
N defaults to 1."
(declare (modes hackernews-mode))
(interactive "p")
- ;; N is kept optional for backward compatibility
- (hackernews--forward-button (or n 1) 'hackernews-link))
+ (if (eq hackernews-ui-style 'modern)
+ ;; Modern UI: search for separator lines
+ (let ((count (or n 1)))
+ (if (< count 0)
+ (hackernews-previous-item (- count))
+ (dotimes (_ count)
+ (let ((separator-regex (concat "^" (regexp-quote (hackernews--string-separator)) "$")))
+ (if (search-forward-regexp separator-regex nil t)
+ (progn
+ (forward-line 2) ; Skip blank line to reach title
+ (beginning-of-line)
+ (recenter)) ; Center cursor vertically
+ (message "No more stories"))))))
+ ;; Classic UI: use original button navigation
+ (hackernews--forward-button (or n 1) 'hackernews-link)))
(defun hackernews-previous-item (&optional n)
- "Move to Nth previous story link (next if N is negative).
+ "Move to Nth previous story (next if N is negative).
N defaults to 1."
+ (declare (modes hackernews-mode))
(interactive "p")
- (hackernews-next-item (- (or n 1))))
+ (if (eq hackernews-ui-style 'modern)
+ ;; Modern UI: search for separator lines
+ (let ((count (or n 1)))
+ (if (< count 0)
+ (hackernews-next-item (- count))
+ (dotimes (_ count)
+ (let ((separator-regex (concat "^" (regexp-quote (hackernews--string-separator)) "$")))
+ (search-backward-regexp separator-regex nil t)
+ (unless (search-backward-regexp separator-regex nil t)
+ (goto-char (point-min)))
+ (forward-line 2) ; Skip blank line to reach title
+ (beginning-of-line)
+ (recenter))))) ; Center cursor vertically
+ ;; Classic UI: use original button navigation (reverse direction)
+ (hackernews-next-item (- (or n 1)))))
+
+(defun hackernews-first-item ()
+ "Move point to first story link in hackernews buffer."
+ (declare (modes hackernews-mode))
+ (interactive)
+ (goto-char (point-min))
+ (hackernews-next-item))
(defun hackernews-next-comment (&optional n)
"Move to Nth next comments link (previous if N is negative).
@@ -372,13 +563,6 @@ N defaults to 1."
(declare (modes hackernews-mode))
(interactive "p")
(hackernews-next-comment (- (or n 1))))
-
-(defun hackernews-first-item ()
- "Move point to first story link in hackernews buffer."
- (declare (modes hackernews-mode))
- (interactive)
- (goto-char (point-min))
- (hackernews-next-item))
;;;; UI
@@ -477,7 +661,7 @@ If UNVISIT is non-nil, mark BUTTON as unvisited."
(inhibit-read-only t))
(puthash id val table)
(when face
- (button-put button 'font-lock-face (button-type-get type face))))
+ (button-put button 'face (button-type-get type face))))
(funcall fn (button-get button 'shr-url)))
(defun hackernews-browse-url-action (button)
@@ -526,13 +710,13 @@ This is for compatibility with various Emacs versions.
'hackernews-visited-face
'hackernews-face))))
(hackernews--text-button label nil
- 'type type 'font-lock-face face
+ 'type type 'face face
'id id 'help-echo url 'shr-url url)))
(autoload 'xml-substitute-special "xml")
-(defun hackernews--render-item (item)
- "Render Hacker News ITEM in current buffer.
+(defun hackernews--render-item-classic (item)
+ "Render Hacker News ITEM in current buffer using classic format.
The user options `hackernews-score-format',
`hackernews-title-format' and `hackernews-comments-format'
control how each of the ITEM's score, title and comments count
@@ -545,12 +729,13 @@ their respective URLs."
(score (cdr (assq 'score item)))
(item-url (cdr (assq 'url item)))
(descendants (cdr (assq 'descendants item)))
- (comments-url (hackernews--comments-url id)))
+ (comments-url (hackernews--comments-url id))
+ (item-start (point)))
(setq title (xml-substitute-special title))
(insert
(format-spec hackernews-item-format
`((?s . ,(propertize (format hackernews-score-format score)
- 'font-lock-face 'hackernews-score))
+ 'face 'hackernews-score))
(?t . ,(hackernews--button-string
'hackernews-link
(format hackernews-title-format title)
@@ -561,33 +746,152 @@ their respective URLs."
(format hackernews-comments-format
(or descendants 0))
comments-url
- id)))))))
+ id)))))
+ ;; Add text property for unified navigation
+ (put-text-property item-start (point) 'hackernews-item-id id)))
+
+(defun hackernews--render-item-modern (item)
+ "Render Hacker News ITEM in current buffer using modern UI.
+The item is displayed with interactive widgets, styled text with
+faces, and visual separators for improved readability."
+ (let* ((id (cdr (assq 'id item)))
+ (title (cdr (assq 'title item)))
+ (score (cdr (assq 'score item)))
+ (by (cdr (assq 'by item)))
+ (item-url (cdr (assq 'url item)))
+ (descendants (cdr (assq 'descendants item)))
+ (comments-url (hackernews--comments-url id))
+ (item-start (point)))
+ (setq title (xml-substitute-special title))
+
+ ;; Title (make it a clickable widget)
+ (widget-create 'push-button
+ :notify (lambda (&rest _)
+ (browse-url (or item-url comments-url)))
+ :help-echo (if item-url
+ (format "Open: %s" item-url)
+ "No URL")
+ :format "%[%v%]"
+ title)
+
+ (insert "\n")
+
+ ;; Score, comments button, and author info
+ (insert (propertize " " 'face 'default))
+ (insert (propertize (format "↑%d" (or score 0))
+ 'face 'hackernews-score-modern))
+ (insert " | ")
+
+ ;; Comments as clickable button
+ (widget-create 'push-button
+ :notify (lambda (&rest _)
+ (browse-url comments-url))
+ :help-echo (format "View comments: %s" comments-url)
+ :format "%[%v%]"
+ (format "%s%d comment%s"
+ (if hackernews-enable-emojis "💬 " "")
+ (or descendants 0)
+ (if (= (or descendants 0) 1) "" "s")))
+
+ ;; Author
+ (when by
+ (insert " | by ")
+ (insert (propertize by 'face 'hackernews-author)))
+
+ (insert "\n")
+ (hackernews--insert-separator)
+
+ ;; Mark the entire item range with a text property for navigation
+ (put-text-property item-start (point) 'hackernews-item-id id)))
+
+(defun hackernews--render-item (item)
+ "Render Hacker News ITEM in current buffer.
+The rendering style is determined by `hackernews-ui-style'."
+ (pcase hackernews-ui-style
+ ('classic (hackernews--render-item-classic item))
+ ('modern (hackernews--render-item-modern item))
+ (_ (hackernews--render-item-classic item))))
+
+
+
(defun hackernews--display-items ()
"Render items associated with, and pop to, the current buffer."
- (let* ((reg (hackernews--get :register))
- (items (hackernews--get :items))
- (nitem (length items))
+ (let* ((reg (hackernews--get :register))
+ (items (hackernews--get :items))
+ (nitem (length items))
+ (feed (hackernews--get :feed))
+ (feed-name (hackernews--feed-name feed))
+ (is-first-load (= (buffer-size) 0))
+ (is-modern (eq hackernews-ui-style 'modern))
(inhibit-read-only t))
- ;; Render items
+ ;; Insert header for modern UI on first load
+ (when (and is-first-load is-modern)
+ (hackernews--insert-header feed-name))
+
+ ;; Render items (filter out null, deleted, and dead items)
(run-hooks 'hackernews-before-render-hook)
(save-excursion
(goto-char (point-max))
- (mapc #'hackernews--render-item items))
+ (mapc #'hackernews--render-item
+ (cl-remove-if (lambda (item)
+ (or (eq item :null)
+ (cdr (assq 'deleted item))
+ (cdr (assq 'dead item))))
+ items)))
(run-hooks 'hackernews-after-render-hook)
+ ;; Setup widgets for modern UI
+ (when is-modern
+ ;; Create a composed keymap that combines widget functionality with mode bindings
+ ;; Priority: widget-keymap (for widget navigation) > hackernews-mode-map > special-mode-map
+ (use-local-map (make-composed-keymap (list widget-keymap hackernews-mode-map)
+ special-mode-map))
+ (widget-setup))
+
+ ;; Enable visual-fill-column for modern UI
+ (when is-modern
+ (when (and (require 'visual-fill-column nil t)
+ (boundp 'visual-fill-column-width))
+ (setq-local visual-fill-column-width hackernews-display-width)
+ (setq-local visual-fill-column-center-text t)
+ (visual-fill-column-mode 1)))
+
+ ;; Disable line numbers
+ (when (fboundp 'display-line-numbers-mode)
+ (display-line-numbers-mode 0))
+
;; Adjust point
- (unless (or (<= nitem 0) hackernews-preserve-point)
+ (cond
+ ;; First load with modern UI: jump to first widget
+ ((and is-first-load is-modern)
+ (goto-char (point-min))
+ (widget-forward 1))
+ ;; First load with classic UI: jump to first item
+ (is-first-load
+ (hackernews-first-item))
+ ;; Loading more items: move to first new item unless preserving point
+ ((not (or (<= nitem 0) hackernews-preserve-point))
(goto-char (point-max))
- (hackernews-previous-item nitem))
+ (hackernews-previous-item nitem)))
;; Persist new offset
(setcar reg (+ (car reg) nitem)))
- (pop-to-buffer (current-buffer) '(() (category . hackernews)))
+ ;; Enable read-only mode after all modifications
+ (when (eq hackernews-ui-style 'modern)
+ (read-only-mode 1))
+
+ ;; Display buffer with appropriate action based on UI style
+ (if (eq hackernews-ui-style 'modern)
+ ;; Modern UI: occupy full window
+ (pop-to-buffer (current-buffer) '((display-buffer-same-window)))
+ ;; Classic UI: default behavior (partial window)
+ (pop-to-buffer (current-buffer) '(() (category . hackernews))))
(run-hooks 'hackernews-finalize-hook))
+
;; TODO: Derive from `tabulated-list-mode'?
(define-derived-mode hackernews-mode special-mode "HN"
"Mode for browsing Hacker News.
@@ -626,6 +930,7 @@ Official major mode key bindings:
(setq truncate-lines t)
(buffer-disable-undo))
+
(defun hackernews--ensure-major-mode ()
"Barf if current buffer is not derived from `hackernews-mode'."
(unless (derived-mode-p #'hackernews-mode)
@@ -698,8 +1003,12 @@ rendered at the end of the hackernews buffer."
(with-current-buffer (get-buffer-create (format "*hackernews %s*" name))
(unless append
+ ;; Clear buffer
(let ((inhibit-read-only t))
(erase-buffer))
+ (remove-overlays)
+
+ ;; Activate hackernews-mode (which calls kill-all-local-variables)
(hackernews-mode))
(hackernews--put :feed feed)