aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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)