Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Description: A suite of tools to build attractive command line interfaces
License: MIT + file LICENSE
URL: https://cli.r-lib.org, https://github.com/r-lib/cli
BugReports: https://github.com/r-lib/cli/issues
Depends:
Depends:
R (>= 3.4)
Imports:
utils
Expand Down Expand Up @@ -60,4 +60,4 @@ Config/Needs/website:
Config/testthat/edition: 3
Config/usethis/last-upkeep: 2025-04-25
Encoding: UTF-8
RoxygenNote: 7.3.2.9000
Config/roxygen2/version: 8.0.0
9 changes: 9 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# cli (development version)

* Multiple concurrent progress bar and status bars are now rendered
on separate lines on ANSI-capable terminals. Non-ANSI dynamic terminals
continue to show only the current bar (@simonpcouch, #819).
Set the new `cli.progress_multiline` option to `FALSE` to keep the
single-line behavior on ANSI terminals.

* New `R_CLI_ANSI` environment variable that is equivalent to the
`cli.ansi` option (the option takes precedence). See `is_ansi_tty()`.

# cli 3.6.6

* New `{.num}` and `{.bytes}` inline styles to format numbers
Expand Down
3 changes: 3 additions & 0 deletions R/cliapp.R
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ cliapp <- function(
styles = NULL,
delayed_item = NULL,
status_bar = list(),
status_bar_lines = 0L,
status_bar_current = NULL,
status_bar_prev_content = "",

margin = 0,
output = NULL,
Expand Down
8 changes: 4 additions & 4 deletions R/internals.R
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,11 @@ clii__cat_ln <- function(app, lines, indent, padding) {

signal <- !identical(app$signal, FALSE)
if (signal && length(app$status_bar)) {
clii__clear_status_bar(app)
clii__clear_all_status_bars(app)
}
app$cat(paste0(paste0(lines, "\n"), collapse = ""))
if (signal && length(app$status_bar)) {
app$cat(paste0(app$status_bar[[1]]$content, "\r"))
clii__restore_status_bars(app)
}
}

Expand All @@ -101,7 +101,7 @@ clii__vspace <- function(app, n) {
sp <- strrep("\n", n - app$margin)
signal <- !identical(app$signal, FALSE)
if (signal && length(app$status_bar)) {
clii__clear_status_bar(app)
clii__clear_all_status_bars(app)
}
clii__message(
sp,
Expand All @@ -111,7 +111,7 @@ clii__vspace <- function(app, n) {
)
app$margin <- n
if (signal && length(app$status_bar)) {
app$cat(paste0(app$status_bar[[1]]$content, "\r"))
clii__restore_status_bars(app)
}
}
}
Expand Down
223 changes: 181 additions & 42 deletions R/status-bar.R
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,20 @@ cli_process_failed <- function(

# -----------------------------------------------------------------------

is_progress_multiline <- function() {
opt <- getOption("cli.progress_multiline", TRUE)
if (isTRUE(opt)) {
return(TRUE)
}
if (isFALSE(opt)) {
return(FALSE)
}
throw(cli_error(
"Invalid value for cli.progress_multiline option.",
"i" = "It must be `TRUE` or `FALSE`, but it is {.type {opt}}."
))
}

clii_status <- function(
app,
id,
Expand All @@ -333,16 +347,17 @@ clii_status <- function(
keep = keep,
auto_result = auto_result
)
app$status_bar_current <- id
if (isTRUE(getOption("cli.hide_cursor", TRUE)) && !isTRUE(globalenv)) {
ansi_hide_cursor(app$output)
}
clii_status_update(app, id, msg, msg_done = NULL, msg_failed = NULL)
}

clii_status_clear <- function(app, id, result, msg_done, msg_failed) {
## If NA then the most recent one
## If NA then the current one
if (is.na(id)) {
id <- names(app$status_bar)[1]
id <- app$status_bar_current
}

## If no active status bar, then ignore
Expand All @@ -362,6 +377,10 @@ clii_status_clear <- function(app, id, result, msg_done, msg_failed) {
}
}

## For done/failed, update content via clii_status_update (renders).
## Save and restore status_bar_current so that clearing a non-current bar
## doesn't shift which bar subsequent id-less operations target.
saved_current <- app$status_bar_current
if (result == "done") {
msg <- msg_done %||% app$status_bar[[id]]$msg_done
clii_status_update(app, id, msg, NULL, NULL)
Expand All @@ -371,47 +390,65 @@ clii_status_clear <- function(app, id, result, msg_done, msg_failed) {
clii_status_update(app, id, msg, NULL, NULL)
app$status_bar[[id]]$keep <- TRUE
}
app$status_bar_current <- saved_current

if (names(app$status_bar)[1] == id) {
## This is the active one
output <- get_real_output(app$output)
is_ansi <- is_ansi_tty(output)
is_multi <- is_ansi && is_progress_multiline()
is_displayed <- if (is_multi) TRUE else identical(app$status_bar_current, id)

## Clear/emit based on terminal type
if (is_multi && length(app$status_bar) > 1L) {
## Multi-bar ANSI: clear all, emit kept content, re-render remaining
clii__clear_all_status_bars(app)
if (app$status_bar[[id]]$keep) {
## Keep? Just emit it
app$cat("\n")
} else {
## Not keep? Remove it
clii__clear_status_bar(app)
}
if (isTRUE(getOption("cli.hide_cursor", TRUE))) {
ansi_show_cursor(app$output)
app$cat(paste0(app$status_bar[[id]]$content, "\n"))
}
} else {
} else if (is_displayed) {
if (app$status_bar[[id]]$keep) {
## Keep?
clii__clear_status_bar(app)
app$cat(paste0(app$status_bar[[id]]$content, "\n"))
app$cat(paste0(app$status_bar[[1]]$content, "\r"))
app$cat("\n")
} else {
## Not keep? Nothing to output
clii__clear_all_status_bars(app)
}
} else if (app$status_bar[[id]]$keep) {
clii__clear_all_status_bars(app)
app$cat(paste0(app$status_bar[[id]]$content, "\n"))
}

## Remove
## Remove the bar
app$status_bar[[id]] <- NULL

## Switch to the previous one
if (length(app$status_bar)) {
app$cat(paste0(app$status_bar[[1]]$content, "\r"))
## Update current pointer
if (identical(app$status_bar_current, id)) {
nms <- names(app$status_bar)
app$status_bar_current <- if (length(nms)) nms[length(nms)] else NULL
}

## Cursor visibility and restore remaining bars
if (length(app$status_bar) == 0L) {
app$status_bar_lines <- 0L
app$status_bar_prev_content <- ""
if (isTRUE(getOption("cli.hide_cursor", TRUE))) {
ansi_show_cursor(app$output)
}
} else if (is_ansi) {
clii__render_all_status_bars(app)
} else {
if (is_displayed && isTRUE(getOption("cli.hide_cursor", TRUE))) {
ansi_show_cursor(app$output)
}
clii__restore_status_bars(app)
}
}

clii_status_update <- function(app, id, msg, msg_done, msg_failed) {
## If NA then the most recent one
## If NA then the current one
if (is.na(id)) {
id <- names(app$status_bar)[1]
id <- app$status_bar_current
}

## If no active status bar, then ignore
if (is.na(id)) {
if (is.null(id) || is.na(id)) {
return(invisible())
}

Expand All @@ -428,9 +465,6 @@ clii_status_update <- function(app, id, msg, msg_done, msg_failed) {
return(invisible())
}

## Do we need to clear the current content?
current <- paste0("", app$status_bar[[1]]$content)

## Format the line
content <- ""
fmsg <- app$inline(msg)
Expand All @@ -440,22 +474,22 @@ clii_status_update <- function(app, id, msg, msg_done, msg_failed) {
content <- ""
}

## Update status bar, put it in front
## Update content in place (stable order)
app$status_bar[[id]]$content <- content
app$status_bar <- c(
app$status_bar[id],
app$status_bar[setdiff(names(app$status_bar), id)]
)
app$status_bar_current <- id

## New content, if it is an ANSI terminal we'll overwrite and clear
## until the end of the line. Otherwise we add some space characters
## to the content to make sure we clear up residual content.
## Render
output <- get_real_output(app$output)
if (is_ansi_tty(output)) {
app$cat(paste0("\r", content, ANSI_EL, "\r"))
clii__render_all_status_bars(app)
} else if (is_dynamic_tty(output)) {
nsp <- max(ansi_nchar(current) - ansi_nchar(content), 0)
app$cat(paste0("\r", content, strrep(" ", nsp), "\r"))
## Non-ANSI dynamic TTY: show only the current bar
current_content <- app$status_bar[[id]]$content
prev <- app$status_bar_prev_content %||% ""
nsp <- max(ansi_nchar(prev) - ansi_nchar(current_content), 0)
app$cat(paste0("\r", current_content, strrep(" ", nsp), "\r"))
app$status_bar_prev_content <- current_content
app$status_bar_lines <- 1L
} else {
app$cat(paste0(content, "\n"))
}
Expand All @@ -466,13 +500,118 @@ clii_status_update <- function(app, id, msg, msg_done, msg_failed) {
invisible()
}

clii__clear_status_bar <- function(app) {
clii__clear_all_status_bars <- function(app) {
n <- app$status_bar_lines
if (n == 0L) {
return(invisible())
}

output <- get_real_output(app$output)
if (is_ansi_tty(output)) {
app$cat(paste0("\r", ANSI_EL))
out <- ""
if (n > 1L) {
out <- ansi_cuu(n - 1L)
}
for (i in seq_len(n)) {
if (i < n) {
out <- paste0(out, "\r", ANSI_EL, "\n")
} else {
out <- paste0(out, "\r", ANSI_EL)
}
}
if (n > 1L) {
out <- paste0(out, ansi_cuu(n - 1L))
}
app$cat(out)
} else if (is_dynamic_tty(output)) {
text <- app$status_bar[[1]]$content
## status_bar_prev_content tracks the painted width in the non-ANSI
## dynamic branch (the ANSI path uses status_bar[[i]]$content directly).
## Reset it unconditionally below in case the terminal mode flipped
## between paint and clear.
text <- app$status_bar_prev_content %||% ""
len <- ansi_nchar(text, type = "width")
app$cat(paste0("\r", strrep(" ", len + rstudio_r_fix), "\r"))
}
app$status_bar_lines <- 0L
app$status_bar_prev_content <- ""
}

clii__render_all_status_bars <- function(app) {
full_n <- length(app$status_bar)
if (full_n == 0L) {
return(invisible())
}

output <- get_real_output(app$output)
if (!is_ansi_tty(output)) {
return(invisible())
}

## When multiline is disabled, render only the current bar as a single line.
is_multi <- is_progress_multiline()
n <- if (is_multi) full_n else 1L

prev <- app$status_bar_lines
out <- ""

## Move up to the top of previously painted area
if (prev > 1L) {
out <- ansi_cuu(prev - 1L)
}

if (is_multi) {
## Render each bar
for (i in seq_len(n)) {
content <- app$status_bar[[i]]$content
if (i < n) {
out <- paste0(out, "\r", content, ANSI_EL, "\n")
} else {
out <- paste0(out, "\r", content, ANSI_EL, "\r")
}
}
} else {
cid <- app$status_bar_current %||% names(app$status_bar)[full_n]
content <- app$status_bar[[cid]]$content
out <- paste0(out, "\r", content, ANSI_EL, "\r")
}

## If we previously had more lines, clear the leftover lines below
if (prev > n) {
for (i in seq_len(prev - n)) {
out <- paste0(out, "\n\r", ANSI_EL)
}
out <- paste0(out, ansi_cuu(prev - n), "\r")
}

app$cat(out)
app$status_bar_lines <- n
}

clii__restore_status_bars <- function(app) {
if (length(app$status_bar) == 0L) {
return(invisible())
}

output <- get_real_output(app$output)
if (is_ansi_tty(output)) {
if (is_progress_multiline() && length(app$status_bar) > 1L) {
clii__render_all_status_bars(app)
} else {
cid <- app$status_bar_current %||%
names(app$status_bar)[length(app$status_bar)]
content <- app$status_bar[[cid]]$content
app$cat(paste0(content, "\r"))
app$status_bar_lines <- 1L
}
} else if (is_dynamic_tty(output)) {
cid <- app$status_bar_current
if (!is.null(cid) && cid %in% names(app$status_bar)) {
content <- app$status_bar[[cid]]$content
} else {
content <- app$status_bar[[length(app$status_bar)]]$content
}
app$cat(paste0(content, "\r"))
app$status_bar_prev_content <- content
app$status_bar_lines <- 1L
}
}
Loading
Loading