Skip to content

Commit 0dd46aa

Browse files
Accept RefValue cursor as TerminalMenu request argument. (#38393)
1 parent f3252bf commit 0dd46aa

File tree

4 files changed

+147
-14
lines changed

4 files changed

+147
-14
lines changed

stdlib/REPL/src/TerminalMenus/AbstractMenu.jl

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ end
6464
# TODO Julia2.0: get rid of parametric intermediate, making it just
6565
# abstract type ConfiguredMenu <: AbstractMenu end
6666
# Or perhaps just make all menus ConfiguredMenus
67+
# Also consider making `cursor` a mandatory field in the Menu structs
68+
# instead of going via the RefValue in `request`.
6769
abstract type _ConfiguredMenu{C} <: AbstractMenu end
6870
const ConfiguredMenu = _ConfiguredMenu{<:AbstractConfig}
6971

@@ -162,19 +164,24 @@ selected(m::AbstractMenu) = m.selected
162164
request(m::AbstractMenu; cursor=1)
163165
164166
Display the menu and enter interactive mode. `cursor` indicates the item
165-
number used for the initial cursor position.
167+
number used for the initial cursor position. `cursor` can be either an
168+
`Int` or a `RefValue{Int}`. The latter is useful for observation and
169+
control of the cursor position from the outside.
166170
167171
Returns `selected(m)`.
168172
"""
169173
request(m::AbstractMenu; kwargs...) = request(terminal, m; kwargs...)
170174

171-
function request(term::REPL.Terminals.TTYTerminal, m::AbstractMenu; cursor::Int=1, suppress_output=false)
175+
function request(term::REPL.Terminals.TTYTerminal, m::AbstractMenu; cursor::Union{Int, Base.RefValue{Int}}=1, suppress_output=false)
176+
if cursor isa Int
177+
cursor = Ref(cursor)
178+
end
172179
menu_header = header(m)
173180
!suppress_output && !isempty(menu_header) && println(term.out_stream, menu_header)
174181

175182
state = nothing
176183
if !suppress_output
177-
state = printmenu(term.out_stream, m, cursor, init=true)
184+
state = printmenu(term.out_stream, m, cursor[], init=true)
178185
end
179186

180187
raw_mode_enabled = try
@@ -193,22 +200,22 @@ function request(term::REPL.Terminals.TTYTerminal, m::AbstractMenu; cursor::Int=
193200
c = readkey(term.in_stream)
194201

195202
if c == Int(ARROW_UP)
196-
cursor = move_up!(m, cursor, lastoption)
203+
cursor[] = move_up!(m, cursor[], lastoption)
197204
elseif c == Int(ARROW_DOWN)
198-
cursor = move_down!(m, cursor, lastoption)
205+
cursor[] = move_down!(m, cursor[], lastoption)
199206
elseif c == Int(PAGE_UP)
200-
cursor = page_up!(m, cursor, lastoption)
207+
cursor[] = page_up!(m, cursor[], lastoption)
201208
elseif c == Int(PAGE_DOWN)
202-
cursor = page_down!(m, cursor, lastoption)
209+
cursor[] = page_down!(m, cursor[], lastoption)
203210
elseif c == Int(HOME_KEY)
204-
cursor = 1
211+
cursor[] = 1
205212
m.pageoffset = 0
206213
elseif c == Int(END_KEY)
207-
cursor = lastoption
214+
cursor[] = lastoption
208215
m.pageoffset = lastoption - m.pagesize
209216
elseif c == 13 # <enter>
210217
# will break if pick returns true
211-
pick(m, cursor) && break
218+
pick(m, cursor[]) && break
212219
elseif c == UInt32('q')
213220
cancel(m)
214221
break
@@ -221,7 +228,7 @@ function request(term::REPL.Terminals.TTYTerminal, m::AbstractMenu; cursor::Int=
221228
end
222229

223230
if !suppress_output
224-
state = printmenu(term.out_stream, m, cursor, oldstate=state)
231+
state = printmenu(term.out_stream, m, cursor[], oldstate=state)
225232
end
226233
end
227234
finally # always disable raw mode

stdlib/REPL/src/TerminalMenus/MultiSelectMenu.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ function MultiSelectMenu(options::Array{String,1}; pagesize::Int=10, selected=In
5959
pagesize = pagesize == -1 ? length(options) : pagesize
6060
# pagesize shouldn't be bigger than options
6161
pagesize = min(length(options), pagesize)
62-
# after other checks, pagesize must be greater than 2
62+
# after other checks, pagesize must be at least 1
6363
pagesize < 1 && error("pagesize must be >= 1")
6464

6565
pageoffset = 0
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# This file is a part of Julia. License is MIT: https://julialang.org/license
2+
3+
# Like MultiSelect but adds `n`/`p` to move to next/previous
4+
# unselected item and `N`/`P` to move to next/previous selected item.
5+
mutable struct MultiSelectWithSkipMenu <: TerminalMenus._ConfiguredMenu{TerminalMenus.Config}
6+
options::Array{String,1}
7+
pagesize::Int
8+
pageoffset::Int
9+
selected::Set{Int}
10+
cursor::Base.RefValue{Int}
11+
config::TerminalMenus.MultiSelectConfig
12+
end
13+
14+
function MultiSelectWithSkipMenu(options::Array{String,1}; pagesize::Int=10,
15+
selected=Int[], kwargs...)
16+
length(options) < 1 && error("MultiSelectWithSkipMenu must have at least one option")
17+
18+
pagesize = pagesize == -1 ? length(options) : pagesize
19+
pagesize = min(length(options), pagesize)
20+
pagesize < 1 && error("pagesize must be >= 1")
21+
22+
pageoffset = 0
23+
_selected = Set{Int}()
24+
for item in selected
25+
push!(_selected, item)
26+
end
27+
28+
MultiSelectWithSkipMenu(options, pagesize, pageoffset, _selected,
29+
Ref{Int}(1),
30+
TerminalMenus.MultiSelectConfig(; kwargs...))
31+
end
32+
33+
TerminalMenus.header(m::MultiSelectWithSkipMenu) = "[press: d=done, a=all, c=none, npNP=move with skip]"
34+
35+
TerminalMenus.options(m::MultiSelectWithSkipMenu) = m.options
36+
37+
TerminalMenus.cancel(m::MultiSelectWithSkipMenu) = m.selected = Set{Int}()
38+
39+
# Do not exit menu when a user selects one of the options
40+
function TerminalMenus.pick(menu::MultiSelectWithSkipMenu, cursor::Int)
41+
if cursor in menu.selected
42+
delete!(menu.selected, cursor)
43+
else
44+
push!(menu.selected, cursor)
45+
end
46+
47+
return false
48+
end
49+
50+
function TerminalMenus.writeline(buf::IOBuffer,
51+
menu::MultiSelectWithSkipMenu,
52+
idx::Int, iscursor::Bool)
53+
if idx in menu.selected
54+
print(buf, menu.config.checked, " ")
55+
else
56+
print(buf, menu.config.unchecked, " ")
57+
end
58+
59+
print(buf, replace(menu.options[idx], "\n" => "\\n"))
60+
end
61+
62+
# d: Done, return from request
63+
# a: Select all
64+
# c: Deselect all
65+
# n: Move to next unselected
66+
# p: Move to previous unselected
67+
# N: Move to next selected
68+
# P: Move to previous selected
69+
function TerminalMenus.keypress(menu::MultiSelectWithSkipMenu, key::UInt32)
70+
if key == UInt32('d') || key == UInt32('D')
71+
return true # break
72+
elseif key == UInt32('a') || key == UInt32('A')
73+
menu.selected = Set(1:length(menu.options))
74+
elseif key == UInt32('c') || key == UInt32('C')
75+
menu.selected = Set{Int}()
76+
elseif key == UInt32('n')
77+
move_cursor!(menu, 1, false)
78+
elseif key == UInt32('p')
79+
move_cursor!(menu, -1, false)
80+
elseif key == UInt32('N')
81+
move_cursor!(menu, 1, true)
82+
elseif key == UInt32('P')
83+
move_cursor!(menu, -1, true)
84+
end
85+
false # don't break
86+
end
87+
88+
function move_cursor!(menu, direction, selected)
89+
c = menu.cursor[]
90+
while true
91+
c += direction
92+
if !(1 <= c <= length(menu.options))
93+
return
94+
end
95+
if (c in menu.selected) == selected
96+
break
97+
end
98+
end
99+
menu.cursor[] = c
100+
if menu.pageoffset >= c - 1
101+
menu.pageoffset = max(c - 2, 0)
102+
end
103+
if menu.pageoffset + menu.pagesize <= c
104+
menu.pageoffset = min(c + 1, length(menu.options)) - menu.pagesize
105+
end
106+
end
107+
108+
# Intercept the `request` call to insert the cursor field.
109+
function TerminalMenus.request(term::REPL.Terminals.TTYTerminal,
110+
m::MultiSelectWithSkipMenu;
111+
cursor::Int=1, kwargs...)
112+
m.cursor[] = cursor
113+
invoke(TerminalMenus.request, Tuple{REPL.Terminals.TTYTerminal,
114+
TerminalMenus.AbstractMenu},
115+
term, m; cursor=m.cursor, kwargs...)
116+
end
117+
118+
# These tests are specifically designed to verify that a `RefValue`
119+
# input to the AbstractMenu `request` function works as intended.
120+
menu = MultiSelectWithSkipMenu(string.(1:5), selected=[2, 3])
121+
@test simulate_input(Set([2, 3, 4]), menu, 'n', :enter, 'd')
122+
123+
menu = MultiSelectWithSkipMenu(string.(1:5), selected=[2, 3])
124+
@test simulate_input(Set([2]), menu, 'P', :enter, 'd', cursor=5)

stdlib/REPL/test/TerminalMenus/runtests.jl

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import REPL
44
using REPL.TerminalMenus
55
using Test
66

7-
function simulate_input(expected, menu::TerminalMenus.AbstractMenu, keys...)
7+
function simulate_input(expected, menu::TerminalMenus.AbstractMenu, keys...;
8+
kwargs...)
89
keydict = Dict(:up => "\e[A",
910
:down => "\e[B",
1011
:enter => "\r")
@@ -17,12 +18,13 @@ function simulate_input(expected, menu::TerminalMenus.AbstractMenu, keys...)
1718
end
1819
end
1920

20-
request(menu; suppress_output=true) == expected
21+
request(menu; suppress_output=true, kwargs...) == expected
2122
end
2223

2324
include("radio_menu.jl")
2425
include("multiselect_menu.jl")
2526
include("dynamic_menu.jl")
27+
include("multiselect_with_skip_menu.jl")
2628

2729
# Legacy tests
2830
include("legacytests/old_radio_menu.jl")

0 commit comments

Comments
 (0)