;;; ---------------------------------------------------------------------------
;;; TallyBar.lsp
;;; code compiled by YZ August 2026
;;; ---------------------------------------------------------------------------
;;; Shows a live count of the current selection in the status bar.
;;;
;;; Select objects and the status bar reads "14 objects selected." Deselect and
;;; it reverts to whatever it said before. Small, but it removes a constant low
;;; grade uncertainty - did that window actually catch the row of blocks, or
;;; only most of them?
;;;
;;; COMMANDS
;;;   TALLYBARON   - enable the counter
;;;   TALLYBAROFF  - disable it and restore the status bar
;;;
;;; The counter is enabled automatically when this file loads.
;;;
;;; HOW IT WORKS
;;; Two reactors do the work:
;;;
;;;   :vlr-pickfirstmodified fires whenever the pick-first selection changes,
;;;   which covers ordinary windowing and clicking. This is the main trigger.
;;;
;;;   :vlr-commandEnded is needed because QSELECT and SELECTSIMILAR build their
;;;   selection without raising a pickfirst event, so without this second
;;;   reactor those two commands would leave a stale count on display.
;;;
;;; The status bar text is the MODEMACRO system variable, so displaying the
;;; count means overwriting MODEMACRO and putting it back afterwards.
;;; ---------------------------------------------------------------------------

(vl-load-com)

;;; ---------------------------------------------------------------------------
;;; A NOTE ON THE GLOBALS BELOW
;;;
;;; These two cannot be localised. Reactor callbacks are invoked by AutoCAD
;;; long after the command that created the reactor has returned, so anything
;;; they depend on must persist at global scope or it will not exist by the
;;; time the callback fires.
;;;
;;; *TallyBar:Saved*    holds MODEMACRO as it was immediately before the count
;;;                     was first displayed, so it can be restored on deselect.
;;;                     nil means "nothing is currently overwritten".
;;;
;;; *TallyBar:Original* holds MODEMACRO as it was when the counter was switched
;;;                     on. This is the fail-safe: if the pickfirst callback is
;;;                     ever missed - which the UNDO command can cause - the
;;;                     command-ended reactor uses this to repair the status bar
;;;                     rather than leaving a stale count stuck there forever.
;;; ---------------------------------------------------------------------------
(setq *TallyBar:Saved*    nil
      *TallyBar:Original* nil
)

;; ---------------------------------------------------------------------------
;; TallyBar:Remove
;; ---------------------------------------------------------------------------
;; Tears down any reactors this routine previously created.
;;
;; Reactors are identified by their data tag, "tallybar", rather than by a
;; stored handle. That is deliberate: it means a reactor left behind by a
;; previous load of this file is still found and removed, so repeatedly loading
;; the file cannot accumulate duplicate reactors all fighting over MODEMACRO.
;; ---------------------------------------------------------------------------
(defun TallyBar:Remove ( / obj )
    (foreach obj
        (apply 'append
               (mapcar 'cdr
                       (vlr-reactors :vlr-miscellaneous-reactor :vlr-command-reactor)
               )
        )
        (if (= "tallybar" (vlr-data obj))
            (vlr-remove obj)
        )
    )
    (princ)
)

;; ---------------------------------------------------------------------------
;; TallyBar:Update  -  :vlr-pickfirstmodified callback
;; ---------------------------------------------------------------------------
;; Counts the current selection and writes it to the status bar, or restores
;; the status bar when the selection is emptied.
;;
;; THE VIEWPORT EXCLUSION
;; The loop that walks the selection deleting certain objects exists to handle
;; non-rectangular viewports. A clipped viewport is implemented as two linked
;; objects - the viewport itself and the clipping boundary - and both land in
;; the selection when the user clicks it. Counting both would report two
;; objects where the user sees one.
;;
;; The test identifies a clip boundary by looking in its reactor list (DXF 102
;; group "{ACAD_REACTORS") for an owner, then checking whether that owner is a
;; VIEWPORT. If it is, this object is the boundary of a clipped viewport and is
;; removed from the count.
;;
;; The two arguments are supplied by the reactor and are not used here, but the
;; signature must still accept them.
;; ---------------------------------------------------------------------------
(defun TallyBar:Update ( reactor args / ent enx idx count sel owner )
    (if
        (and
            ;; ssgetfirst returns (grips selection); the selection is the cadr.
            (setq sel (cadr (ssgetfirst)))
            (progn
                (repeat (setq idx (sslength sel))
                    (if (and (setq ent   (ssname sel (setq idx (1- idx))))
                             (setq enx   (member '(102 . "{ACAD_REACTORS") (entget ent)))
                             (setq owner (cdr (assoc 330 enx)))
                             (= "VIEWPORT" (cdr (assoc 0 (entget owner))))
                        )
                        (ssdel ent sel)
                    )
                )
                (< 0 (setq count (sslength sel)))
            )
        )

        ;; Something is selected - display the count, remembering what the
        ;; status bar said first. The null test matters: without it, moving from
        ;; one selection straight to another would save the count message itself
        ;; as the "original" text and it could never be restored.
        (progn
            (if (null *TallyBar:Saved*)
                (setq *TallyBar:Saved* (getvar 'modemacro))
            )
            (setvar 'modemacro
                    (strcat (itoa count) " object" (if (= 1 count) "" "s") " selected.")
            )
        )

        ;; Nothing selected - put the status bar back and forget the saved text.
        (progn
            (if (= 'str (type *TallyBar:Saved*))
                (setvar 'modemacro *TallyBar:Saved*)
            )
            (setq *TallyBar:Saved* nil)
        )
    )
    (princ)
)

;; ---------------------------------------------------------------------------
;; TallyBar:CommandEnded  -  :vlr-commandEnded callback
;; ---------------------------------------------------------------------------
;; Covers the two cases the pickfirst reactor cannot see.
;;
;;   1. QSELECT and SELECTSIMILAR build a selection without raising a pickfirst
;;      event, so the counter is driven manually after either of them runs.
;;
;;   2. If nothing is selected but the status bar still shows a count, the
;;      display is stale - typically after an UNDO - so it is repaired from the
;;      value captured when the counter was switched on.
;;
;; args is a list whose car is the name of the command that just ended.
;; ---------------------------------------------------------------------------
(defun TallyBar:CommandEnded ( reactor args )
    (cond
        (   (wcmatch (strcase (car args) t) "qselect,selectsimilar")
            (TallyBar:Update nil nil)
        )
        (   (and (null (cadr (ssgetfirst)))
                 (wcmatch (getvar 'modemacro) "*object*selected.")
                 (= 'str (type *TallyBar:Original*))
            )
            (setvar 'modemacro *TallyBar:Original*)
            (setq *TallyBar:Saved* nil)
        )
    )
    (princ)
)

;; ---------------------------------------------------------------------------
;; c:TALLYBARON  -  enable the counter
;; ---------------------------------------------------------------------------
;; Existing reactors are removed first so that running this twice replaces the
;; counter rather than doubling it.
;;
;; No undo group and no system variable capture: MODEMACRO is managed by the
;; callbacks over the whole life of the reactor, not within the span of this
;; command, so the ordinary save-and-restore pattern does not apply.
;; ---------------------------------------------------------------------------
(defun c:TALLYBARON ( / *error* )

    (defun *error* ( msg )
        (if (and msg (not (wcmatch (strcase msg t) "*break*,*cancel*,*exit*")))
            (princ (strcat "\n** TALLYBARON error: " msg " **"))
        )
        (princ)
    )

    (TallyBar:Remove)

    (vlr-miscellaneous-reactor "tallybar" '((:vlr-pickfirstmodified . TallyBar:Update)))
    (vlr-command-reactor       "tallybar" '((:vlr-commandEnded      . TallyBar:CommandEnded)))

    (setq *TallyBar:Original* (getvar 'modemacro)
          *TallyBar:Saved*    nil
    )

    (princ "\nSelection counter enabled - run TALLYBAROFF to disable.")
    (princ)
)

;; ---------------------------------------------------------------------------
;; c:TALLYBAROFF  -  disable the counter
;; ---------------------------------------------------------------------------
(defun c:TALLYBAROFF ( / *error* )

    (defun *error* ( msg )
        (if (and msg (not (wcmatch (strcase msg t) "*break*,*cancel*,*exit*")))
            (princ (strcat "\n** TALLYBAROFF error: " msg " **"))
        )
        (princ)
    )

    (TallyBar:Remove)

    ;; Repair the status bar on the way out, in case it was showing a count at
    ;; the moment the counter was switched off - otherwise that text would be
    ;; left stranded there with no reactor left alive to clear it.
    (if (and (= 'str (type *TallyBar:Original*))
             (wcmatch (getvar 'modemacro) "*object*selected.")
        )
        (setvar 'modemacro *TallyBar:Original*)
    )

    (setq *TallyBar:Saved*    nil
          *TallyBar:Original* nil
    )

    (princ "\nSelection counter disabled.")
    (princ)
)

;; Enabled on load, matching the original behaviour.
(c:TALLYBARON)
(princ)
