aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-28 03:20:12 -0400
committerCraig Jennings <c@cjennings.net>2026-06-28 03:20:12 -0400
commite8ea03ecc8c1a2499046b34f785bca4f871531df (patch)
tree33232649069265c4541545fc114a6410de369f3c
parentee8fdeb692d666c12ce068a2b1ee90e9451ac892 (diff)
downloademacs-wttrin-e8ea03ecc8c1a2499046b34f785bca4f871531df.tar.gz
emacs-wttrin-e8ea03ecc8c1a2499046b34f785bca4f871531df.zip
feat: fit and center the weather buffer in its window
Centering and auto-fit share one resize hook (wttrin--update-layout on window-configuration-change-hook). Centering is always on. The block centers via the window's left margin, measured in pixels from the buffer font so it stays exact under a font-height remap. It centers against the available width including the current margin, so the margin shrinking the body can't make the value oscillate. Auto-fit is opt-in (wttrin-auto-fit-font, off by default). When on, the font is sized so the whole buffer fits top to bottom (down to the row past the footer's last menu item) without the widest line truncating, clamped to wttrin-font-height-min and -max. The pixel-exact measurement needs window-font-width and window-font-height, so the minimum is now Emacs 25.1.
-rw-r--r--Eask2
-rw-r--r--README.org2
-rw-r--r--tests/test-wttrin--center.el109
-rw-r--r--tests/test-wttrin--fit-font-height.el64
-rw-r--r--wttrin-geolocation.el2
-rw-r--r--wttrin.el154
6 files changed, 322 insertions, 11 deletions
diff --git a/Eask b/Eask
index eae1b17..1f2af16 100644
--- a/Eask
+++ b/Eask
@@ -15,7 +15,7 @@
(source "nongnu")
(source "melpa")
-(depends-on "emacs" "24.4")
+(depends-on "emacs" "25.1")
(depends-on "xterm-color")
(development
diff --git a/README.org b/README.org
index 0043fe3..8701468 100644
--- a/README.org
+++ b/README.org
@@ -8,7 +8,7 @@
[[https://github.com/cjennings/emacs-wttrin/actions/workflows/ci.yml][file:https://github.com/cjennings/emacs-wttrin/actions/workflows/ci.yml/badge.svg]]
[[https://coveralls.io/github/cjennings/emacs-wttrin?branch=main][file:https://coveralls.io/repos/github/cjennings/emacs-wttrin/badge.svg?branch=main]]
-Wttrin is a simple Emacs frontend for Igor Chubin's [[https://github.com/chubin/wttr.in][wttr.in]] weather service. It works with Emacs 24.4+ and needs [[https://github.com/atomontage/xterm-color][xterm-color]] for the colored ASCII art (MELPA handles that dependency for you).
+Wttrin is a simple Emacs frontend for Igor Chubin's [[https://github.com/chubin/wttr.in][wttr.in]] weather service. It works with Emacs 25.1+ and needs [[https://github.com/atomontage/xterm-color][xterm-color]] for the colored ASCII art (MELPA handles that dependency for you).
[[assets/wttrin.png]]
diff --git a/tests/test-wttrin--center.el b/tests/test-wttrin--center.el
new file mode 100644
index 0000000..2da639c
--- /dev/null
+++ b/tests/test-wttrin--center.el
@@ -0,0 +1,109 @@
+;;; test-wttrin--center.el --- Tests for weather-buffer centering -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024-2026 Craig Jennings
+
+;;; Commentary:
+;; Unit tests for the block-centering helpers: wttrin--block-width,
+;; wttrin--center-margin, and the wttrin--center-buffer applier.
+
+;;; Code:
+
+(require 'ert)
+(require 'wttrin)
+(require 'testutil-wttrin)
+
+;;; wttrin--block-width — Normal
+
+(ert-deftest test-wttrin--block-width-normal-returns-widest-line ()
+ "Normal: returns the display width of the widest line."
+ (should (= 4 (wttrin--block-width "ab\nabcd\nabc"))))
+
+(ert-deftest test-wttrin--block-width-normal-counts-wide-chars ()
+ "Normal: wide (double-width) characters count as two columns."
+ ;; "你好" is two double-width chars = 4 columns, wider than the "a" line.
+ (should (= 4 (wttrin--block-width "a\n你好"))))
+
+(ert-deftest test-wttrin--block-width-normal-ignores-text-properties ()
+ "Normal: text properties do not affect the measured width."
+ (should (= 4 (wttrin--block-width (propertize "abcd" 'face 'bold)))))
+
+;;; wttrin--block-width — Boundary
+
+(ert-deftest test-wttrin--block-width-boundary-single-line ()
+ "Boundary: a single line returns its own width."
+ (should (= 5 (wttrin--block-width "hello"))))
+
+(ert-deftest test-wttrin--block-width-boundary-empty-string-is-zero ()
+ "Boundary: the empty string has width 0."
+ (should (= 0 (wttrin--block-width ""))))
+
+(ert-deftest test-wttrin--block-width-boundary-trailing-newline ()
+ "Boundary: a trailing newline's empty final line does not inflate the width."
+ (should (= 3 (wttrin--block-width "abc\n"))))
+
+;;; wttrin--block-width — Error
+
+(ert-deftest test-wttrin--block-width-error-nil-is-zero ()
+ "Error: nil input returns 0 rather than signaling."
+ (should (= 0 (wttrin--block-width nil))))
+
+;;; wttrin--center-margin — Normal
+
+(ert-deftest test-wttrin--center-margin-normal-centers-block ()
+ "Normal: a block narrower than the window gets half the slack as margin."
+ (should (= 30 (wttrin--center-margin 20 80))))
+
+;;; wttrin--center-margin — Boundary
+
+(ert-deftest test-wttrin--center-margin-boundary-equal-widths-zero ()
+ "Boundary: a block exactly as wide as the window gets no margin."
+ (should (= 0 (wttrin--center-margin 40 40))))
+
+(ert-deftest test-wttrin--center-margin-boundary-block-wider-than-window-zero ()
+ "Boundary: a block wider than the window gets no margin (never negative)."
+ (should (= 0 (wttrin--center-margin 50 40))))
+
+(ert-deftest test-wttrin--center-margin-boundary-odd-slack-floors ()
+ "Boundary: an odd amount of slack floors to a whole column."
+ (should (= 30 (wttrin--center-margin 20 81))))
+
+(ert-deftest test-wttrin--center-margin-boundary-one-column-slack-zero ()
+ "Boundary: one column of slack floors to 0."
+ (should (= 0 (wttrin--center-margin 20 21))))
+
+;;; wttrin--center-margin — Error
+
+(ert-deftest test-wttrin--center-margin-error-non-integer-is-zero ()
+ "Error: non-integer inputs return 0 rather than signaling."
+ (should (= 0 (wttrin--center-margin nil 80)))
+ (should (= 0 (wttrin--center-margin 20 nil))))
+
+;;; wttrin--center-buffer — applier
+
+(ert-deftest test-wttrin--center-buffer-normal-is-idempotent ()
+ "Normal: centering a displayed buffer is stable — a second call does not move
+the margin (the margin-shrinks-the-body feedback is handled)."
+ (let ((buf (get-buffer-create "*wttrin-center-test*")))
+ (unwind-protect
+ (save-window-excursion
+ (with-current-buffer buf
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (insert "hi\nthere")))
+ (set-window-buffer (selected-window) buf)
+ (with-current-buffer buf
+ (wttrin--center-buffer)
+ (let* ((win (get-buffer-window buf))
+ (first (or (car (window-margins win)) 0)))
+ (wttrin--center-buffer)
+ (should (= first (or (car (window-margins win)) 0))))))
+ (kill-buffer buf))))
+
+(ert-deftest test-wttrin--center-buffer-boundary-no-window-is-noop ()
+ "Boundary: with no window for the buffer, centering is a no-op, no error."
+ (with-temp-buffer
+ (insert "content")
+ (should-not (wttrin--center-buffer))))
+
+(provide 'test-wttrin--center)
+;;; test-wttrin--center.el ends here
diff --git a/tests/test-wttrin--fit-font-height.el b/tests/test-wttrin--fit-font-height.el
new file mode 100644
index 0000000..2952fc6
--- /dev/null
+++ b/tests/test-wttrin--fit-font-height.el
@@ -0,0 +1,64 @@
+;;; test-wttrin--fit-font-height.el --- Tests for font auto-fit math -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024-2026 Craig Jennings
+
+;;; Commentary:
+;; Unit tests for wttrin--fit-font-height, the pure helper that scales the
+;; weather font so the fixed-width block fills the window, clamped to a floor
+;; and cap.
+
+;;; Code:
+
+(require 'ert)
+(require 'wttrin)
+(require 'testutil-wttrin)
+
+;;; Normal
+
+(ert-deftest test-wttrin--fit-font-height-normal-exact-fit-keeps-height ()
+ "Normal: a block that already fills the width keeps the current height.
+50 cols x 12 px = 600 px = avail, ratio 1.0, so 130 stays 130."
+ (should (= 130 (wttrin--fit-font-height 50 600 12 130 100 200))))
+
+(ert-deftest test-wttrin--fit-font-height-normal-wider-window-grows-height ()
+ "Normal: a wider window grows the height proportionally.
+avail 900 / block-px 600 = 1.5, so 130 -> 195, within [100,200]."
+ (should (= 195 (wttrin--fit-font-height 50 900 12 130 100 200))))
+
+(ert-deftest test-wttrin--fit-font-height-normal-narrower-window-shrinks-height ()
+ "Normal: a narrower window shrinks the height proportionally.
+avail 480 / block-px 600 = 0.8, so 130 -> 104, within [100,200]."
+ (should (= 104 (wttrin--fit-font-height 50 480 12 130 100 200))))
+
+;;; Boundary
+
+(ert-deftest test-wttrin--fit-font-height-boundary-clamps-to-cap ()
+ "Boundary: a very wide window clamps the height to the cap."
+ ;; avail 1800 / 600 = 3 -> 390 -> capped at 200.
+ (should (= 200 (wttrin--fit-font-height 50 1800 12 130 100 200))))
+
+(ert-deftest test-wttrin--fit-font-height-boundary-clamps-to-floor ()
+ "Boundary: a very narrow window clamps the height to the floor."
+ ;; avail 120 / 600 = 0.2 -> 26 -> floored at 100.
+ (should (= 100 (wttrin--fit-font-height 50 120 12 130 100 200))))
+
+(ert-deftest test-wttrin--fit-font-height-boundary-floor-equals-cap ()
+ "Boundary: when floor equals cap, that value is forced."
+ (should (= 150 (wttrin--fit-font-height 50 900 12 130 150 150))))
+
+;;; Error
+
+(ert-deftest test-wttrin--fit-font-height-error-zero-block-returns-clamped-current ()
+ "Error: a zero block width returns the current height, clamped."
+ (should (= 130 (wttrin--fit-font-height 0 900 12 130 100 200))))
+
+(ert-deftest test-wttrin--fit-font-height-error-zero-char-px-returns-clamped-current ()
+ "Error: a zero per-character width returns the current height, clamped."
+ (should (= 130 (wttrin--fit-font-height 50 900 0 130 100 200))))
+
+(ert-deftest test-wttrin--fit-font-height-error-nil-avail-returns-clamped-current ()
+ "Error: a nil available width returns the current height, clamped."
+ (should (= 130 (wttrin--fit-font-height 50 nil 12 130 100 200))))
+
+(provide 'test-wttrin--fit-font-height)
+;;; test-wttrin--fit-font-height.el ends here
diff --git a/wttrin-geolocation.el b/wttrin-geolocation.el
index 9f00b7f..7cb8d43 100644
--- a/wttrin-geolocation.el
+++ b/wttrin-geolocation.el
@@ -3,7 +3,7 @@
;; Copyright (C) 2026 Craig Jennings
;; Maintainer: Craig Jennings <c@cjennings.net>
;; Version: 0.3.1
-;; Package-Requires: ((emacs "24.4"))
+;; Package-Requires: ((emacs "25.1"))
;; Keywords: weather, wttrin
;; SPDX-License-Identifier: GPL-3.0-or-later
diff --git a/wttrin.el b/wttrin.el
index 82516b9..c5cff9e 100644
--- a/wttrin.el
+++ b/wttrin.el
@@ -6,7 +6,7 @@
;; Original Authors: Carl X. Su <bcbcarl@gmail.com>
;; ono hiroko (kuanyui) <azazabc123@gmail.com>
;; Version: 0.3.2
-;; Package-Requires: ((emacs "24.4") (xterm-color "1.0"))
+;; Package-Requires: ((emacs "25.1") (xterm-color "1.0"))
;; Keywords: weather, wttrin, games
;; URL: https://github.com/cjennings/emacs-wttrin
@@ -80,7 +80,7 @@ visible on monochrome glyphs."
'((t :inherit bold))
"Face for the bracketed key chords in the weather buffer footer.
`help-key-binding' would be the natural parent, but it only exists in
-Emacs 28+, and wttrin supports 24.4, so the default inherits `bold'."
+Emacs 28+, and wttrin supports 25.1, so the default inherits `bold'."
:group 'wttrin)
(defface wttrin-instructions-header
@@ -96,7 +96,29 @@ two key-hint columns."
:type 'string)
(defcustom wttrin-font-height 130
- "Preferred font height for weather display."
+ "Preferred font height for weather display.
+Used as the fixed height when `wttrin-auto-fit-font' is nil."
+ :group 'wttrin
+ :type 'integer)
+
+(defcustom wttrin-auto-fit-font nil
+ "When non-nil, scale the weather font so the whole buffer fits the window.
+The height is sized so the buffer shows top to bottom (one row past the
+footer) without the widest line truncating, then clamped to
+[`wttrin-font-height-min', `wttrin-font-height-max'] and recomputed on resize.
+When nil, `wttrin-font-height' is a fixed height (the default behavior)."
+ :group 'wttrin
+ :type 'boolean)
+
+(defcustom wttrin-font-height-min 100
+ "Minimum font height (1/10 pt) when `wttrin-auto-fit-font' is enabled.
+A floor so the auto-fitted font never becomes unreadably small."
+ :group 'wttrin
+ :type 'integer)
+
+(defcustom wttrin-font-height-max 200
+ "Maximum font height (1/10 pt) when `wttrin-auto-fit-font' is enabled.
+A cap so the auto-fitted font never becomes absurdly large."
:group 'wttrin
:type 'integer)
@@ -898,6 +920,12 @@ When NAME is the favorite, it is left as a literal query with a warning."
map)
"Keymap for wttrin-mode.")
+(defvar-local wttrin--face-remap-cookie nil
+ "Cookie for the buffer's default-face remap, so its height can be updated.")
+
+(defvar-local wttrin--current-font-height nil
+ "The font height (1/10 pt) currently applied via `wttrin--face-remap-cookie'.")
+
(define-derived-mode wttrin-mode special-mode "Wttrin"
"Major mode for displaying wttr.in weather information.
@@ -907,10 +935,15 @@ Weather data is displayed in a read-only buffer with the following keybindings:
(buffer-disable-undo)
;; ASCII art breaks if lines wrap at the window edge
(setq truncate-lines t)
- ;; Use face-remap instead of buffer-face-mode to preserve xterm-color faces
- (face-remap-add-relative 'default
- :family wttrin-font-name
- :height wttrin-font-height))
+ ;; Use face-remap instead of buffer-face-mode to preserve xterm-color faces.
+ ;; Keep the cookie and applied height so auto-fit can update it in place.
+ (setq wttrin--current-font-height wttrin-font-height)
+ (setq wttrin--face-remap-cookie
+ (face-remap-add-relative 'default
+ :family wttrin-font-name
+ :height wttrin-font-height))
+ ;; Re-fit (when enabled) and re-center the block on resize or split
+ (add-hook 'window-configuration-change-hook #'wttrin--update-layout nil t))
(defun wttrin--save-debug-data (location-name raw-string)
"Save RAW-STRING to a timestamped debug file for LOCATION-NAME.
@@ -1014,6 +1047,109 @@ even though the weather was fetched by raw coordinates."
(when (and (stringp address) (> (length address) 0))
(propertize (concat "Location: " address) 'face 'wttrin-staleness-header)))
+(defun wttrin--block-width (text)
+ "Return the widest line's display width in TEXT.
+Width is measured with `string-width' (display columns, ignoring text
+properties). Returns 0 for nil or empty TEXT."
+ (if (or (null text) (string= text ""))
+ 0
+ (let ((width 0))
+ (dolist (line (split-string text "\n"))
+ (setq width (max width (string-width line))))
+ width)))
+
+(defun wttrin--center-margin (block-width window-width)
+ "Return the left margin that centers BLOCK-WIDTH within WINDOW-WIDTH.
+Both are column counts. The result is floored to a whole column and is never
+negative, so a block at least as wide as the window yields 0. Non-integer
+inputs yield 0."
+ (if (and (integerp block-width) (integerp window-width)
+ (> window-width block-width))
+ (/ (- window-width block-width) 2)
+ 0))
+
+(defun wttrin--fit-font-height (block-cols avail-px char-px cur-height floor cap)
+ "Return a font height (1/10 pt) so BLOCK-COLS chars span AVAIL-PX pixels.
+CHAR-PX is the per-character pixel width at CUR-HEIGHT. The height scales by
+the ratio of AVAIL-PX to the block's current pixel width, then clamps to
+\[FLOOR, CAP]. When an input is unusable (non-positive BLOCK-COLS, CHAR-PX,
+AVAIL-PX, or CUR-HEIGHT), the current height is returned, still clamped."
+ (let ((target
+ (if (and (integerp block-cols) (> block-cols 0)
+ (numberp avail-px) (> avail-px 0)
+ (numberp char-px) (> char-px 0)
+ (integerp cur-height) (> cur-height 0))
+ (round (* cur-height (/ (float avail-px) (* block-cols char-px))))
+ cur-height)))
+ (max floor (min cap target))))
+
+(defun wttrin--center-buffer (&optional window)
+ "Center the weather block in WINDOW via the window's left margin.
+Measures the block's pixel width from the buffer font (`window-font-width') and
+divides the leftover by the frame's canonical character width, so the result is
+exact regardless of any font-height remap. WINDOW defaults to the buffer's
+window. Centers against the stable available width (body + current margin) and
+skips a redundant update, so it is safe on `window-configuration-change-hook'.
+No-op when the buffer has no window."
+ (let ((win (or window (get-buffer-window (current-buffer)))))
+ (when win
+ (let* ((fcw (frame-char-width (window-frame win)))
+ (char-px (or (window-font-width win) fcw))
+ (block-px (* (wttrin--block-width (buffer-string)) char-px))
+ (cur-cols (or (car (window-margins win)) 0))
+ ;; body excludes the margin, so add it back for a stable total
+ (avail-px (+ (window-body-width win t) (* cur-cols fcw)))
+ (margin-px (wttrin--center-margin block-px avail-px))
+ (margin-cols (if (> fcw 0) (floor margin-px fcw) 0)))
+ (unless (= margin-cols cur-cols)
+ (setq left-margin-width margin-cols)
+ (set-window-margins win margin-cols))))))
+
+(defun wttrin--apply-fit-font (win)
+ "Resize the font so the whole weather buffer fits WIN, clamped to floor/cap.
+Take the smaller of two fits so the buffer stays fully visible: its line
+count (plus one, so the row past the footer shows) against the window
+height, and its widest line against the window width (so nothing truncates
+under `truncate-lines'). Height binds on a wide window. Update the
+face-remap cookie in place and record the applied height. No-op when the
+height is unchanged."
+ (let* ((fcw (frame-char-width (window-frame win)))
+ (fch (frame-char-height (window-frame win)))
+ (char-px (or (window-font-width win) fcw))
+ (line-px (or (window-font-height win) fch))
+ (cur-cols (or (car (window-margins win)) 0))
+ (avail-w (+ (window-body-width win t) (* cur-cols fcw)))
+ (avail-h (window-body-height win t))
+ (cur-height (or wttrin--current-font-height wttrin-font-height))
+ ;; +1 so the line just past the footer's last item is visible
+ (lines (1+ (count-lines (point-min) (point-max))))
+ (block-cols (wttrin--block-width (buffer-string)))
+ (height-fit (wttrin--fit-font-height lines avail-h line-px cur-height
+ wttrin-font-height-min
+ wttrin-font-height-max))
+ (width-fit (wttrin--fit-font-height block-cols avail-w char-px cur-height
+ wttrin-font-height-min
+ wttrin-font-height-max))
+ (height (min height-fit width-fit)))
+ (unless (eql height wttrin--current-font-height)
+ (when wttrin--face-remap-cookie
+ (face-remap-remove-relative wttrin--face-remap-cookie))
+ (setq wttrin--face-remap-cookie
+ (face-remap-add-relative 'default
+ :family wttrin-font-name
+ :height height))
+ (setq wttrin--current-font-height height))))
+
+(defun wttrin--update-layout (&rest _)
+ "Auto-fit the font (when enabled), then center the block in the buffer's window.
+Accepts and ignores hook arguments, so it is safe on
+`window-configuration-change-hook'. No-op when the buffer has no window."
+ (let ((win (get-buffer-window (current-buffer))))
+ (when win
+ (when wttrin-auto-fit-font
+ (wttrin--apply-fit-font win))
+ (wttrin--center-buffer win))))
+
(defun wttrin--display-weather (query raw-string &optional error-msg display address)
"Display weather RAW-STRING for QUERY in the weather buffer.
QUERY is the location wttr.in was fetched with — the cache key and the buffer's
@@ -1063,7 +1199,9 @@ coordinates but can name the place)."
;; buffer is taller than the window a reused window can keep an old
;; mid-buffer window-start, hiding the weather above the fold.
(let ((win (get-buffer-window buffer)))
- (when win (set-window-start win (point-min))))
+ (when win
+ (set-window-start win (point-min))
+ (wttrin--update-layout)))
(setq-local wttrin--current-location query)
(setq-local wttrin--current-display display)