Skip to content
15 changes: 7 additions & 8 deletions R/Gargle-class.R
Original file line number Diff line number Diff line change
Expand Up @@ -273,14 +273,13 @@ encourage_httpuv <- function() {
if (!is_interactive() || isTRUE(is_installed("httpuv"))) {
return(invisible())
}
local_gargle_verbosity("info")
gargle_info(c(
"The {.pkg httpuv} package enables a nicer Google auth experience, \\
in many cases.",
"It doesn't seem to be installed.",
"Would you like to install it now?"
))
if (utils::menu(c("Yes", "No")) == 1) {
choice <- cli_menu(
"The {.pkg httpuv} package enables a nicer Google auth experience, in many \\
cases, but it isn't installed.",
"Would you like to install it now?",
choices = c("Yes", "No")
)
if (choice == 1) {
utils::install.packages("httpuv")
}
invisible()
Expand Down
31 changes: 17 additions & 14 deletions R/oauth-cache.R
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,13 @@ cache_allowed <- function(path) {
return(FALSE)
}

local_gargle_verbosity("info")
gargle_info("
Is it OK to cache OAuth access credentials in the folder \\
{.path {path}} between R sessions?")
utils::menu(c("Yes", "No")) == 1
choice <- cli_menu(
header = character(),
prompt = "Is it OK to cache OAuth access credentials in the folder \\
{.path {path}} between R sessions?",
choices = c("Yes", "No")
)
choice == 1
}

cache_create <- function(path) {
Expand Down Expand Up @@ -314,19 +316,20 @@ token_match <- function(candidate, existing, package = "gargle") {
}

# we need user to OK our discovery or pick from multiple emails
emails <- extract_email(existing)
local_gargle_verbosity("info")
gargle_info(c(
choices <- c(
"Send me to the browser for a new auth process.",
extract_email(existing)
)
choice <- cli_menu(
"The {.pkg {package}} package is requesting access to your Google account.",
"Select a pre-authorised account or enter '0' to obtain a new token.",
"Press Esc/Ctrl + C to cancel."
))
choice <- utils::menu(emails)
"Enter '1' to start a new auth process or select a pre-authorized account.",
choices = choices
)

if (choice == 0) {
if (choice == 1) {
NULL
} else {
existing[[choice]]
existing[[choice - 1]]
}
}

Expand Down
77 changes: 77 additions & 0 deletions R/utils-ui.R
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,80 @@ compute_n_show <- function(n, n_show_nominal = 5, n_fudge = 2) {
n
}
}

# menu(), but based on readline() + cli and mockable ---------------------------
# https://github.com/r-lib/cli/issues/228
# https://github.com/rstudio/rsconnect/blob/main/R/utils-cli.R

cli_menu <- function(header,
prompt,
choices,
not_interactive = choices,
exit = integer(),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I called this exit (instead of quit) to better match the typical vocabulary here (e.g. in the docs for utils::menu()) and to avoid any confusion with actuallly quitting R.

.envir = caller_env(),
error_call = caller_env()) {
if (!is_interactive()) {
cli::cli_abort(
c(header, not_interactive),
.envir = .envir,
call = error_call
)
}

choices <- paste0(cli::style_bold(seq_along(choices)), ": ", choices)
cli::cli_inform(
c(header, prompt, choices),
.envir = .envir
)

repeat {
selected <- cli_readline("Selection: ")
if (selected %in% c("0", seq_along(choices))) {
break
}
cli::cli_inform(
"Enter a number between 1 and {length(choices)}, or enter 0 to exit."
)
}

selected <- as.integer(selected)
if (selected %in% c(0, exit)) {
if (is_testing()) {
cli::cli_abort("Exiting...", call = NULL)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exiting not quitting

} else {
cli::cli_alert_danger("Exiting...")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto

# simulate user pressing Ctrl + C
invokeRestart("abort")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Omitted the rogue cnd that the rsconnect version has here (presumably a think-o).

}
}

selected
}

cli_readline <- function(prompt) {
local_input <- getOption("cli_input", character())

# not convinced that we need to plan for multiple mocked inputs, but leaving
# this feature in for now
if (length(local_input) > 0) {
input <- local_input[[1]]
cli::cli_inform(paste0(prompt, input))
options(cli_input = local_input[-1])
input
} else {
readline(prompt)
}
}

local_user_input <- function(x, env = caller_env()) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with a local_*()-style name

withr::local_options(
rlang_interactive = TRUE,
# trailing 0 prevents infinite loop if x only contains invalid choices
cli_input = c(x, "0"),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Appending "0" here means you can't get stuck in an infinite loop, which means I can also delete the fussy check inside the repeat { ... } in cli_menu().

Maybe this makes both of us happy? 😅

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or that could go in cli_readline()? I think I forgot how these functions were factored.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's an interesting idea. I've mentally moved on to other projects in gargle, but I think this conversation and the state of these functions here can still inform whatever a more official version looks like.

.local_envir = env
)
}

is_testing <- function() {
identical(Sys.getenv("TESTTHAT"), "true")
}
95 changes: 95 additions & 0 deletions tests/testthat/_snaps/utils-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,98 @@
* g
* h

# cli_menu() basic usage

Code
cli_menu_with_mock(1)
Message
Found multiple thingies.
Which one do you want to use?
1: label a
2: label b
3: label c
Selection: 1
Output
[1] 1

# cli_menu() invalid selection

Code
cli_menu_with_mock("nope")
Message
Found multiple thingies.
Which one do you want to use?
1: label a
2: label b
3: label c
Selection: nope
Enter a number between 1 and 3, or enter 0 to exit.
Selection: 0
Condition
Error:
! Exiting...

# cli_menu(), request exit via 0

Code
cli_menu_with_mock(0)
Message
Found multiple thingies.
Which one do you want to use?
1: label a
2: label b
3: label c
Selection: 0
Condition
Error:
! Exiting...

# cli_menu(exit =) works

Code
cli_menu_with_mock(1)
Message
Hey we need to talk.
What do you want to do?
1: Give up
2: Some other thing
Selection: 1
Condition
Error:
! Exiting...

---

Code
cli_menu_with_mock(2)
Message
Hey we need to talk.
What do you want to do?
1: Give up
2: Some other thing
Selection: 2
Output
[1] 2

# cli_menu() inline markup and environment passing

Code
cli_menu_with_mock(1)
Message
Hey we need to "talk".
What do you want to "do"?
1: Send email to '[email protected]'
2: Install the nifty package
Selection: 1
Output
[1] 1

# cli_menu() not_interactive, many strings, chained error

Code
wrapper_fun()
Condition
Error in `wrapper_fun()`:
! Multiple things found.
i Use `thingy` to specify one of "thing 1", "thing 2", and "thing 3".

2 changes: 1 addition & 1 deletion tests/testthat/test-Gargle-class.R
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ test_that("email can be set in option", {
})

test_that("Attempt to initiate OAuth2 flow fails if non-interactive", {
rlang::local_interactive(FALSE)
local_interactive(FALSE)
expect_snapshot(gargle2.0_token(cache = FALSE), error = TRUE)
})

Expand Down
1 change: 1 addition & 0 deletions tests/testthat/test-oauth-cache.R
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ test_that("default is to consult and set the oauth cache option", {
# cache_allowed() --------------------------------------------------------------

test_that("cache_allowed() returns false when non-interactive (or testing)", {
local_interactive(FALSE)
expect_false(cache_allowed(getwd()))
})

Expand Down
91 changes: 91 additions & 0 deletions tests/testthat/test-utils-ui.R
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,94 @@ test_that("bulletize() works", {
expect_snapshot(cli::cli_bullets(bulletize(letters[1:6], n_fudge = 0)))
expect_snapshot(cli::cli_bullets(bulletize(letters[1:8], n_fudge = 3)))
})

# menu(), but based on readline() + cli and mockable ---------------------------
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This basically covers the cli_menu() stuff 100%, except the lines you can only hit in interactive use.


test_that("cli_menu() basic usage", {
cli_menu_with_mock <- function(x) {
local_user_input(x)
cli_menu(
"Found multiple thingies.",
"Which one do you want to use?",
glue("label {head(letters, 3)}")
)
}

expect_snapshot(cli_menu_with_mock(1))
})

test_that("cli_menu() invalid selection", {
cli_menu_with_mock <- function(x) {
local_user_input(x)
cli_menu(
"Found multiple thingies.",
"Which one do you want to use?",
glue("label {head(letters, 3)}")
)
}

expect_snapshot(cli_menu_with_mock("nope"), error = TRUE)
})

test_that("cli_menu(), request exit via 0", {
cli_menu_with_mock <- function(x) {
local_user_input(x)
cli_menu(
"Found multiple thingies.",
"Which one do you want to use?",
glue("label {head(letters, 3)}")
)
}

expect_snapshot(error = TRUE, cli_menu_with_mock(0))
})

test_that("cli_menu(exit =) works", {
cli_menu_with_mock <- function(x) {
local_user_input(x)
cli_menu(
header = "Hey we need to talk.",
prompt = "What do you want to do?",
choices = c(
"Give up",
"Some other thing"
),
exit = 1
)
}

expect_snapshot(error = TRUE, cli_menu_with_mock(1))
expect_snapshot(cli_menu_with_mock(2))
})

test_that("cli_menu() inline markup and environment passing", {
cli_menu_with_mock <- function(x) {
local_user_input(x)
verb <- "talk"
action <- "do"
pkg_name <- "nifty"
cli_menu(
header = "Hey we need to {.str {verb}}.",
prompt = "What do you want to {.str {action}}?",
choices = c(
"Send email to {.email [email protected]}",
"Install the {.pkg {pkg_name}} package"
)
)
}
expect_snapshot(cli_menu_with_mock(1))
})

test_that("cli_menu() not_interactive, many strings, chained error", {
wrapper_fun <- function() {
local_interactive(FALSE)
things <- glue("thing {1:3}")
cli_menu(
header = "Multiple things found.",
prompt = "Which one do you want to use?",
choices = things,
not_interactive = c(i = "Use {.arg thingy} to specify one of {.str {things}}.")
)
}
expect_snapshot(wrapper_fun(), error = TRUE)
})