From 0dd46aae4de3d61e3cf075a67d1db71c53e8350f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gunnar=20Farneb=C3=A4ck?= Date: Sun, 15 Nov 2020 21:15:10 +0100 Subject: [PATCH] Accept RefValue cursor as TerminalMenu request argument. (#38393) --- stdlib/REPL/src/TerminalMenus/AbstractMenu.jl | 29 ++-- .../REPL/src/TerminalMenus/MultiSelectMenu.jl | 2 +- .../multiselect_with_skip_menu.jl | 124 ++++++++++++++++++ stdlib/REPL/test/TerminalMenus/runtests.jl | 6 +- 4 files changed, 147 insertions(+), 14 deletions(-) create mode 100644 stdlib/REPL/test/TerminalMenus/multiselect_with_skip_menu.jl diff --git a/stdlib/REPL/src/TerminalMenus/AbstractMenu.jl b/stdlib/REPL/src/TerminalMenus/AbstractMenu.jl index 10cad446e099a..d0c262aa26335 100644 --- a/stdlib/REPL/src/TerminalMenus/AbstractMenu.jl +++ b/stdlib/REPL/src/TerminalMenus/AbstractMenu.jl @@ -64,6 +64,8 @@ end # TODO Julia2.0: get rid of parametric intermediate, making it just # abstract type ConfiguredMenu <: AbstractMenu end # Or perhaps just make all menus ConfiguredMenus +# Also consider making `cursor` a mandatory field in the Menu structs +# instead of going via the RefValue in `request`. abstract type _ConfiguredMenu{C} <: AbstractMenu end const ConfiguredMenu = _ConfiguredMenu{<:AbstractConfig} @@ -162,19 +164,24 @@ selected(m::AbstractMenu) = m.selected request(m::AbstractMenu; cursor=1) Display the menu and enter interactive mode. `cursor` indicates the item -number used for the initial cursor position. +number used for the initial cursor position. `cursor` can be either an +`Int` or a `RefValue{Int}`. The latter is useful for observation and +control of the cursor position from the outside. Returns `selected(m)`. """ request(m::AbstractMenu; kwargs...) = request(terminal, m; kwargs...) -function request(term::REPL.Terminals.TTYTerminal, m::AbstractMenu; cursor::Int=1, suppress_output=false) +function request(term::REPL.Terminals.TTYTerminal, m::AbstractMenu; cursor::Union{Int, Base.RefValue{Int}}=1, suppress_output=false) + if cursor isa Int + cursor = Ref(cursor) + end menu_header = header(m) !suppress_output && !isempty(menu_header) && println(term.out_stream, menu_header) state = nothing if !suppress_output - state = printmenu(term.out_stream, m, cursor, init=true) + state = printmenu(term.out_stream, m, cursor[], init=true) end raw_mode_enabled = try @@ -193,22 +200,22 @@ function request(term::REPL.Terminals.TTYTerminal, m::AbstractMenu; cursor::Int= c = readkey(term.in_stream) if c == Int(ARROW_UP) - cursor = move_up!(m, cursor, lastoption) + cursor[] = move_up!(m, cursor[], lastoption) elseif c == Int(ARROW_DOWN) - cursor = move_down!(m, cursor, lastoption) + cursor[] = move_down!(m, cursor[], lastoption) elseif c == Int(PAGE_UP) - cursor = page_up!(m, cursor, lastoption) + cursor[] = page_up!(m, cursor[], lastoption) elseif c == Int(PAGE_DOWN) - cursor = page_down!(m, cursor, lastoption) + cursor[] = page_down!(m, cursor[], lastoption) elseif c == Int(HOME_KEY) - cursor = 1 + cursor[] = 1 m.pageoffset = 0 elseif c == Int(END_KEY) - cursor = lastoption + cursor[] = lastoption m.pageoffset = lastoption - m.pagesize elseif c == 13 # # will break if pick returns true - pick(m, cursor) && break + pick(m, cursor[]) && break elseif c == UInt32('q') cancel(m) break @@ -221,7 +228,7 @@ function request(term::REPL.Terminals.TTYTerminal, m::AbstractMenu; cursor::Int= end if !suppress_output - state = printmenu(term.out_stream, m, cursor, oldstate=state) + state = printmenu(term.out_stream, m, cursor[], oldstate=state) end end finally # always disable raw mode diff --git a/stdlib/REPL/src/TerminalMenus/MultiSelectMenu.jl b/stdlib/REPL/src/TerminalMenus/MultiSelectMenu.jl index b68255c3ecef2..77bf4197e9a81 100644 --- a/stdlib/REPL/src/TerminalMenus/MultiSelectMenu.jl +++ b/stdlib/REPL/src/TerminalMenus/MultiSelectMenu.jl @@ -59,7 +59,7 @@ function MultiSelectMenu(options::Array{String,1}; pagesize::Int=10, selected=In pagesize = pagesize == -1 ? length(options) : pagesize # pagesize shouldn't be bigger than options pagesize = min(length(options), pagesize) - # after other checks, pagesize must be greater than 2 + # after other checks, pagesize must be at least 1 pagesize < 1 && error("pagesize must be >= 1") pageoffset = 0 diff --git a/stdlib/REPL/test/TerminalMenus/multiselect_with_skip_menu.jl b/stdlib/REPL/test/TerminalMenus/multiselect_with_skip_menu.jl new file mode 100644 index 0000000000000..5d1f97cc4ecbf --- /dev/null +++ b/stdlib/REPL/test/TerminalMenus/multiselect_with_skip_menu.jl @@ -0,0 +1,124 @@ +# This file is a part of Julia. License is MIT: https://julialang.org/license + +# Like MultiSelect but adds `n`/`p` to move to next/previous +# unselected item and `N`/`P` to move to next/previous selected item. +mutable struct MultiSelectWithSkipMenu <: TerminalMenus._ConfiguredMenu{TerminalMenus.Config} + options::Array{String,1} + pagesize::Int + pageoffset::Int + selected::Set{Int} + cursor::Base.RefValue{Int} + config::TerminalMenus.MultiSelectConfig +end + +function MultiSelectWithSkipMenu(options::Array{String,1}; pagesize::Int=10, + selected=Int[], kwargs...) + length(options) < 1 && error("MultiSelectWithSkipMenu must have at least one option") + + pagesize = pagesize == -1 ? length(options) : pagesize + pagesize = min(length(options), pagesize) + pagesize < 1 && error("pagesize must be >= 1") + + pageoffset = 0 + _selected = Set{Int}() + for item in selected + push!(_selected, item) + end + + MultiSelectWithSkipMenu(options, pagesize, pageoffset, _selected, + Ref{Int}(1), + TerminalMenus.MultiSelectConfig(; kwargs...)) +end + +TerminalMenus.header(m::MultiSelectWithSkipMenu) = "[press: d=done, a=all, c=none, npNP=move with skip]" + +TerminalMenus.options(m::MultiSelectWithSkipMenu) = m.options + +TerminalMenus.cancel(m::MultiSelectWithSkipMenu) = m.selected = Set{Int}() + +# Do not exit menu when a user selects one of the options +function TerminalMenus.pick(menu::MultiSelectWithSkipMenu, cursor::Int) + if cursor in menu.selected + delete!(menu.selected, cursor) + else + push!(menu.selected, cursor) + end + + return false +end + +function TerminalMenus.writeline(buf::IOBuffer, + menu::MultiSelectWithSkipMenu, + idx::Int, iscursor::Bool) + if idx in menu.selected + print(buf, menu.config.checked, " ") + else + print(buf, menu.config.unchecked, " ") + end + + print(buf, replace(menu.options[idx], "\n" => "\\n")) +end + +# d: Done, return from request +# a: Select all +# c: Deselect all +# n: Move to next unselected +# p: Move to previous unselected +# N: Move to next selected +# P: Move to previous selected +function TerminalMenus.keypress(menu::MultiSelectWithSkipMenu, key::UInt32) + if key == UInt32('d') || key == UInt32('D') + return true # break + elseif key == UInt32('a') || key == UInt32('A') + menu.selected = Set(1:length(menu.options)) + elseif key == UInt32('c') || key == UInt32('C') + menu.selected = Set{Int}() + elseif key == UInt32('n') + move_cursor!(menu, 1, false) + elseif key == UInt32('p') + move_cursor!(menu, -1, false) + elseif key == UInt32('N') + move_cursor!(menu, 1, true) + elseif key == UInt32('P') + move_cursor!(menu, -1, true) + end + false # don't break +end + +function move_cursor!(menu, direction, selected) + c = menu.cursor[] + while true + c += direction + if !(1 <= c <= length(menu.options)) + return + end + if (c in menu.selected) == selected + break + end + end + menu.cursor[] = c + if menu.pageoffset >= c - 1 + menu.pageoffset = max(c - 2, 0) + end + if menu.pageoffset + menu.pagesize <= c + menu.pageoffset = min(c + 1, length(menu.options)) - menu.pagesize + end +end + +# Intercept the `request` call to insert the cursor field. +function TerminalMenus.request(term::REPL.Terminals.TTYTerminal, + m::MultiSelectWithSkipMenu; + cursor::Int=1, kwargs...) + m.cursor[] = cursor + invoke(TerminalMenus.request, Tuple{REPL.Terminals.TTYTerminal, + TerminalMenus.AbstractMenu}, + term, m; cursor=m.cursor, kwargs...) +end + +# These tests are specifically designed to verify that a `RefValue` +# input to the AbstractMenu `request` function works as intended. +menu = MultiSelectWithSkipMenu(string.(1:5), selected=[2, 3]) +@test simulate_input(Set([2, 3, 4]), menu, 'n', :enter, 'd') + +menu = MultiSelectWithSkipMenu(string.(1:5), selected=[2, 3]) +@test simulate_input(Set([2]), menu, 'P', :enter, 'd', cursor=5) diff --git a/stdlib/REPL/test/TerminalMenus/runtests.jl b/stdlib/REPL/test/TerminalMenus/runtests.jl index d4e1b5c1a83bd..fab105244d0a1 100644 --- a/stdlib/REPL/test/TerminalMenus/runtests.jl +++ b/stdlib/REPL/test/TerminalMenus/runtests.jl @@ -4,7 +4,8 @@ import REPL using REPL.TerminalMenus using Test -function simulate_input(expected, menu::TerminalMenus.AbstractMenu, keys...) +function simulate_input(expected, menu::TerminalMenus.AbstractMenu, keys...; + kwargs...) keydict = Dict(:up => "\e[A", :down => "\e[B", :enter => "\r") @@ -17,12 +18,13 @@ function simulate_input(expected, menu::TerminalMenus.AbstractMenu, keys...) end end - request(menu; suppress_output=true) == expected + request(menu; suppress_output=true, kwargs...) == expected end include("radio_menu.jl") include("multiselect_menu.jl") include("dynamic_menu.jl") +include("multiselect_with_skip_menu.jl") # Legacy tests include("legacytests/old_radio_menu.jl")