Skip to content

Commit

Permalink
Accept RefValue cursor as TerminalMenu request argument. (JuliaLang#3…
Browse files Browse the repository at this point in the history
  • Loading branch information
GunnarFarneback committed Nov 15, 2020
1 parent f3252bf commit 0dd46aa
Show file tree
Hide file tree
Showing 4 changed files with 147 additions and 14 deletions.
29 changes: 18 additions & 11 deletions stdlib/REPL/src/TerminalMenus/AbstractMenu.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down Expand Up @@ -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
Expand All @@ -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 # <enter>
# will break if pick returns true
pick(m, cursor) && break
pick(m, cursor[]) && break
elseif c == UInt32('q')
cancel(m)
break
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion stdlib/REPL/src/TerminalMenus/MultiSelectMenu.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
124 changes: 124 additions & 0 deletions stdlib/REPL/test/TerminalMenus/multiselect_with_skip_menu.jl
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 4 additions & 2 deletions stdlib/REPL/test/TerminalMenus/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand Down

0 comments on commit 0dd46aa

Please sign in to comment.