Page MenuHomeFreeBSD

D58133.id.diff
No OneTemporary

D58133.id.diff

diff --git a/etc/mtree/BSD.usr.dist b/etc/mtree/BSD.usr.dist
--- a/etc/mtree/BSD.usr.dist
+++ b/etc/mtree/BSD.usr.dist
@@ -10,6 +10,8 @@
private tags=package=clibs-dev
bsddialog tags=package=utilities-dev
..
+ bsdpv tags=package=utilities-dev
+ ..
bsdstat tags=package=libbsdstat-dev
..
event1 tags=package=libevent1-dev
diff --git a/lib/Makefile b/lib/Makefile
--- a/lib/Makefile
+++ b/lib/Makefile
@@ -37,6 +37,7 @@
libbegemot \
libblocksruntime \
libbsddialog \
+ libbsdpv \
libbsdstat \
libbsm \
libbz2 \
@@ -123,6 +124,7 @@
SUBDIR_DEPEND_libarchive= libz libbz2 libexpat liblzma libmd libzstd
SUBDIR_DEPEND_libauditdm= libbsm
SUBDIR_DEPEND_libbsddialog= ncurses
+SUBDIR_DEPEND_libbsdpv= libbsddialog ncurses libutil
SUBDIR_DEPEND_libbsnmp= ${_libnetgraph}
SUBDIR_DEPEND_libc++:= libcxxrt
# libssp_nonshared doesn't need to be linked into libc on every arch, but it is
diff --git a/lib/libbsdpv/Makefile b/lib/libbsdpv/Makefile
new file mode 100644
--- /dev/null
+++ b/lib/libbsdpv/Makefile
@@ -0,0 +1,17 @@
+PACKAGE= bsdpv
+LIB= bsdpv
+PRIVATELIB= yes
+SHLIB_MAJOR= 0
+INCS= bsdpv.h
+MAN= bsdpv.3
+MLINKS= bsdpv.3 bsdpv_announce.3 \
+ bsdpv.3 bsdpv_end.3 \
+ bsdpv.3 bsdpv_free.3
+
+LIBADD= bsddialog util tinfow ncursesw
+
+SRCS= bsdpv.c cli.c display.c dprompt.c status.c util.c
+
+CFLAGS+= -I${.CURDIR} -I${SRCTOP}/contrib/bsddialog/lib
+
+.include <bsd.lib.mk>
diff --git a/lib/libbsdpv/Makefile.depend b/lib/libbsdpv/Makefile.depend
new file mode 100644
--- /dev/null
+++ b/lib/libbsdpv/Makefile.depend
@@ -0,0 +1,20 @@
+# Autogenerated - do NOT edit!
+
+DIRDEPS = \
+ include \
+ include/xlocale \
+ lib/${CSU_DIR} \
+ lib/libbsddialog \
+ lib/libc \
+ lib/libcompiler_rt \
+ lib/libutil \
+ lib/msun \
+ lib/ncurses/ncurses \
+ lib/ncurses/tinfo \
+
+
+.include <dirdeps.mk>
+
+.if ${DEP_RELDIR} == ${_DEP_RELDIR}
+# local dependencies - needed for -jN in clean tree
+.endif
diff --git a/lib/libbsdpv/bsdpv.h b/lib/libbsdpv/bsdpv.h
new file mode 100644
--- /dev/null
+++ b/lib/libbsdpv/bsdpv.h
@@ -0,0 +1,144 @@
+/*
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ */
+
+#ifndef _BSDPV_H_
+#define _BSDPV_H_
+
+#include <sys/types.h>
+
+#ifndef TRUE
+#define TRUE 1
+#endif
+#ifndef FALSE
+#define FALSE 0
+#endif
+
+/* localeconv(3) */
+#define LC_NUMERIC_DEFAULT "en_US.ISO8859-1"
+
+/* Data to process */
+extern long long bsdpv_overall_read;
+
+/* Interrupt flag */
+extern int bsdpv_interrupt; /* Set to TRUE in interrupt handler */
+extern int bsdpv_abort; /* Set to true in callback to abort */
+
+/*
+ * Display types for use with display_type member of bsdpv_config structure.
+ * Zero remains BSDDIALOG for ABI stability of zero-initialized configs;
+ * the bsdpv(1) utility defaults to CLI.
+ */
+enum bsdpv_display {
+ BSDPV_DISPLAY_BSDDIALOG = 0, /* Display with bsddialog(3) */
+ BSDPV_DISPLAY_STDOUT, /* Debug gauge dump on stdout */
+ BSDPV_DISPLAY_CLI, /* Human-readable progress on stderr */
+};
+
+/*
+ * Output types for use with output_type member of bsdpv_config structure
+ */
+enum bsdpv_output {
+ BSDPV_OUTPUT_NONE = 0, /* No output (default) */
+ BSDPV_OUTPUT_FILE, /* Read `output' member as file path */
+ BSDPV_OUTPUT_SHELL, /* Read `output' member as shell cmd */
+};
+
+/*
+ * Activity types for use with status member of bsdpv_file_node structure.
+ * If you set a status other than BSDPV_STATUS_RUNNING on the current file in
+ * the action callback of bsdpv_config structure, you'll end callbacks for
+ * that bsdpv_file_node.
+ */
+enum bsdpv_status {
+ BSDPV_STATUS_RUNNING = 0, /* Running (default) */
+ BSDPV_STATUS_DONE, /* Completed */
+ BSDPV_STATUS_FAILED, /* Oops, something went wrong */
+};
+
+/*
+ * Anatomy of file option; pass an array of these as bsdpv() file_list
+ * argument terminated with a NULL pointer.
+ */
+struct bsdpv_file_node {
+ enum bsdpv_status status; /* status of read operation */
+ char *msg; /* display instead of "Done/Fail" */
+ char *name; /* name of file to read */
+ char *path; /* path to file */
+ long long length; /* expected size */
+ long long read; /* number units read (e.g., bytes) */
+ struct bsdpv_file_node *next; /* pointer to next (end with NULL) */
+};
+
+/*
+ * Anatomy of config option to pass as bsdpv() config argument
+ */
+struct bsdpv_config {
+ uint8_t keep_tite; /* Prevent visually distracting exit */
+ enum bsdpv_display display_type; /* Def. BSDPV_DISPLAY_BSDDIALOG */
+ enum bsdpv_output output_type; /* Output (default TYPE_NONE) */
+ int debug; /* Enable debugging output on stderr */
+ int display_limit; /* Files per `page'. Default 0 */
+ int label_size; /* Label size. Default 28 */
+ int pbar_size; /* Mini-progress size. See bsdpv(3) */
+ int display_updates_per_second; /* Progress updates/s. Def. 16 */
+ int status_updates_per_second; /* Status updates/s. 0=off; Def. 2 */
+ uint16_t options; /* Special options. Default 0 */
+ char *title; /* widget title */
+ char *backtitle; /* Widget backtitle */
+ char *aprompt; /* Prompt append. Default NULL */
+ char *pprompt; /* Prompt prefix. Default NULL */
+ char *msg_done; /* Progress text. Default `Done' */
+ char *msg_fail; /* Progress text. Default `Fail' */
+ char *msg_pending; /* Progress text. Default `Pending' */
+ char *output; /* Output format string; see bsdpv(3) */
+ const char *status_solo; /* Solo-status format; see bsdpv(3) */
+ const char *status_many; /* Many-status format; see bsdpv(3) */
+
+ /*
+ * Function pointer; action to perform data transfer
+ */
+ int (*action)(struct bsdpv_file_node *file, int out);
+};
+
+/*
+ * Macros for bsdpv() options bitmask argument
+ */
+#define BSDPV_TEST_MODE 0x0001 /* Test mode (fake reading data) */
+#define BSDPV_WIDE_MODE 0x0002 /* prefix/append bump dialog width */
+#define BSDPV_NO_LABELS 0x0004 /* Hide file_node.name labels */
+#define BSDPV_USE_COLOR 0x0008 /* Override to force color output */
+#define BSDPV_NO_OVERRUN 0x0010 /* Stop transfers when they hit 100% */
+
+/*
+ * Limits (modify with extreme care)
+ */
+#define BSDPV_APROMPT_MAX 4096 /* Buffer size for `-a text' */
+#define BSDPV_DISPLAY_LIMIT 10 /* Max file progress lines */
+#define BSDPV_PPROMPT_MAX 4096 /* Buffer size for `-p text' */
+#define BSDPV_STATUS_FORMAT_MAX 80 /* Buffer size for `-u format' */
+
+/*
+ * Extra display information
+ */
+#define BSDPV_STATUS_SOLO "%'10lli bytes read @ %'9.1f bytes/sec."
+#define BSDPV_STATUS_MANY (BSDPV_STATUS_SOLO " [%i/%i busy/wait]")
+
+/*
+ * Strings
+ */
+#define BSDPV_DONE_DEFAULT "Done"
+#define BSDPV_FAIL_DEFAULT "Fail"
+#define BSDPV_PENDING_DEFAULT "Pending"
+
+__BEGIN_DECLS
+void bsdpv_announce(const char *_fmt, ...) __printflike(1, 2);
+void bsdpv_end(void);
+void bsdpv_free(void);
+int bsdpv(struct bsdpv_config *_config,
+ struct bsdpv_file_node *_file_list);
+__END_DECLS
+
+#endif /* !_BSDPV_H_ */
diff --git a/lib/libbsdpv/bsdpv.3 b/lib/libbsdpv/bsdpv.3
new file mode 100644
--- /dev/null
+++ b/lib/libbsdpv/bsdpv.3
@@ -0,0 +1,463 @@
+.\"
+.\" SPDX-License-Identifier: BSD-2-Clause
+.\"
+.\" Copyright (c) 2013-2026 Devin Teske
+.\"
+.Dd July 26, 2026
+.Dt BSDPV 3
+.Os
+.Sh NAME
+.Nm bsdpv
+.Nd BSD progress view library
+.Sh LIBRARY
+.Lb libbsdpv
+.Sh SYNOPSIS
+.In bsdpv.h
+.Ft int
+.Fo bsdpv
+.Fa "struct bsdpv_config *config"
+.Fa "struct bsdpv_file_node *file_list"
+.Fc
+.Ft void
+.Fo bsdpv_announce
+.Fa "const char *fmt"
+.Fa "..."
+.Fc
+.Ft void
+.Fo bsdpv_end
+.Fa "void"
+.Fc
+.Ft void
+.Fo bsdpv_free
+.Fa "void"
+.Fc
+.Sh DESCRIPTION
+The
+.Nm
+library provides an interface for creating complex
+.Dq gauge
+widgets for displaying progress on various actions.
+The
+.Nm
+library can display progress with
+.Xr bsddialog 3 ,
+as a human-readable status line on standard error
+.Pq Dv BSDPV_DISPLAY_CLI ,
+or as a gauge-protocol dump on standard output
+.Pq Dv BSDPV_DISPLAY_STDOUT .
+The
+.Xr bsdpv 1
+utility defaults to the CLI display;
+library callers that leave
+.Va display_type
+zero continue to get
+.Dv BSDPV_DISPLAY_BSDDIALOG
+for ABI stability.
+.Pp
+The
+.Nm
+library is a direct descendant of the
+.Ql libdpv
+.Dq dialog progress view
+library,
+rewritten to use
+.Xr bsddialog 3
+in place of the LGPL-licensed
+.Ql dialog
+library it once required.
+.Pp
+The
+.Fn bsdpv
+.Fa config
+argument properties for configuring global display features:
+.Bd -literal -offset indent
+struct bsdpv_config {
+ uint8_t keep_tite; /* Cleaner exit for scripts */
+ enum bsdpv_display display_type; /* Def. BSDPV_DISPLAY_BSDDIALOG */
+ enum bsdpv_output output_type; /* Default BSDPV_OUTPUT_NONE */
+ int debug; /* Enable debug on stderr */
+ int display_limit; /* Files/page. Default 0 */
+ int label_size; /* Label size. Default 28 */
+ int pbar_size; /* Mini-progress size */
+ int display_updates_per_second; /* Default 16 */
+ int status_updates_per_second; /* 0=off; Default 2 */
+ uint16_t options; /* Default 0 (none) */
+ char *title; /* Widget title */
+ char *backtitle; /* Widget backtitle */
+ char *aprompt; /* Append. Default NULL */
+ char *pprompt; /* Prefix. Default NULL */
+ char *msg_done; /* Default `Done' */
+ char *msg_fail; /* Default `Fail' */
+ char *msg_pending; /* Default `Pending' */
+ char *output; /* Output format string */
+ const char *status_solo; /* solo-status format.
+ * Default BSDPV_STATUS_SOLO */
+ const char *status_many; /* many-status format.
+ * Default BSDPV_STATUS_MANY */
+
+ /*
+ * Function pointer; action to perform data transfer
+ */
+ int (*action)(struct bsdpv_file_node *file, int out);
+};
+
+enum bsdpv_display {
+ BSDPV_DISPLAY_BSDDIALOG = 0, /* Use bsddialog(3) */
+ BSDPV_DISPLAY_STDOUT, /* Gauge dump on stdout */
+ BSDPV_DISPLAY_CLI, /* Status line on stderr */
+};
+
+enum bsdpv_output {
+ BSDPV_OUTPUT_NONE = 0, /* No output (default) */
+ BSDPV_OUTPUT_FILE, /* Read `output' member as file path */
+ BSDPV_OUTPUT_SHELL, /* Read `output' member as shell cmd */
+};
+.Ed
+.Pp
+The
+.Va options
+member of the
+.Fn bsdpv
+.Fa config
+argument is a mask of bit fields indicating various processing options.
+Possible flags are:
+.Bl -tag -width BSDPV_NO_OVERRUN
+.It Dv BSDPV_TEST_MODE
+Enable test mode.
+In test mode,
+the
+.Fn action
+callback of the
+.Fa config
+argument is not called but instead simulated-data is used to drive progress.
+Appends
+.Dq [TEST MODE]
+to the status line
+.Po
+to override,
+set the
+.Va status_solo
+or
+.Va status_many
+members of the
+.Fn bsdpv
+.Fa config
+argument
+.Pc .
+.It Dv BSDPV_WIDE_MODE
+Enable wide mode.
+In wide mode,
+the length of the
+.Va aprompt
+and
+.Va pprompt
+members of the
+.Fn bsdpv
+.Fa config
+argument will bump the width of the gauge widget.
+Prompts wider than the maximum width will wrap.
+.It Dv BSDPV_NO_LABELS
+Disables the display of labels associated with each transfer
+.Po
+.Va label_size
+member of
+.Fn bsdpv
+.Fa config
+argument is ignored
+.Pc .
+.It Dv BSDPV_USE_COLOR
+Force the use of color
+.Po
+.Ev USE_COLOR
+environment variable is ignored
+.Pc .
+.It Dv BSDPV_NO_OVERRUN
+When enabled,
+callbacks for the current
+.Vt bsdpv_file_node
+are terminated when
+.Fn action
+returns 100 or greater
+.Po
+alleviates the need to change the
+.Va status
+of the current
+.Vt bsdpv_file_node
+but may also cause file truncation if the stream exceeds expected length
+.Pc .
+.El
+.Pp
+The
+.Fa file_list
+argument to
+.Fn bsdpv
+is a pointer to a
+.Dq linked-list ,
+described in
+.In bsdpv.h :
+.Bd -literal -offset indent
+struct bsdpv_file_node {
+ enum bsdpv_status status; /* status of read operation */
+ char *msg; /* display instead of "Done/Fail" */
+ char *name; /* name of file to read */
+ char *path; /* path to file */
+ long long length; /* expected size */
+ long long read; /* number units read (e.g., bytes) */
+ struct bsdpv_file_node *next;/* pointer to next (end with NULL) */
+};
+.Ed
+.Pp
+For each of the items in the
+.Fa file_list
+.Dq linked-list
+argument,
+the
+.Fn action
+callback member of the
+.Fn bsdpv
+.Fa config
+argument is called.
+The
+.Fn action
+function performs a
+.Dq nominal
+action on the file and return.
+The return value of
+.Vt int
+represents the current progress percentage
+.Pq 0-100
+for the current file.
+.Pp
+The
+.Fn action
+callback provides two variables for each call.
+.Fa file
+provides a reference to the current
+.Vt bsdpv_file_node
+being processed.
+.Fa out
+provides a file descriptor where the data goes.
+.Pp
+If the
+.Va output_type
+member of the
+.Fn bsdpv
+.Fa config
+argument was set to BSDPV_OUTPUT_NONE
+.Pq default ; when invoking Fn bsdpv ,
+the
+.Fa out
+file descriptor of
+.Fn action
+will be negative and can be ignored.
+If
+.Va output_type
+was set to BSDPV_OUTPUT_FILE,
+.Fa out
+will be an open file descriptor to a file
+.Pq or standard output if the
+.Va output
+path is
+.Ql - Ns ) .
+If
+.Va output_type
+was set to BSDPV_OUTPUT_SHELL,
+.Fa out
+will be an open file descriptor to a pipe for a spawned shell program.
+When
+.Fa out
+is greater than or equal to zero,
+write data that has been read back to
+.Fa out .
+.Pp
+To abort
+.Fn bsdpv ,
+either from the
+.Fn action
+callback or asynchronously from a signal handler,
+two globals are provided via
+.In bsdpv.h :
+.Bd -literal -offset indent
+extern int bsdpv_interrupt; /* Set to TRUE in interrupt handler */
+extern int bsdpv_abort; /* Set to true in callback to abort */
+.Ed
+.Pp
+These globals are not automatically reset and must be manually maintained.
+Do not forget to reset these globals before subsequent invocations of
+.Fn bsdpv
+when making multiple calls from the same program.
+.Pp
+In addition,
+the
+.Va status
+member of the
+.Fn action
+.Fa file
+argument can be used to control callbacks for the current file.
+The
+.Va status
+member can be set to any of the below from
+.In bsdpv.h :
+.Bd -literal -offset indent
+enum bsdpv_status {
+ BSDPV_STATUS_RUNNING = 0, /* Running (default) */
+ BSDPV_STATUS_DONE, /* Completed */
+ BSDPV_STATUS_FAILED, /* Oops, something went wrong */
+};
+.Ed
+.Pp
+The default
+.Fa status
+is zero,
+BSDPV_STATUS_RUNNING,
+which keeps the callbacks coming for the current
+.Fn file .
+Setting
+.Ql file->status
+to anything other than BSDPV_STATUS_RUNNING will cause
+.Fn bsdpv
+to loop to the next file,
+effecting the next callback,
+if any.
+.Pp
+The
+.Fn action
+callback is responsible for calculating percentages and
+.Pq recommended
+maintaining a
+.Nm
+global counter so
+.Fn bsdpv
+can display throughput statistics.
+Percentages are reported through the
+.Vt int
+return value of the
+.Fn action
+callback.
+Throughput statistics are calculated from the below global in
+.In bsdpv.h :
+.Bd -literal -offset indent
+extern long long bsdpv_overall_read;
+.Ed
+.Pp
+Set this to the number of bytes that have been read for all files.
+Throughput information is displayed in the status line at the bottom of the
+screen.
+.Pp
+Note that
+.Va bsdpv_overall_read
+does not have to represent bytes.
+For example,
+the
+.Va status_solo
+and
+.Va status_many
+formats can be changed to display something other than
+.Dq Li bytes
+and increment
+.Va bsdpv_overall_read
+accordingly
+.Pq for example, counting lines .
+.Pp
+When
+.Fn bsdpv
+is processing the current file,
+the
+.Va length
+and
+.Va read
+members of the
+.Fn action
+.Fa file
+argument are used for calculating the display of mini progress bars
+.Po
+if enabled;
+see
+.Va pbar_size
+above
+.Pc .
+If the
+.Va length
+member of the current
+.Fa file
+is less than zero
+.Pq indicating an unknown file length ,
+a
+.Xr humanize_number 3
+version of the
+.Va read
+member is used instead of a traditional progress bar.
+Otherwise a progress bar is calculated as percentage read to file length.
+.Fn action
+callback must maintain these member values for mini-progress bars.
+.Pp
+The
+.Fn bsdpv_announce
+function displays a one-line status message outside the normal progress
+update loop
+.Pq for example, while waiting for the first input on a pipe, FIFO, file, socket, or device .
+In dialog mode the message uses the status line; in CLI mode it rewrites the
+progress line on standard error; otherwise it uses
+.Xr warnx 3 .
+.Pp
+The
+.Fn bsdpv_end
+function ends
+.Xr bsddialog 3
+mode,
+restoring the terminal.
+It is safe to call
+.Fn bsdpv_end
+even when
+.Xr bsddialog 3
+mode is not active
+.Pq for example, from an error handler .
+.Pp
+The
+.Fn bsdpv_free
+function performs
+.Xr free 3
+on private global variables initialized by
+.Fn bsdpv .
+.Sh ENVIRONMENT
+The below environment variables are referenced by
+.Nm :
+.Bl -tag -width ".Ev USE_COLOR"
+.It Ev USE_COLOR
+If set and NULL,
+disables the use of color.
+.It Ev msg_done Ev msg_fail Ev msg_pending
+Internationalization strings for overriding the default English strings
+.Ql Done ,
+.Ql Fail ,
+and
+.Ql Pending
+respectively.
+To prevent their usage,
+explicitly set the
+.Va msg_done ,
+.Va msg_fail ,
+and
+.Va msg_pending
+members of
+.Fn bsdpv
+.Fa config
+argument to default macros
+.Pq BSDPV_DONE_DEFAULT, BSDPV_FAIL_DEFAULT, and BSDPV_PENDING_DEFAULT
+or desired values.
+.El
+.Sh SEE ALSO
+.Xr bsddialog 1 ,
+.Xr bsdpv 1 ,
+.Xr bsddialog 3
+.Sh HISTORY
+The
+.Nm
+library first appeared in
+.Fx 16.0 ,
+derived from the
+.Ql libdpv
+library that first appeared in
+.Fx 10.2 .
+.Sh AUTHORS
+.An Devin Teske Aq dteske@FreeBSD.org
diff --git a/lib/libbsdpv/bsdpv.c b/lib/libbsdpv/bsdpv.c
new file mode 100644
--- /dev/null
+++ b/lib/libbsdpv/bsdpv.c
@@ -0,0 +1,1003 @@
+/*
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ */
+
+#include <sys/stat.h>
+#include <sys/time.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+
+#include <err.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <locale.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <bsddialog.h>
+#include <bsddialog_theme.h>
+
+#include "bsdpv.h"
+#include "bsdpv_private.h"
+#include "cli.h"
+#include "display.h"
+#include "dprompt.h"
+#include "status.h"
+#include "util.h"
+
+/* Test Mechanics (Only used when bsdpv_config.options |= BSDPV_TEST_MODE) */
+#define INCREMENT 1 /* Increment % per-pass test-mode */
+static uint8_t increment = INCREMENT;
+
+/* Debugging */
+uint8_t debug = FALSE;
+
+/* Data to process */
+int bsdpv_interrupt = FALSE;
+int bsdpv_abort = FALSE;
+unsigned int bsdpv_nfiles = 0;
+
+/* Data processing */
+long long bsdpv_overall_read = 0;
+static char pathbuf[PATH_MAX];
+
+/* Extra display information */
+uint8_t keep_tite = FALSE; /* bsdpv_config.keep_tite */
+uint8_t no_labels = FALSE; /* bsdpv_config.options & BSDPV_NO_LABELS */
+uint8_t wide = FALSE; /* bsdpv_config.options & BSDPV_WIDE_MODE */
+char *aprompt = NULL; /* bsdpv_config.aprompt */
+char *msg_done = NULL; /* bsdpv_config.msg_done */
+char *msg_fail = NULL; /* bsdpv_config.msg_fail */
+char *msg_pending = NULL; /* bsdpv_config.msg_pending */
+char *pprompt = NULL; /* bsdpv_config.pprompt */
+
+/* Status-Line format */
+static const char *status_format_custom = NULL;
+static char status_format_default[BSDPV_STATUS_FORMAT_MAX];
+
+/*
+ * True if a later path has begun transferring (read > 0). Used to defer the
+ * Overall credit for a finished file until the next path starts reading;
+ * e.g. Fail or Done on path1 while still waiting for data on path2 should
+ * not jump Overall to path1's full share yet.
+ */
+static int
+successor_has_started(struct bsdpv_file_node *fp)
+{
+ struct bsdpv_file_node *n;
+
+ for (n = fp->next; n != NULL; n = n->next) {
+ if (n->read > 0)
+ return (1);
+ }
+ return (0);
+}
+
+/*
+ * Whether to credit a finished file's full labeled share (or 100% weight)
+ * beyond what was actually read. Only once a later path has started;
+ * never for Fail/Done on the last path alone.
+ *
+ * Done/Failed paths that already read >= length are capped to their share
+ * by the caller without needing this credit.
+ */
+static int
+credit_full_share(struct bsdpv_file_node *fp)
+{
+
+ if (fp->status != BSDPV_STATUS_DONE &&
+ fp->status != BSDPV_STATUS_FAILED)
+ return (0);
+ return (successor_has_started(fp));
+}
+
+/*
+ * Overall percent for the main progress indicator. When every label has a
+ * known length, weigh by expected data; but never credit more than each
+ * label's stated length, so overrun past 100% on a file does not push the
+ * main bar beyond that file's share. Otherwise fall back to equal weight
+ * per file (capping the active file's pct at 100 for the same reason).
+ *
+ * Fail/completion fill for a path is deferred until the next path has
+ * started reading (see credit_full_share), so Overall does not jump while
+ * we are still waiting for the next path to start reading.
+ */
+static int
+calc_overall(struct bsdpv_file_node *file_list, long long total_length,
+ unsigned int nthfile __unused, int pct)
+{
+ struct bsdpv_file_node *fp;
+ long long credited;
+ long long share;
+ int overall;
+ int p;
+ int sum;
+ int fpct;
+
+ if (total_length > 0) {
+ credited = 0;
+ for (fp = file_list; fp != NULL; fp = fp->next) {
+ if (fp->length >= 0) {
+ if (credit_full_share(fp))
+ share = fp->length;
+ else if (fp->read < fp->length)
+ share = fp->read;
+ else
+ share = fp->length;
+ credited += share;
+ } else
+ credited += fp->read;
+ }
+ overall = (int)(credited * 100 / total_length);
+ if (((credited * 1000 / total_length) % 10) > 5)
+ overall++;
+ if (overall > 100)
+ overall = 100;
+ return (overall);
+ }
+
+ /*
+ * Equal weight per file (taken when at least one label is
+ * unquantified). Same deferral: a finished path only counts as a
+ * full slot once a successor has started. Exception: successful
+ * Done on the last path (solo or -m) completes the transfer; snap
+ * to 100%. Fail on the last path does not force that snap.
+ */
+ sum = 0;
+ for (fp = file_list; fp != NULL; fp = fp->next) {
+ if (fp->status == BSDPV_STATUS_RUNNING) {
+ /*
+ * Defer Overall until the path has started reading,
+ * except in -T where pct is synthetic and read stays
+ * unused unless we update it in the test loop.
+ */
+ p = (display_test || fp->read > 0) ? pct : 0;
+ if (p < 0)
+ p = 0;
+ if (p > 100)
+ p = 100;
+ sum += p;
+ break;
+ }
+ if (credit_full_share(fp))
+ sum += 100;
+ else if (fp->length > 0) {
+ fpct = (int)(fp->read * 100 / fp->length);
+ if (fpct < 0)
+ fpct = 0;
+ if (fpct > 100)
+ fpct = 100;
+ sum += fpct;
+ }
+ /* else unlabeled finished with no successor: contribute 0 */
+ }
+ if (bsdpv_nfiles == 0)
+ return (0);
+ {
+ struct bsdpv_file_node *last = NULL;
+
+ for (fp = file_list; fp != NULL; fp = fp->next)
+ last = fp;
+ if (last != NULL && last->status == BSDPV_STATUS_DONE)
+ return (100);
+ }
+ overall = sum / (int)bsdpv_nfiles;
+ if ((sum * 10 / (int)bsdpv_nfiles % 10) > 5)
+ overall++;
+ if (overall > 100)
+ overall = 100;
+ return (overall);
+}
+
+/*
+ * Takes a pointer to a bsdpv_config structure containing layout details and
+ * pointer to initial element in a linked-list of bsdpv_file_node structures,
+ * each presenting a file to process. Executes the `action' function passed-in
+ * as a member to the `config' structure argument.
+ */
+int
+bsdpv(struct bsdpv_config *config, struct bsdpv_file_node *file_list)
+{
+ uint8_t keep_going;
+ uint8_t no_overrun = FALSE;
+ uint8_t pbar_explicit = FALSE;
+ uint8_t shrink_label_size = FALSE;
+ mode_t mask;
+ uint16_t options;
+ char *cp;
+ char *last;
+ char *name;
+ char *output;
+ long long total_length;
+ const char *status_fmt;
+ const char *path_fmt;
+ enum bsddialog_color bar_bg;
+ enum bsddialog_color bar_fg;
+ enum bsdpv_display display_type;
+ enum bsdpv_output output_type;
+ uint8_t use_cli;
+ int (*action)(struct bsdpv_file_node *file, int out);
+ int display_did_update;
+ int display_last_update = 0;
+ int display_old_nthfile = 0;
+ int display_old_seconds = -1;
+ int display_update_usec = 0;
+ int display_updates_per_second;
+ int files_left;
+ int forced_100;
+ int status_last_update = 0;
+ int status_old_nthfile = 0;
+ int status_old_seconds = -1;
+ int status_update_usec = 0;
+ int max_cols;
+ int nthfile = 0;
+ int output_out;
+ int overall = 0;
+ int pct;
+ int seconds;
+ int status_updates_per_second;
+ pid_t output_pid;
+ size_t len;
+ unsigned int bar_flags;
+ struct bsddialog_theme theme;
+ struct bsdpv_file_node *curfile;
+ struct bsdpv_file_node *lastfile;
+ struct bsdpv_file_node *list_head;
+ struct timeval now;
+ struct timeval start;
+
+ /* Initialize globals to default values */
+ aprompt = NULL;
+ pprompt = NULL;
+ options = 0;
+ action = NULL;
+ backtitle = NULL;
+ debug = FALSE;
+ display_test = FALSE;
+ display_updates_per_second = DISPLAY_UPDATES_PER_SEC;
+ display_limit = DISPLAY_LIMIT_DEFAULT;
+ display_type = BSDPV_DISPLAY_BSDDIALOG;
+ keep_tite = FALSE;
+ label_size = LABEL_SIZE_DEFAULT;
+ msg_done = NULL;
+ msg_fail = NULL;
+ msg_pending = NULL;
+ no_labels = FALSE;
+ output = NULL;
+ output_type = BSDPV_OUTPUT_NONE;
+ pbar_size = PBAR_SIZE_DEFAULT;
+ status_format_custom = NULL;
+ status_updates_per_second = STATUS_UPDATES_PER_SEC;
+ title = NULL;
+ wide = FALSE;
+
+ /* Process config options (overriding defaults) */
+ if (config != NULL) {
+ if (config->aprompt != NULL) {
+ if (aprompt == NULL) {
+ aprompt = malloc(BSDPV_APROMPT_MAX);
+ if (aprompt == NULL)
+ return (-1);
+ }
+ snprintf(aprompt, BSDPV_APROMPT_MAX, "%s",
+ config->aprompt);
+ }
+ if (config->pprompt != NULL) {
+ if (pprompt == NULL) {
+ pprompt = malloc(BSDPV_PPROMPT_MAX + 2);
+ /* +2 is for implicit "\n" appended later */
+ if (pprompt == NULL)
+ return (-1);
+ }
+ snprintf(pprompt, BSDPV_PPROMPT_MAX, "%s",
+ config->pprompt);
+ }
+
+ options = config->options;
+ action = config->action;
+ backtitle = config->backtitle;
+ debug = config->debug;
+ display_test = ((options & BSDPV_TEST_MODE) != 0);
+ display_updates_per_second =
+ config->display_updates_per_second;
+ display_limit = config->display_limit;
+ display_type = config->display_type;
+ keep_tite = config->keep_tite;
+ label_size = config->label_size;
+ msg_done = (char *)config->msg_done;
+ msg_fail = (char *)config->msg_fail;
+ msg_pending = (char *)config->msg_pending;
+ no_labels = ((options & BSDPV_NO_LABELS) != 0);
+ no_overrun = ((options & BSDPV_NO_OVERRUN) != 0);
+ output = config->output;
+ output_type = config->output_type;
+ pbar_size = config->pbar_size;
+ /* Non-zero means the caller chose a size (including -1). */
+ pbar_explicit = (config->pbar_size != 0);
+ status_updates_per_second = config->status_updates_per_second;
+ title = config->title;
+ wide = ((options & BSDPV_WIDE_MODE) != 0);
+
+ /* Enforce some minimums (pedantic) */
+ if (display_limit < -1)
+ display_limit = -1;
+ if (label_size < -1)
+ label_size = -1;
+ if (pbar_size < -1)
+ pbar_size = -1;
+
+ /*
+ * Mini-pbar: -1 hides; zero means "pick a default" once we
+ * know the display type. Multi-file always wants a default
+ * width; solo is settled after use_cli is known (dialog solo
+ * hides the mini-pbar because the main gauge carries %;
+ * CLI solo needs the mini-pbar because it is the only bar).
+ */
+ if (pbar_size == 0 && file_list != NULL &&
+ file_list->next != NULL)
+ pbar_size = PBAR_SIZE_DEFAULT;
+
+ /*
+ * For the label, -1 means auto-size, zero is invalid unless
+ * specifically requested through the use of options flag.
+ */
+ if (label_size == 0 && no_labels == FALSE)
+ label_size = LABEL_SIZE_DEFAULT;
+
+ /*
+ * status_updates_per_second of 0 disables the status line
+ * (bsdpv(1) -U 0). Do not remap zero to the default; callers
+ * that want STATUS_UPDATES_PER_SEC must set it explicitly.
+ */
+ } /* config != NULL */
+
+ /* Process the type of display we've been requested to produce */
+ use_cli = FALSE;
+ switch (display_type) {
+ case BSDPV_DISPLAY_STDOUT:
+ debug = TRUE;
+ use_color = FALSE;
+ use_bsddialog = FALSE;
+ break;
+ case BSDPV_DISPLAY_CLI:
+ use_color = FALSE;
+ use_bsddialog = FALSE;
+ /* Debug gauge dump (-d) overrides the CLI renderer */
+ if (!debug)
+ use_cli = TRUE;
+ break;
+ default:
+ use_color = TRUE;
+ use_bsddialog = TRUE;
+ break;
+ } /* display_type */
+
+ /* Enforce additional minimums that require knowing our display type */
+ if (display_updates_per_second == 0)
+ display_updates_per_second = DISPLAY_UPDATES_PER_SEC;
+
+ /* Allow forceful override of use_color */
+ if (config != NULL && (config->options & BSDPV_USE_COLOR) != 0)
+ use_color = TRUE;
+
+ /* If $USE_COLOR is set and non-NULL enable color; otherwise disable */
+ if ((cp = getenv(ENV_USE_COLOR)) != 0)
+ use_color = *cp != '\0' ? 1 : 0;
+
+ /*
+ * Initialize bsddialog(3) and read the active theme
+ */
+ bsddialog_initconf(&dconf);
+ dconf.title = title;
+ dconf.text.escape = use_color;
+ dconf.auto_downmargin = 1; /* reserve bottom row for status_printf(3) */
+ if (use_bsddialog && !debug) {
+ enum bsddialog_default_theme deftheme;
+
+ if (bsddialog_init_notheme() != BSDDIALOG_OK) {
+ warnx("%s: %s", __func__, bsddialog_geterror());
+ return (-1);
+ }
+ /*
+ * Leave the alternate screen before applying the theme, as
+ * libdialog does before dlg_clear(3). Otherwise refresh(3)
+ * during keep-tite flushes a blue backdrop into scrollback.
+ */
+ if (!keep_tite)
+ bsddialog_keep_tite();
+ deftheme = bsddialog_hascolors() ? BSDDIALOG_THEME_FLAT :
+ BSDDIALOG_THEME_BLACKWHITE;
+ if (bsddialog_set_default_theme_noflush(deftheme) != BSDDIALOG_OK) {
+ warnx("%s: %s", __func__, bsddialog_geterror());
+ bsddialog_end();
+ return (-1);
+ }
+ bsddialog_fill_backdrop();
+ use_shadow = dconf.shadow;
+ use_colors = bsddialog_hascolors();
+ bsddialog_get_theme(&theme);
+ bar_fg = BSDDIALOG_WHITE;
+ bar_bg = BSDDIALOG_BLUE;
+ bar_flags = BSDDIALOG_BOLD;
+ bsddialog_color_attrs(theme.bar.f_color, &bar_fg, &bar_bg,
+ &bar_flags);
+ gauge_color[0] = '0' + bar_bg;
+ /* NB: filled portion is drawn in reverse video */
+ gauge_color[1] = '0' + bar_fg;
+ gauge_color[2] = (bar_flags & BSDDIALOG_BOLD) != 0 ?
+ 'b' : 'B';
+ gauge_color[3] = '\0';
+ } else if (use_cli) {
+ /*
+ * Match dialog: 0 means "as many as fit" capped at
+ * BSDPV_DISPLAY_LIMIT so -m paging via list_head works.
+ */
+ if (display_limit == 0 || display_limit > BSDPV_DISPLAY_LIMIT)
+ display_limit = BSDPV_DISPLAY_LIMIT;
+ cli_init();
+ }
+ if (debug) {
+ warnx("Finished initializing bsddialog(3) library");
+ warnx("use_shadow = %i (Boolean)", use_shadow);
+ warnx("use_colors = %i (Boolean)", use_colors);
+ warnx("gauge_color=[%s] (FBH)", gauge_color);
+ }
+
+ max_cols = display_maxcols();
+ if (label_size == -1)
+ shrink_label_size = TRUE;
+
+ /* Process file arguments; total_length stays -1 if any size unknown */
+ total_length = 0;
+ for (curfile = file_list; curfile != NULL; curfile = curfile->next) {
+ bsdpv_nfiles++;
+
+ if (total_length >= 0) {
+ if (curfile->length < 0)
+ total_length = -1;
+ else
+ total_length += curfile->length;
+ }
+
+ /* bsddialog(3) only expands literal newlines */
+ if (use_bsddialog)
+ strexpandnl(curfile->name);
+
+ /* Optionally calculate label size for file */
+ if (shrink_label_size) {
+ name = curfile->name;
+ last = prompt_lastline(name);
+ len = prompt_longestline(last);
+ if ((int)len > (label_size - 3)) {
+ if (label_size > 0)
+ label_size += 3;
+ label_size = len;
+ /* Room for ellipsis (unless NULL) */
+ if (label_size > 0)
+ label_size += 3;
+ }
+
+ if (max_cols > 0 && label_size > (max_cols - pbar_size
+ - 9))
+ label_size = max_cols - pbar_size - 9;
+ }
+
+ if (debug)
+ warnx("label=[%s] path=[%s] size=%lli",
+ curfile->name, curfile->path, curfile->length);
+ } /* file_list */
+
+ /* display_test fakes overall_read independently of labeled sizes */
+ if (display_test)
+ total_length = -1;
+
+ /*
+ * Solo + unset -P: dialog hides the mini-pbar (main gauge shows %);
+ * CLI keeps a default-width bar (there is no separate main gauge).
+ * Unlabeled solo still forces a visible field for humanized totals
+ * when -P was left unset; but an explicit -P -1 must be honored.
+ */
+ if (pbar_size == 0) {
+ if (use_cli)
+ pbar_size = PBAR_SIZE_DEFAULT;
+ else
+ pbar_size = -1;
+ }
+ if (!pbar_explicit && bsdpv_nfiles <= 1 && file_list != NULL &&
+ file_list->length < 0 && !display_test)
+ pbar_size = PBAR_SIZE_DEFAULT;
+
+ /* Print error and return `-1' if not given at least one name */
+ if (bsdpv_nfiles == 0) {
+ warnx("%s: no labels provided", __func__);
+ return (-1);
+ } else if (debug)
+ warnx("%s: %u label%s provided", __func__, bsdpv_nfiles,
+ bsdpv_nfiles == 1 ? "" : "s");
+
+ /* Print some debugging information */
+ if (debug)
+ warnx("%s: bsddialog(3) max rows x cols = %i x %i",
+ __func__, display_maxrows(), display_maxcols());
+
+ /*
+ * Dialog gauge text uses an implicit trailing newline on pprompt and
+ * expanded "\n" sequences. CLI headers want real newlines too, but
+ * not a dialog-only "\\n" trailer left unexpanded.
+ */
+ if (use_bsddialog && pprompt != NULL && *pprompt != '\0') {
+ len = strlen(pprompt);
+ /*
+ * NOTE: pprompt = malloc(BSDPV_PPROMPT_MAX + 2)
+ * NOTE: (see config section above for pprompt allocation)
+ */
+ pprompt[len++] = '\\';
+ pprompt[len++] = 'n';
+ pprompt[len++] = '\0';
+ }
+ if (pprompt != NULL)
+ strexpandnl(pprompt);
+ if (aprompt != NULL)
+ strexpandnl(aprompt);
+
+ if (!use_cli)
+ dprompt_init(file_list);
+ /* Reads: label_size pbar_size pprompt aprompt bsdpv_nfiles */
+ /* Inits: dheight and dwidth */
+ if (use_bsddialog && !debug)
+ status_init();
+
+ /* Default localeconv(3) settings for status line */
+ setlocale(LC_NUMERIC,
+ getenv("LC_ALL") == NULL && getenv("LC_NUMERIC") == NULL ?
+ LC_NUMERIC_DEFAULT : "");
+
+ /* Set default/custom status line format */
+ if (bsdpv_nfiles > 1) {
+ snprintf(status_format_default, BSDPV_STATUS_FORMAT_MAX, "%s",
+ BSDPV_STATUS_MANY);
+ status_format_custom = config != NULL ?
+ config->status_many : NULL;
+ } else {
+ snprintf(status_format_default, BSDPV_STATUS_FORMAT_MAX, "%s",
+ BSDPV_STATUS_SOLO);
+ status_format_custom = config != NULL ?
+ config->status_solo : NULL;
+ }
+
+ /* Add test mode identifier to default status line if enabled */
+ if (display_test && (strlen(status_format_default) + 12) <
+ BSDPV_STATUS_FORMAT_MAX)
+ strcat(status_format_default, " [TEST MODE]");
+
+ /* Verify custom status format */
+ status_fmt = fmtcheck(status_format_custom, status_format_default);
+ if (status_format_custom != NULL &&
+ status_fmt == status_format_default) {
+ warnx("WARNING! Invalid status_format configuration `%s'",
+ status_format_custom);
+ warnx("Default status_format `%s'", status_format_default);
+ }
+
+ if (!debug && !use_cli) {
+ /* Build initial prompt text; first physical draw is in loop */
+ dprompt_recreate(file_list, (struct bsdpv_file_node *)NULL, 0);
+ } /* !debug && !use_cli */
+
+ /* Seed the random(3) generator */
+ if (display_test)
+ srandom(0xf1eeface);
+
+ /* Record when we started (used to prevent updating too quickly) */
+ (void)gettimeofday(&start, (struct timezone *)NULL);
+
+ /* Calculate number of microseconds in-between sub-second updates */
+ if (status_updates_per_second != 0)
+ status_update_usec = 1000000 / status_updates_per_second;
+ if (display_updates_per_second != 0)
+ display_update_usec = 1000000 / display_updates_per_second;
+
+ /*
+ * Process the file list [serially] (one for each argument passed)
+ */
+ files_left = bsdpv_nfiles;
+ list_head = file_list;
+ lastfile = NULL;
+ for (curfile = file_list; curfile != NULL; curfile = curfile->next) {
+ lastfile = curfile;
+ keep_going = TRUE;
+ forced_100 = FALSE;
+ output_out = -1;
+ pct = 0;
+ nthfile++;
+ files_left--;
+
+ if (bsdpv_interrupt)
+ break;
+ if (display_test)
+ pct = 0 - increment;
+
+ /* Attempt to spawn output program for this file */
+ if (!display_test && output != NULL) {
+ mask = umask(0022);
+ (void)umask(mask);
+
+ switch (output_type) {
+ case BSDPV_OUTPUT_SHELL:
+ output_out = shell_spawn_pipecmd(output,
+ curfile->name, &output_pid);
+ break;
+ case BSDPV_OUTPUT_FILE:
+ path_fmt = fmtcheck(output, "%s");
+ if (path_fmt == output)
+ len = snprintf(pathbuf,
+ PATH_MAX, output, curfile->name);
+ else
+ len = snprintf(pathbuf,
+ PATH_MAX, "%s", output);
+ if (len >= PATH_MAX) {
+ warnx("%s:%d:%s: pathbuf[%u] too small"
+ "to hold output argument",
+ __FILE__, __LINE__, __func__,
+ PATH_MAX);
+ return (-1);
+ }
+ /* "-" is a synonym for standard output */
+ if (strcmp(pathbuf, "-") == 0)
+ output_out = STDOUT_FILENO;
+ else if ((output_out = open(pathbuf,
+ O_CREAT | O_WRONLY, DEFFILEMODE & ~mask))
+ < 0) {
+ warn("%s", pathbuf);
+ return (-1);
+ }
+ break;
+ default:
+ break;
+ }
+ }
+
+ while (!bsdpv_interrupt && keep_going) {
+ int do_display;
+ int do_status;
+ double rate;
+
+ if (display_test) {
+ int fake;
+
+ usleep(50000);
+ pct += increment;
+ /* 512 limits fake readout to Megabytes */
+ fake = (int)(random() / 512 / bsdpv_nfiles);
+ bsdpv_overall_read += fake;
+ curfile->read += fake;
+ } else if (action != NULL)
+ pct = action(curfile, output_out);
+
+ /* Ctrl-C during action (e.g. Waiting for data): exit */
+ if (bsdpv_interrupt)
+ break;
+
+ /*
+ * End the file when the action marks it done/failed.
+ * Do not keep calling action after FAILED (e.g. open
+ * error); with -N, pct < 100 alone would loop forever.
+ */
+ if (curfile->status != BSDPV_STATUS_RUNNING)
+ keep_going = FALSE;
+ else if (no_overrun || display_test)
+ keep_going = (pct < 100);
+ else
+ keep_going = TRUE;
+
+ /* Get current time and calculate seconds elapsed */
+ gettimeofday(&now, (struct timezone *)NULL);
+ now.tv_sec = now.tv_sec - start.tv_sec;
+ now.tv_usec = now.tv_usec - start.tv_usec;
+ if (now.tv_usec < 0)
+ now.tv_sec--, now.tv_usec += 1000000;
+ seconds = now.tv_sec + (now.tv_usec / 1000000.0);
+ display_did_update = FALSE;
+ rate = (bsdpv_overall_read /
+ (seconds == 0 ? 1 : seconds) * 1.0);
+
+ /*
+ * Display/twiddle: display_updates_per_second (default
+ * 16); same for -D and CLI. Status/throughput text:
+ * status_updates_per_second (default 2; -U) so humans
+ * can read it. CLI rewrites only the rows that need
+ * it (CSI).
+ *
+ * Force one paint the first time pct reaches 100, and
+ * again when leaving the file. While pct stays at or
+ * above 100, do not force a paint on every iteration.
+ */
+ do_display = (display_updates_per_second != 0 &&
+ (
+ keep_going != TRUE ||
+ (pct >= 100 && !forced_100) ||
+ seconds != display_old_seconds ||
+ now.tv_usec - display_last_update >=
+ display_update_usec ||
+ nthfile != display_old_nthfile
+ ));
+ do_status = (status_updates_per_second != 0 &&
+ (
+ keep_going != TRUE ||
+ seconds != status_old_seconds ||
+ now.tv_usec - status_last_update >=
+ status_update_usec ||
+ nthfile != status_old_nthfile
+ ));
+
+ if (use_cli) {
+ if (do_display || do_status) {
+ if (pct >= 100)
+ forced_100 = TRUE;
+ overall = calc_overall(file_list,
+ total_length, nthfile, pct);
+ if (do_display) {
+ cli_draw_progress(list_head,
+ curfile, pct, overall);
+ display_old_seconds = seconds;
+ display_old_nthfile = nthfile;
+ display_last_update =
+ now.tv_usec;
+ }
+ if (do_status) {
+ cli_draw_status(status_fmt,
+ bsdpv_overall_read, rate,
+ 1, /* XXX parallelism XXX */
+ files_left);
+ status_old_seconds = seconds;
+ status_old_nthfile = nthfile;
+ status_last_update =
+ now.tv_usec;
+ }
+ }
+ } else if (do_display) {
+ if (pct >= 100)
+ forced_100 = TRUE;
+ overall = calc_overall(file_list, total_length,
+ nthfile, pct);
+
+ dprompt_recreate(list_head, curfile, pct);
+
+ if (!debug) {
+ /* Update bsddialog(3) widget */
+ dprompt_draw(pprompt, aprompt,
+ overall);
+ } else {
+ /* stdout */
+ dprompt_dprint(STDOUT_FILENO, pprompt,
+ aprompt, overall);
+ fsync(STDOUT_FILENO);
+ }
+ display_old_seconds = seconds;
+ display_old_nthfile = nthfile;
+ display_last_update = now.tv_usec;
+ display_did_update = TRUE;
+ }
+
+ /* Dialog status line (independent of gauge rate) */
+ if (use_bsddialog && !debug && do_status) {
+ status_printf(status_fmt, bsdpv_overall_read,
+ rate,
+ 1, /* XXX until we add parallelism XXX */
+ files_left);
+ status_old_seconds = seconds;
+ status_old_nthfile = nthfile;
+ status_last_update = now.tv_usec;
+ } else if (use_bsddialog && !debug &&
+ display_did_update) {
+ status_backdrop_flush();
+ bsddialog_gauge_flush();
+ /*
+ * With -U 0, do_status never runs, so the
+ * throughput line never replaces Opening /
+ * Waiting from bsdpv_announce(). Clear that
+ * transient text; otherwise status_reflush()
+ * would keep painting it after every gauge
+ * update.
+ */
+ if (status_updates_per_second == 0)
+ status_printf(NULL);
+ else
+ status_reflush();
+ }
+ }
+
+ /*
+ * -T never runs action(), so mark DONE after the final paint
+ * for this file before advancing to the next path.
+ */
+ if (display_test && !bsdpv_interrupt &&
+ curfile->status == BSDPV_STATUS_RUNNING && pct >= 100)
+ curfile->status = BSDPV_STATUS_DONE;
+
+ if (!display_test && output_out >= 0) {
+ if (output_type == BSDPV_OUTPUT_SHELL) {
+ close(output_out);
+ waitpid(output_pid, (int *)NULL, 0);
+ } else if (output_out != STDOUT_FILENO)
+ close(output_out);
+ }
+
+ if (bsdpv_abort)
+ break;
+
+ /* Advance head of list when we hit the max display lines */
+ if (display_limit > 0 && nthfile % display_limit == 0)
+ list_head = curfile->next;
+ }
+
+ if (use_cli) {
+ /*
+ * -T marks each file DONE after its last 100% paint. Repaint
+ * once at exit so the last file shows Done.
+ */
+ if (display_test && !bsdpv_interrupt && !bsdpv_abort &&
+ lastfile != NULL &&
+ lastfile->status == BSDPV_STATUS_DONE) {
+ int final_overall;
+ double rate;
+
+ final_overall = calc_overall(file_list, total_length,
+ bsdpv_nfiles, 100);
+ cli_draw_progress(list_head, lastfile, 100,
+ final_overall);
+ if (status_updates_per_second != 0) {
+ gettimeofday(&now, (struct timezone *)NULL);
+ now.tv_sec = now.tv_sec - start.tv_sec;
+ now.tv_usec = now.tv_usec - start.tv_usec;
+ if (now.tv_usec < 0)
+ now.tv_sec--, now.tv_usec += 1000000;
+ seconds = now.tv_sec +
+ (now.tv_usec / 1000000.0);
+ rate = (bsdpv_overall_read /
+ (seconds == 0 ? 1 : seconds) * 1.0);
+ cli_draw_status(status_fmt, bsdpv_overall_read,
+ rate, 1, 0);
+ }
+ }
+ cli_finish(bsdpv_interrupt || bsdpv_abort);
+ } else if (!debug) {
+ if (bsddialog_inmode() && lastfile != NULL) {
+ if (!bsdpv_interrupt && !bsdpv_abort) {
+ int final_overall;
+ int final_pct;
+
+ /*
+ * Final gauge percent from calc_overall(); do
+ * not force 100%. Same rules as the live loop.
+ */
+ if (lastfile->status == BSDPV_STATUS_FAILED &&
+ lastfile->length > 0) {
+ final_pct = (int)(lastfile->read *
+ 100 / lastfile->length);
+ if (final_pct > 100)
+ final_pct = 100;
+ if (final_pct < 0)
+ final_pct = 0;
+ } else if (lastfile->status ==
+ BSDPV_STATUS_FAILED)
+ final_pct = 0;
+ else
+ final_pct = 100;
+ final_overall = calc_overall(file_list,
+ total_length, nthfile, final_pct);
+ dprompt_recreate(list_head, lastfile,
+ final_pct);
+ dprompt_draw(pprompt, aprompt, final_overall);
+ bsddialog_gauge_flush();
+ if (status_updates_per_second != 0) {
+ gettimeofday(&now, (struct timezone *)NULL);
+ now.tv_sec = now.tv_sec - start.tv_sec;
+ now.tv_usec = now.tv_usec - start.tv_usec;
+ if (now.tv_usec < 0)
+ now.tv_sec--, now.tv_usec += 1000000;
+ seconds = now.tv_sec +
+ (now.tv_usec / 1000000.0);
+ status_printf(status_fmt, bsdpv_overall_read,
+ (bsdpv_overall_read / (seconds == 0 ? 1 :
+ seconds) * 1.0),
+ 1, files_left);
+ } else
+ /* -U 0: drop leftover announce text */
+ status_printf(NULL);
+ } else {
+ /*
+ * Match dpv(1): leave the last status line on
+ * screen after Ctrl-C or abort for examination.
+ */
+ status_reflush();
+ }
+ if (!keep_tite) {
+ if (bsdpv_interrupt || bsdpv_abort)
+ status_leave_no_nl();
+ else
+ status_leave();
+ }
+ }
+ bsdpv_end();
+ if (bsdpv_interrupt || bsdpv_abort)
+ status_restore_tty();
+ dprompt_gauge_close();
+ } else
+ warnx("%s: %lli overall read", __func__, bsdpv_overall_read);
+
+ if (bsdpv_interrupt || bsdpv_abort)
+ return (-1);
+ else
+ return (0);
+}
+
+/*
+ * User-visible one-line status while blocked outside the normal progress
+ * loop (e.g., waiting for the first input on a pipe or -m path). Routes to
+ * the dialog status line, the CLI progress line, or warnx(3).
+ */
+void
+bsdpv_announce(const char *fmt, ...)
+{
+ char buf[256];
+ va_list ap;
+ int cols;
+
+ va_start(ap, fmt);
+ (void)vsnprintf(buf, sizeof(buf), fmt, ap);
+ va_end(ap);
+
+ if (bsddialog_inmode()) {
+ status_printf("%s", buf);
+ return;
+ }
+ if (cli_announce(buf))
+ return;
+ if (isatty(STDERR_FILENO)) {
+ cols = tty_maxcols();
+ if (cols < 20)
+ cols = 20;
+ (void)fprintf(stderr, "\r%-*.*s\033[K", cols - 1, cols - 1,
+ buf);
+ (void)fflush(stderr);
+ return;
+ }
+ warnx("%s", buf);
+}
+
+/*
+ * End bsddialog(3) mode, restoring the terminal. Safe to call even when
+ * bsddialog(3) mode is not active (e.g., from an error path).
+ */
+void
+bsdpv_end(void)
+{
+ if (bsddialog_inmode())
+ bsddialog_end();
+}
+
+/*
+ * Free allocated items initialized by bsdpv()
+ */
+void
+bsdpv_free(void)
+{
+ cli_free();
+ dprompt_free();
+ display_maxsize_free();
+ if (aprompt != NULL) {
+ free(aprompt);
+ aprompt = NULL;
+ }
+ if (pprompt != NULL) {
+ free(pprompt);
+ pprompt = NULL;
+ }
+ status_free();
+}
diff --git a/lib/libbsdpv/bsdpv_private.h b/lib/libbsdpv/bsdpv_private.h
new file mode 100644
--- /dev/null
+++ b/lib/libbsdpv/bsdpv_private.h
@@ -0,0 +1,43 @@
+/*
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ */
+
+#ifndef _BSDPV_PRIVATE_H_
+#define _BSDPV_PRIVATE_H_
+
+#include <sys/types.h>
+
+/* Debugging */
+extern uint8_t debug;
+
+/* Data to process */
+extern unsigned int bsdpv_nfiles;
+
+/* Extra display information */
+extern uint8_t keep_tite;
+extern uint8_t no_labels;
+extern uint8_t wide;
+extern char *msg_done, *msg_fail, *msg_pending;
+extern char *pprompt, *aprompt;
+
+/* Defaults */
+#define DISPLAY_UPDATES_PER_SEC 16
+#define DISPLAY_LIMIT_DEFAULT 0 /* Auto-calculate */
+#define LABEL_SIZE_DEFAULT 28
+#define PBAR_SIZE_DEFAULT 17
+#define STATUS_UPDATES_PER_SEC 2
+
+/* states for dprompt_add_files() of dprompt.c */
+enum dprompt_state {
+ DPROMPT_NONE = 0, /* Default */
+ DPROMPT_PENDING, /* Pending */
+ DPROMPT_PBAR, /* Progress bar */
+ DPROMPT_END_STATE, /* Done/Fail */
+ DPROMPT_DETAILS, /* bsdpv_file_node->read */
+ DPROMPT_CUSTOM_MSG, /* bsdpv_file_node->msg */
+ DPROMPT_MINIMAL, /* whitespace */
+};
+
+#endif /* !_BSDPV_PRIVATE_H_ */
diff --git a/lib/libbsdpv/cli.h b/lib/libbsdpv/cli.h
new file mode 100644
--- /dev/null
+++ b/lib/libbsdpv/cli.h
@@ -0,0 +1,37 @@
+/*
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2026 Devin Teske <dteske@FreeBSD.org>
+ */
+
+#ifndef _CLI_H_
+#define _CLI_H_
+
+#include <sys/cdefs.h>
+
+#include "bsdpv.h"
+
+__BEGIN_DECLS
+void cli_init(void);
+/*
+ * Progress lines (per-file + Overall) and twiddle. Intended for
+ * display_updates_per_second; same cadence as -D gauge/twiddle.
+ */
+void cli_draw_progress(struct bsdpv_file_node *_file_list,
+ struct bsdpv_file_node *_curfile, int _pct, int _overall);
+/*
+ * Status / throughput line only. Intended for status_updates_per_second (-U).
+ */
+void cli_draw_status(const char *_status_fmt, long long _overall_read,
+ double _rate, int _busy, int _waiting);
+/* Full block once (progress + status); used for forced end-of-file paints. */
+void cli_draw(struct bsdpv_file_node *_file_list,
+ struct bsdpv_file_node *_curfile, int _pct, int _overall,
+ const char *_status_fmt, long long _overall_read, double _rate,
+ int _busy, int _waiting);
+int cli_announce(const char *_msg);
+void cli_finish(int _interrupted);
+void cli_free(void);
+__END_DECLS
+
+#endif /* !_CLI_H_ */
diff --git a/lib/libbsdpv/cli.c b/lib/libbsdpv/cli.c
new file mode 100644
--- /dev/null
+++ b/lib/libbsdpv/cli.c
@@ -0,0 +1,787 @@
+/*
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2026 Devin Teske <dteske@FreeBSD.org>
+ */
+
+#include <sys/types.h>
+
+#include <err.h>
+#include <libutil.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "bsdpv.h"
+#include "bsdpv_private.h"
+#include "cli.h"
+#include "display.h"
+#include "dprompt.h"
+
+#define CLI_LINE_MAX 512
+#define CLI_BAR_MAX 40
+#define CLI_LABEL_MAX 128
+#define CLI_SPIN_SIZE 4
+/* file lines + Overall + several -a rows (status is painted separately) */
+#define CLI_APROMPT_ROWS_MAX 16
+#define CLI_ROWS_MAX (BSDPV_DISPLAY_LIMIT + 1 + CLI_APROMPT_ROWS_MAX)
+
+static char *cli_line;
+static int cli_active;
+static int cli_tty;
+static int cli_drawn;
+static int cli_headers_done;
+static int cli_block_rows; /* total content lines on screen */
+static int cli_progress_rows; /* lines above the status row */
+static int cli_have_status; /* status row is part of the block */
+static int cli_sticky; /* announce painted before first draw */
+static int cli_status_is_announce; /* status_buf holds Opening/Waiting */
+static char cli_status_buf[CLI_LINE_MAX];
+static char cli_spin[CLI_SPIN_SIZE + 1] = "/-\\|";
+static char *cli_spin_cp = cli_spin;
+
+static char
+cli_spin_char(void)
+{
+ char ch;
+
+ if (*cli_spin_cp == '\0')
+ cli_spin_cp = cli_spin;
+ ch = *cli_spin_cp;
+ if (++cli_spin_cp >= (cli_spin + CLI_SPIN_SIZE))
+ cli_spin_cp = cli_spin;
+ return (ch);
+}
+
+static void
+cli_put_static(const char *text, int cols)
+{
+ const char *p;
+ const char *start;
+ int n;
+
+ if (text == NULL || *text == '\0')
+ return;
+
+ /*
+ * Static headers sit above the progress block. Print each logical
+ * line once, truncated to the terminal width.
+ */
+ for (p = start = text; ; p++) {
+ if (*p != '\n' && *p != '\0')
+ continue;
+ n = (int)(p - start);
+ if (n > cols - 1)
+ n = cols - 1;
+ if (n < 0)
+ n = 0;
+ (void)fprintf(stderr, "%.*s\n", n, start);
+ if (*p == '\0')
+ break;
+ start = p + 1;
+ }
+}
+
+static void
+cli_fill_bar(char *bar, int bar_width, int pct)
+{
+ int filled;
+ int i;
+
+ if (bar_width <= 0) {
+ bar[0] = '\0';
+ return;
+ }
+ if (bar_width > CLI_BAR_MAX)
+ bar_width = CLI_BAR_MAX;
+ filled = ((pct > 100 ? 100 : (pct < 0 ? 0 : pct)) * bar_width) / 100;
+ if (filled > bar_width)
+ filled = bar_width;
+ for (i = 0; i < bar_width; i++)
+ bar[i] = (i < filled) ? '=' : '-';
+ bar[bar_width] = '\0';
+}
+
+/* Center text in width columns (no brackets). */
+static void
+cli_center_text(char *dst, size_t dstsz, int width, const char *text)
+{
+ int tlen;
+ int lpad;
+ int rpad;
+
+ if (width < 0)
+ width = 0;
+ if (width > CLI_BAR_MAX)
+ width = CLI_BAR_MAX;
+ tlen = (int)strlen(text);
+ if (tlen > width)
+ tlen = width;
+ lpad = (width - tlen) / 2;
+ rpad = width - tlen - lpad;
+ (void)snprintf(dst, dstsz, "%*s%.*s%*s", lpad, "", tlen, text, rpad,
+ "");
+}
+
+static void
+cli_bracket_text(char *dst, size_t dstsz, int width, const char *text)
+{
+ char inner[CLI_BAR_MAX + 1];
+
+ cli_center_text(inner, sizeof(inner), width, text);
+ (void)snprintf(dst, dstsz, "[%s]", inner);
+}
+
+static void
+cli_fmt_label(char *flabel, size_t flabel_sz, const char *name, int *out_width)
+{
+ int flabel_size;
+
+ if (out_width != NULL)
+ *out_width = 0;
+ if (no_labels || label_size == 0) {
+ flabel[0] = '\0';
+ return;
+ }
+ flabel_size = label_size;
+ if (flabel_size < 0)
+ flabel_size = (int)strlen(name);
+ if (flabel_size >= (int)flabel_sz)
+ flabel_size = (int)flabel_sz - 1;
+ if (snprintf(flabel, (size_t)flabel_size + 1, "%s", name) >
+ flabel_size && flabel_size >= 3)
+ (void)sprintf(flabel + flabel_size - 3, "...");
+ if (out_width != NULL)
+ *out_width = flabel_size;
+}
+
+static int
+cli_bar_width(int cols)
+{
+ int bar_width;
+
+ if (pbar_size < 0)
+ return (-1);
+ bar_width = pbar_size > 0 ? pbar_size : (cols / 5);
+ if (bar_width < 4)
+ bar_width = 4;
+ if (bar_width > CLI_BAR_MAX)
+ bar_width = CLI_BAR_MAX;
+ return (bar_width);
+}
+
+static int
+cli_fmt_bracket_line(char *dst, size_t dstsz, int label_w, const char *flabel,
+ char spin, const char *bracket)
+{
+
+ /*
+ * Live pct lines use "%-*s %c %3d%% [%s]"; eight columns between the
+ * label and '[' (" %c " + "%3d%%" + " "). End-state lines must pad the
+ * same width so Done/Pending brackets line up with the progress bar.
+ */
+ if (label_w > 0)
+ return (snprintf(dst, dstsz, "%-*s %c %s", label_w,
+ flabel, spin, bracket) < 0 ? -1 : 0);
+ return (snprintf(dst, dstsz, "%c %s", spin, bracket) < 0 ? -1 : 0);
+}
+
+/*
+ * Format one progress/status content line into dst (no trailing newline).
+ * Returns 0 on success.
+ */
+static int
+cli_fmt_file_line(char *dst, size_t dstsz, struct bsdpv_file_node *fp,
+ struct bsdpv_file_node *curfile, int cur_pct, int after_cur, int cols)
+{
+ char bar[CLI_BAR_MAX + 8];
+ char flabel[CLI_LABEL_MAX];
+ char human[32];
+ const char *done_txt;
+ const char *fail_txt;
+ const char *pend_txt;
+ const char *name;
+ int bar_width;
+ int fpct;
+ int label_w;
+ char spin;
+
+ name = (fp->name != NULL) ? fp->name : "";
+ cli_fmt_label(flabel, sizeof(flabel), name, &label_w);
+ bar_width = cli_bar_width(cols);
+ done_txt = (msg_done != NULL) ? msg_done : BSDPV_DONE_DEFAULT;
+ fail_txt = (msg_fail != NULL) ? msg_fail : BSDPV_FAIL_DEFAULT;
+ pend_txt = (msg_pending != NULL) ? msg_pending : BSDPV_PENDING_DEFAULT;
+
+ spin = ' ';
+ if (fp == curfile && fp->status == BSDPV_STATUS_RUNNING)
+ spin = cli_spin_char();
+
+ /*
+ * -P -1: match dialog; label + twiddle only. Hide the mini-pbar and
+ * Done/Pending/Fail/count/% that would sit inside it. In -m, Overall
+ * still gets its own fill bar (see cli_fmt_overall_line).
+ */
+ if (bar_width < 0) {
+ if (label_w > 0)
+ return (snprintf(dst, dstsz, "%-*s %c", label_w, flabel,
+ spin) < 0 ? -1 : 0);
+ return (snprintf(dst, dstsz, "%c", spin) < 0 ? -1 : 0);
+ }
+
+ if (fp->msg != NULL) {
+ cli_bracket_text(bar, sizeof(bar), bar_width, fp->msg);
+ return (cli_fmt_bracket_line(dst, dstsz, label_w, flabel, spin,
+ bar));
+ }
+
+ if (after_cur) {
+ cli_bracket_text(bar, sizeof(bar), bar_width, pend_txt);
+ return (cli_fmt_bracket_line(dst, dstsz, label_w, flabel, ' ',
+ bar));
+ }
+
+ if (fp->status == BSDPV_STATUS_FAILED) {
+ /*
+ * Plain brackets like Done/Pending; CLI has no green on Done
+ * and no color on '='/'‑' fills, so no red fill on Fail.
+ */
+ cli_bracket_text(bar, sizeof(bar), bar_width, fail_txt);
+ return (cli_fmt_bracket_line(dst, dstsz, label_w, flabel, ' ',
+ bar));
+ }
+
+ /*
+ * Live transfer (including overrun past 100% without -N): keep the
+ * twiddle and numeric %; never "Done" until status is DONE.
+ * Unknown length → humanized count (except -T, which drives a
+ * synthetic % like dprompt). Known length → percentage + fill.
+ */
+ if (fp->status == BSDPV_STATUS_RUNNING) {
+ if (fp == curfile)
+ fpct = cur_pct;
+ else if (fp->length > 0)
+ fpct = (int)(fp->read * 100 / fp->length);
+ else
+ fpct = 0;
+ if (fpct < 0)
+ fpct = 0;
+ if (fp->length < 0 && !display_test) {
+ humanize_number(human, bar_width + 2, fp->read, "",
+ HN_AUTOSCALE, HN_NOSPACE | HN_DIVISOR_1000);
+ cli_bracket_text(bar, sizeof(bar), bar_width, human);
+ return (cli_fmt_bracket_line(dst, dstsz, label_w,
+ flabel, spin, bar));
+ }
+ /* Fill saturates; the percentage itself may exceed 100. */
+ cli_fill_bar(bar, bar_width, fpct);
+ if (label_w > 0)
+ return (snprintf(dst, dstsz, "%-*s %c %3d%% [%s]",
+ label_w, flabel, spin, fpct, bar) < 0 ? -1 : 0);
+ return (snprintf(dst, dstsz, "%c %3d%% [%s]", spin, fpct, bar)
+ < 0 ? -1 : 0);
+ }
+
+ /*
+ * True end state. Unlabeled → humanized amount read; labeled (or
+ * -T, where % is synthetic like dprompt) → Done.
+ */
+ if (fp->length < 0 && !display_test) {
+ humanize_number(human, bar_width + 2, fp->read, "",
+ HN_AUTOSCALE, HN_NOSPACE | HN_DIVISOR_1000);
+ cli_bracket_text(bar, sizeof(bar), bar_width, human);
+ } else
+ cli_bracket_text(bar, sizeof(bar), bar_width, done_txt);
+ return (cli_fmt_bracket_line(dst, dstsz, label_w, flabel, ' ', bar));
+}
+
+static int
+cli_fmt_overall_line(char *dst, size_t dstsz, int overall, int cols)
+{
+ char bar[CLI_BAR_MAX + 1];
+ int bar_width;
+ int label_w;
+
+ /*
+ * Overall always keeps a fill bar. -P -1 only hides per-file
+ * mini-pbars (and their contents in -m), not this gauge. Use the
+ * same default width as multi-file mini-pbars so Overall does not
+ * pick up cols/5 and become one (or more) columns wider.
+ */
+ bar_width = cli_bar_width(cols);
+ if (bar_width < 0)
+ bar_width = PBAR_SIZE_DEFAULT;
+ label_w = 0;
+ if (!no_labels && label_size != 0) {
+ label_w = label_size;
+ if (label_w < 0)
+ label_w = (int)sizeof("Overall") - 1;
+ }
+ cli_fill_bar(bar, bar_width, overall);
+ /* Blank twiddle column so '[' lines up with per-file bars */
+ if (label_w > 0)
+ return (snprintf(dst, dstsz, "%-*s %3d%% [%s]", label_w,
+ "Overall", overall, bar) < 0 ? -1 : 0);
+ return (snprintf(dst, dstsz, "Overall: %3d%% [%s]", overall, bar) < 0 ?
+ -1 : 0);
+}
+
+static int
+cli_fmt_status_line(char *dst, size_t dstsz, const char *status_fmt,
+ long long overall_read, double rate, int busy, int waiting)
+{
+
+ if (status_fmt == NULL) {
+ dst[0] = '\0';
+ return (0);
+ }
+ if (bsdpv_nfiles > 1)
+ return (snprintf(dst, dstsz, status_fmt, overall_read, rate,
+ busy, waiting) < 0 ? -1 : 0);
+ return (snprintf(dst, dstsz, status_fmt, overall_read, rate) < 0 ?
+ -1 : 0);
+}
+
+/*
+ * -a text as row(s) between progress bars and the status line (dialog puts
+ * aprompt below the bars; CLI matches that, including embedded newlines /
+ * "\\n" after strexpandnl). Returns the number of lines appended.
+ */
+static int
+cli_add_aprompt_lines(char lines[][CLI_LINE_MAX], int nrows, int rows_max)
+{
+ const char *ap;
+ const char *start;
+ const char *p;
+ int added;
+ size_t n;
+
+ ap = aprompt;
+ if (ap == NULL || *ap == '\0' || nrows >= rows_max)
+ return (0);
+
+ added = 0;
+ for (p = start = ap; ; p++) {
+ if (*p != '\n' && *p != '\0')
+ continue;
+ if (nrows >= rows_max)
+ break;
+ n = (size_t)(p - start);
+ if (n >= CLI_LINE_MAX)
+ n = CLI_LINE_MAX - 1;
+ (void)memcpy(lines[nrows], start, n);
+ lines[nrows][n] = '\0';
+ nrows++;
+ added++;
+ if (*p == '\0')
+ break;
+ start = p + 1;
+ }
+ return (added);
+}
+
+/*
+ * Move the cursor to the first line of the progress block. Cursor is assumed
+ * to sit on the blank line immediately below the block (each content line was
+ * terminated with \n).
+ */
+static void
+cli_home(void)
+{
+
+ if (cli_tty && cli_block_rows > 0)
+ (void)fprintf(stderr, "\033[%dA", cli_block_rows);
+}
+
+static void
+cli_emit_line(const char *text, int cols)
+{
+ int width = cols - 1;
+
+ if (width < 1)
+ width = 1;
+ if (cli_tty)
+ (void)fprintf(stderr, "\r%-*.*s\033[K\n", width, width, text);
+ else
+ (void)fprintf(stderr, "%s\n", text);
+}
+
+/*
+ * Human-readable progress on stderr. Data output (-o/-x) stays on its own
+ * fd; progress never contaminates that stream.
+ *
+ * On a TTY, multi-file mode paints a CSI-updated block:
+ * per-file lines (up to display_limit; -n -1 hides them)
+ * Overall: ...
+ * -a text (optional; between bars and status)
+ * status line (rate / busy-wait)
+ *
+ * Solo is the same without Overall / per-file siblings: mini-pbar, optional
+ * -a, then status.
+ *
+ * Progress/twiddle refresh at display_updates_per_second (same as -D).
+ * Status refreshes at status_updates_per_second (-U). Each can rewrite
+ * only its rows via CSI.
+ *
+ * Dialog-oriented options map onto the CLI as follows:
+ * -b backtitle one-time header above the progress block
+ * -t title one-time header above the progress block
+ * -p text one-time header above the progress block
+ * -a text row between progress bars and the status line
+ * -P size progress-bar width; -1 hides the bar
+ * -L size label width (ellipsis when truncated)
+ * -n num file lines per page (negative = overall only)
+ * BSDPV_NO_LABELS omit labels
+ */
+void
+cli_init(void)
+{
+
+ cli_active = 1;
+ cli_tty = isatty(STDERR_FILENO);
+ cli_drawn = 0;
+ cli_headers_done = 0;
+ cli_block_rows = 0;
+ cli_progress_rows = 0;
+ cli_have_status = 0;
+ cli_sticky = 0;
+ cli_status_is_announce = 0;
+ cli_status_buf[0] = '\0';
+ cli_spin_cp = cli_spin;
+ if (cli_line == NULL) {
+ cli_line = malloc(CLI_LINE_MAX);
+ if (cli_line == NULL)
+ return;
+ }
+ cli_line[0] = '\0';
+}
+
+static void
+cli_drop_announce(void)
+{
+
+ /*
+ * Opening/Waiting is stored in cli_status_buf. With -U 0, status
+ * never redraws, so progress would keep re-emitting that text as
+ * the status row (and cli_finish could print it at exit). Drop it
+ * when progress resumes.
+ */
+ if (!cli_status_is_announce)
+ return;
+ cli_status_buf[0] = '\0';
+ cli_have_status = 0;
+ cli_status_is_announce = 0;
+}
+
+static void
+cli_clear_sticky(void)
+{
+
+ /*
+ * Opening/Waiting announce is painted with \\r and no newline
+ * (cli_sticky). Clear it before headers or progress so -p/-t/-b
+ * text is not appended onto the same line.
+ */
+ if (!cli_sticky || !cli_tty)
+ return;
+ (void)fprintf(stderr, "\r\033[K");
+ cli_sticky = 0;
+}
+
+static void
+cli_ensure_headers(int cols)
+{
+
+ if (cli_headers_done)
+ return;
+ /*
+ * Opening/Waiting may already occupy this line (\\r, no newline).
+ * Always clear it before static headers so -p/-t/-b never append
+ * onto the announce (do not rely on cli_sticky alone).
+ */
+ if (cli_tty) {
+ (void)fprintf(stderr, "\r\033[K");
+ cli_sticky = 0;
+ }
+ /* Match dialog: backtitle above title, then -p above the bars. */
+ cli_put_static(backtitle, cols);
+ cli_put_static(title, cols);
+ cli_put_static(pprompt, cols);
+ (void)fflush(stderr);
+ cli_headers_done = 1;
+}
+
+static int
+cli_fmt_progress_lines(char lines[][CLI_LINE_MAX],
+ struct bsdpv_file_node *file_list, struct bsdpv_file_node *curfile,
+ int pct, int overall, int cols)
+{
+ struct bsdpv_file_node *fp;
+ int after_cur;
+ int limit;
+ int n;
+ int nrows;
+ int show_files;
+
+ if (pct < 0)
+ pct = 0;
+ if (overall < 0)
+ overall = 0;
+
+ nrows = 0;
+ show_files = (bsdpv_nfiles > 1 && display_limit >= 0);
+ limit = display_limit;
+ if (show_files) {
+ if (limit == 0 || limit > BSDPV_DISPLAY_LIMIT)
+ limit = BSDPV_DISPLAY_LIMIT;
+ after_cur = 0;
+ n = 0;
+ for (fp = file_list; fp != NULL && n < limit; fp = fp->next) {
+ if (cli_fmt_file_line(lines[nrows], CLI_LINE_MAX, fp,
+ curfile, pct, after_cur, cols) == 0)
+ nrows++;
+ n++;
+ if (fp == curfile)
+ after_cur = 1;
+ if (nrows >= CLI_ROWS_MAX - 2)
+ break;
+ }
+ } else if (bsdpv_nfiles <= 1 && curfile != NULL) {
+ if (cli_fmt_file_line(lines[nrows], CLI_LINE_MAX, curfile,
+ curfile, pct, 0, cols) == 0)
+ nrows++;
+ }
+
+ if (bsdpv_nfiles > 1) {
+ if (cli_fmt_overall_line(lines[nrows], CLI_LINE_MAX, overall,
+ cols) == 0)
+ nrows++;
+ }
+ nrows += cli_add_aprompt_lines(lines, nrows, CLI_ROWS_MAX);
+ return (nrows);
+}
+
+static void
+cli_paint_status_line(const char *text, int cols __unused)
+{
+
+ if (!cli_tty) {
+ (void)fprintf(stderr, "%s\n", text);
+ return;
+ }
+ /*
+ * Do not space-pad to the terminal width; that left a long blank
+ * trail after "Waiting for data...". \033[K clears the rest.
+ */
+ if (cli_drawn && cli_have_status && cli_block_rows > 0) {
+ /* Cursor is below the block; status is the last content row. */
+ (void)fprintf(stderr, "\033[1A\r%s\033[K\n", text);
+ } else if (cli_drawn && cli_block_rows > 0 && !cli_have_status) {
+ /* Append a new status row below progress. */
+ (void)fprintf(stderr, "\r%s\033[K\n", text);
+ cli_have_status = 1;
+ cli_block_rows = cli_progress_rows + 1;
+ } else {
+ (void)fprintf(stderr, "\r%s\033[K", text);
+ cli_sticky = 1;
+ }
+ (void)fflush(stderr);
+}
+
+void
+cli_draw_progress(struct bsdpv_file_node *file_list,
+ struct bsdpv_file_node *curfile, int pct, int overall)
+{
+ char lines[CLI_ROWS_MAX][CLI_LINE_MAX];
+ int cols;
+ int i;
+ int nrows;
+ int old_rows;
+
+ if (cli_line == NULL || bsdpv_interrupt)
+ return;
+
+ cols = tty_maxcols();
+ if (cols < 20)
+ cols = 20;
+ cli_clear_sticky();
+ cli_drop_announce();
+ cli_ensure_headers(cols);
+
+ nrows = cli_fmt_progress_lines(lines, file_list, curfile, pct, overall,
+ cols);
+ if (nrows <= 0)
+ return;
+
+ old_rows = cli_block_rows;
+ if (cli_tty && cli_drawn && cli_block_rows > 0)
+ cli_home();
+
+ for (i = 0; i < nrows; i++)
+ cli_emit_line(lines[i], cols);
+
+ cli_progress_rows = nrows;
+ /* Keep status row only when it has content (-i '' / -I '' omit it). */
+ if (cli_have_status && cli_status_buf[0] != '\0') {
+ cli_emit_line(cli_status_buf, cols);
+ cli_block_rows = nrows + 1;
+ } else {
+ cli_have_status = 0;
+ cli_block_rows = nrows;
+ }
+
+ if (cli_tty && old_rows > cli_block_rows) {
+ /*
+ * Clear leftover rows from a taller previous block (e.g. a
+ * transient Opening/Waiting status row dropped by -I '').
+ * After clearing, cursor sits on the blank line immediately
+ * below the new block; do not emit an extra newline or the
+ * next cli_home() undershoots and duplicates the first row.
+ */
+ for (i = cli_block_rows; i < old_rows; i++)
+ cli_emit_line("", cols);
+ (void)fprintf(stderr, "\033[%dA", old_rows - cli_block_rows);
+ }
+
+ (void)fflush(stderr);
+ cli_drawn = 1;
+}
+
+void
+cli_draw_status(const char *status_fmt, long long overall_read, double rate,
+ int busy, int waiting)
+{
+ int cols;
+
+ if (cli_line == NULL || bsdpv_interrupt)
+ return;
+
+ /*
+ * -i '' / -I '': suppress the status row entirely (no blank line).
+ * Distinguish from a missing format pointer (library default path).
+ */
+ if (status_fmt != NULL && *status_fmt == '\0') {
+ cli_status_buf[0] = '\0';
+ cli_status_is_announce = 0;
+ return;
+ }
+
+ cols = tty_maxcols();
+ if (cols < 20)
+ cols = 20;
+ cli_clear_sticky();
+ cli_ensure_headers(cols);
+
+ if (cli_fmt_status_line(cli_status_buf, sizeof(cli_status_buf),
+ status_fmt, overall_read, rate, busy, waiting) != 0)
+ return;
+ if (cli_status_buf[0] == '\0')
+ return;
+
+ cli_status_is_announce = 0;
+ cli_paint_status_line(cli_status_buf, cols);
+ if (cli_drawn)
+ cli_have_status = 1;
+}
+
+void
+cli_draw(struct bsdpv_file_node *file_list, struct bsdpv_file_node *curfile,
+ int pct, int overall, const char *status_fmt, long long overall_read,
+ double rate, int busy, int waiting)
+{
+
+ cli_draw_progress(file_list, curfile, pct, overall);
+ cli_draw_status(status_fmt, overall_read, rate, busy, waiting);
+}
+
+/*
+ * Status-line override (e.g. Waiting for data...). Returns 1 if handled
+ * for CLI. No-op once interrupted so Ctrl-C does not repaint.
+ */
+int
+cli_announce(const char *msg)
+{
+ int cols;
+
+ if (!cli_active || msg == NULL)
+ return (0);
+ if (bsdpv_interrupt)
+ return (1);
+
+ cols = tty_maxcols();
+ if (cols < 20)
+ cols = 20;
+
+ if (!cli_tty) {
+ warnx("%s", msg);
+ return (1);
+ }
+
+ (void)snprintf(cli_status_buf, sizeof(cli_status_buf), "%s", msg);
+ cli_status_is_announce = 1;
+ /*
+ * If this is the first paint, emit -p/-t/-b first so a sticky
+ * Opening/Waiting line does not share a row with those headers.
+ */
+ if (!cli_drawn)
+ cli_ensure_headers(cols);
+ cli_paint_status_line(cli_status_buf, cols);
+ if (cli_drawn) {
+ cli_have_status = 1;
+ if (cli_block_rows < cli_progress_rows + 1)
+ cli_block_rows = cli_progress_rows + 1;
+ }
+ return (1);
+}
+
+void
+cli_finish(int interrupted)
+{
+
+ if (!cli_active)
+ return;
+ if (cli_tty) {
+ if (interrupted &&
+ (cli_sticky ||
+ (!cli_drawn && cli_status_buf[0] != '\0'))) {
+ /*
+ * Leave the \r announce in scrollback on interrupt
+ * (e.g. Ctrl-C while "Waiting for data...").
+ */
+ if (cli_status_buf[0] != '\0')
+ (void)fprintf(stderr, "\r%s\033[K\n",
+ cli_status_buf);
+ else
+ (void)fprintf(stderr, "\r\033[K\n");
+ } else if (cli_sticky) {
+ /* Successful exit: erase transient announce. */
+ (void)fprintf(stderr, "\r\033[K");
+ }
+ (void)fflush(stderr);
+ }
+ cli_drawn = 0;
+ cli_block_rows = 0;
+ cli_progress_rows = 0;
+ cli_have_status = 0;
+ cli_sticky = 0;
+ cli_status_is_announce = 0;
+ cli_status_buf[0] = '\0';
+}
+
+void
+cli_free(void)
+{
+
+ free(cli_line);
+ cli_line = NULL;
+ cli_active = 0;
+ cli_drawn = 0;
+ cli_headers_done = 0;
+ cli_block_rows = 0;
+ cli_progress_rows = 0;
+ cli_have_status = 0;
+ cli_sticky = 0;
+ cli_status_is_announce = 0;
+ cli_status_buf[0] = '\0';
+}
diff --git a/lib/libbsdpv/display.h b/lib/libbsdpv/display.h
new file mode 100644
--- /dev/null
+++ b/lib/libbsdpv/display.h
@@ -0,0 +1,41 @@
+/*
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ */
+
+#ifndef _DISPLAY_H_
+#define _DISPLAY_H_
+
+#include <sys/types.h>
+
+#include <bsddialog.h>
+
+/* bsddialog(3) characteristics */
+#define PROMPT_MAX 16384
+#define ENV_USE_COLOR "USE_COLOR"
+extern uint8_t display_test;
+extern uint8_t use_bsddialog;
+extern uint8_t use_color;
+extern uint8_t use_colors;
+extern uint8_t use_shadow;
+extern char gauge_color[];
+extern struct bsddialog_conf dconf;
+
+/* bsddialog(3) functionality */
+extern char *title, *backtitle;
+extern int dheight, dwidth;
+
+__BEGIN_DECLS
+void display_maxsize_free(void);
+char *prompt_lastline(char *_prompt);
+int display_maxcols(void);
+int display_maxrows(void);
+int prompt_wrappedlines(char *_prompt, int _ncols);
+int tty_maxcols(void);
+#define tty_maxrows() display_maxrows()
+unsigned int prompt_longestline(const char *_prompt);
+unsigned int prompt_numlines(const char *_prompt);
+__END_DECLS
+
+#endif /* !_DISPLAY_H_ */
diff --git a/lib/libbsdpv/display.c b/lib/libbsdpv/display.c
new file mode 100644
--- /dev/null
+++ b/lib/libbsdpv/display.c
@@ -0,0 +1,312 @@
+/*
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ */
+
+#include <sys/ioctl.h>
+
+#include <ctype.h>
+#include <err.h>
+#include <fcntl.h>
+#include <stdlib.h>
+#include <string.h>
+#include <termios.h>
+#include <unistd.h>
+
+#include "bsdpv.h"
+#include "bsdpv_private.h"
+#include "display.h"
+
+#define TTY_DEFAULT_ROWS 24
+#define TTY_DEFAULT_COLS 80
+
+/* bsddialog(3) characteristics */
+uint8_t display_test = 0;
+uint8_t use_bsddialog = 1;
+uint8_t use_color = 1;
+uint8_t use_colors = 1;
+uint8_t use_shadow = 1;
+char gauge_color[4] = "47b"; /* blue on white, bold */
+struct bsddialog_conf dconf;
+
+/* bsddialog(3) functionality */
+char *title = NULL;
+char *backtitle = NULL;
+int dheight = 0;
+int dwidth = 0;
+
+/* TTY/Screen characteristics */
+static struct winsize *maxsize = NULL;
+static int tty_size_fd = -1; /* cached /dev/tty when stdio are not ttys */
+
+/*
+ * Update row/column fields of `maxsize' global (used by display_maxrows() and
+ * display_maxcols()). If the `maxsize' pointer is NULL, it will be
+ * initialized. The `ws_row' and `ws_col' fields of `maxsize' are updated to
+ * hold current maximum height and width (respectively) for a bsddialog(3)
+ * widget based on the active TTY size.
+ *
+ * This function is called automatically by display_maxrows/cols() to reflect
+ * changes in terminal size in-between calls.
+ *
+ * Prefer stderr (CLI progress / dialog paint), then stdout, then stdin, then
+ * a single cached /dev/tty fd. Never open /dev/tty per-call without caching:
+ * high -U CLI refresh rates would leak fds, hit EMFILE, fall back to 80
+ * columns, and wrap the \r status line into scrollback.
+ */
+static void
+tty_maxsize_update(void)
+{
+ int fd;
+ struct termios t;
+
+ if (maxsize == NULL) {
+ if ((maxsize = malloc(sizeof(struct winsize))) == NULL)
+ errx(EXIT_FAILURE, "Out of memory?!");
+ memset((void *)maxsize, '\0', sizeof(struct winsize));
+ }
+
+ if (isatty(STDERR_FILENO))
+ fd = STDERR_FILENO;
+ else if (isatty(STDOUT_FILENO))
+ fd = STDOUT_FILENO;
+ else if (isatty(STDIN_FILENO))
+ fd = STDIN_FILENO;
+ else {
+ if (tty_size_fd < 0)
+ tty_size_fd = open("/dev/tty", O_RDONLY | O_CLOEXEC);
+ fd = tty_size_fd;
+ }
+
+ if (fd < 0 || tcgetattr(fd, &t) < 0 ||
+ ioctl(fd, TIOCGWINSZ, maxsize) < 0 || maxsize->ws_col == 0) {
+ maxsize->ws_row = TTY_DEFAULT_ROWS;
+ maxsize->ws_col = TTY_DEFAULT_COLS;
+ }
+}
+
+/*
+ * Return the current maximum height (rows) for a bsddialog(3) widget.
+ */
+int
+display_maxrows(void)
+{
+
+ tty_maxsize_update();
+ return (maxsize->ws_row);
+}
+
+/*
+ * Return the current maximum width (cols) for a bsddialog(3) widget.
+ */
+int
+display_maxcols(void)
+{
+
+ tty_maxsize_update();
+ if (use_bsddialog && use_shadow)
+ return (maxsize->ws_col - 2);
+ else
+ return (maxsize->ws_col);
+}
+
+/*
+ * Return the current maximum width (cols) for the terminal.
+ */
+int
+tty_maxcols(void)
+{
+
+ tty_maxsize_update();
+ return (maxsize->ws_col);
+}
+
+/*
+ * Returns the number of lines in buffer pointed to by `prompt'. Takes literal
+ * newlines into account (escaped newlines should be expanded beforehand; see
+ * strexpandnl() of util.c).
+ */
+unsigned int
+prompt_numlines(const char *prompt)
+{
+ const char *cp = prompt;
+ unsigned int nlines = 1;
+
+ if (prompt == NULL || *prompt == '\0')
+ return (0);
+
+ while (*cp != '\0') {
+ if (*cp == '\n')
+ nlines++;
+ cp++;
+ }
+
+ return (nlines);
+}
+
+/*
+ * Returns the length in bytes of the longest line in buffer pointed to by
+ * `prompt'. Takes newlines into account. Also discounts bsddialog(3) color
+ * escape codes if enabled (via `use_color' global).
+ */
+unsigned int
+prompt_longestline(const char *prompt)
+{
+ uint8_t backslash = 0;
+ const char *p = prompt;
+ int longest = 0;
+ int n = 0;
+
+ /* `prompt' parameter is required */
+ if (prompt == NULL)
+ return (0);
+ if (*prompt == '\0')
+ return (0); /* shortcut */
+
+ /* Loop until the end of the string */
+ while (*p != '\0') {
+ /* bsddialog(3) renders literal newlines */
+ if (*p == '\n') {
+ if (n > longest)
+ longest = n;
+ n = 0;
+ p++;
+ continue;
+ }
+
+ /* Check for backslash character */
+ if (*p == '\\') {
+ /* If second backslash, count as a single-char */
+ if ((backslash ^= 1) == 0)
+ n++;
+ } else if (backslash) {
+ if (use_color && *p == 'Z') {
+ if (*++p != '\0')
+ p++;
+ backslash = 0;
+ continue;
+ } else /* bsddialog(3) only expands \Z sequences */
+ n += 2;
+
+ backslash = 0;
+ } else
+ n++;
+ p++;
+ }
+ if (n > longest)
+ longest = n;
+
+ return (longest);
+}
+
+/*
+ * Returns a pointer to the last line in buffer pointed to by `prompt'. If no
+ * newlines appear in the buffer, `prompt' is returned. If passed a NULL
+ * pointer, returns NULL.
+ */
+char *
+prompt_lastline(char *prompt)
+{
+ char *lastline;
+ char *p;
+
+ if (prompt == NULL)
+ return (NULL);
+ if (*prompt == '\0')
+ return (prompt); /* shortcut */
+
+ lastline = p = prompt;
+ while (*p != '\0') {
+ if (*p == '\n')
+ lastline = p + 1;
+ p++;
+ }
+
+ return (lastline);
+}
+
+/*
+ * Returns the number of extra lines generated by wrapping the text in buffer
+ * pointed to by `prompt' within `ncols' columns (for prompts, this should be
+ * dwidth - 4). Also discounts bsddialog(3) color escape codes if enabled (via
+ * `use_color' global).
+ */
+int
+prompt_wrappedlines(char *prompt, int ncols)
+{
+ uint8_t backslash = 0;
+ char *cp;
+ char *p = prompt;
+ int n = 0;
+ int wlines = 0;
+
+ /* `prompt' parameter is required */
+ if (p == NULL)
+ return (0);
+ if (*p == '\0')
+ return (0); /* shortcut */
+
+ /* Loop until the end of the string */
+ while (*p != '\0') {
+ /* bsddialog(3) renders literal newlines */
+ if (*p == '\n')
+ n = 0;
+
+ /* Check for backslash character */
+ if (*p == '\\') {
+ /* If second backslash, count as a single-char */
+ if ((backslash ^= 1) == 0)
+ n++;
+ } else if (backslash) {
+ if (use_color && *p == 'Z') {
+ if (*++p != '\0')
+ p++;
+ backslash = 0;
+ continue;
+ } else /* bsddialog(3) only expands \Z sequences */
+ n += 2;
+
+ backslash = 0;
+ } else
+ n++;
+
+ /* Did we pass the width barrier? */
+ if (n > ncols) {
+ /*
+ * Work backward to find the first whitespace on-which
+ * bsddialog(3) will wrap the line (but don't go before
+ * the start of this line).
+ */
+ cp = p;
+ while (n > 1 && !isspace(*cp)) {
+ cp--;
+ n--;
+ }
+ if (n > 0 && isspace(*cp))
+ p = cp;
+ wlines++;
+ n = 1;
+ }
+
+ p++;
+ }
+
+ return (wlines);
+}
+
+/*
+ * Free allocated items initialized by tty_maxsize_update()
+ */
+void
+display_maxsize_free(void)
+{
+ if (tty_size_fd >= 0) {
+ close(tty_size_fd);
+ tty_size_fd = -1;
+ }
+ if (maxsize != NULL) {
+ free(maxsize);
+ maxsize = NULL;
+ }
+}
diff --git a/lib/libbsdpv/dprompt.h b/lib/libbsdpv/dprompt.h
new file mode 100644
--- /dev/null
+++ b/lib/libbsdpv/dprompt.h
@@ -0,0 +1,37 @@
+/*
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ */
+
+#ifndef _DPROMPT_H_
+#define _DPROMPT_H_
+
+#include <sys/cdefs.h>
+
+#include "bsdpv.h"
+
+/* Display characteristics */
+#define ENV_MSG_DONE "msg_done"
+#define ENV_MSG_FAIL "msg_fail"
+#define ENV_MSG_PENDING "msg_pending"
+extern int display_limit;
+extern int label_size;
+extern int pbar_size;
+
+__BEGIN_DECLS
+void dprompt_clear(void);
+void dprompt_dprint(int _fd, const char *_prefix, const char *_append,
+ int _overall);
+void dprompt_draw(const char *_prefix, const char *_append, int _overall);
+void dprompt_gauge_close(void);
+void dprompt_free(void);
+void dprompt_init(struct bsdpv_file_node *_file_list);
+void dprompt_recreate(struct bsdpv_file_node *_file_list,
+ struct bsdpv_file_node *_curfile, int _pct);
+int dprompt_add(const char *_format, ...) __printflike(1, 2);
+int dprompt_sprint(char * restrict _str, const char *_prefix,
+ const char *_append);
+__END_DECLS
+
+#endif /* !_DPROMPT_H_ */
diff --git a/lib/libbsdpv/dprompt.c b/lib/libbsdpv/dprompt.c
new file mode 100644
--- /dev/null
+++ b/lib/libbsdpv/dprompt.c
@@ -0,0 +1,753 @@
+/*
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ */
+
+#include <sys/types.h>
+
+#include <err.h>
+#include <libutil.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <bsddialog.h>
+
+#include "bsdpv.h"
+#include "bsdpv_private.h"
+#include "display.h"
+#include "dprompt.h"
+#include "util.h"
+
+#define FLABEL_MAX 1024
+
+static int fheight = 0; /* initialized by dprompt_init() */
+static char dprompt[PROMPT_MAX + 1] = "";
+static char *dprompt_pos = (char *)(0); /* treated numerically */
+
+/* Display characteristics */
+#define FM_DONE 0x01
+#define FM_FAIL 0x02
+#define FM_PEND 0x04
+static uint8_t dprompt_free_mask;
+static char *done = NULL;
+static char *fail = NULL;
+static char *pend = NULL;
+int display_limit = DISPLAY_LIMIT_DEFAULT; /* Max entries to show */
+int label_size = LABEL_SIZE_DEFAULT; /* Max width for labels */
+int pbar_size = PBAR_SIZE_DEFAULT; /* Mini-progressbar size */
+static int gauge_percent = 0;
+static bool gauge_open = false;
+static int done_size, done_lsize, done_rsize;
+static int fail_size, fail_lsize, fail_rsize;
+static int mesg_size, mesg_lsize, mesg_rsize;
+static int pend_size, pend_lsize, pend_rsize;
+static int pct_lsize, pct_rsize;
+#define SPIN_SIZE 4
+static char spin[SPIN_SIZE + 1] = "/-\\|";
+static char msg[PROMPT_MAX + 1];
+static char *spin_cp = spin;
+
+/* Function prototypes */
+static char spin_char(void);
+static int dprompt_add_files(struct bsdpv_file_node *file_list,
+ struct bsdpv_file_node *curfile, int pct);
+
+/*
+ * Returns a pointer to the current spin character in the spin string and
+ * advances the global position to the next character for the next call.
+ */
+static char
+spin_char(void)
+{
+ char ch;
+
+ if (*spin_cp == '\0')
+ spin_cp = spin;
+ ch = *spin_cp;
+
+ /* Advance the spinner to the next char */
+ if (++spin_cp >= (spin + SPIN_SIZE))
+ spin_cp = spin;
+
+ return (ch);
+}
+
+/*
+ * Initialize heights and widths based on various strings and environment
+ * variables (such as ENV_USE_COLOR).
+ */
+void
+dprompt_init(struct bsdpv_file_node *file_list)
+{
+ int len;
+ int max_cols;
+ int max_rows;
+ int nthfile;
+ int numlines;
+ struct bsdpv_file_node *curfile;
+
+ /*
+ * Draw the bsddialog(3) backtitle
+ */
+ if (use_bsddialog && !debug && backtitle != NULL)
+ bsddialog_backtitle(&dconf, backtitle);
+
+ /* Calculate width of the bsddialog(3) gauge box */
+ if (pbar_size < 0) {
+ dwidth = label_size + 8; /* "| " label " - |"; no mini-pbar */
+ } else {
+ dwidth = label_size + pbar_size + 9;
+ /* -9 = "| " label " - [" pbar "] |" */
+ }
+
+ /*
+ * Calculate height of the bsddialog(3) gauge box
+ */
+ dheight = 5;
+ max_rows = display_maxrows();
+ /* adjust max_rows for backtitle, status line, and/or shadow */
+ if (use_bsddialog && !debug)
+ max_rows -= 1;
+ if (backtitle != NULL)
+ max_rows -= use_shadow ? 3 : 2;
+ if (use_bsddialog && use_shadow)
+ max_rows -= 2;
+ /* add lines for `-p text' */
+ numlines = prompt_numlines(pprompt);
+ if (debug)
+ warnx("`-p text' is %i line%s long", numlines,
+ numlines == 1 ? "" : "s");
+ dheight += numlines;
+ /* the implicit trailing newline of `-p text' pads the gauge bar */
+ if (use_bsddialog && pprompt != NULL && *pprompt != '\0')
+ dheight--;
+ /* limit the number of display items (necessary per bsddialog(3)) */
+ if (display_limit == 0 || display_limit > BSDPV_DISPLAY_LIMIT)
+ display_limit = BSDPV_DISPLAY_LIMIT;
+ /* verify fheight will fit (stop if we hit 1) */
+ for (; display_limit > 0; display_limit--) {
+ nthfile = numlines = 0;
+ fheight = (int)bsdpv_nfiles > display_limit ?
+ (unsigned int)display_limit : bsdpv_nfiles;
+ for (curfile = file_list; curfile != NULL;
+ curfile = curfile->next) {
+ nthfile++;
+ numlines += prompt_numlines(curfile->name);
+ if ((nthfile % display_limit) == 0) {
+ if (numlines > fheight)
+ fheight = numlines;
+ numlines = nthfile = 0;
+ }
+ }
+ if (numlines > fheight)
+ fheight = numlines;
+ if ((dheight + fheight + (int)prompt_numlines(aprompt))
+ <= max_rows)
+ break;
+ }
+ /* don't show any items if we run the risk of hitting a blank set */
+ if ((max_rows - (use_shadow ? 5 : 4)) >= fheight)
+ dheight += fheight;
+ else
+ fheight = 0;
+ /* add lines for `-a text' */
+ numlines = prompt_numlines(aprompt);
+ if (debug)
+ warnx("`-a text' is %i line%s long", numlines,
+ numlines == 1 ? "" : "s");
+ dheight += numlines;
+
+ /*
+ * For wide mode, long prefix (`pprompt') or append (`aprompt')
+ * strings will bump width.
+ */
+ if (wide) {
+ len = (int)prompt_longestline(pprompt);
+ if ((len + 4) > dwidth)
+ dwidth = len + 4;
+ len = (int)prompt_longestline(aprompt);
+ if ((len + 4) > dwidth)
+ dwidth = len + 4;
+ }
+
+ /* Enforce width constraints to maximum values */
+ max_cols = display_maxcols();
+ if (max_cols > 0 && dwidth > max_cols)
+ dwidth = max_cols;
+
+ /* Optimize widths to sane values*/
+ if (pbar_size > dwidth - 9) {
+ pbar_size = dwidth - 9;
+ label_size = 0;
+ /* -9 = "| - [" ... "] |" */
+ }
+ if (pbar_size < 0)
+ label_size = dwidth - 8;
+ /* -8 = "| " ... " - |" */
+ else if (label_size > (dwidth - pbar_size - 9) || wide)
+ label_size = no_labels ? 0 : dwidth - pbar_size - 9;
+ /* -9 = "| " ... " - [" ... "] |" */
+
+ /* Hide labels if requested */
+ if (no_labels)
+ label_size = 0;
+
+ /* Touch up the height (now that we know dwidth) */
+ dheight += prompt_wrappedlines(pprompt, dwidth - 4);
+ dheight += prompt_wrappedlines(aprompt, dwidth - 4);
+
+ if (debug)
+ warnx("dheight = %i dwidth = %i fheight = %i",
+ dheight, dwidth, fheight);
+
+ /* Calculate left/right portions of % */
+ pct_lsize = (pbar_size - 4) / 2; /* -4 == printf("%-3s%%", pct) */
+ pct_rsize = pct_lsize;
+ /* If not evenly divisible by 2, increment the right-side */
+ if ((pct_rsize + pct_rsize + 4) != pbar_size)
+ pct_rsize++;
+
+ /* Initialize "Done" text */
+ if (done == NULL && (done = msg_done) == NULL) {
+ if ((done = getenv(ENV_MSG_DONE)) != NULL)
+ done_size = strlen(done);
+ else {
+ done_size = strlen(BSDPV_DONE_DEFAULT);
+ if ((done = malloc(done_size + 1)) == NULL)
+ errx(EXIT_FAILURE, "Out of memory?!");
+ dprompt_free_mask |= FM_DONE;
+ snprintf(done, done_size + 1, BSDPV_DONE_DEFAULT);
+ }
+ }
+ if (pbar_size >= 0 && pbar_size < done_size) {
+ done_lsize = done_rsize = 0;
+ *(done + pbar_size) = '\0';
+ done_size = pbar_size;
+ } else if (pbar_size >= 0) {
+ /* Calculate left/right portions for mini-progressbar */
+ done_lsize = (pbar_size - done_size) / 2;
+ done_rsize = done_lsize;
+ /* If not evenly divisible by 2, increment the right-side */
+ if ((done_rsize + done_size + done_lsize) != pbar_size)
+ done_rsize++;
+ }
+
+ /* Initialize "Fail" text */
+ if (fail == NULL && (fail = msg_fail) == NULL) {
+ if ((fail = getenv(ENV_MSG_FAIL)) != NULL)
+ fail_size = strlen(fail);
+ else {
+ fail_size = strlen(BSDPV_FAIL_DEFAULT);
+ if ((fail = malloc(fail_size + 1)) == NULL)
+ errx(EXIT_FAILURE, "Out of memory?!");
+ dprompt_free_mask |= FM_FAIL;
+ snprintf(fail, fail_size + 1, BSDPV_FAIL_DEFAULT);
+ }
+ }
+ if (pbar_size >= 0 && pbar_size < fail_size) {
+ fail_lsize = fail_rsize = 0;
+ *(fail + pbar_size) = '\0';
+ fail_size = pbar_size;
+ } else if (pbar_size >= 0) {
+ /* Calculate left/right portions for mini-progressbar */
+ fail_lsize = (pbar_size - fail_size) / 2;
+ fail_rsize = fail_lsize;
+ /* If not evenly divisible by 2, increment the right-side */
+ if ((fail_rsize + fail_size + fail_lsize) != pbar_size)
+ fail_rsize++;
+ }
+
+ /* Initialize "Pending" text */
+ if (pend == NULL && (pend = msg_pending) == NULL) {
+ if ((pend = getenv(ENV_MSG_PENDING)) != NULL)
+ pend_size = strlen(pend);
+ else {
+ pend_size = strlen(BSDPV_PENDING_DEFAULT);
+ if ((pend = malloc(pend_size + 1)) == NULL)
+ errx(EXIT_FAILURE, "Out of memory?!");
+ dprompt_free_mask |= FM_PEND;
+ snprintf(pend, pend_size + 1, BSDPV_PENDING_DEFAULT);
+ }
+ }
+ if (pbar_size >= 0 && pbar_size < pend_size) {
+ pend_lsize = pend_rsize = 0;
+ *(pend + pbar_size) = '\0';
+ pend_size = pbar_size;
+ } else if (pbar_size >= 0) {
+ /* Calculate left/right portions for mini-progressbar */
+ pend_lsize = (pbar_size - pend_size) / 2;
+ pend_rsize = pend_lsize;
+ /* If not evenly divisible by 2, increment the right-side */
+ if ((pend_rsize + pend_lsize + pend_size) != pbar_size)
+ pend_rsize++;
+ }
+
+ if (debug)
+ warnx("label_size = %i pbar_size = %i", label_size, pbar_size);
+
+ dprompt_clear();
+}
+
+/*
+ * Clear the gauge prompt buffer.
+ */
+void
+dprompt_clear(void)
+{
+
+ *dprompt = '\0';
+ dprompt_pos = dprompt;
+}
+
+/*
+ * Append to the gauge prompt buffer. Syntax is like printf(3) and returns the
+ * number of bytes appended to the buffer.
+ */
+int
+dprompt_add(const char *format, ...)
+{
+ int len;
+ va_list ap;
+
+ if (dprompt_pos >= (dprompt + PROMPT_MAX))
+ return (0);
+
+ va_start(ap, format);
+ len = vsnprintf(dprompt_pos, (size_t)(PROMPT_MAX -
+ (dprompt_pos - dprompt)), format, ap);
+ va_end(ap);
+ if (len == -1)
+ errx(EXIT_FAILURE, "%s: Oops, dprompt buffer overflow",
+ __func__);
+
+ if ((dprompt_pos + len) < (dprompt + PROMPT_MAX))
+ dprompt_pos += len;
+ else
+ dprompt_pos = dprompt + PROMPT_MAX;
+
+ return (len);
+}
+
+/*
+ * Append active files to the gauge prompt buffer. Syntax requires a pointer
+ * to the head of the bsdpv_file_node linked-list. Returns the number of files
+ * processed successfully.
+ */
+static int
+dprompt_add_files(struct bsdpv_file_node *file_list,
+ struct bsdpv_file_node *curfile, int pct)
+{
+ char c;
+ char bold_code = 'b'; /* default: enabled */
+ char color_code = '4'; /* default: blue */
+ uint8_t after_curfile = curfile != NULL ? FALSE : TRUE;
+ char *cp;
+ char *lastline;
+ char *name;
+ const char *bg_code;
+ const char *estext;
+ const char *format;
+ enum dprompt_state dstate;
+ int estext_lsize;
+ int estext_rsize;
+ int flabel_size;
+ int hlen;
+ int lsize;
+ int nlines = 0;
+ int nthfile = 0;
+ int pwidth;
+ int rsize;
+ struct bsdpv_file_node *fp;
+ char flabel[FLABEL_MAX + 1];
+ char human[32];
+ char pbar[pbar_size + 16]; /* +15 for optional color */
+ char pbar_cap[sizeof(pbar)];
+ char pbar_fill[sizeof(pbar)];
+
+
+ /* Override color defaults with that of main progress bar */
+ if (use_colors || use_shadow) { /* NB: shadow enables color */
+ color_code = gauge_color[0];
+ /* NB: str[1] aka bg is unused */
+ bold_code = gauge_color[2];
+ if (bold_code == 'b')
+ bold_code = 'B';
+ }
+
+ /*
+ * Create mini-progressbar for current file (if applicable)
+ */
+ *pbar = '\0';
+ if (pbar_size >= 0 && pct >= 0 && curfile != NULL &&
+ (curfile->length >= 0 || display_test)) {
+ int showpct = pct;
+ unsigned int fillpct;
+
+ /* Numeric % may overrun; fill width saturates at 100%. */
+ if (showpct < 0)
+ showpct = 0;
+ fillpct = showpct > 100 ? 100 : (unsigned int)showpct;
+
+ snprintf(pbar, pbar_size + 1, "%*s%3d%%%*s", pct_lsize, "",
+ showpct, pct_rsize, "");
+ if (use_color) {
+ /* Calculate the fill-width of progressbar */
+ pwidth = fillpct * pbar_size / 100;
+ /* Round up based on one-tenth of a percent */
+ if ((fillpct * pbar_size % 100) > 50)
+ pwidth++;
+
+ /*
+ * Make two copies of pbar. Make one represent the fill
+ * and the other the remainder (cap). We'll insert the
+ * ANSI delimiter in between.
+ */
+ *pbar_fill = '\0';
+ *pbar_cap = '\0';
+ strncat(pbar_fill, (const char *)(pbar), dwidth);
+ *(pbar_fill + pwidth) = '\0';
+ strncat(pbar_cap, (const char *)(pbar + pwidth),
+ dwidth);
+
+ /* Finalize the mini [color] progressbar */
+ snprintf(pbar, sizeof(pbar),
+ "\\Z%c\\Zr\\Z%c%s%s%s\\Zn", bold_code, color_code,
+ pbar_fill, "\\ZR", pbar_cap);
+ }
+ }
+
+ for (fp = file_list; fp != NULL; fp = fp->next) {
+ flabel_size = label_size;
+ name = fp->name;
+ nthfile++;
+
+ /*
+ * Support multiline filenames (where the filename is taken as
+ * the last line and the text leading up to the last line can
+ * be used as (for example) a heading/separator between files.
+ */
+ nlines += prompt_numlines(name);
+ lastline = prompt_lastline(name);
+ if (name != lastline) {
+ c = *lastline;
+ *lastline = '\0';
+ dprompt_add("%s", name);
+ *lastline = c;
+ name = lastline;
+ }
+
+ /* Support color codes (of bsddialog(3)) in file names */
+ if (use_color) {
+ cp = name;
+ while (*cp != '\0') {
+ if (*cp == '\\' && *(cp + 1) != '\0' &&
+ *(++cp) == 'Z' && *(cp + 1) != '\0') {
+ cp++;
+ flabel_size += 3;
+ }
+ cp++;
+ }
+ if (flabel_size > FLABEL_MAX)
+ flabel_size = FLABEL_MAX;
+ }
+
+ /* label_size is already dwidth-8 for -P -1; do not grow it */
+
+ /* If name is too long, add an ellipsis */
+ if (snprintf(flabel, flabel_size + 1, "%s", name) >
+ flabel_size)
+ sprintf(flabel + flabel_size - 3, "...");
+
+ /*
+ * Append the label (processing the current file differently).
+ * Twiddle while RUNNING even past 100%; Done is only for a
+ * true end state, not overrun.
+ */
+ if (fp == curfile &&
+ fp->status == BSDPV_STATUS_RUNNING) {
+ /*
+ * Add an ellipsis to current file name if it will fit.
+ * There may be an ellipsis already from truncating the
+ * label (in which case, we already have one).
+ */
+ cp = flabel + strlen(flabel);
+ if (cp < (flabel + flabel_size))
+ snprintf(cp, flabel_size -
+ (cp - flabel) + 1, "...");
+
+ /* Append label (with spinner and optional color) */
+ dprompt_add("%-*s %c", flabel_size, flabel,
+ spin_char());
+ } else
+ dprompt_add("%-*s%s %s", flabel_size,
+ flabel, use_color ? "\\Zn" : "", " ");
+
+ /*
+ * Append pbar/status (processing the current file differently)
+ */
+ dstate = DPROMPT_NONE;
+ if (fp->msg != NULL)
+ dstate = DPROMPT_CUSTOM_MSG;
+ else if (pbar_size < 0)
+ dstate = DPROMPT_NONE;
+ else if (pbar_size < 4)
+ dstate = DPROMPT_MINIMAL;
+ else if (after_curfile)
+ dstate = DPROMPT_PENDING;
+ else if (fp == curfile) {
+ if (*pbar == '\0') {
+ if (fp->length < 0)
+ dstate = DPROMPT_DETAILS;
+ else if (fp->status == BSDPV_STATUS_RUNNING)
+ dstate = DPROMPT_DETAILS;
+ else
+ dstate = DPROMPT_END_STATE;
+ } else if (display_test) /* status/length ignored */
+ dstate = (pct < 100 ||
+ fp->status == BSDPV_STATUS_RUNNING) ?
+ DPROMPT_PBAR : DPROMPT_END_STATE;
+ else if (fp->status == BSDPV_STATUS_RUNNING)
+ dstate = fp->length < 0 ?
+ DPROMPT_DETAILS : DPROMPT_PBAR;
+ else /* not running */
+ dstate = fp->length < 0 ?
+ DPROMPT_DETAILS : DPROMPT_END_STATE;
+ } else { /* before curfile */
+ if (display_test)
+ dstate = DPROMPT_END_STATE;
+ else
+ dstate = fp->length < 0 ?
+ DPROMPT_DETAILS : DPROMPT_END_STATE;
+ }
+ format = use_color ?
+ " [\\Z%c%s%-*s%s%-*s\\ZR\\Zn]\\n" :
+ " [%-*s%s%-*s]\\n";
+ if (fp->status == BSDPV_STATUS_FAILED) {
+ bg_code = "\\Zr\\Z1"; /* Red */
+ estext_lsize = fail_lsize;
+ estext_rsize = fail_rsize;
+ estext = fail;
+ } else { /* e.g., BSDPV_STATUS_DONE */
+ bg_code = "\\Zr\\Z2"; /* Green */
+ estext_lsize = done_lsize;
+ estext_rsize = done_rsize;
+ estext = done;
+ }
+ switch (dstate) {
+ case DPROMPT_PENDING: /* Future file(s) */
+ dprompt_add(" [%-*s%s%-*s]\\n",
+ pend_lsize, "", pend, pend_rsize, "");
+ break;
+ case DPROMPT_PBAR: /* Current file */
+ dprompt_add(" [%s]\\n", pbar);
+ break;
+ case DPROMPT_END_STATE: /* Past/Current file(s) */
+ if (use_color)
+ dprompt_add(format, bold_code, bg_code,
+ estext_lsize, "", estext,
+ estext_rsize, "");
+ else
+ dprompt_add(format,
+ estext_lsize, "", estext,
+ estext_rsize, "");
+ break;
+ case DPROMPT_DETAILS: /* Past/Current file(s) */
+ humanize_number(human, pbar_size + 2, fp->read, "",
+ HN_AUTOSCALE, HN_NOSPACE | HN_DIVISOR_1000);
+
+ /* Calculate center alignment */
+ hlen = (int)strlen(human);
+ lsize = (pbar_size - hlen) / 2;
+ rsize = lsize;
+ if ((lsize + hlen + rsize) != pbar_size)
+ rsize++;
+
+ if (use_color)
+ dprompt_add(format, bold_code, bg_code,
+ lsize, "", human, rsize, "");
+ else
+ dprompt_add(format,
+ lsize, "", human, rsize, "");
+ break;
+ case DPROMPT_CUSTOM_MSG: /* File-specific message override */
+ snprintf(msg, PROMPT_MAX + 1, "%s", fp->msg);
+ if (pbar_size < (mesg_size = strlen(msg))) {
+ mesg_lsize = mesg_rsize = 0;
+ *(msg + pbar_size) = '\0';
+ mesg_size = pbar_size;
+ } else {
+ mesg_lsize = (pbar_size - mesg_size) / 2;
+ mesg_rsize = mesg_lsize;
+ if ((mesg_rsize + mesg_size + mesg_lsize)
+ != pbar_size)
+ mesg_rsize++;
+ }
+ if (use_color)
+ dprompt_add(format, bold_code, bg_code,
+ mesg_lsize, "", msg, mesg_rsize, "");
+ else
+ dprompt_add(format, mesg_lsize, "", msg,
+ mesg_rsize, "");
+ break;
+ case DPROMPT_MINIMAL: /* Short progress bar, minimal room */
+ if (use_color)
+ dprompt_add(format, bold_code, bg_code,
+ pbar_size, "", "", 0, "");
+ else
+ dprompt_add(format, pbar_size, "", "", 0, "");
+ break;
+ case DPROMPT_NONE: /* pbar_size < 0 */
+ /* FALLTHROUGH */
+ default:
+ dprompt_add(" \\n");
+ /*
+ * NB: Leading space required for the case when
+ * spin_char() returns a single backslash [\] which
+ * without the space, changes the meaning of `\n'
+ */
+ }
+
+ /* Stop building if we've hit the internal limit */
+ if (nthfile >= display_limit)
+ break;
+
+ /* If this is the current file, all others are pending */
+ if (fp == curfile)
+ after_curfile = TRUE;
+ }
+
+ /*
+ * Pad the prompt height so that the `-a text' always appears in the
+ * same spot.
+ *
+ * NOTE: fheight is calculated in dprompt_init(). It represents the
+ * maximum height required to display the set of items (broken up into
+ * pieces of display_limit chunks) whose names contain the most
+ * newlines for any given set.
+ */
+ while (nlines < fheight) {
+ dprompt_add("\n");
+ nlines++;
+ }
+
+ return (nthfile);
+}
+
+/*
+ * Process the bsdpv_file_node linked-list of named files, re-generating the
+ * gauge prompt text for the current state of transfers.
+ */
+void
+dprompt_recreate(struct bsdpv_file_node *file_list,
+ struct bsdpv_file_node *curfile, int pct)
+{
+
+ /*
+ * Re-Build the prompt text
+ */
+ dprompt_clear();
+ if (display_limit > 0)
+ dprompt_add_files(file_list, curfile, pct);
+
+ /* bsddialog(3) requires literal newlines */
+ if (use_bsddialog)
+ strexpandnl(dprompt);
+}
+
+/*
+ * Print the gauge prompt text to a buffer.
+ */
+int
+dprompt_sprint(char * restrict str, const char *prefix, const char *append)
+{
+
+ return (snprintf(str, PROMPT_MAX, "%s%s%s%s", use_color ? "\\Zn" : "",
+ prefix ? prefix : "", dprompt, append ? append : ""));
+}
+
+/*
+ * Print the gauge prompt text to file descriptor fd (e.g., STDOUT_FILENO).
+ * Output is suitable for consumption by `bsddialog --gauge'.
+ */
+void
+dprompt_dprint(int fd, const char *prefix, const char *append, int overall)
+{
+ int percent = gauge_percent;
+
+ if (overall >= 0)
+ gauge_percent = percent = overall > 100 ? 100 : overall;
+ dprintf(fd, "XXX\n%s%s%s%s\nXXX\n%i\n", use_color ? "\\Zn" : "",
+ prefix ? prefix : "", dprompt, append ? append : "", percent);
+ fsync(fd);
+}
+
+/*
+ * Draw the gauge prompt text using bsddialog(3).
+ */
+void
+dprompt_draw(const char *prefix, const char *append, int overall)
+{
+ int percent = gauge_percent;
+ int rv;
+ char buf[BSDPV_PPROMPT_MAX + BSDPV_APROMPT_MAX +
+ BSDPV_DISPLAY_LIMIT * 1024];
+
+ dprompt_sprint(buf, prefix, append);
+
+ if (overall >= 0)
+ gauge_percent = percent = overall > 100 ? 100 : overall;
+ if (!gauge_open) {
+ rv = bsddialog_gauge_open(&dconf, buf, dheight, dwidth,
+ (unsigned int)percent);
+ if (rv == BSDDIALOG_OK)
+ gauge_open = true;
+ } else
+ rv = bsddialog_gauge_update(buf, dheight, dwidth,
+ (unsigned int)percent);
+ if (rv != BSDDIALOG_OK)
+ errx(EXIT_FAILURE, "bsddialog gauge: %s",
+ bsddialog_geterror());
+}
+
+/*
+ * Close the persistent gauge widget, if open.
+ */
+void
+dprompt_gauge_close(void)
+{
+
+ if (!gauge_open)
+ return;
+ if (bsddialog_inmode())
+ bsddialog_gauge_close();
+ gauge_open = false;
+}
+
+/*
+ * Free allocated items initialized by dprompt_init()
+ */
+void
+dprompt_free(void)
+{
+
+ dprompt_gauge_close();
+ if ((dprompt_free_mask & FM_DONE) != 0) {
+ dprompt_free_mask ^= FM_DONE;
+ free(done);
+ done = NULL;
+ }
+ if ((dprompt_free_mask & FM_FAIL) != 0) {
+ dprompt_free_mask ^= FM_FAIL;
+ free(fail);
+ fail = NULL;
+ }
+ if ((dprompt_free_mask & FM_PEND) != 0) {
+ dprompt_free_mask ^= FM_PEND;
+ free(pend);
+ pend = NULL;
+ }
+}
diff --git a/lib/libbsdpv/status.h b/lib/libbsdpv/status.h
new file mode 100644
--- /dev/null
+++ b/lib/libbsdpv/status.h
@@ -0,0 +1,23 @@
+/*
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ */
+
+#ifndef _STATUS_H_
+#define _STATUS_H_
+
+#include <sys/cdefs.h>
+
+__BEGIN_DECLS
+void status_backdrop_flush(void);
+void status_free(void);
+void status_init(void);
+void status_leave(void);
+void status_leave_no_nl(void);
+void status_printf(const char *_format, ...) __printflike(1, 2);
+void status_restore_tty(void);
+void status_reflush(void);
+__END_DECLS
+
+#endif /* !_STATUS_H_ */
diff --git a/lib/libbsdpv/status.c b/lib/libbsdpv/status.c
new file mode 100644
--- /dev/null
+++ b/lib/libbsdpv/status.c
@@ -0,0 +1,240 @@
+/*
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ */
+
+#include <curses.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <bsddialog.h>
+#include <bsddialog_theme.h>
+
+#include "status.h"
+
+/* static globals */
+static char *status_buf = NULL;
+static int status_bufsize = -1;
+static int status_color;
+static int status_row;
+static int status_width;
+static bool backdrop_done;
+static bool status_has_content;
+static char status_sgr[24];
+
+static void
+status_sgr_set(int color)
+{
+ enum bsddialog_color fg, bg;
+ unsigned int flags;
+ int n;
+
+ if (bsddialog_color_attrs(color, &fg, &bg, &flags) != BSDDIALOG_OK) {
+ status_sgr[0] = '\0';
+ return;
+ }
+
+ n = snprintf(status_sgr, sizeof(status_sgr), "\033[3%dm\033[4%dm",
+ (int)fg, (int)bg);
+ if ((flags & BSDDIALOG_BOLD) != 0 &&
+ n > 0 && n < (int)sizeof(status_sgr) - 4)
+ (void)strlcat(status_sgr, "\033[1m", sizeof(status_sgr));
+}
+
+static void
+status_pick_color(void)
+{
+ struct bsddialog_theme theme;
+
+ bsddialog_get_theme(&theme);
+ status_color = theme.screen.color;
+ status_sgr_set(status_color);
+}
+
+/*
+ * Initialize status-line colors after bsddialog(3) init.
+ */
+void
+status_init(void)
+{
+
+ if (!bsddialog_inmode())
+ return;
+
+ backdrop_done = false;
+ status_has_content = false;
+ status_pick_color();
+}
+
+/*
+ * Mark the screen backdrop as handled. bsddialog_gauge_repaint(3) syncs the
+ * backdrop via dialog_backdrop_sync(3) before queueing widget/shadow damage;
+ * touchwin(3)/wnoutrefresh(3) on stdscr(3) here would merge sparse stdscr over
+ * newscr(3) and wipe the L-shaped shadow already queued by the gauge.
+ */
+void
+status_backdrop_flush(void)
+{
+
+ if (!bsddialog_inmode() || backdrop_done)
+ return;
+
+ backdrop_done = true;
+}
+
+/*
+ * Paint the last status message onto stdscr(3) and refresh(3).
+ */
+static void
+status_paint(void)
+{
+
+ if (!bsddialog_inmode() || !status_has_content)
+ return;
+
+ status_row = getmaxy(stdscr) - 1;
+ status_width = getmaxx(stdscr);
+
+ status_pick_color();
+ attrset(status_color);
+ mvaddstr(status_row, 0, status_buf);
+ attrset(A_NORMAL);
+ move(status_row, status_width - 1);
+ refresh();
+}
+
+/*
+ * Repaint the last status message after a gauge-only flush(3).
+ */
+void
+status_reflush(void)
+{
+
+ status_paint();
+}
+
+/*
+ * Print a `one-liner' status message at the bottom of the screen. Messages are
+ * trimmed to fit within the console length (ANSI coloring not accounted for).
+ */
+void
+status_printf(const char *fmt, ...)
+{
+ int n;
+ va_list args;
+
+ if (!bsddialog_inmode())
+ return;
+
+ status_row = getmaxy(stdscr) - 1;
+ status_width = getmaxx(stdscr);
+
+ /* NULL is a special convention meaning "erase the old stuff" */
+ if (fmt == NULL) {
+ move(status_row, 0);
+ clrtoeol();
+ status_has_content = false;
+ refresh();
+ return;
+ }
+
+ /* Resize buffer if terminal width is greater */
+ if ((status_width + 1) > status_bufsize) {
+ status_buf = realloc(status_buf, status_width + 1);
+ if (status_buf == NULL) {
+ status_bufsize = -1;
+ return;
+ }
+ status_bufsize = status_width + 1;
+ }
+
+ /* Print the message within a space-filled buffer */
+ memset(status_buf, ' ', status_width);
+ va_start(args, fmt);
+ n = vsnprintf(status_buf, status_width + 1, fmt, args);
+ va_end(args);
+
+ /*
+ * If vsnprintf(3) produced less bytes than the maximum, change the
+ * implicitly-added NUL-terminator into a space and terminate at max.
+ */
+ if (n < status_width) {
+ status_buf[n] = ' ';
+ status_buf[status_width] = '\0';
+ }
+
+ status_has_content = true;
+ status_backdrop_flush();
+ status_paint();
+}
+
+/*
+ * Leave the cursor below the status line for the shell prompt.
+ */
+static void
+status_leave_common(bool newline)
+{
+
+ if (!bsddialog_inmode() || !status_has_content)
+ return;
+
+ status_row = getmaxy(stdscr) - 1;
+ status_width = getmaxx(stdscr);
+ move(status_row, status_width - 1);
+ refresh();
+ if (newline) {
+ (void)putchar('\n');
+ (void)fflush(stdout);
+ }
+}
+
+void
+status_leave(void)
+{
+
+ status_leave_common(true);
+}
+
+void
+status_leave_no_nl(void)
+{
+
+ status_leave_common(false);
+}
+
+/*
+ * After endwin(3), rewrite the last status line directly to /dev/tty so it
+ * survives curses teardown (Ctrl-C / abort).
+ */
+void
+status_restore_tty(void)
+{
+
+ if (!status_has_content || status_buf == NULL || status_width <= 0)
+ return;
+
+ printf("\033[%d;1H", status_row + 1);
+ if (status_sgr[0] != '\0')
+ fputs(status_sgr, stdout);
+ (void)fwrite(status_buf, 1, (size_t)status_width, stdout);
+ fputs("\033[0m", stdout);
+ (void)fflush(stdout);
+}
+
+/*
+ * Free allocated items initialized by status_printf()
+ */
+void
+status_free(void)
+{
+
+ if (status_buf != NULL) {
+ free(status_buf);
+ status_buf = NULL;
+ }
+}
diff --git a/lib/libbsdpv/util.h b/lib/libbsdpv/util.h
new file mode 100644
--- /dev/null
+++ b/lib/libbsdpv/util.h
@@ -0,0 +1,29 @@
+/*
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ */
+
+#ifndef _UTIL_H_
+#define _UTIL_H_
+
+#include <sys/types.h>
+
+#include <paths.h>
+
+#define SHELL_SPAWN_DEBUG 0 /* Debug spawning of sh(1) commands */
+
+#ifdef _PATH_BSHELL
+#define PATH_SHELL _PATH_BSHELL
+#else
+#define PATH_SHELL "/bin/sh"
+#endif
+
+#define CMDBUFMAX 65536
+
+__BEGIN_DECLS
+int shell_spawn_pipecmd(const char *_cmd, const char *_label, pid_t *_pid);
+void strexpandnl(char *_source);
+__END_DECLS
+
+#endif /* !_UTIL_H_ */
diff --git a/lib/libbsdpv/util.c b/lib/libbsdpv/util.c
new file mode 100644
--- /dev/null
+++ b/lib/libbsdpv/util.c
@@ -0,0 +1,114 @@
+/*
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ */
+
+#include <err.h>
+#include <limits.h>
+#include <spawn.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <unistd.h>
+
+#include "util.h"
+
+extern char **environ;
+static char cmdbuf[CMDBUFMAX] = "";
+static char shellcmd[PATH_MAX] = PATH_SHELL;
+static char *shellcmd_argv[6] = {
+ shellcmd,
+ __DECONST(char *, "-c"),
+ cmdbuf,
+ __DECONST(char *, "--"),
+ shellcmd,
+ NULL,
+};
+
+/*
+ * Expand escaped newlines (`\n' as two characters) in buffer pointed to by
+ * `source' into literal newlines. The string is modified in-place and will
+ * either shorten or stay the same length.
+ */
+void
+strexpandnl(char *source)
+{
+ uint8_t backslash = 0;
+ char *cp1;
+ char *cp2;
+
+ cp1 = cp2 = source;
+ while (*cp2 != '\0') {
+ *cp1 = *cp2;
+ if (*cp2 == '\\')
+ backslash++;
+ else if (*cp2 != 'n')
+ backslash = 0;
+ else if (backslash > 0) {
+ *(--cp1) = (backslash & 1) == 1 ? '\n' : 'n';
+ backslash = 0;
+ }
+ cp1++;
+ cp2++;
+ }
+ *cp1 = *cp2;
+}
+
+/*
+ * Spawn a sh(1) command. Writes the resulting process ID to the pid_t pointed
+ * at by `pid'. Returns a file descriptor (int) suitable for writing data to
+ * the spawned command (data written to file descriptor is seen as standard-in
+ * by the spawned sh(1) command). Returns `-1' if unable to spawn command.
+ *
+ * If cmd contains a single "%s" sequence, replace it with label if non-NULL.
+ */
+int
+shell_spawn_pipecmd(const char *cmd, const char *label, pid_t *pid)
+{
+ int error;
+ int len;
+ posix_spawn_file_actions_t action;
+#if SHELL_SPAWN_DEBUG
+ unsigned int i;
+#endif
+ int stdin_pipe[2] = { -1, -1 };
+
+ /* Populate argument array */
+ if (label != NULL && fmtcheck(cmd, "%s") == cmd)
+ len = snprintf(cmdbuf, CMDBUFMAX, cmd, label);
+ else
+ len = snprintf(cmdbuf, CMDBUFMAX, "%s", cmd);
+ if (len >= CMDBUFMAX) {
+ warnx("%s:%d:%s: cmdbuf[%u] too small to hold cmd argument",
+ __FILE__, __LINE__, __func__, CMDBUFMAX);
+ return (-1);
+ }
+
+ /* Open a pipe to communicate with the spawned command */
+ if (pipe(stdin_pipe) < 0)
+ err(EXIT_FAILURE, "%s: pipe(2)", __func__);
+
+ /* Fork sh(1) process */
+#if SHELL_SPAWN_DEBUG
+ fprintf(stderr, "%s: spawning `", __func__);
+ for (i = 0; shellcmd_argv[i] != NULL; i++) {
+ if (i == 0)
+ fprintf(stderr, "%s", shellcmd_argv[i]);
+ else if (i == 2)
+ fprintf(stderr, " '%s'", shellcmd_argv[i]);
+ else
+ fprintf(stderr, " %s", shellcmd_argv[i]);
+ }
+ fprintf(stderr, "'\n");
+#endif
+ posix_spawn_file_actions_init(&action);
+ posix_spawn_file_actions_adddup2(&action, stdin_pipe[0], STDIN_FILENO);
+ posix_spawn_file_actions_addclose(&action, stdin_pipe[1]);
+ error = posix_spawnp(pid, shellcmd, &action,
+ (const posix_spawnattr_t *)NULL, shellcmd_argv, environ);
+ if (error != 0)
+ err(EXIT_FAILURE, "%s", shellcmd);
+
+ return (stdin_pipe[1]);
+}
diff --git a/packages/Makefile b/packages/Makefile
--- a/packages/Makefile
+++ b/packages/Makefile
@@ -18,6 +18,7 @@
SUBDIR= \
bsdconfig \
+ bsdpv \
bzip2 \
clibs \
cron \
diff --git a/packages/bsdpv/Makefile b/packages/bsdpv/Makefile
new file mode 100644
--- /dev/null
+++ b/packages/bsdpv/Makefile
@@ -0,0 +1,5 @@
+WORLDPACKAGE= bsdpv
+SUBPACKAGES= dbg dev lib man
+COMPAT_PKGS= dbg dev lib
+
+.include <bsd.pkg.mk>
diff --git a/packages/bsdpv/bsdpv.ucl b/packages/bsdpv/bsdpv.ucl
new file mode 100644
--- /dev/null
+++ b/packages/bsdpv/bsdpv.ucl
@@ -0,0 +1,16 @@
+/*
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ */
+
+comment = "Stream data with a dialog or line-mode progress view"
+
+desc = <<EOD
+bsdpv(1) streams data from stdin or multiple paths while displaying
+transfer progress. Progress can be shown as a line-mode status on the
+terminal, or as a full-screen gauge via bsddialog(3).
+
+Also provided is bsdpv(3) (libprivatebsdpv), a private library used by
+bsdpv(1) and by other base utilities that need a progress view.
+EOD
diff --git a/share/mk/bsd.incs.mk b/share/mk/bsd.incs.mk
--- a/share/mk/bsd.incs.mk
+++ b/share/mk/bsd.incs.mk
@@ -40,6 +40,10 @@
${group}TAG_ARGS= -T ${${group}TAGS:ts,:[*]}
.endif
+install-${group}dir: .PHONY
+ ${INSTALL} -d ${${group}TAG_ARGS} -o ${${group}OWN} -g ${${group}GRP} -m 755 \
+ ${DESTDIR}${${group}DIR}
+
_${group}INCS=
.for header in ${${group}}
.if defined(${group}OWN_${header:T}) || defined(${group}GRP_${header:T}) || \
@@ -62,10 +66,10 @@
stage_includes: stage_as.${header:T}
installincludes: _${group}INS_${header:T}
-_${group}INS_${header:T}: ${header}
+_${group}INS_${header:T}: ${header} install-${group}dir
${INSTALL} ${TAG_ARGS:D${TAG_ARGS},dev} -C -o ${${group}OWN_${.ALLSRC:T}} \
-g ${${group}GRP_${.ALLSRC:T}} -m ${${group}MODE_${.ALLSRC:T}} \
- ${.ALLSRC} \
+ ${.ALLSRC:Ninstall-${group}dir} \
${DESTDIR}${${group}DIR_${.ALLSRC:T}}/${${group}NAME_${.ALLSRC:T}}
.else
_${group}INCS+= ${header}
@@ -76,13 +80,13 @@
stage_includes: stage_files.${group}
installincludes: _${group}INS
-_${group}INS: ${_${group}INCS}
+_${group}INS: ${_${group}INCS} install-${group}dir
.if defined(${group}NAME)
${INSTALL} ${${group}TAG_ARGS} -C -o ${${group}OWN} -g ${${group}GRP} -m ${${group}MODE} \
- ${.ALLSRC} ${DESTDIR}${${group}DIR}/${${group}NAME}
+ ${.ALLSRC:Ninstall-${group}dir} ${DESTDIR}${${group}DIR}/${${group}NAME}
.else
${INSTALL} ${${group}TAG_ARGS} -C -o ${${group}OWN} -g ${${group}GRP} -m ${${group}MODE} \
- ${.ALLSRC} ${DESTDIR}${${group}DIR}/
+ ${.ALLSRC:Ninstall-${group}dir} ${DESTDIR}${${group}DIR}/
.endif
.endif # !empty(_${group}INCS)
diff --git a/share/mk/src.libnames.mk b/share/mk/src.libnames.mk
--- a/share/mk/src.libnames.mk
+++ b/share/mk/src.libnames.mk
@@ -16,6 +16,7 @@
atf_cxx \
auditd \
bsddialog \
+ bsdpv \
bsdstat \
cbor \
devdctl \
@@ -324,6 +325,7 @@
_DP_avl= spl
_DP_be= zfs spl nvpair zfsbootenv
_DP_bsddialog= ncursesw tinfow
+_DP_bsdpv= bsddialog util tinfow ncursesw
.if ${MK_OPENSSL} != "no"
_DP_bsnmp= crypto
.endif
diff --git a/targets/pseudo/userland/Makefile.depend b/targets/pseudo/userland/Makefile.depend
--- a/targets/pseudo/userland/Makefile.depend
+++ b/targets/pseudo/userland/Makefile.depend
@@ -151,6 +151,7 @@
usr.bin/bsdiff/bsdiff \
usr.bin/bsdiff/bspatch \
usr.bin/bsdcat \
+ usr.bin/bsdpv \
usr.bin/bzip2 \
usr.bin/bzip2recover \
usr.bin/c89 \
diff --git a/targets/pseudo/userland/lib/Makefile.depend b/targets/pseudo/userland/lib/Makefile.depend
--- a/targets/pseudo/userland/lib/Makefile.depend
+++ b/targets/pseudo/userland/lib/Makefile.depend
@@ -40,6 +40,7 @@
lib/libblocksruntime \
lib/libbluetooth \
lib/libbsddialog \
+ lib/libbsdpv \
lib/libbsdstat \
lib/libbsm \
lib/libbsnmp/libbsnmp \
diff --git a/usr.bin/Makefile b/usr.bin/Makefile
--- a/usr.bin/Makefile
+++ b/usr.bin/Makefile
@@ -11,6 +11,7 @@
bsdcat \
bsddialog \
bsdiff \
+ bsdpv \
bzip2 \
bzip2recover \
cap_mkdb \
diff --git a/usr.bin/bsdpv/Makefile b/usr.bin/bsdpv/Makefile
new file mode 100644
--- /dev/null
+++ b/usr.bin/bsdpv/Makefile
@@ -0,0 +1,9 @@
+PACKAGE= bsdpv
+
+PROG= bsdpv
+
+CFLAGS+= -I${.CURDIR} -I${SRCTOP}/lib/libbsdpv
+
+LIBADD= bsdpv bsddialog
+
+.include <bsd.prog.mk>
diff --git a/usr.bin/bsdpv/Makefile.depend b/usr.bin/bsdpv/Makefile.depend
new file mode 100644
--- /dev/null
+++ b/usr.bin/bsdpv/Makefile.depend
@@ -0,0 +1,19 @@
+# Autogenerated - do NOT edit!
+
+DIRDEPS = \
+ include \
+ include/xlocale \
+ lib/${CSU_DIR} \
+ lib/libbsddialog \
+ lib/libbsdpv \
+ lib/libc \
+ lib/libcompiler_rt \
+ lib/msun \
+ lib/ncurses/tinfo \
+
+
+.include <dirdeps.mk>
+
+.if ${DEP_RELDIR} == ${_DEP_RELDIR}
+# local dependencies - needed for -jN in clean tree
+.endif
diff --git a/usr.bin/bsdpv/bsdpv.1 b/usr.bin/bsdpv/bsdpv.1
new file mode 100644
--- /dev/null
+++ b/usr.bin/bsdpv/bsdpv.1
@@ -0,0 +1,368 @@
+.\"
+.\" SPDX-License-Identifier: BSD-2-Clause
+.\"
+.\" Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+.\"
+.Dd July 26, 2026
+.Dt BSDPV 1
+.Os
+.Sh NAME
+.Nm bsdpv
+.Nd stream data from stdin or multiple paths with progress view
+.Sh SYNOPSIS
+.Nm
+.Op options
+.Sm off
+.Op Ar bytes Cm \&:
+.Ar label
+.Sm on
+.Nm
+.Op options
+.Fl m
+.Sm off
+.Op Ar bytes1 Cm \& :
+. Ar label1
+.Sm on
+.Ar path1
+.Oo
+.Sm off
+.Op Ar bytes2 Cm \&:
+.Ar label2
+.Sm on
+.Ar path2
+.Ar ...
+.Oc
+.Sh DESCRIPTION
+.Nm
+provides a progress view, allowing a user to see current throughput rate
+and total data transferred for one or more streams.
+.Pp
+The
+.Nm
+utility has two main modes for processing input.
+.Pp
+The default input mode, without
+.Ql Fl m ,
+.Nm
+reads bytes from standard input.
+A label for the data must be provided.
+.Pp
+The secondary input mode, with
+.Ql Fl m ,
+.Nm
+reads multiple paths
+.Pq up to 2047 or Dq ARG_MAX/2-1 ,
+sequentially.
+.Pp
+Data read in either mode is either thrown away
+.Pq default ,
+sent to a spawned instance of the program specified via
+.Ql Fl x Ar cmd ,
+or sent to a unique file specified by
+.Ql Fl o Ar file .
+.Pp
+With or without
+.Ql Fl m ,
+progress is written to standard error by default as a command-line view:
+per-file progress line(s), an optional
+.Fl a
+line, and a status line.
+With
+.Fl m ,
+an Overall bar sits between the per-file lines and
+.Fl a ;
+single-file CLI omits Overall so the display stays compact
+.Pq dialog mode still shows overall percent for one file .
+Bars and the activity twiddle refresh at the display rate
+.Pq default 16/sec ;
+the status line follows
+.Fl U .
+Pass
+.Fl D
+for a
+.Xr bsddialog 3
+gauge
+.Pq per-file mini-bars plus an overall gauge when using Fl m .
+.Pp
+The following options are available:
+.Bl -tag -width "-b backtitle"
+.It Fl a Ar text
+Display
+.Ar text
+below the file progress indicator(s)
+.Pq with Fl D ,
+or as its own line between the command-line progress bar(s) and the status line
+.Pq default .
+.It Fl b Ar backtitle
+Display
+.Ar backtitle
+on the backdrop, at top-left, behind the dialog widget
+.Pq with Fl D ,
+or as a one-time header line above the command-line progress line
+.Pq default .
+.It Fl D
+Dialog mode.
+Display progress using
+.Xr bsddialog 3
+instead of the default command-line status line on standard error.
+.It Fl d
+Debug mode.
+Print gauge prompt data to standard out and provide additional debugging on
+standard error.
+Output is suitable for consumption by
+.Ql bsddialog --gauge
+.Pq see Xr bsddialog 1 .
+.It Fl h
+Produce a short syntax usage with brief option descriptions and exit.
+Output is produced on standard error.
+.It Fl I Ar format
+Customize the multi-file format string used to update the status line.
+Default value
+is
+.Dq Li %'10lli bytes read @ %'9.1f bytes/sec. [%i/%i busy/wait] .
+This format is used when handling more than one file.
+.It Fl i Ar format
+Customize the single-file format string used to update the status line.
+Default value
+is
+.Dq Li %'10lli bytes read @ %'9.1f bytes/sec. .
+This format is used when handling one file.
+.It Fl k
+Keep tite.
+Prevent visually distracting initialization/exit routines for scripts running
+.Nm
+several times.
+.It Fl L Ar size
+Label size.
+If negative, shrink to longest label width.
+Applies to both dialog and command-line progress.
+.It Fl l
+Line mode.
+Read lines from input instead of bytes.
+.It Fl m
+Multi-input mode.
+Read from a path for each label instead of from standard input.
+A path of
+.Ql -
+means
+.Pa /dev/stdin .
+Paths are processed in order, one open at a time; the descriptor is closed
+before the next path is opened
+.Pq at EOF, or earlier with Fl N .
+Clean EOF is shown as Done; a hard read error, or reading fewer units than a
+labeled length, is shown as Fail.
+While waiting for the first data on a path,
+.Nm
+shows a
+.Dq Waiting for data
+status rather than appearing hung; if interrupted, that status is left in the
+scrollback.
+Overall does not credit a finished path's full share until the next path has
+started reading.
+Fail on the last path leaves Overall at actual progress; successful Done on
+the last path
+.Pq when any label is unquantified
+advances Overall to 100%.
+See
+.Fl N
+for stopping at a labeled length
+.Pq useful with FIFOs .
+CLI and dialog layout for multiple paths is described in
+.Sx DESCRIPTION .
+.It Fl N
+No overrun.
+If enabled, stop reading known-length inputs when input reaches stated length
+and close the reader
+.Pq so a still-writing peer, such as a FIFO writer, typically receives EPIPE
+rather than blocking forever .
+Works in
+.Fl m
+mode as well as single-input mode.
+.It Fl n Ar num
+Display at-most
+.Ar num
+progress indicators per screen.
+If zero, display as many as possible.
+If negative, only display the main progress indicator.
+Default is 0.
+Maximum value is 10.
+.It Fl o Ar file
+Output data to
+.Ar file .
+A
+.Ar file
+of
+.Ql -
+writes to standard output.
+The first occurrence of
+.Ql %s
+.Pq if any
+in
+.Ql Ar file
+will be replaced with the
+.Ar label
+text.
+.It Fl P Ar size
+Mini-progressbar size.
+If negative, don't display mini-progressbars
+.Pq only the large overall progress indicator is shown in Fl m .
+In command-line mode, per-file Done/Pending/count/% text that would appear
+inside those mini-progressbars is also omitted; with
+.Fl m
+the Overall bar remains.
+If zero, auto-adjust based on number of files to read.
+When zero and only one file to read, defaults to -1.
+When zero and more than one file to read, defaults to 17.
+.It Fl p Ar text
+Display
+.Ar text
+above the file progress indicator(s)
+.Pq with Fl D ,
+or as a one-time header line above the command-line progress line
+.Pq default .
+.It Fl T
+Test mode.
+Simulate reading a number of bytes, divided evenly across the number of files,
+while stepping through each percent value of each file to process.
+Appends
+.Dq Li [TEST MODE]
+to the status line
+.Pq to override, use Ql Fl i Ar format or Ql Fl I Ar format .
+No data is actually read.
+.It Fl t Ar title
+Display
+.Ar title
+atop the dialog box
+.Pq with Fl D ,
+or as a one-time header line above the command-line progress line
+.Pq default .
+.It Fl U Ar num
+Update the status line
+.Pq throughput / busy-wait text
+.Ar num
+times per-second in both CLI and dialog
+.Pq Fl D
+modes.
+Default value is
+.Ql Li 2 .
+A value of
+.Ql Li 0
+disables status-line updates.
+If negative, update as fast as possible.
+Progress bars and the activity twiddle
+.Pq spinning
+.Ql Li /-\e|
+on the active file line
+follow the separate display update rate
+.Pq default
+.Ql Li 16
+per-second
+in both CLI and
+.Fl D
+modes.
+.It Fl w
+Wide mode.
+Allows long
+.Ar text
+arguments used with
+.Ql Fl p
+and
+.Ql Fl a
+to bump the dialog width.
+Prompts wider than the maximum width will wrap.
+.It Fl x Ar cmd
+Execute
+.Ar cmd
+.Pq via Xr sh 1
+and send it data that has been read.
+Data is available to
+.Ar cmd
+on standard input.
+With
+.Ql Fl m ,
+.Ar cmd
+is executed once for each
+.Ar path
+argument.
+The first occurrence of
+.Ql %s
+.Pq if any
+in
+.Ql Ar cmd
+will be replaced with the
+.Ar label
+text.
+.El
+.Sh ENVIRONMENT
+The following environment variables are referenced by
+.Nm :
+.Bl -tag -width ".Ev USE_COLOR"
+.It Ev USE_COLOR
+If set and NULL, disables the use of color.
+.El
+.Sh EXAMPLES
+Simple example to show how fast
+.Xr yes 1
+produces lines
+.Pq usually about ten-million per-second; your results may vary :
+.Bd -literal -offset indent
+yes | bsdpv -l yes
+.Ed
+.Pp
+Display progress while timing how long it takes
+.Xr yes 1
+to produce a half-billion lines
+.Pq usually under one minute; your results may vary :
+.Bd -literal -offset indent
+time yes | bsdpv -Nl 500000000:yes
+.Ed
+.Pp
+An example to watch how quickly a file is transferred using
+.Xr nc 1 :
+.Bd -literal -offset indent
+bsdpv -x "nc -w 1 somewhere.com 3000" -m label file
+.Ed
+.Pp
+A similar example, transferring a file from another process and passing the
+expected size to
+.Nm :
+.Bd -literal -offset indent
+cat file | bsdpv -x "nc -w 1 somewhere.com 3000" 12345:label
+.Ed
+.Pp
+A more complicated example:
+.Bd -literal -offset indent
+tar cf - . | bsdpv -x "gzip -9 > out.tgz" \\
+ $( du -s . | awk '{print $1 * 1024}' ):label
+.Ed
+.Pp
+Taking an image of a disk:
+.Bd -literal -offset indent
+bsdpv -o disk-image.img -m label /dev/ada0
+.Ed
+.Pp
+Writing an image back to a disk:
+.Bd -literal -offset indent
+bsdpv -o /dev/ada0 -m label disk-image.img
+.Ed
+.Pp
+Zeroing a disk:
+.Bd -literal -offset indent
+bsdpv -o /dev/md42 "Zeroing md42" < /dev/zero
+.Ed
+.Sh SEE ALSO
+.Xr bsddialog 1 ,
+.Xr sh 1 ,
+.Xr bsddialog 3 ,
+.Xr bsdpv 3
+.Sh HISTORY
+The
+.Nm
+utility first appeared in
+.Fx 16.0 ,
+derived from the
+.Ql dpv
+utility that first appeared in
+.Fx 10.2 .
+.Sh AUTHORS
+.An Devin Teske Aq dteske@FreeBSD.org
diff --git a/usr.bin/bsdpv/bsdpv.c b/usr.bin/bsdpv/bsdpv.c
new file mode 100644
--- /dev/null
+++ b/usr.bin/bsdpv/bsdpv.c
@@ -0,0 +1,910 @@
+/*
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ */
+
+#include <sys/stat.h>
+#include <sys/types.h>
+
+#include <bsdpv.h>
+#include <err.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "bsdpv_util.h"
+
+/* Debugging */
+static uint8_t debug = FALSE;
+
+/* Data to process */
+static struct bsdpv_file_node *file_list = NULL;
+static unsigned int nfiles = 0;
+
+/* Data processing */
+static uint8_t line_mode = FALSE;
+static uint8_t no_overrun = FALSE;
+static char *buf = NULL;
+static int fd = -1;
+static struct bsdpv_file_node *fd_file = NULL; /* file that owns `fd' */
+static int input_has_pushback = 0;
+static unsigned char input_pushback;
+static int output_type = BSDPV_OUTPUT_NONE;
+static size_t bsize;
+static char rpath[PATH_MAX];
+
+/* Extra display information */
+static uint8_t multiple = FALSE; /* `-m' */
+static char *pgm; /* set to argv[0] by main() */
+
+/* Function prototypes */
+static void sig_int(int sig);
+static void usage(void);
+static void input_close(void);
+static ssize_t input_read(void *dst, size_t nbytes);
+static int stop_at_length(struct bsdpv_file_node *file);
+static int finish_input(struct bsdpv_file_node *file, int is_error);
+static int open_path_waiting(struct bsdpv_file_node *file,
+ const char *path);
+static int wait_for_input(struct bsdpv_file_node *file);
+static int operate_common(struct bsdpv_file_node *file, int out);
+static int operate_on_bytes(struct bsdpv_file_node *file, int out);
+static int operate_on_lines(struct bsdpv_file_node *file, int out);
+
+/*
+ * Release the current input descriptor. Essential when -N stops a transfer
+ * before EOF, and when advancing to the next -m path: leaving the previous
+ * descriptor open can block the next open(2) (notably for FIFOs) and confuse
+ * still-attached writers.
+ */
+static void
+input_close(void)
+{
+
+ if (fd >= 0 && fd != STDIN_FILENO)
+ (void)close(fd);
+ fd = -1;
+ fd_file = NULL;
+ input_has_pushback = 0;
+}
+
+/*
+ * Read with a short poll timeout so we notice writer hangup and Ctrl-C.
+ * FIFO descriptors are kept O_NONBLOCK (see open_path_waiting): on poll
+ * timeout we probe with read(2) so a departed writer surfaces as EOF even
+ * when poll does not report POLLIN for the hangup alone.
+ */
+static ssize_t
+input_read(void *dst, size_t nbytes)
+{
+ unsigned char *p = dst;
+ struct pollfd pfd;
+ ssize_t n;
+ size_t got;
+ int fl;
+ int nonblock;
+
+ if (nbytes == 0)
+ return (0);
+
+ got = 0;
+ if (input_has_pushback) {
+ p[0] = input_pushback;
+ input_has_pushback = 0;
+ got = 1;
+ if (nbytes == 1)
+ return (1);
+ }
+
+ fl = fcntl(fd, F_GETFL);
+ nonblock = (fl >= 0 && (fl & O_NONBLOCK) != 0);
+
+ for (;;) {
+ if (bsdpv_interrupt) {
+ errno = EINTR;
+ return (got > 0 ? (ssize_t)got : -1);
+ }
+ pfd.fd = fd;
+ pfd.events = POLLIN;
+ pfd.revents = 0;
+ n = poll(&pfd, 1, 200);
+ if (n < 0) {
+ if (errno == EINTR)
+ continue;
+ return (got > 0 ? (ssize_t)got : -1);
+ }
+ if (n == 0) {
+ /*
+ * Idle. On a non-blocking FIFO, try read anyway;
+ * FreeBSD returns 0 here when the last writer has
+ * gone, which is how peer hangup is detected.
+ */
+ if (!nonblock)
+ continue;
+ n = read(fd, p + got, nbytes - got);
+ if (n < 0 && (errno == EAGAIN || errno == EINTR))
+ continue;
+ if (n < 0)
+ return (got > 0 ? (ssize_t)got : -1);
+ if (n == 0)
+ return (got > 0 ? (ssize_t)got : 0);
+ return ((ssize_t)(got + n));
+ }
+
+ if (pfd.revents & (POLLERR | POLLNVAL)) {
+ errno = EIO;
+ return (got > 0 ? (ssize_t)got : -1);
+ }
+
+ n = read(fd, p + got, nbytes - got);
+ if (n < 0) {
+ if (errno == EINTR || errno == EAGAIN)
+ continue;
+ return (got > 0 ? (ssize_t)got : -1);
+ }
+ if (n == 0)
+ return (got > 0 ? (ssize_t)got : 0);
+ return ((ssize_t)(got + n));
+ }
+}
+
+/*
+ * Close the current input and mark the file done or failed. Clean EOF
+ * (including a FIFO peer closing after writing) is Done. Fail only on a
+ * hard read error, or when a labeled length was given and we got less than
+ * that amount before EOF.
+ */
+static int
+finish_input(struct bsdpv_file_node *file, int is_error)
+{
+ int progress;
+ int save_errno = errno;
+ int short_read;
+
+ input_close();
+ errno = save_errno;
+
+ short_read = (file->length >= 0 && file->read < file->length);
+ if (is_error || short_read) {
+ file->status = BSDPV_STATUS_FAILED;
+ if (file->length > 0)
+ progress = (int)(file->read * 100 / file->length);
+ else if (file->length == 0)
+ progress = 100;
+ else
+ progress = 0;
+ if (progress > 100)
+ progress = 100;
+ return (progress);
+ }
+
+ file->status = BSDPV_STATUS_DONE;
+ if (file->length >= 0 && file->read > 0)
+ return ((int)(file->read * 100 /
+ (file->length > 0 ? file->length : 1)));
+ return (100);
+}
+
+static int
+stop_at_length(struct bsdpv_file_node *file)
+{
+
+ if (!no_overrun || file->length < 0 || file->read < file->length)
+ return (0);
+ return (1);
+}
+
+/*
+ * Open path for reading. FIFOs block in open(2) until the other end is
+ * opened; without feedback that looks like a hang. Open O_NONBLOCK and wait
+ * until the peer exists (EAGAIN) or data arrives, refreshing the UI
+ * meanwhile. Paths need not be FIFOs (-m also serves files, sockets, and
+ * devices); non-FIFOs use a plain open with an "Opening..." status.
+ */
+static int
+open_path_waiting(struct bsdpv_file_node *file, const char *path)
+{
+ struct pollfd pfd;
+ struct stat st;
+ unsigned char b;
+ int announced = 0;
+ int ofd;
+ ssize_t n;
+ const char *label;
+
+ label = file->name != NULL ? file->name : path;
+
+ if (lstat(path, &st) != 0 || !S_ISFIFO(st.st_mode)) {
+ bsdpv_announce("Opening %s (%s)...", label, path);
+ return (open(path, O_RDONLY));
+ }
+
+ /*
+ * Keep O_NONBLOCK for the life of this fd. Clearing it made hangup
+ * detection unreliable: a blocking read can sit forever if a peer
+ * fd still exists briefly, and poll(POLLIN) alone does not always
+ * wake on FIFO EOF until a non-blocking read probes it.
+ */
+ ofd = open(path, O_RDONLY | O_NONBLOCK);
+ if (ofd < 0)
+ return (-1);
+
+ /*
+ * FreeBSD FIFO + O_NONBLOCK: read() returns 0 when no peer exists,
+ * EAGAIN when a peer is connected but idle, or data when available.
+ */
+ for (;;) {
+ if (bsdpv_interrupt) {
+ close(ofd);
+ errno = EINTR;
+ return (-1);
+ }
+ if (!announced) {
+ bsdpv_announce("Waiting for data on %s (%s)...",
+ label, path);
+ announced = 1;
+ }
+ n = read(ofd, &b, 1);
+ if (n > 0) {
+ input_pushback = b;
+ input_has_pushback = 1;
+ break;
+ }
+ if (n < 0 && errno == EAGAIN)
+ break; /* peer present */
+ if (n < 0 && errno == EINTR) {
+ if (bsdpv_interrupt) {
+ close(ofd);
+ errno = EINTR;
+ return (-1);
+ }
+ continue;
+ }
+ if (n < 0) {
+ close(ofd);
+ return (-1);
+ }
+ /* n == 0: no peer yet; wait and retry */
+ pfd.fd = ofd;
+ pfd.events = POLLIN;
+ (void)poll(&pfd, 1, 200);
+ if (bsdpv_interrupt) {
+ close(ofd);
+ errno = EINTR;
+ return (-1);
+ }
+ }
+
+ return (ofd);
+}
+
+/*
+ * Keep the UI alive until the current input fd is readable (or ends).
+ * Covers idle stdin pipes, -m paths whose peer is connected but silent,
+ * and slow files/sockets/devices; do not assume the path is a FIFO.
+ */
+static int
+wait_for_input(struct bsdpv_file_node *file)
+{
+ struct pollfd pfd;
+ int announced = 0;
+ int n;
+ const char *label;
+ const char *path;
+
+ if (fd < 0 || file == NULL)
+ return (0);
+ if (input_has_pushback || file->read > 0)
+ return (0);
+
+ /* Data already available: avoid a one-frame "Waiting..." flash. */
+ pfd.fd = fd;
+ pfd.events = POLLIN;
+ pfd.revents = 0;
+ n = poll(&pfd, 1, 0);
+ if (n < 0 && errno != EINTR)
+ return (-1);
+ if (n > 0)
+ return (0);
+
+ label = file->name != NULL ? file->name : "-";
+ path = file->path != NULL ? file->path : NULL;
+
+ for (;;) {
+ if (bsdpv_interrupt) {
+ errno = EINTR;
+ return (-1);
+ }
+ if (!announced) {
+ if (!multiple)
+ bsdpv_announce("Waiting for data on stdin...");
+ else if (path != NULL)
+ bsdpv_announce("Waiting for data on %s (%s)...",
+ label, path);
+ else
+ bsdpv_announce("Waiting for data on %s...",
+ label);
+ announced = 1;
+ }
+ pfd.fd = fd;
+ pfd.events = POLLIN;
+ pfd.revents = 0;
+ n = poll(&pfd, 1, 200);
+ if (n < 0) {
+ if (errno == EINTR)
+ continue;
+ return (-1);
+ }
+ if (n > 0)
+ return (0);
+ }
+}
+
+static int
+operate_common(struct bsdpv_file_node *file, int out)
+{
+ struct stat sb;
+
+ /*
+ * -m is serial: one path at a time. If we still hold a descriptor
+ * from a previous file_node (e.g. -N stopped short of EOF), drop it
+ * before opening the next; otherwise later paths never open.
+ */
+ if (fd >= 0 && fd_file != file)
+ input_close();
+
+ /* Open the file if necessary */
+ if (fd < 0) {
+ if (multiple) {
+ /* Resolve the file path and attempt to open it */
+ if (realpath(file->path, rpath) == 0 ||
+ (fd = open_path_waiting(file, rpath)) < 0) {
+ /* Ctrl-C while waiting: do not paint Fail */
+ if (bsdpv_interrupt)
+ return (-1);
+ warn("%s", file->path);
+ file->status = BSDPV_STATUS_FAILED;
+ return (-1);
+ }
+ fd_file = file;
+ } else {
+ /*
+ * Assume stdin, but if that's a TTY instead use the
+ * highest numbered file descriptor (obtained by
+ * generating new fd and then decrementing).
+ *
+ * NB: /dev/stdin should always be open(2)'able
+ */
+ fd = STDIN_FILENO;
+ if (isatty(fd)) {
+ fd = open("/dev/stdin", O_RDONLY);
+ close(fd--);
+
+ /*
+ * This answer might be wrong, if bsdpv(3)
+ * has (by request) opened an output file or
+ * pipe. If we told bsdpv(3) to open a file,
+ * subtract one from previous answer. If
+ * instead we told bsdpv(3) to prepare a pipe
+ * output, subtract two.
+ */
+ switch (output_type) {
+ case BSDPV_OUTPUT_FILE:
+ fd -= 1;
+ break;
+ case BSDPV_OUTPUT_SHELL:
+ fd -= 2;
+ break;
+ }
+ }
+ fd_file = file;
+ }
+
+ /*
+ * Open succeeded (or stdin was inherited) but nothing is
+ * readable yet: keep the display alive rather than looking
+ * hung. Announce while we wait for the first byte.
+ */
+ if (wait_for_input(file) < 0)
+ return (-1);
+ }
+
+ /* Allocate buffer if necessary */
+ if (buf == NULL) {
+ /* Use output block size as buffer size if available */
+ if (out >= 0) {
+ if (fstat(out, &sb) != 0) {
+ warn("%i", out);
+ file->status = BSDPV_STATUS_FAILED;
+ return (-1);
+ }
+ if (S_ISREG(sb.st_mode)) {
+ if (sysconf(_SC_PHYS_PAGES) >
+ PHYSPAGES_THRESHOLD)
+ bsize = MIN(BUFSIZE_MAX, MAXPHYS * 8);
+ else
+ bsize = BUFSIZE_SMALL;
+ } else
+ bsize = MAX(sb.st_blksize,
+ (blksize_t)sysconf(_SC_PAGESIZE));
+ } else
+ bsize = MIN(BUFSIZE_MAX, MAXPHYS * 8);
+
+ /* Attempt to allocate */
+ if ((buf = malloc(bsize + 1)) == NULL) {
+ bsdpv_end();
+ err(EXIT_FAILURE, "Out of memory?!");
+ }
+ }
+
+ return (0);
+}
+
+static int
+operate_on_bytes(struct bsdpv_file_node *file, int out)
+{
+ int progress;
+ size_t to_read;
+ ssize_t r, w;
+ int cap_len;
+
+ if (operate_common(file, out) < 0)
+ return (-1);
+
+ cap_len = no_overrun && file->length >= 0;
+
+ to_read = bsize;
+ if (stop_at_length(file)) {
+ input_close();
+ file->status = BSDPV_STATUS_DONE;
+ return (100);
+ }
+ if (cap_len && file->read < file->length) {
+ long long left = file->length - file->read;
+
+ if (left < (long long)to_read)
+ to_read = (size_t)left;
+ }
+
+ /* [Re-]Fill the buffer */
+ r = input_read(buf, to_read);
+ if (r < 0 && bsdpv_interrupt) {
+ if (file->length > 0)
+ return ((int)(file->read * 100 / file->length));
+ return (file->length == 0 ? 100 : -1);
+ }
+ if (r <= 0)
+ return (finish_input(file, r < 0));
+
+ /* [Re-]Dump the buffer */
+ if (out >= 0) {
+ if ((w = write(out, buf, r)) < 0) {
+ bsdpv_end();
+ err(EXIT_FAILURE, "output");
+ }
+ fsync(out);
+ }
+
+ bsdpv_overall_read += r;
+ file->read += r;
+
+ /* Calculate percentage of completion (if possible) */
+ if (file->length >= 0) {
+ progress = (file->read * 100 / (file->length > 0 ?
+ file->length : 1));
+
+ if (cap_len && progress == 100 && file->read < file->length)
+ progress--;
+ if (cap_len && progress > 100)
+ progress = 100;
+
+ if (stop_at_length(file)) {
+ input_close();
+ file->status = BSDPV_STATUS_DONE;
+ }
+
+ return (progress);
+ } else
+ return (-1);
+}
+
+static int
+operate_on_lines(struct bsdpv_file_node *file, int out)
+{
+ char *p;
+ int progress;
+ ssize_t r, w;
+ int cap_len;
+
+ if (operate_common(file, out) < 0)
+ return (-1);
+
+ cap_len = no_overrun && file->length >= 0;
+
+ if (stop_at_length(file)) {
+ input_close();
+ file->status = BSDPV_STATUS_DONE;
+ return (100);
+ }
+
+ /* [Re-]Fill the buffer */
+ r = input_read(buf, bsize);
+ if (r < 0 && bsdpv_interrupt) {
+ if (file->length > 0)
+ return ((int)(file->read * 100 / file->length));
+ return (file->length == 0 ? 100 : -1);
+ }
+ if (r <= 0)
+ return (finish_input(file, r < 0));
+ buf[r] = '\0';
+
+ /* [Re-]Dump the buffer */
+ if (out >= 0) {
+ if ((w = write(out, buf, r)) < 0) {
+ bsdpv_end();
+ err(EXIT_FAILURE, "output");
+ }
+ fsync(out);
+ }
+
+ /* Process the buffer for number of lines */
+ for (p = buf; p != NULL && *p != '\0';) {
+ if ((p = strchr(p, '\n')) == NULL)
+ break;
+ bsdpv_overall_read++, p++, file->read++;
+ /*
+ * Stop counting once the stated length is reached (even
+ * mid-buffer) when -N says so.
+ */
+ if (stop_at_length(file))
+ break;
+ }
+
+ /* Calculate percentage of completion (if possible) */
+ if (file->length >= 0) {
+ progress = (file->read * 100 / file->length);
+
+ if (cap_len && progress == 100 && file->read < file->length)
+ progress--;
+ if (cap_len && progress > 100)
+ progress = 100;
+
+ if (stop_at_length(file)) {
+ input_close();
+ file->status = BSDPV_STATUS_DONE;
+ }
+
+ return (progress);
+ } else
+ return (-1);
+}
+
+/*
+ * Takes a list of names that are to correspond to input streams coming from
+ * stdin or -m paths and produces necessary config to drive bsdpv(3). Progress
+ * defaults to a human-readable status line on stderr; `-D' selects the
+ * bsddialog(3) gauge widget. With `-d', gauge-protocol output is sent to
+ * stdout (suitable for saving or piping into `bsddialog --gauge').
+ */
+int
+main(int argc, char *argv[])
+{
+ char dummy;
+ int ch;
+ int n = 0;
+ size_t config_size = sizeof(struct bsdpv_config);
+ size_t file_node_size = sizeof(struct bsdpv_file_node);
+ struct bsdpv_config *config;
+ struct bsdpv_file_node *curfile;
+ struct sigaction act;
+
+ pgm = argv[0]; /* store a copy of invocation name */
+
+ /* Allocate config structure */
+ if ((config = malloc(config_size)) == NULL)
+ errx(EXIT_FAILURE, "Out of memory?!");
+ memset((void *)(config), '\0', config_size);
+ /* Utility default: human-readable CLI progress (see -D for dialog) */
+ config->display_type = BSDPV_DISPLAY_CLI;
+ /* Default status cadence; -U 0 means disable (not "use default"). */
+ config->status_updates_per_second = 2;
+
+ /*
+ * Process command-line options
+ */
+ while ((ch = getopt(argc, argv,
+ "a:b:Ddhi:I:klL:mn:No:p:P:t:TU:wx:")) != -1) {
+ switch (ch) {
+ case 'a': /* additional message text to append */
+ if (config->aprompt == NULL) {
+ config->aprompt = malloc(BSDPV_APROMPT_MAX);
+ if (config->aprompt == NULL)
+ errx(EXIT_FAILURE, "Out of memory?!");
+ }
+ snprintf(config->aprompt, BSDPV_APROMPT_MAX, "%s",
+ optarg);
+ break;
+ case 'b': /* bsddialog(3) backtitle */
+ if (config->backtitle != NULL)
+ free((char *)config->backtitle);
+ config->backtitle = malloc(strlen(optarg) + 1);
+ if (config->backtitle == NULL)
+ errx(EXIT_FAILURE, "Out of memory?!");
+ *(config->backtitle) = '\0';
+ strcat(config->backtitle, optarg);
+ break;
+ case 'd': /* debugging */
+ debug = TRUE;
+ config->debug = debug;
+ break;
+ case 'D': /* dialog (bsddialog) progress UI */
+ config->display_type = BSDPV_DISPLAY_BSDDIALOG;
+ break;
+ case 'h': /* help/usage */
+ usage();
+ break; /* NOTREACHED */
+ case 'i': /* status line format string for single-file */
+ config->status_solo = optarg;
+ break;
+ case 'I': /* status line format string for many-files */
+ config->status_many = optarg;
+ break;
+ case 'k': /* keep tite */
+ config->keep_tite = TRUE;
+ break;
+ case 'l': /* Line mode */
+ line_mode = TRUE;
+ break;
+ case 'L': /* custom label size */
+ config->label_size =
+ (int)strtol(optarg, (char **)NULL, 10);
+ if (config->label_size == 0 && errno == EINVAL)
+ errx(EXIT_FAILURE,
+ "`-L' argument must be numeric");
+ else if (config->label_size < -1)
+ config->label_size = -1;
+ break;
+ case 'm': /* enable multiple file arguments */
+ multiple = TRUE;
+ break;
+ case 'o': /* `-o path' for sending data-read to file */
+ output_type = BSDPV_OUTPUT_FILE;
+ config->output_type = BSDPV_OUTPUT_FILE;
+ config->output = optarg;
+ break;
+ case 'n': /* custom number of files per `page' */
+ config->display_limit =
+ (int)strtol(optarg, (char **)NULL, 10);
+ if (config->display_limit == 0 && errno == EINVAL)
+ errx(EXIT_FAILURE,
+ "`-n' argument must be numeric");
+ else if (config->display_limit < 0)
+ config->display_limit = -1;
+ break;
+ case 'N': /* No overrun (truncate reads of known-length) */
+ no_overrun = TRUE;
+ config->options |= BSDPV_NO_OVERRUN;
+ break;
+ case 'p': /* additional message text to use as prefix */
+ if (config->pprompt == NULL) {
+ config->pprompt =
+ malloc(BSDPV_PPROMPT_MAX + 2);
+ if (config->pprompt == NULL)
+ errx(EXIT_FAILURE, "Out of memory?!");
+ /* +2 is for implicit "\n" appended later */
+ }
+ snprintf(config->pprompt, BSDPV_PPROMPT_MAX, "%s",
+ optarg);
+ break;
+ case 'P': /* custom size for mini-progressbar */
+ config->pbar_size =
+ (int)strtol(optarg, (char **)NULL, 10);
+ if (config->pbar_size == 0 && errno == EINVAL)
+ errx(EXIT_FAILURE,
+ "`-P' argument must be numeric");
+ else if (config->pbar_size < -1)
+ config->pbar_size = -1;
+ break;
+ case 't': /* bsddialog(3) title */
+ if (config->title != NULL)
+ free(config->title);
+ config->title = malloc(strlen(optarg) + 1);
+ if (config->title == NULL)
+ errx(EXIT_FAILURE, "Out of memory?!");
+ *(config->title) = '\0';
+ strcat(config->title, optarg);
+ break;
+ case 'T': /* test mode (don't read data, fake it) */
+ config->options |= BSDPV_TEST_MODE;
+ break;
+ case 'U': /* updates per second */
+ config->status_updates_per_second =
+ (int)strtol(optarg, (char **)NULL, 10);
+ if (config->status_updates_per_second == 0 &&
+ errno == EINVAL)
+ errx(EXIT_FAILURE,
+ "`-U' argument must be numeric");
+ break;
+ case 'w': /* `-p' and `-a' widths bump display width */
+ config->options |= BSDPV_WIDE_MODE;
+ break;
+ case 'x': /* `-x cmd' for sending data-read to sh(1) code */
+ output_type = BSDPV_OUTPUT_SHELL;
+ config->output_type = BSDPV_OUTPUT_SHELL;
+ config->output = optarg;
+ break;
+ case '?': /* unknown argument (based on optstring) */
+ /* FALLTHROUGH */
+ default: /* unhandled argument (based on switch) */
+ usage();
+ /* NOTREACHED */
+ }
+ }
+ argc -= optind;
+ argv += optind;
+
+ /* Process remaining arguments as list of names to display */
+ for (curfile = file_list; n < argc; n++) {
+ nfiles++;
+
+ /* Allocate a new struct for the file argument */
+ if (curfile == NULL) {
+ if ((curfile = malloc(file_node_size)) == NULL)
+ errx(EXIT_FAILURE, "Out of memory?!");
+ memset((void *)(curfile), '\0', file_node_size);
+ file_list = curfile;
+ } else {
+ if ((curfile->next = malloc(file_node_size)) == NULL)
+ errx(EXIT_FAILURE, "Out of memory?!");
+ memset((void *)(curfile->next), '\0', file_node_size);
+ curfile = curfile->next;
+ }
+ curfile->name = argv[n];
+
+ /* Read possible `lines:' prefix from label syntax */
+ if (sscanf(curfile->name, "%lli:%c", &(curfile->length),
+ &dummy) == 2)
+ curfile->name = strchr(curfile->name, ':') + 1;
+ else
+ curfile->length = -1;
+
+ /* Read path argument if enabled */
+ if (multiple) {
+ if (++n >= argc)
+ errx(EXIT_FAILURE, "Missing path argument "
+ "for label number %i", nfiles);
+ /* "-" is a synonym for standard input */
+ if (strcmp(argv[n], "-") == 0)
+ curfile->path = __DECONST(char *,
+ "/dev/stdin");
+ else
+ curfile->path = argv[n];
+ } else
+ break;
+ }
+
+ /* Display usage and exit if not given at least one name */
+ if (nfiles == 0) {
+ warnx("no labels provided");
+ usage();
+ /* NOTREACHED */
+ }
+
+ /*
+ * Ctrl-C sets bsdpv_interrupt. Do not use SA_RESTART; otherwise a
+ * blocking read/poll while waiting for input never observes the
+ * flag and the process appears wedged until kill(1).
+ */
+ if (config->display_type == BSDPV_DISPLAY_BSDDIALOG ||
+ config->display_type == BSDPV_DISPLAY_CLI) {
+ memset(&act, 0, sizeof(act));
+ sigemptyset(&act.sa_mask);
+ act.sa_handler = sig_int;
+ act.sa_flags = 0;
+ sigaction(SIGINT, &act, NULL);
+ }
+
+ /*
+ * Set status formats (unless customized with `-i' or `-I') and
+ * action.
+ */
+ if (line_mode) {
+ if (config->status_solo == NULL)
+ config->status_solo = LINE_STATUS_SOLO;
+ if (config->status_many == NULL)
+ config->status_many = LINE_STATUS_MANY;
+ config->action = operate_on_lines;
+ } else {
+ if (config->status_solo == NULL)
+ config->status_solo = BYTE_STATUS_SOLO;
+ if (config->status_many == NULL)
+ config->status_many = BYTE_STATUS_MANY;
+ config->action = operate_on_bytes;
+ }
+
+ /*
+ * Hand off to bsdpv(3)...
+ */
+ if (bsdpv(config, file_list) != 0 && debug)
+ warnx("bsdpv(3) returned error!?");
+
+ bsdpv_free();
+
+ exit(EXIT_SUCCESS);
+}
+
+/*
+ * Interrupt handler to indicate we received a Ctrl-C interrupt.
+ */
+static void
+sig_int(int sig __unused)
+{
+ bsdpv_interrupt = TRUE;
+}
+
+/*
+ * Print short usage statement to stderr and exit with error status.
+ */
+static void
+usage(void)
+{
+
+ if (debug) /* No need for usage */
+ exit(EXIT_FAILURE);
+
+ fprintf(stderr, "Usage: %s [options] [bytes:]label\n", pgm);
+ fprintf(stderr, " %s [options] -m [bytes1:]label1 path1 "
+ "[[bytes2:]label2 path2 ...]\n", pgm);
+ fprintf(stderr, "OPTIONS:\n");
+#define OPTFMT "\t%-14s %s\n"
+ fprintf(stderr, OPTFMT, "-a text",
+ "Append text (below bars; CLI above the status line).");
+ fprintf(stderr, OPTFMT, "-b backtitle",
+ "Backtitle (dialog backdrop; CLI header above progress).");
+ fprintf(stderr, OPTFMT, "-D",
+ "Dialog. Use bsddialog(3) instead of default CLI progress.");
+ fprintf(stderr, OPTFMT, "-d",
+ "Debug. Write gauge protocol to standard output.");
+ fprintf(stderr, OPTFMT, "-h",
+ "Produce this output on standard error and exit.");
+ fprintf(stderr, OPTFMT, "-I format",
+ "Customize the multi-file status line format.");
+ fprintf(stderr, OPTFMT, "-i format",
+ "Customize the single-file status line format.");
+ fprintf(stderr, OPTFMT, "-k",
+ "Keep tite. Prevent visually distracting exit routines.");
+ fprintf(stderr, OPTFMT, "-L size",
+ "Label size. Must be a number greater than 0, or -1.");
+ fprintf(stderr, OPTFMT, "-l",
+ "Line mode. Read lines from input instead of bytes.");
+ fprintf(stderr, OPTFMT, "-m",
+ "Enable processing of multiple file arguments.");
+ fprintf(stderr, OPTFMT, "-N",
+ "No overrun. Stop reading input at stated length, if any.");
+ fprintf(stderr, OPTFMT, "-n num",
+ "Display at-most num files per screen. Default is 0.");
+ fprintf(stderr, OPTFMT, "-o file",
+ "Output data to file (or '-' for stdout). %s -> label.");
+ fprintf(stderr, OPTFMT, "-P size",
+ "Progress-bar size (dialog/CLI [=] width); -1 hides.");
+ fprintf(stderr, OPTFMT, "-p text",
+ "Prefix text (dialog above bars; CLI header above).");
+ fprintf(stderr, OPTFMT, "-T",
+ "Test mode. Don't actually read any data, but fake it.");
+ fprintf(stderr, OPTFMT, "-t title",
+ "Title (dialog box title; CLI header above progress).");
+ fprintf(stderr, OPTFMT, "-U num",
+ "Update status line num/sec (0=off). Default is 2.");
+ fprintf(stderr, OPTFMT, "-w",
+ "Wide. Width of `-p' and `-a' text bump dialog width.");
+ fprintf(stderr, OPTFMT, "-x cmd",
+ "Send data to executed cmd. First %s replaced with label.");
+ exit(EXIT_FAILURE);
+}
diff --git a/usr.bin/bsdpv/bsdpv_util.h b/usr.bin/bsdpv/bsdpv_util.h
new file mode 100644
--- /dev/null
+++ b/usr.bin/bsdpv/bsdpv_util.h
@@ -0,0 +1,46 @@
+/*
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Copyright (c) 2013-2026 Devin Teske <dteske@FreeBSD.org>
+ */
+
+#ifndef _BSDPV_UTIL_H_
+#define _BSDPV_UTIL_H_
+
+/* Limits */
+#define BUFSIZE_MAX (2 * 1024 * 1024)
+ /* Buffer size for read(2) input */
+#ifndef MAXPHYS
+#define MAXPHYS (128 * 1024)
+ /* max raw I/O transfer size */
+#endif
+
+/*
+ * Memory strategry threshold, in pages: if physmem is larger than this,
+ * use a large buffer.
+ */
+#define PHYSPAGES_THRESHOLD (32 * 1024)
+
+/*
+ * Small (default) buffer size in bytes. It's inefficient for this to be
+ * smaller than MAXPHYS.
+ */
+#define BUFSIZE_SMALL (MAXPHYS)
+
+/*
+ * Math macros
+ */
+#undef MIN
+#define MIN(x, y) ((x) < (y) ? (x) : (y))
+#undef MAX
+#define MAX(x, y) ((x) > (y) ? (x) : (y))
+
+/*
+ * Extra display information
+ */
+#define BYTE_STATUS_SOLO "%'10lli bytes read @ %'9.1f bytes/sec."
+#define BYTE_STATUS_MANY (BYTE_STATUS_SOLO " [%i/%i busy/wait]")
+#define LINE_STATUS_SOLO "%'10lli lines read @ %'9.1f lines/sec."
+#define LINE_STATUS_MANY (LINE_STATUS_SOLO " [%i/%i busy/wait]")
+
+#endif /* !_BSDPV_UTIL_H_ */

File Metadata

Mime Type
text/plain
Expires
Sun, Aug 2, 7:19 AM (7 h, 53 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
35806979
Default Alt Text
D58133.id.diff (147 KB)

Event Timeline