Skip to content
Open
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
2 changes: 1 addition & 1 deletion stdlib/REPL/Project.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name = "REPL"
uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb"
version = "1.11.0"
version = "1.12.0"

[deps]
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
Expand Down
31 changes: 20 additions & 11 deletions stdlib/REPL/docs/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -922,20 +922,20 @@ options = ["apple", "orange", "grape", "strawberry",

The RadioMenu allows the user to select one option from the list. The `request`
function displays the interactive menu and returns the index of the selected
choice. If a user presses 'q' or `ctrl-c`, `request` will return a `-1`.

choice. If a user presses 'q' or `ctrl-c`, `request` will return `nothing`
(if `on_cancel` not provided, the return on cancel is `-1`, which is legacy).

```julia
# `pagesize` is the number of items to be displayed at a time.
# The UI will scroll if the number of options is greater
# than the `pagesize`
menu = RadioMenu(options, pagesize=4)
menu = RadioMenu(options, on_cancel=nothing, pagesize=4)

# `request` displays the menu and returns the index after the
# user has selected a choice
choice = request("Choose your favorite fruit:", menu)

if choice != -1
if !isnothing(choice)
println("Your favorite fruit is ", options[choice], "!")
else
println("Menu canceled.")
Expand All @@ -960,19 +960,21 @@ The MultiSelectMenu allows users to select many choices from a list.

```julia
# here we use the default `pagesize` 10
menu = MultiSelectMenu(options)
menu = MultiSelectMenu(options, on_cancel=nothing)

# `request` returns a `Set` of selected indices
# if the menu us canceled (ctrl-c or q), return an empty set
# if the menu is canceled (ctrl-c or q), return `nothing`
choices = request("Select the fruits you like:", menu)

if length(choices) > 0
if isnothing(choices)
println("Menu canceled.")
elseif length(choices) > 0
println("You like the following fruits:")
for i in choices
println(" - ", options[i])
end
else
println("Menu canceled.")
println("You don't like any fruits.")
end
```

Expand Down Expand Up @@ -1003,7 +1005,7 @@ Starting with Julia 1.6, the recommended way to configure menus is via the const
For instance, the default multiple-selection menu

```
julia> menu = MultiSelectMenu(options, pagesize=5);
julia> menu = MultiSelectMenu(options, on_cancel=nothing, pagesize=5),

julia> request(menu) # ASCII is used by default
[press: Enter=toggle, a=all, n=none, d=done, q=abort]
Expand Down Expand Up @@ -1051,11 +1053,18 @@ Aside from the overall `charset` option, for `RadioMenu` the configurable option
- `updown_arrow::Char='I'|'↕'`: character to use for up/down arrow in one-line page
- `scroll_wrap::Bool=false`: optionally wrap-around at the beginning/end of a menu
- `ctrl_c_interrupt::Bool=true`: If `false`, return empty on ^C, if `true` throw InterruptException() on ^C
- `on_cancel::Union{Nothing, Int}`: added in REPL v1.12; recommended value is `nothing` for consistency
- `header::String`: added in REPL v1.12; default is "".
Call `RadioMenu` constructor with kwarg `header=true` to set header to "[press: Enter=select, q=abort]"

`MultiSelectMenu` adds:
In `MultiSelectMenu`, the added/differing fields are:

- `checked::String="[X]"|"✓"`: string to use for checked
- `unchecked::String="[ ]"|"⬚")`: string to use for unchecked
- `on_cancel::Union{Nothing, Set{Int}}`: added in REPL v1.12; default is empty set for backward compat.
It is recommended to set `on_cancel=nothing` to be able to discriminate between "nothing selected" vs. "aborted".
- `header::String`: added in REPL v1.12; default is "[press: Enter=toggle, a=all, n=none, d=done, q=abort]. "
Call `MultiSelectMenu` constructor with kwarg `header=false` to display no header.

You can create new menu types of your own.
Types that are derived from `TerminalMenus.ConfiguredMenu` configure the menu options at construction time.
Expand Down Expand Up @@ -1104,7 +1113,6 @@ Any subtype must also implement the following functions:

```@docs
REPL.TerminalMenus.pick
REPL.TerminalMenus.cancel
REPL.TerminalMenus.writeline
```

Expand All @@ -1124,6 +1132,7 @@ REPL.TerminalMenus.selected
The following are optional but can allow additional customization:

```@docs
REPL.TerminalMenus.cancel
REPL.TerminalMenus.header
REPL.TerminalMenus.keypress
```
29 changes: 18 additions & 11 deletions stdlib/REPL/src/TerminalMenus/AbstractMenu.jl
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ All subtypes must be mutable, and must contain the fields `pagesize::Int` and
These functions must be implemented for all subtypes of AbstractMenu.

- `pick(m::AbstractMenu, cursor::Int)`
- `cancel(m::AbstractMenu)`
- `options(m::AbstractMenu)` # `numoptions` is an alternative
- `writeline(buf::IO, m::AbstractMenu, idx::Int, iscursor::Bool)`

Expand All @@ -43,6 +42,7 @@ If `m` does not have a field called `selected`, then you must also implement `se
These functions do not need to be implemented for all AbstractMenu
subtypes.

- `cancel(m::AbstractMenu)`
- `header(m::AbstractMenu)`
- `keypress(m::AbstractMenu, i::UInt32)`
- `numoptions(m::AbstractMenu)`
Expand Down Expand Up @@ -82,14 +82,6 @@ If `true` is returned, `request()` will exit.
"""
pick(m::AbstractMenu, cursor::Int) = error("unimplemented")

"""
cancel(m::AbstractMenu)

Define what happens when a user cancels ('q' or ctrl-c) a menu.
`request()` will always exit after calling this function.
"""
cancel(m::AbstractMenu) = error("unimplemented")

"""
options(m::AbstractMenu)

Expand Down Expand Up @@ -128,13 +120,28 @@ end
# These functions do not need to be implemented for all menu types
##################################################################

"""
cancel(m::AbstractMenu)

Define what happens when a user cancels ('q' or ctrl-c) a menu.
`request()` will always exit after calling this function.
"""
function cancel(m::AbstractMenu)
if hasproperty(m, :on_cancel) && hasproperty(m, :selected)
m.selected = m.on_cancel
return nothing
end

error("unimplemented")
end

"""
header(m::AbstractMenu)::String

Return a header string to be printed above the menu.
Defaults to "".
Defaults to "" if the menu object does not have a `header` field.
"""
header(m::AbstractMenu) = ""
header(m::AbstractMenu) = hasproperty(m, :header) ? m.header : ""

"""
keypress(m::AbstractMenu, i::UInt32)::Bool
Expand Down
37 changes: 27 additions & 10 deletions stdlib/REPL/src/TerminalMenus/MultiSelectMenu.jl
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,20 @@ mutable struct MultiSelectMenu{C} <: _ConfiguredMenu{C}
options::Array{String,1}
pagesize::Int
pageoffset::Int
selected::Set{Int}
selected::Union{Nothing, Set{Int}}
on_cancel::Union{Nothing, Set{Int}}
header::String
config::C
end

const default_msm_header = "[press: Enter=toggle, a=all, n=none, d=done, q=abort]"

MultiSelectMenu(options, pagesize, pageoffset, selected, config) =
MultiSelectMenu(options, pagesize, pageoffset, selected, Set{Int}(), default_msm_header, config)

"""

MultiSelectMenu(options::Vector{String}; pagesize::Int=10, selected=[], kwargs...)
MultiSelectMenu(options::Vector{String}; on_cancel=Set{Int}(), header=true, pagesize::Int=10, selected=[], kwargs...)

Create a MultiSelectMenu object. Use `request(menu::MultiSelectMenu)` to get
user input. It returns a `Set` containing the indices of options that
Expand All @@ -48,14 +54,19 @@ were selected by the user.

- `options::Vector{String}`: Options to be displayed
- `pagesize::Int=10`: The number of options to be displayed at one time, the menu will scroll if length(options) > pagesize
- `selected=[]`: pre-selected items. `i ∈ selected` means that `options[i]` is preselected.
- `selected=Set{Int}()`: pre-selected items. `i ∈ selected` means that `options[i]` is preselected.
- `on_cancel::Union{Nothing, Set{Int}}=Set{Int}()`: Value returned if aborted. Default is empty set for backward compat. It is recommended to set `on_cancel=nothing` to be able to discriminate between "nothing selected" vs. "aborted".
- `header::Union{String, Bool}`: Header displayed above menu. Default is `true`, producing "[press: Enter=toggle, a=all, n=none, d=done, q=abort]". `false`
results in no header. You can provide your own string.

Any additional keyword arguments will be passed to [`TerminalMenus.MultiSelectConfig`](@ref).

!!! compat "Julia 1.6"
The `selected` argument requires Julia 1.6 or later.
"""
function MultiSelectMenu(options::Array{String,1}; pagesize::Int=10, selected=Int[], warn::Bool=true, kwargs...)
function MultiSelectMenu(options::Array{String,1};
on_cancel=Set{Int}(), header=true, pagesize::Int=10, selected=Int[], warn::Bool=true, kwargs...)

length(options) < 1 && error("MultiSelectMenu must have at least one option")

# if pagesize is -1, use automatic paging
Expand All @@ -71,11 +82,19 @@ function MultiSelectMenu(options::Array{String,1}; pagesize::Int=10, selected=In
push!(_selected, item)
end

if !isempty(kwargs)
MultiSelectMenu(options, pagesize, pageoffset, _selected, MultiSelectConfig(; kwargs...))
is_not_legacy = isnothing(on_cancel) || (header != true) || !isempty(kwargs)

if header == true
header = default_msm_header
elseif header == false
header = ""
end

if is_not_legacy
MultiSelectMenu(options, pagesize, pageoffset, _selected, on_cancel, header, MultiSelectConfig(; kwargs...))
else
warn && Base.depwarn("Legacy `MultiSelectMenu` interface is deprecated, set a configuration option such as `MultiSelectMenu(options; charset=:ascii)` to trigger the new interface.", :MultiSelectMenu)
MultiSelectMenu(options, pagesize, pageoffset, _selected, CONFIG)
MultiSelectMenu(options, pagesize, pageoffset, _selected, on_cancel, header, CONFIG)
end

end
Expand All @@ -86,11 +105,9 @@ end
# See AbstractMenu.jl
#######################################

header(m::MultiSelectMenu) = "[press: Enter=toggle, a=all, n=none, d=done, q=abort]"

options(m::MultiSelectMenu) = m.options

cancel(m::MultiSelectMenu) = m.selected = Set{Int}()
cancel(m::MultiSelectMenu) = m.selected = m.on_cancel

# Do not exit menu when a user selects one of the options
function pick(menu::MultiSelectMenu, cursor::Int)
Expand Down
32 changes: 25 additions & 7 deletions stdlib/REPL/src/TerminalMenus/RadioMenu.jl
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,22 @@ mutable struct RadioMenu{C} <: _ConfiguredMenu{C}
keybindings::Vector{Char}
pagesize::Int
pageoffset::Int
selected::Int
selected::Union{Nothing, Int}
on_cancel::Union{Nothing, Int}
header::String
config::C
end

RadioMenu(options, keybindings, pagesize, pageoffset, selected, config) =
RadioMenu(options, keybindings, pagesize, pageoffset, selected, -1, "", config)

const default_radio_header = "[press: Enter=select, q=abort]"

"""

RadioMenu(options::Vector{String}; pagesize::Int=10,
on_cancel::Union{Nothing, Int}=-1,
header::Union{String, Bool}=false,
keybindings::Vector{Char}=Char[],
kwargs...)

Expand All @@ -44,13 +52,17 @@ user.
- `options::Vector{String}`: Options to be displayed
- `pagesize::Int=10`: The number of options to be displayed at one time, the menu will scroll if length(options) > pagesize
- `keybindings::Vector{Char}=Char[]`: Shortcuts to pick corresponding entry from `options`
- `on_cancel::Union{Nothing, Int}=-1`: Value returned if aborted. Default is `-1` for backward compat. It is recommended to set `on_cancel=nothing` for consistency.
- `header::Union{String, Bool}=false`: Header displayed above the menu. If `true`, the default header "[press: Enter=select, q=abort]" is used. If `false`, no header is displayed. A custom header can be provided as a `String`.

Any additional keyword arguments will be passed to [`TerminalMenus.Config`](@ref).

!!! compat "Julia 1.8"
The `keybindings` argument requires Julia 1.8 or later.
"""
function RadioMenu(options::Array{String,1}; pagesize::Int=10, warn::Bool=true, keybindings::Vector{Char}=Char[], kwargs...)
function RadioMenu(options::Array{String,1};
on_cancel=-1, header=false, pagesize::Int=10, warn::Bool=true, keybindings::Vector{Char}=Char[], kwargs...)

length(options) < 1 && error("RadioMenu must have at least one option")
length(keybindings) in [0, length(options)] || error("RadioMenu must have either no keybindings, or one per option")

Expand All @@ -64,11 +76,19 @@ function RadioMenu(options::Array{String,1}; pagesize::Int=10, warn::Bool=true,
pageoffset = 0
selected = -1 # none

if !isempty(kwargs)
RadioMenu(options, keybindings, pagesize, pageoffset, selected, Config(; kwargs...))
is_not_legacy = isnothing(on_cancel) || (header != false) || !isempty(kwargs)

if header == true
header = default_radio_header
elseif header == false
header = ""
end

if is_not_legacy
RadioMenu(options, keybindings, pagesize, pageoffset, selected, on_cancel, header, Config(; kwargs...))
else
warn && Base.depwarn("Legacy `RadioMenu` interface is deprecated, set a configuration option such as `RadioMenu(options; charset=:ascii)` to trigger the new interface.", :RadioMenu)
RadioMenu(options, keybindings, pagesize, pageoffset, selected, CONFIG)
RadioMenu(options, keybindings, pagesize, pageoffset, selected, on_cancel, header, CONFIG)
end
end

Expand All @@ -80,8 +100,6 @@ end

options(m::RadioMenu) = m.options

cancel(m::RadioMenu) = m.selected = -1

function pick(menu::RadioMenu, cursor::Int)
menu.selected = cursor
return true #break out of the menu
Expand Down
67 changes: 67 additions & 0 deletions stdlib/REPL/test/TerminalMenus/custom_header.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# This file is a part of Julia. License is MIT: https://julialang.org/license

function simulate_input_and_capture_output(menu::TerminalMenus.AbstractMenu, keys...; kwargs...)
keydict = Dict(:up => "\e[A",
:down => "\e[B",
:enter => "\r")

new_stdin = Base.BufferStream()
for key in keys
if isa(key, Symbol)
write(new_stdin, keydict[key])
else
write(new_stdin, "$key")
end
end

out_buffer = IOBuffer()
terminal = TerminalMenus.default_terminal(; in=new_stdin, out=out_buffer)

with_logger(SimpleLogger(stderr, Logging.Error)) do
request(terminal, menu; kwargs...)
end

return String(take!(out_buffer))
end

@testset "Custom Header" begin
header = "My Custom Header"
menu = RadioMenu(["a", "b", "c"]; header=header)
output = simulate_input_and_capture_output(menu, 'q')
@test startswith(output, "\e[2K" * header)

menu = MultiSelectMenu(["a", "b", "c"]; header=header)
output = simulate_input_and_capture_output(menu, 'q')
@test startswith(output, "\e[2K" * header)

header = ""
menu = RadioMenu(["1st", "b", "c"]; header=header)
output = simulate_input_and_capture_output(menu, 'q')
@test startswith(output, "\e[2K > 1st")

menu = RadioMenu(["1st", "b", "c"]; header = false, warn=false)
output = simulate_input_and_capture_output(menu, 'q')
@test startswith(output, "\e[2K > 1st")

menu = MultiSelectMenu(["1st", "b", "c"]; header = false)
output = simulate_input_and_capture_output(menu, 'q')
@test startswith(output, "\e[2K > [ ] 1st")

menu = RadioMenu(["1st", "b", "c"]; warn=false)
output = simulate_input_and_capture_output(menu, 'q')
@test startswith(output, "\e[2K > 1st")

header = TerminalMenus.default_radio_header
menu = RadioMenu(["1st", "b", "c"]; header = true)
output = simulate_input_and_capture_output(menu, 'q')
@test startswith(output, "\e[2K" * header)

header = TerminalMenus.default_msm_header
menu = MultiSelectMenu(["a", "b", "c"]; header=true, warn=false)
output = simulate_input_and_capture_output(menu, 'q')
@test startswith(output, "\e[2K" * header)

menu = MultiSelectMenu(["a", "b", "c"]; warn=false)
output = simulate_input_and_capture_output(menu, 'q')
@test startswith(output, "\e[2K" * header)
end
Loading