Skip to content

Commit 2b41f0f

Browse files
committed
Add Windows support to FFI backend
Let JRuby use Reline's native Windows path without Fiddle.
1 parent e712668 commit 2b41f0f

5 files changed

Lines changed: 329 additions & 1 deletion

File tree

io-console.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Gem::Specification.new do |s|
4646
lib/ffi/io/console/stty_console.rb
4747
lib/ffi/io/console/stub_console.rb
4848
lib/ffi/io/console/windows_constants.rb
49+
lib/ffi/io/console/windows_console.rb
4950
lib/ffi/io/console/version.rb
5051
])
5152
end

lib/ffi/io/console.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ class Mode
4545
libs << 'linux' << 'stty'
4646
when /mswin|win32|ming/i
4747
require_relative 'console/windows_constants'
48-
# If Windows, stty is not possible, always use the stub version
48+
require_relative 'console/windows_console'
49+
return
4950
else
5051
libs << 'stty'
5152
end

lib/ffi/io/console/common.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# Methods common to all backend impls
2+
require 'io/wait'
3+
24
module IO::Console
35
end
46

@@ -65,6 +67,20 @@ def getpass(prompt = nil)
6567
str.chomp
6668
end
6769

70+
def input_pending?
71+
!wait_readable(0).nil?
72+
end
73+
74+
def hide_cursor
75+
write "\e[?25l"
76+
self
77+
end
78+
79+
def show_cursor
80+
write "\e[?25h"
81+
self
82+
end
83+
6884
def cursor
6985
raw do
7086
syswrite "\e[6n"
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
require 'ffi'
2+
3+
module IO::Console::Windows
4+
STD_INPUT_HANDLE = -10
5+
STD_OUTPUT_HANDLE = -11
6+
WAIT_OBJECT_0 = 0
7+
WAIT_TIMEOUT = 258
8+
ENABLE_WRAP_AT_EOL_OUTPUT = 2
9+
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 4
10+
11+
module Native
12+
extend FFI::Library
13+
ffi_convention :stdcall
14+
ffi_lib 'kernel32'
15+
attach_function :GetStdHandle, [:int32], :pointer
16+
attach_function :GetConsoleMode, [:pointer, :pointer], :int
17+
attach_function :SetConsoleMode, [:pointer, :uint32], :int
18+
attach_function :WaitForSingleObject, [:pointer, :uint32], :uint32
19+
attach_function :ReadConsoleInputW,
20+
[:pointer, :pointer, :uint32, :pointer], :int
21+
attach_function :GetFileType, [:pointer], :uint32
22+
attach_function :GetFileInformationByHandleEx,
23+
[:pointer, :int, :pointer, :uint32], :int
24+
attach_function :GetConsoleScreenBufferInfo, [:pointer, :pointer], :int
25+
attach_function :SetConsoleCursorPosition, [:pointer, :uint32], :int
26+
attach_function :FillConsoleOutputCharacterW,
27+
[:pointer, :uint16, :uint32, :uint32, :pointer], :int
28+
attach_function :FillConsoleOutputAttribute,
29+
[:pointer, :uint16, :uint32, :uint32, :pointer], :int
30+
attach_function :GetConsoleCursorInfo, [:pointer, :pointer], :int
31+
attach_function :SetConsoleCursorInfo, [:pointer, :pointer], :int
32+
end
33+
34+
module CRT
35+
extend FFI::Library
36+
ffi_convention :cdecl
37+
ffi_lib 'msvcrt'
38+
attach_function :_kbhit, [], :int
39+
end
40+
41+
GetStdHandle = Native.method(:GetStdHandle)
42+
GetConsoleMode = Native.method(:GetConsoleMode)
43+
SetConsoleMode = Native.method(:SetConsoleMode)
44+
WaitForSingleObject = Native.method(:WaitForSingleObject)
45+
ReadConsoleInputW = Native.method(:ReadConsoleInputW)
46+
GetFileType = Native.method(:GetFileType)
47+
GetFileInformationByHandleEx = Native.method(:GetFileInformationByHandleEx)
48+
GetConsoleScreenBufferInfo = Native.method(:GetConsoleScreenBufferInfo)
49+
SetConsoleCursorPosition = Native.method(:SetConsoleCursorPosition)
50+
FillConsoleOutputCharacter = Native.method(:FillConsoleOutputCharacterW)
51+
FillConsoleOutputAttribute = Native.method(:FillConsoleOutputAttribute)
52+
GetConsoleCursorInfo = Native.method(:GetConsoleCursorInfo)
53+
SetConsoleCursorInfo = Native.method(:SetConsoleCursorInfo)
54+
Kbhit = CRT.method(:_kbhit)
55+
56+
INPUT_HANDLE = GetStdHandle.call(STD_INPUT_HANDLE)
57+
OUTPUT_HANDLE = GetStdHandle.call(STD_OUTPUT_HANDLE)
58+
59+
module_function
60+
61+
def handle(io)
62+
io.equal?(STDIN) ? INPUT_HANDLE : OUTPUT_HANDLE
63+
end
64+
65+
def console_mode(io)
66+
buffer = "\0" * 4
67+
raise SystemCallError, 'GetConsoleMode' if GetConsoleMode.call(handle(io), buffer) == 0
68+
buffer.unpack1('L')
69+
end
70+
71+
def set_console_mode(io, mode)
72+
raise SystemCallError, 'SetConsoleMode' if SetConsoleMode.call(handle(io), mode) == 0
73+
end
74+
75+
def screen_buffer_info(io)
76+
buffer = "\0" * 22
77+
raise SystemCallError, 'GetConsoleScreenBufferInfo' if GetConsoleScreenBufferInfo.call(handle(io), buffer) == 0
78+
buffer.unpack('s9')
79+
end
80+
81+
def coordinate(x, y)
82+
(y & 0xffff) << 16 | (x & 0xffff)
83+
end
84+
end
85+
86+
module IO::Console::Windows::TTY
87+
def tty?(*types)
88+
return super() if types.empty?
89+
90+
default = msys = cygwin = false
91+
types.each do |type|
92+
case type
93+
when nil
94+
default = true
95+
when :any
96+
default = msys = cygwin = true
97+
when :msys
98+
msys = true
99+
when :cygwin
100+
cygwin = true
101+
when Symbol
102+
raise ArgumentError, "unknown tty type: #{type.inspect}"
103+
else
104+
raise TypeError, "expected Symbol, got #{type.class}"
105+
end
106+
end
107+
108+
return true if default && super()
109+
(msys || cygwin) && msys_tty?(msys, cygwin)
110+
end
111+
alias isatty tty?
112+
113+
private def msys_tty?(msys, cygwin)
114+
windows = IO::Console::Windows
115+
handle = windows.handle(self)
116+
return false unless windows::GetFileType.call(handle) == 3
117+
buffer = "\0" * 1024
118+
return false if windows::GetFileInformationByHandleEx.call(handle, 2, buffer, 1022) == 0
119+
length = buffer.unpack1('L')
120+
name = buffer[4, length].encode(Encoding::UTF_8, Encoding::UTF_16LE, invalid: :replace)
121+
return false unless (msys && name.start_with?('\\msys-')) || (cygwin && name.start_with?('\\cygwin-'))
122+
name.include?('-pty')
123+
end
124+
end
125+
126+
IO.prepend(IO::Console::Windows::TTY)
127+
128+
class IO::ConsoleMode
129+
def initialize(mode)
130+
@mode = mode
131+
end
132+
133+
def virtual_terminal_processing?
134+
@mode & IO::Console::Windows::ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0
135+
end
136+
137+
def virtual_terminal_processing=(enabled)
138+
set_flag(IO::Console::Windows::ENABLE_VIRTUAL_TERMINAL_PROCESSING, enabled)
139+
end
140+
141+
def wrap_at_eol_output?
142+
@mode & IO::Console::Windows::ENABLE_WRAP_AT_EOL_OUTPUT != 0
143+
end
144+
145+
def wrap_at_eol_output=(enabled)
146+
set_flag(IO::Console::Windows::ENABLE_WRAP_AT_EOL_OUTPUT, enabled)
147+
end
148+
149+
private def set_flag(flag, enabled)
150+
enabled ? @mode |= flag : @mode &= ~flag
151+
self
152+
end
153+
154+
private def to_i
155+
@mode
156+
end
157+
end
158+
159+
class IO
160+
def console_mode
161+
IO::ConsoleMode.new(IO::Console::Windows.console_mode(self))
162+
end
163+
164+
def console_mode=(mode)
165+
IO::Console::Windows.set_console_mode(self, mode.__send__(:to_i))
166+
mode
167+
end
168+
169+
def input_pending?
170+
windows = IO::Console::Windows
171+
return windows::Kbhit.call != 0 if windows::GetConsoleMode.call(windows.handle(self), "\0" * 4) != 0
172+
respond_to?(:wait_readable) && !!wait_readable(0)
173+
end
174+
175+
def console_input_events(max_events = 1, timeout: nil)
176+
raise ArgumentError, 'max_events must be positive' unless max_events > 0
177+
raise ArgumentError, 'time interval must not be negative' if timeout && timeout < 0
178+
179+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout if timeout
180+
loop do
181+
wait = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
182+
return [] if wait && wait <= 0
183+
milliseconds = wait ? [[(wait * 1000).ceil, 100].min, 0].max : 100
184+
windows = IO::Console::Windows
185+
result = windows::WaitForSingleObject.call(windows.handle(self), milliseconds)
186+
break if result == windows::WAIT_OBJECT_0
187+
return [] if result != windows::WAIT_TIMEOUT
188+
return [] if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
189+
end
190+
191+
records = "\0" * 20 * max_events
192+
count = "\0" * 4
193+
windows = IO::Console::Windows
194+
if windows::ReadConsoleInputW.call(windows.handle(self), records, max_events, count) == 0
195+
raise SystemCallError, 'ReadConsoleInputW'
196+
end
197+
count.unpack1('L').times.map do |index|
198+
record = records[index * 20, 20]
199+
event_type = record.unpack1('S')
200+
case event_type
201+
when 1
202+
key_down, repeat_count, virtual_key_code, virtual_scan_code,
203+
unicode_char, control_key_state = record[4, 16].unpack('LS4L')
204+
{
205+
type: :key, key_down: key_down != 0, repeat_count: repeat_count,
206+
virtual_key_code: virtual_key_code, virtual_scan_code: virtual_scan_code,
207+
unicode_char: unicode_char, control_key_state: control_key_state,
208+
}
209+
when 2
210+
x, y, button_state, control_key_state, event_flags = record[4, 16].unpack('s2L3')
211+
{type: :mouse, position: [y, x], button_state: button_state,
212+
control_key_state: control_key_state, event_flags: event_flags}
213+
when 4
214+
x, y = record[4, 4].unpack('s2')
215+
{type: :window_buffer_size, size: [y, x]}
216+
when 8
217+
{type: :menu, command_id: record[4, 4].unpack1('L')}
218+
when 16
219+
{type: :focus, set_focus: record[4, 4].unpack1('L') != 0}
220+
else
221+
{type: event_type}
222+
end
223+
end
224+
end
225+
226+
def winsize
227+
width, _, _, _, _, _, top, _, bottom = IO::Console::Windows.screen_buffer_info(self)
228+
[bottom - top + 1, width]
229+
end
230+
231+
def cursor
232+
_, _, x, y, _, _, top, = IO::Console::Windows.screen_buffer_info(self)
233+
[y - top, x]
234+
end
235+
236+
def goto(row, column)
237+
windows = IO::Console::Windows
238+
_, _, _, _, _, _, top, = windows.screen_buffer_info(self)
239+
position = windows.coordinate(column, row + top)
240+
raise SystemCallError, 'SetConsoleCursorPosition' if windows::SetConsoleCursorPosition.call(windows.handle(self), position) == 0
241+
self
242+
end
243+
244+
def goto_column(column)
245+
row, = cursor
246+
goto(row, column)
247+
end
248+
249+
def erase_line(mode)
250+
raise ArgumentError, 'invalid line erase mode' unless (0..2).cover?(mode)
251+
windows = IO::Console::Windows
252+
width, _, x, y, attributes, = windows.screen_buffer_info(self)
253+
start = mode == 0 ? x : 0
254+
length = mode == 0 ? width - x : mode == 1 ? x + 1 : width
255+
position = windows.coordinate(start, y)
256+
written = "\0" * 4
257+
windows::FillConsoleOutputCharacter.call(windows.handle(self), 0x20, length, position, written)
258+
windows::FillConsoleOutputAttribute.call(windows.handle(self), attributes, length, position, written)
259+
self
260+
end
261+
262+
def clear_screen
263+
windows = IO::Console::Windows
264+
width, _, _, _, attributes, _, top, _, bottom = windows.screen_buffer_info(self)
265+
length = width * (bottom - top + 1)
266+
position = windows.coordinate(0, top)
267+
written = "\0" * 4
268+
windows::FillConsoleOutputCharacter.call(windows.handle(self), 0x20, length, position, written)
269+
windows::FillConsoleOutputAttribute.call(windows.handle(self), attributes, length, position, written)
270+
windows::SetConsoleCursorPosition.call(windows.handle(self), position)
271+
self
272+
end
273+
274+
def hide_cursor
275+
set_cursor_visibility(false)
276+
end
277+
278+
def show_cursor
279+
set_cursor_visibility(true)
280+
end
281+
282+
private def set_cursor_visibility(visible)
283+
info = "\0" * 8
284+
windows = IO::Console::Windows
285+
handle = windows.handle(self)
286+
raise SystemCallError, 'GetConsoleCursorInfo' if windows::GetConsoleCursorInfo.call(handle, info) == 0
287+
size, = info.unpack('L2')
288+
info = [size, visible ? 1 : 0].pack('L2')
289+
raise SystemCallError, 'SetConsoleCursorInfo' if windows::SetConsoleCursorInfo.call(handle, info) == 0
290+
self
291+
end
292+
293+
end

test/io/console/test_io_console.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -905,6 +905,23 @@ def test_check_winsize_changed_deprecated
905905
end
906906
end
907907

908+
RUBY_ENGINE == "jruby" && RbConfig::CONFIG["host_os"] =~ /mswin|mingw/ and \
909+
TestIO_Console.class_eval do
910+
def test_jruby_windows_console_api
911+
assert(IO::Console::Windows.const_defined?(:Native, false))
912+
assert_respond_to(STDIN, :console_input_events)
913+
assert_respond_to(STDIN, :input_pending?)
914+
assert_respond_to(STDOUT, :console_mode)
915+
assert_respond_to(STDOUT, :hide_cursor)
916+
assert_respond_to(STDOUT, :show_cursor)
917+
end
918+
919+
def test_jruby_windows_tty_types_are_all_validated
920+
assert_raise(ArgumentError) {STDOUT.tty?(:any, :unknown)}
921+
assert_raise(TypeError) {STDOUT.tty?(:any, "msys")}
922+
end
923+
end
924+
908925
class TestIO_Console
909926
def test_stringio_getch
910927
assert_ruby_status %w"--disable=gems -rstringio -rio/console", %q{

0 commit comments

Comments
 (0)