diff --git a/stdlib/REPL/Project.toml b/stdlib/REPL/Project.toml index 6b37c892f8aa3..eebf0da05a762 100644 --- a/stdlib/REPL/Project.toml +++ b/stdlib/REPL/Project.toml @@ -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" diff --git a/stdlib/REPL/docs/src/index.md b/stdlib/REPL/docs/src/index.md index fa36f8edb35ec..3e201486f9af8 100644 --- a/stdlib/REPL/docs/src/index.md +++ b/stdlib/REPL/docs/src/index.md @@ -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.") @@ -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 ``` @@ -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] @@ -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. @@ -1104,7 +1113,6 @@ Any subtype must also implement the following functions: ```@docs REPL.TerminalMenus.pick -REPL.TerminalMenus.cancel REPL.TerminalMenus.writeline ``` @@ -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 ``` diff --git a/stdlib/REPL/src/TerminalMenus/AbstractMenu.jl b/stdlib/REPL/src/TerminalMenus/AbstractMenu.jl index fb546c8185232..cad267b5e9393 100644 --- a/stdlib/REPL/src/TerminalMenus/AbstractMenu.jl +++ b/stdlib/REPL/src/TerminalMenus/AbstractMenu.jl @@ -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)` @@ -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)` @@ -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) @@ -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 diff --git a/stdlib/REPL/src/TerminalMenus/MultiSelectMenu.jl b/stdlib/REPL/src/TerminalMenus/MultiSelectMenu.jl index fd660fc0f7824..99bd53f916511 100644 --- a/stdlib/REPL/src/TerminalMenus/MultiSelectMenu.jl +++ b/stdlib/REPL/src/TerminalMenus/MultiSelectMenu.jl @@ -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 @@ -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 @@ -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 @@ -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) diff --git a/stdlib/REPL/src/TerminalMenus/RadioMenu.jl b/stdlib/REPL/src/TerminalMenus/RadioMenu.jl index 8e35e37f7f973..dca1494ed374b 100644 --- a/stdlib/REPL/src/TerminalMenus/RadioMenu.jl +++ b/stdlib/REPL/src/TerminalMenus/RadioMenu.jl @@ -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...) @@ -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") @@ -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 @@ -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 diff --git a/stdlib/REPL/test/TerminalMenus/custom_header.jl b/stdlib/REPL/test/TerminalMenus/custom_header.jl new file mode 100644 index 0000000000000..bbf0937079d44 --- /dev/null +++ b/stdlib/REPL/test/TerminalMenus/custom_header.jl @@ -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 diff --git a/stdlib/REPL/test/TerminalMenus/quit_selection.jl b/stdlib/REPL/test/TerminalMenus/quit_selection.jl new file mode 100644 index 0000000000000..56af22cdf5f36 --- /dev/null +++ b/stdlib/REPL/test/TerminalMenus/quit_selection.jl @@ -0,0 +1,21 @@ +# This file is a part of Julia. License is MIT: https://julialang.org/license + +@testset "Quit Selection" begin + menu = RadioMenu(["a", "b", "c"]; warn=false) + @test simulate_input(menu, 'q') == -1 + + menu = MultiSelectMenu(["a", "b", "c"]; warn=false) + @test simulate_input(menu, 'q') == Set{Int}() + + menu = RadioMenu(["a", "b", "c"]; charset=:ascii) + @test simulate_input(menu, 'q') == -1 + + menu = MultiSelectMenu(["a", "b", "c"]; charset=:ascii) + @test simulate_input(menu, 'q') == Set{Int}() + + menu = RadioMenu(["a", "b", "c"]; on_cancel=nothing) + @test simulate_input(menu, 'q') |> isnothing + + menu = MultiSelectMenu(["a", "b", "c"]; on_cancel=nothing) + @test simulate_input(menu, 'q') |> isnothing +end diff --git a/stdlib/REPL/test/TerminalMenus/runtests.jl b/stdlib/REPL/test/TerminalMenus/runtests.jl index 9455632d9f418..bec7a5d5483c2 100644 --- a/stdlib/REPL/test/TerminalMenus/runtests.jl +++ b/stdlib/REPL/test/TerminalMenus/runtests.jl @@ -2,7 +2,7 @@ import REPL using REPL.TerminalMenus -using Test +using Test, Logging function simulate_input(menu::TerminalMenus.AbstractMenu, keys...; kwargs...) keydict = Dict(:up => "\e[A", @@ -27,6 +27,8 @@ include("multiselect_menu.jl") include("dynamic_menu.jl") include("multiselect_with_skip_menu.jl") include("pager.jl") +include("quit_selection.jl") +include("custom_header.jl") # Legacy tests include("legacytests/old_radio_menu.jl")