;;; spatial-navigate.el --- Directional navigation between blank-space blocks -*- lexical-binding: t -*-

;; SPDX-License-Identifier: GPL-2.0-or-later
;; Copyright (C) 2020  Campbell Barton

;; Author: Campbell Barton <ideasman42@gmail.com>

;; URL: https://codeberg.org/ideasman42/emacs-spatial-navigate
;; Package-Version: 20260705.40
;; Package-Revision: 358337ddaef4
;; Package-Requires: ((emacs "29.1"))

;;; Commentary:

;; Support jumping horizontally and vertically
;; across blocks of blank-space or non-blank-space.

;;; Usage:

;; ;; This shows how Alt-arrow keys can be used for directional navigation.
;; (global-set-key (kbd "<M-up>") 'spatial-navigate-backward-vertical-bar)
;; (global-set-key (kbd "<M-down>") 'spatial-navigate-forward-vertical-bar)
;; (global-set-key (kbd "<M-left>") 'spatial-navigate-backward-horizontal-bar)
;; (global-set-key (kbd "<M-right>") 'spatial-navigate-forward-horizontal-bar)

;; ;; If you use evil-mode, the 'box' navigation functions make sense in normal mode,
;; ;; while the 'bar' functions make the most sense in insert mode.

;; (define-key evil-normal-state-map (kbd "M-k") 'spatial-navigate-backward-vertical-box)
;; (define-key evil-normal-state-map (kbd "M-j") 'spatial-navigate-forward-vertical-box)
;; (define-key evil-normal-state-map (kbd "M-h") 'spatial-navigate-backward-horizontal-box)
;; (define-key evil-normal-state-map (kbd "M-l") 'spatial-navigate-forward-horizontal-box)
;; (define-key evil-insert-state-map (kbd "M-k") 'spatial-navigate-backward-vertical-bar)
;; (define-key evil-insert-state-map (kbd "M-j") 'spatial-navigate-forward-vertical-bar)
;; (define-key evil-insert-state-map (kbd "M-h") 'spatial-navigate-backward-horizontal-bar)
;; (define-key evil-insert-state-map (kbd "M-l") 'spatial-navigate-forward-horizontal-bar)

;;; Code:

;; ---------------------------------------------------------------------------
;; Compatibility

(eval-when-compile
  (when (version< emacs-version "31.1")
    (defmacro incf (place &optional delta)
      "Increment PLACE by DELTA or 1."
      (declare (debug (gv-place &optional form)))
      (gv-letplace (getter setter) place
        (funcall setter `(+ ,getter ,(or delta 1)))))
    (defmacro decf (place &optional delta)
      "Decrement PLACE by DELTA or 1."
      (declare (debug (gv-place &optional form)))
      (gv-letplace (getter setter) place
        (funcall setter `(- ,getter ,(or delta 1)))))))


;; ---------------------------------------------------------------------------
;; Custom Variables

(defcustom spatial-navigate-wrap-horizontal-motion nil
  "Skip blank lines when horizontal motion reaches line bounds."
  :group 'spatial-navigate
  :type 'boolean)

(defcustom spatial-navigate-region-line-vertical-motion nil
  "Select whole lines when extending an active region vertically.

When non-nil and a non-rectangular region is active with point at the
beginning of a line, vertical motion lands on the boundaries between filled
blocks and blank-space so that whole lines end up selected, whichever side
of the mark point is on:

- Motion downward over filled lines moves point one line further, placing
  the crossed block wholly on one side of the region bound.
- Motion upward over blank-space stops one line short of the filled line it
  would otherwise land on, the same rule mirrored.
- A lone filled line reached with no blank gap in between is crossed rather
  than landed on, and a motion that would collapse the region onto the mark
  continues past it instead."
  :group 'spatial-navigate
  :type 'boolean)


;; ---------------------------------------------------------------------------
;; Evil Mode Support

(defun spatial-navigate--evil-visual-mode-workaround (state)
  "Workaround for evil-visual line mode, STATE must be \\='pre or \\='post."
  (declare (important-return-value nil))
  (when (and (fboundp 'evil-visual-state-p)
             (funcall #'evil-visual-state-p)
             (fboundp 'evil-visual-type)
             (eq (funcall #'evil-visual-type) 'line)
             (boundp 'evil-visual-point))
    (let ((mark (symbol-value 'evil-visual-point)))
      (when (markerp mark)
        (cond
         ;; Without this, `point' will be at the beginning of the line
         ;; (from the pre command hook).
         ((eq state 'pre)
          (goto-char mark))
         ;; Without this, the `point' won't move.
         ;; See: https://github.com/emacs-evil/evil/issues/1708
         ((eq state 'post)
          (set-marker mark (point)))
         (t
          (error "Invalid input, internal error")))))))


;; ---------------------------------------------------------------------------
;; Private Functions

(defun spatial-navigate--char-filled-p (pos beg end default)
  "Return non-nil if POS contains a non-blank-space character.
BEG and END are line bounds.  DEFAULT is returned if POS is out of range."
  (declare (important-return-value t))
  (cond
   ((and (>= pos beg) (< pos end))
    (let ((ch (char-after pos)))
      (not (memq ch '(?\s ?\t)))))
   (t
    default)))

(defun spatial-navigate--chars-filled-at-point (pos-bol pos-eol)
  "Return list (PREV CURR NEXT) filled status for chars around point.
POS-BOL and POS-EOL are line bounds.  Out-of-range PREV/NEXT inherit CURR."
  (declare (important-return-value t))
  (let ((is-fill-curr (spatial-navigate--char-filled-p (point) pos-bol pos-eol nil)))
    (list
     (spatial-navigate--char-filled-p (1- (point)) pos-bol pos-eol is-fill-curr)
     is-fill-curr
     (spatial-navigate--char-filled-p (1+ (point)) pos-bol pos-eol is-fill-curr))))

(defun spatial-navigate--vertical-line-empty-p (col col-detect is-block-cursor)
  "Return non-nil if the line at point counts as empty for vertical motion.
COL is the column reached on this line via `move-to-column'.
COL-DETECT is the column blank detection runs against, which
`spatial-navigate--vertical-calc' shifts from the tracked column by its
COL-OFFSET (so they differ on the rectangle-edge path).
IS-BLOCK-CURSOR see `spatial-navigate--vertical-calc'."
  (declare (important-return-value t))
  (or (< col col-detect)
      ;; End of the line is also considered empty.
      (and (or (zerop col-detect) is-block-cursor) (eolp))

      ;; Avoid delimiting on spaces between words by checking
      ;; if we're surrounded by spaces before and after.
      (pcase-let ((`(,is-fill-prev ,is-fill-curr ,is-fill-next)
                   (spatial-navigate--chars-filled-at-point (pos-bol) (pos-eol))))
        (cond
         ;; Check three characters: current char, before, and after.
         ;; Empty if current is blank and at least one neighbor is also blank.
         (is-block-cursor
          (null (or is-fill-curr (and is-fill-prev is-fill-next))))

         ;; Check only two characters: current and previous.
         (t
          (null (or is-fill-curr is-fill-prev)))))))

(defun spatial-navigate--resolve-block-cursor (is-block-cursor)
  "Return IS-BLOCK-CURSOR adjusted for rectangle selection.
When `rectangle-mark-mode' is active, use box behavior (return t),
unless the rectangle has zero width, then use bar behavior (return nil)."
  (declare (important-return-value t))
  (cond
   ((bound-and-true-p rectangle-mark-mode)
    (not
     (= (current-column)
        (save-excursion
          (goto-char (mark))
          (current-column)))))
   (t
    is-block-cursor)))

(defun spatial-navigate--vertical-calc (dir is-block-cursor &optional col-offset)
  "Calculate the next/previous vertical position based on DIR (-1 or 1).

Argument IS-BLOCK-CURSOR causes the cursor to detect blank-space using
characters before and after the current cursor.  This behaves in a way that
is logical for a block cursor.

COL-OFFSET (default 0) shifts the column used for blank detection relative
to the column being tracked; rectangle selection passes -1 when point is
one column past the rectangle's right edge, see
`spatial-navigate--vertical-calc-rect-next'.  Result positions always use
the tracked column."
  (declare (important-return-value t))
  (let* ((col-init (current-column))
         (col-detect (+ col-init (or col-offset 0)))
         (result nil)
         (result-fallback (cons 0 (point)))
         (lines 0)
         (lines-prev 0)
         (is-first t)
         (is-empty-state nil)
         (pos-prev (point)))
    (while (null result)

      ;; Step to next line and move to column.
      (forward-line dir)
      (incf lines dir)

      (let* ((col (move-to-column col-detect))
             (is-empty (spatial-navigate--vertical-line-empty-p col col-detect is-block-cursor))
             ;; Restore to the tracked column for result positions.
             (col-result
              (cond
               ((eq col-detect col-init)
                col)
               (t
                (move-to-column col-init)))))

        ;; Keep searching for whatever we encounter first.
        (when is-first
          (setq is-empty-state is-empty)
          (setq is-first nil))

        ;; Either set the result, or continue looping.
        (cond
         ((null (eq is-empty is-empty-state))
          ;; We have hit a different state, stop!
          (setq result
                (cond
                 (is-empty-state
                  (cons lines (point)))
                 (t
                  (cons lines-prev pos-prev)))))
         ((eq pos-prev (point))
          ;; Point didn't move, we're at buffer boundary.
          (setq result result-fallback))
         (t ; Keep looping.
          ;; If we reach the beginning or end of the document,
          ;; use the last time we reached a valid result column.
          (when (eq col-result col-init)
            (setq result-fallback (cons lines (point))))
          (setq lines-prev lines)
          (setq pos-prev (point))))))
    result))


(defun spatial-navigate--vertical-calc-rect-prev (dir)
  "Calculate the vertical position for rectangle selection when point < mark column.
DIR is -1 or 1.  Point is at the left edge of the rectangle,
so no column offset is needed."
  (declare (important-return-value t))
  (spatial-navigate--vertical-calc dir t))

(defun spatial-navigate--vertical-calc-rect-next (dir)
  "Calculate the vertical position for rectangle selection when point > mark column.
DIR is -1 or 1.  Point is one column past the right edge of the rectangle,
so blank detection uses the column before point: the char at the tracked
column is outside the rectangle and must not influence the blank check."
  (declare (important-return-value t))
  (spatial-navigate--vertical-calc dir t -1))


(defun spatial-navigate--horizontal-calc (dir is-block-cursor)
  "Calculate the next/previous horizontal position based on DIR (-1 or 1).

Argument IS-BLOCK-CURSOR causes the cursor to detect blank-space using
characters before and after the current cursor.  This behaves in a way that
is logical for a block cursor."
  (declare (important-return-value t))
  (spatial-navigate--evil-visual-mode-workaround 'pre)

  (let ((result nil)
        (is-first t)
        (is-empty-state nil)
        (pos-prev (point))

        (pos-eol (pos-eol))
        (pos-bol (pos-bol)))

    ;; Initial step is needed here because forward-char requires explicit call,
    ;; whereas forward-line in vertical-calc implicitly moves to the next line.
    (when (cond
           ((< dir 0)
            (> pos-prev pos-bol))
           (t
            (<= pos-prev pos-eol)))
      (forward-char dir))

    (while (null result)
      (let ((is-empty
             ;; Avoid delimiting on spaces between words by checking
             ;; if we're surrounded by spaces before and after.
             (pcase-let ((`(,is-fill-prev ,is-fill-curr ,is-fill-next)
                          (spatial-navigate--chars-filled-at-point pos-bol pos-eol)))
               (null (or is-fill-curr (and is-fill-prev is-fill-next))))))

        ;; Keep searching for whatever we encounter first.
        (when is-first
          (setq is-empty-state is-empty)
          (setq is-first nil))

        ;; Either set the result, or continue looping.
        (cond
         ((null (eq is-empty is-empty-state))
          ;; We have hit a different state, stop!
          (setq result
                (cond
                 (is-block-cursor
                  (cond
                   (is-empty-state
                    (point))
                   (t
                    pos-prev)))
                 (t
                  (cond
                   ((> dir 0)
                    (point))
                   (t
                    pos-prev))))))
         ((eq pos-prev (point))
          ;; Point didn't move, we're at buffer boundary.
          (setq result (point)))
         ;; If we get out of range, use last usable point.
         ((cond
           ((< dir 0)
            (< (point) pos-bol))
           (t
            (>= (point) pos-eol)))
          ;; Point moved past line bounds, use previous valid position.
          (setq result pos-prev))
         (t ; Keep looping.
          ;; If we reach the beginning or end of the line, we may need to use this.
          (setq pos-prev (point))
          (when (cond
                 ((< dir 0)
                  (> pos-prev pos-bol))
                 (t
                  (<= pos-prev pos-eol)))
            ;; Step to next character.
            (forward-char dir))))))
    result))


;; ---------------------------------------------------------------------------
;; Region Line Selection

(defun spatial-navigate--region-line-filled-p ()
  "Return non-nil when the line at point starts with a filled character.
Point must be at the beginning of a line (bolp).  At column zero every
blank-detection rule in this file (bar and block cursor alike, see
`spatial-navigate--vertical-line-empty-p') reduces to this single character
check, since out-of-range neighbors inherit the current character's state.
The line bounds are not consulted: `eolp' already establishes the current
character exists on this line, avoiding a `pos-eol' scan of what may be a
long line, several times per keypress."
  (declare (important-return-value t))
  (and (null (eolp)) (spatial-navigate--char-filled-p (point) (point) (1+ (point)) nil)))

(defun spatial-navigate--region-line-search-filled-p (dir)
  "Return non-nil if the line one step in DIR (-1 or 1) from point is filled.
Point must be at the beginning of a line (bolp).

This matches how `spatial-navigate--vertical-calc' itself seeds its own
search state from the first line it steps to -- *not* from the origin's own
fill state, which can differ (a filled origin sitting right at the edge of
its own block, with blank-space immediately in DIR, searches as blank; a
blank origin immediately adjacent to a block in DIR searches as filled).
That first-stepped line, not the origin, is therefore what determines
whether the landing needs adjusting, see
`spatial-navigate--region-line-adjust'."
  (declare (important-return-value t))
  (save-excursion (and (zerop (forward-line dir)) (spatial-navigate--region-line-filled-p))))

(defun spatial-navigate--region-line-next-bol ()
  "Return the buffer position of the next line's beginning.
Return nil when there is no next line to move to (buffer boundary, or the
final line has no trailing newline)."
  (declare (important-return-value t))
  (save-excursion (and (zerop (forward-line 1)) (bolp) (point))))

(defun spatial-navigate--region-line-active-p ()
  "Return non-nil when whole-line region adjustments may apply at point.
True when `spatial-navigate-region-line-vertical-motion' is enabled, a
non-rectangular region is active, and point is at the beginning of a line."
  (declare (important-return-value t))
  (and spatial-navigate-region-line-vertical-motion
       (region-active-p)
       (not (bound-and-true-p rectangle-mark-mode))
       (bolp)))

(defun spatial-navigate--region-line-adjust (dir search-filled-p)
  "Move point one line forward so the landing selects whole lines.
Call with point on the raw landing position of a vertical motion in
DIR (-1 or 1); landings are always at the beginning of a line, since
region-line motion originates at bol and tracks column zero.
SEARCH-FILLED-P is
`spatial-navigate--region-line-search-filled-p' for DIR, captured at the
motion's origin.  Return nil when no adjustment applies, `adjust' when
point moved, or `collapse' when point moved to prevent the region
collapsing (see below).

A run of lines (filled or blank) is delimited by two line beginnings: the
first run line's own, and the one just past the last run line.  Selecting
whole lines means landing on the run's *far* delimiter: the lower one going
down, the upper one going up.  `spatial-navigate--vertical-calc' instead
lands on the last line a filled search matched, or on the first line past
the run a blank search stopped on -- one line short of the lower delimiter
for a filled search going down, and one line past the upper delimiter for a
blank search going up.  Both are corrected by the same move, one line
forward; the other two direction/state combinations already land on the far
delimiter and are left alone.  Which side of the mark point is on plays no
part in this rule: inclusive-start and exclusive-end bounds both belong on
the delimiter.  The mark only constrains and rescues:

- A landing exactly on the mark would leave the region empty; it is pushed
  one line forward regardless of the search state, keeping the mark's own
  line selected (see `region-line-backward-filled-prevents-collapse'), and
  reported as `collapse' so `spatial-navigate--vertical-step' can retry
  past a mark that sits directly adjacent to the origin.  When there is
  nowhere to push (the mark on a final line with no trailing newline),
  `collapse-blocked' is returned instead so the caller can refuse the
  motion outright rather than leave a collapsed region behind.
- The adjustment never crosses the mark to invert the region (see
  `region-line-forward-shrink-safety-avoids-mark'), and never strands
  point past a missing final newline.

The delimiter rule only ever adjusts a landing on a filled line: a blank
landing is a buffer boundary fallback, not a run delimiter the rule speaks
to, and adjusting it would leave the buffer's edge unreachable.  The
collapse rescue is exempt from this -- the mark can sit on a blank line,
and an empty region is worse than an imperfect landing."
  (declare (important-return-value t))
  (let ((is-at-mark (= (point) (mark))))
    (when (or is-at-mark
              (and (spatial-navigate--region-line-filled-p) (eq search-filled-p (> dir 0))))
      (let ((pos-adjust (spatial-navigate--region-line-next-bol)))
        (cond
         ((and pos-adjust
               ;; NOTE: never cross the mark from below and invert the
               ;; region.  The mirrored at-or-after-mark case needs no
               ;; guard: the next line's beginning is always past a mark
               ;; at or before point.
               (or (>= (point) (mark)) (< pos-adjust (mark))))
          (goto-char pos-adjust)
          (cond
           (is-at-mark
            'collapse)
           (t
            'adjust)))
         (is-at-mark
          'collapse-blocked))))))


;; ---------------------------------------------------------------------------
;; Wrapper Functions

(defun spatial-navigate--vertical-1 (dir is-block-cursor calc-fn region-line-p)
  "Perform a single vertical motion in DIR (-1 or 1) and adjust the landing.
IS-BLOCK-CURSOR see `spatial-navigate--vertical-calc'.  CALC-FN is the
rectangle-mode calc function, or nil for the default.  REGION-LINE-P is
`spatial-navigate--region-line-active-p' captured at the motion's origin,
enabling the whole-line landing adjustment.

Return a list (POS-RAW LINES ADJUST) on success: POS-RAW is the raw landing
position before any adjustment, LINES the signed number of lines it lies
from the origin, ADJUST is the result of
`spatial-navigate--region-line-adjust' (nil when it did not act).
Return nil when there is no line to move to, leaving point unmoved."
  (declare (important-return-value t))
  (pcase-let ((`(,lines . ,pos-next)
               (save-excursion
                 (cond
                  (calc-fn
                   (funcall calc-fn dir))
                  (t
                   (spatial-navigate--vertical-calc dir is-block-cursor))))))
    (cond
     ((zerop lines)
      nil)
     (t
      ;; NOTE: captured at the origin (the calc above ran in
      ;; `save-excursion'), only on the success path where the adjustment
      ;; can use it.
      (let ((search-filled
             (and region-line-p (spatial-navigate--region-line-search-filled-p dir))))
        (goto-char pos-next)
        (list
         pos-next
         lines
         (and region-line-p (spatial-navigate--region-line-adjust dir search-filled))))))))

(defun spatial-navigate--vertical-step (dir is-block-cursor calc-fn)
  "Perform one motion step in DIR (-1 or 1), retrying past thin landings.
IS-BLOCK-CURSOR and CALC-FN see `spatial-navigate--vertical-1'.
Return t when point moved, `collapse' when the motion was refused because
it could only collapse the region onto the mark, or nil when there was no
line to move to; in both failure cases point is restored to where the step
started.

With whole-line region adjustments active at the origin (see
`spatial-navigate--region-line-active-p'), a motion whose raw landing is
exactly one line away is not trusted as the real destination; the motion is
retried from that raw landing, in either direction, when:

- The origin is blank and the raw landing is filled: a lone filled line
  reached with no gap is a one-line island in otherwise-blank space, not a
  block worth stopping on (see
  `region-line-diagram-extend-shrink-retried-past-thin-block' and
  `region-line-diagram-retract-growing-retried-past-thin-block', both of
  which cross such an island only to find a genuine multi-line block just
  beyond it).
- The landing hit the mark and was pushed forward as collapse prevention
  (see `spatial-navigate--region-line-adjust'): the motion barely moved, or
  not at all, so the retry continues past the adjacent mark entirely (see
  `region-line-diagram-extend-collapse-retried-past-adjacent-mark').

Repeats while each new raw landing is again one line from the last.  A
retry is only accepted when it found what it went looking for: an adjusted
landing, the mark, or a filled line.  When it instead finds nothing to move
to (buffer boundary), lands unadjusted on blank-space (a boundary fallback,
not a block), or could itself only land collapsed on the mark, the previous
successful landing is kept rather than failing the whole step: a one-line
block at the buffer's edge is a valid destination, the retry past it was
merely optimistic."
  (declare (important-return-value t))
  (let* ((pos-origin (point))
         (region-line-p (spatial-navigate--region-line-active-p))
         (is-blank-origin (and region-line-p (null (spatial-navigate--region-line-filled-p))))
         (result (spatial-navigate--vertical-1 dir is-block-cursor calc-fn region-line-p))
         (keep-trying (and result region-line-p)))
    (while keep-trying
      (setq keep-trying nil)
      (pcase-let ((`(,pos-raw ,lines ,adjust) result))
        (when (= (abs lines) 1)
          (let ((raw-filled
                 (save-excursion
                   (goto-char pos-raw)
                   (spatial-navigate--region-line-filled-p))))
            (when (or (eq adjust 'collapse) (and is-blank-origin raw-filled))
              (let ((pos-settled (point)))
                ;; NOTE: retry from the raw landing, undoing any
                ;; adjustment: for a one-line result the adjustment itself
                ;; is what needs redoing from the new starting state.
                (goto-char pos-raw)
                (let* ((result-next
                        (spatial-navigate--vertical-1 dir is-block-cursor calc-fn region-line-p))
                       (adjust-next (nth 2 result-next)))
                  (cond
                   ((and result-next
                         (not (eq adjust-next 'collapse-blocked))
                         ;; NOTE: an unadjusted blank landing is a
                         ;; boundary fallback, not the block the retry
                         ;; went looking for -- accepting it would drag
                         ;; point across every remaining blank line to
                         ;; the buffer's edge.
                         (or adjust-next
                             (save-excursion
                               (goto-char (car result-next))
                               (spatial-navigate--region-line-filled-p))))
                    (setq result result-next)
                    (setq is-blank-origin (null raw-filled))
                    (setq keep-trying t))
                   (t
                    ;; NOTE: keep the pre-retry landing (see the
                    ;; docstring), discarding only the retry.
                    (goto-char pos-settled))))))))))
    (let ((adjust (nth 2 result)))
      (cond
       ((and result
             (/= (point) pos-origin)
             ;; NOTE: a blocked collapse rescue leaves point sitting on the
             ;; mark with an empty region: refuse the whole step instead.
             (not (eq adjust 'collapse-blocked)))
        t)
       (t
        ;; NOTE: either there was no line to move to, or collapse prevention
        ;; could not find a landing that kept the region intact: a step that
        ;; ends where it started has not been performed.
        (goto-char pos-origin)
        (cond
         ((and result (memq adjust '(collapse collapse-blocked)))
          'collapse)
         (t
          nil)))))))

(defun spatial-navigate--vertical (dir is-block-cursor)
  "See `spatial-navigate--vertical-calc' for docs on DIR and IS-BLOCK-CURSOR.

When DIR is outside the -1/1 range, motion will run multiple times: each
step behaves exactly like a single motion, including the whole-line region
adjustments and retries (see `spatial-navigate--vertical-step'), so a
numeric prefix argument composes the same way repeated presses do.

Return the number of steps remaining (0 when all succeed)
or nil when the motion could not be performed."
  (declare (important-return-value nil))

  ;; NOTE: run the evil workaround once, up front, moving point to the
  ;; true origin.  The calc functions used to run this per invocation,
  ;; which re-anchored every retry and prefix-arg step to the
  ;; `evil-visual-point' marker -- stale mid-command, since 'post only
  ;; fires at the end.
  (spatial-navigate--evil-visual-mode-workaround 'pre)

  ;; Determine the calc function once before the loop.
  ;; Rectangle mode overrides: point > mark col uses rect-next,
  ;; point < mark col uses rect-prev, equal cols uses bar behavior.
  (let ((calc-fn
         (cond
          ((bound-and-true-p rectangle-mark-mode)
           (let ((col-point (current-column))
                 (col-mark
                  (save-excursion
                    (goto-char (mark))
                    (current-column))))
             (cond
              ((> col-point col-mark)
               #'spatial-navigate--vertical-calc-rect-next)
              ((< col-point col-mark)
               #'spatial-navigate--vertical-calc-rect-prev)
              (t
               (setq is-block-cursor nil)
               nil))))
          (t
           nil)))
        (times (abs dir))
        (fail-reason nil)
        (changed nil))
    (cond
     ((eq times dir)
      (setq dir 1))
     (t
      (setq dir -1)))

    (while (and (null fail-reason) (> times 0))
      (let ((step-result (spatial-navigate--vertical-step dir is-block-cursor calc-fn)))
        (cond
         ((eq step-result t)
          (setq changed t)
          (decf times))
         (t
          (setq fail-reason (or step-result 'no-lines))))))

    ;; NOTE: report a collapse even when earlier steps moved.  For a
    ;; prefix-arg motion the warning would otherwise vanish the moment any
    ;; step succeeded, unlike a single collapsing press.  A partial motion
    ;; stopped by a buffer boundary (`no-lines') is left unreported: it
    ;; moved as far as it could, which is not an error.
    (cond
     ((eq fail-reason 'collapse)
      (message "Spatial-navigate: motion would collapse the region!"))
     ((null changed)
      (message "Spatial-navigate: no lines to jump to!")))
    (cond
     (changed
      ;; NOTE: once per command, at the final landing: intermediate
      ;; landings abandoned by a failed retry must not leak into the evil
      ;; marker.
      (spatial-navigate--evil-visual-mode-workaround 'post)
      (* times dir))
     (t
      nil))))

(defun spatial-navigate--horizontal (dir is-block-cursor)
  "See `spatial-navigate--horizontal-calc' for docs on DIR and IS-BLOCK-CURSOR."
  (declare (important-return-value nil))
  (setq is-block-cursor (spatial-navigate--resolve-block-cursor is-block-cursor))
  (let ((times (abs dir))
        (keep-searching t)
        (changed nil))
    (cond
     ((eq times dir)
      (setq dir 1))
     (t
      (setq dir -1)))

    (while (and keep-searching
                (null
                 (zerop
                  (prog1 times
                    (decf times)))))

      (let ((pos-next (save-excursion (spatial-navigate--horizontal-calc dir is-block-cursor))))

        ;; Optionally skip over blank lines.
        (when spatial-navigate-wrap-horizontal-motion
          (when (eq pos-next (point))
            (save-excursion
              (when (zerop (forward-line dir))
                ;; Skip blank lines.
                (while (and (looking-at-p "[[:blank:]]*$") (zerop (forward-line dir))))
                (setq pos-next
                      (cond
                       ((< dir 0)
                        (pos-eol))
                       (t
                        (pos-bol))))))))
        (cond
         ((eq pos-next (point))
          (setq keep-searching nil))
         (t
          (setq changed t)
          (goto-char pos-next)))))

    (cond
     (changed
      (spatial-navigate--evil-visual-mode-workaround 'post)
      (* times dir))
     (t
      (message "Spatial-navigate: boundary reached!")
      nil))))


;; ---------------------------------------------------------------------------
;; Public Functions

;; Vertical motion.

;;;###autoload
(defun spatial-navigate-forward-vertical-box (arg)
  "Jump forward vertically ARG times across blank-space and non-blank-space.
A negative ARG reverses the motion.
Return the number of steps remaining (0 when all succeed)
or nil when the motion could not be performed.

Use for a box cursor."
  (declare (important-return-value nil))
  (interactive "p")
  (cond
   ((> arg 0)
    (spatial-navigate--vertical arg t))
   ((< arg 0)
    (spatial-navigate-backward-vertical-box (- arg)))
   (t
    nil)))

;;;###autoload
(defun spatial-navigate-backward-vertical-box (arg)
  "Jump backward vertically ARG times across blank-space and non-blank-space.
A negative ARG reverses the motion.
Return the number of steps remaining (0 when all succeed)
or nil when the motion could not be performed.

Use for a box cursor."
  (declare (important-return-value nil))
  (interactive "p")
  (cond
   ((> arg 0)
    (spatial-navigate--vertical (- arg) t))
   ((< arg 0)
    (spatial-navigate-forward-vertical-box (- arg)))
   (t
    nil)))

;;;###autoload
(defun spatial-navigate-forward-vertical-bar (arg)
  "Jump forward vertically ARG times across blank-space and non-blank-space.
A negative ARG reverses the motion.
Return the number of steps remaining (0 when all succeed)
or nil when the motion could not be performed.

Use for a bar cursor."
  (declare (important-return-value nil))
  (interactive "p")
  (cond
   ((> arg 0)
    (spatial-navigate--vertical arg nil))
   ((< arg 0)
    (spatial-navigate-backward-vertical-bar (- arg)))
   (t
    nil)))

;;;###autoload
(defun spatial-navigate-backward-vertical-bar (arg)
  "Jump backward vertically ARG times across blank-space and non-blank-space.
A negative ARG reverses the motion.
Return the number of steps remaining (0 when all succeed)
or nil when the motion could not be performed.

Use for a bar cursor."
  (declare (important-return-value nil))
  (interactive "p")
  (cond
   ((> arg 0)
    (spatial-navigate--vertical (- arg) nil))
   ((< arg 0)
    (spatial-navigate-forward-vertical-bar (- arg)))
   (t
    nil)))

;; Horizontal motion.

;;;###autoload
(defun spatial-navigate-forward-horizontal-box (arg)
  "Jump forward horizontally ARG times across blank-space and non-blank-space.
A negative ARG reverses the motion.
Return the number of steps remaining (0 when all succeed)
or nil when the motion could not be performed.

Use for a box cursor."
  (declare (important-return-value nil))
  (interactive "p")
  (cond
   ((> arg 0)
    (spatial-navigate--horizontal arg t))
   ((< arg 0)
    (spatial-navigate-backward-horizontal-box (- arg)))
   (t
    nil)))

;;;###autoload
(defun spatial-navigate-backward-horizontal-box (arg)
  "Jump backward horizontally ARG times across blank-space and non-blank-space.
A negative ARG reverses the motion.
Return the number of steps remaining (0 when all succeed)
or nil when the motion could not be performed.

Use for a box cursor."
  (declare (important-return-value nil))
  (interactive "p")
  (cond
   ((> arg 0)
    (spatial-navigate--horizontal (- arg) t))
   ((< arg 0)
    (spatial-navigate-forward-horizontal-box (- arg)))
   (t
    nil)))

;;;###autoload
(defun spatial-navigate-forward-horizontal-bar (arg)
  "Jump forward horizontally ARG times across blank-space and non-blank-space.
A negative ARG reverses the motion.
Return the number of steps remaining (0 when all succeed)
or nil when the motion could not be performed.

Use for a bar cursor."
  (declare (important-return-value nil))
  (interactive "p")
  (cond
   ((> arg 0)
    (spatial-navigate--horizontal arg nil))
   ((< arg 0)
    (spatial-navigate-backward-horizontal-bar (- arg)))
   (t
    nil)))

;;;###autoload
(defun spatial-navigate-backward-horizontal-bar (arg)
  "Jump backward horizontally ARG times across blank-space and non-blank-space.
A negative ARG reverses the motion.
Return the number of steps remaining (0 when all succeed)
or nil when the motion could not be performed.

Use for a bar cursor."
  (declare (important-return-value nil))
  (interactive "p")
  (cond
   ((> arg 0)
    (spatial-navigate--horizontal (- arg) nil))
   ((< arg 0)
    (spatial-navigate-forward-horizontal-bar (- arg)))
   (t
    nil)))

(provide 'spatial-navigate)
;; Local Variables:
;; fill-column: 99
;; indent-tabs-mode: nil
;; End:
;;; spatial-navigate.el ends here
