Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
414 changes: 413 additions & 1 deletion ext/io/console/console.c

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions ext/io/console/extconf.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
elsif have_func("rb_scheduler_timeout") # Ruby 3.0 (internal)
have_func("rb_io_wait") # Ruby 3.0
end
have_func("rb_category_warn")
have_const("RB_WARN_CATEGORY_DEPRECATED")
win32 or have_func("ttyname_r") or have_func("ttyname")
have_func("rb_prepend_module") # not exported by TruffleRuby
create_makefile("io/console") {|conf|
Expand Down
2 changes: 2 additions & 0 deletions io-console.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ Gem::Specification.new do |s|
lib/ffi/io/console/native_console.rb
lib/ffi/io/console/stty_console.rb
lib/ffi/io/console/stub_console.rb
lib/ffi/io/console/windows_constants.rb
lib/ffi/io/console/windows_console.rb
lib/ffi/io/console/version.rb
])
end
Expand Down
6 changes: 3 additions & 3 deletions lib/ffi/io/console.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@
end

when /mswin|win32|ming/i
# If Windows, stty is not possible, always use the stub version

ready = false
require_relative 'console/windows_constants'
require_relative 'console/windows_console'
ready = true

end

Expand Down
19 changes: 19 additions & 0 deletions lib/ffi/io/console/common.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
# Methods common to all backend impls
require 'io/wait'

module IO::Console
end

class IO
# TODO: Windows version uses "conin$" and "conout$" instead of /dev/tty
def self.console(sym = nil, *args)
Expand Down Expand Up @@ -62,6 +67,20 @@ def getpass(prompt = nil)
str.chomp
end

def input_pending?
!wait_readable(0).nil?
end

def hide_cursor
write "\e[?25l"
self
end

def show_cursor
write "\e[?25h"
self
end

def cursor
raw do
syswrite "\e[6n"
Expand Down
293 changes: 293 additions & 0 deletions lib/ffi/io/console/windows_console.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
require 'ffi'

module IO::Console::Windows
STD_INPUT_HANDLE = -10
STD_OUTPUT_HANDLE = -11
WAIT_OBJECT_0 = 0
WAIT_TIMEOUT = 258
ENABLE_WRAP_AT_EOL_OUTPUT = 2
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 4

module Native
extend FFI::Library
ffi_convention :stdcall
ffi_lib 'kernel32'
attach_function :GetStdHandle, [:int32], :pointer
attach_function :GetConsoleMode, [:pointer, :pointer], :int
attach_function :SetConsoleMode, [:pointer, :uint32], :int
attach_function :WaitForSingleObject, [:pointer, :uint32], :uint32
attach_function :ReadConsoleInputW,
[:pointer, :pointer, :uint32, :pointer], :int
attach_function :GetFileType, [:pointer], :uint32
attach_function :GetFileInformationByHandleEx,
[:pointer, :int, :pointer, :uint32], :int
attach_function :GetConsoleScreenBufferInfo, [:pointer, :pointer], :int
attach_function :SetConsoleCursorPosition, [:pointer, :uint32], :int
attach_function :FillConsoleOutputCharacterW,
[:pointer, :uint16, :uint32, :uint32, :pointer], :int
attach_function :FillConsoleOutputAttribute,
[:pointer, :uint16, :uint32, :uint32, :pointer], :int
attach_function :GetConsoleCursorInfo, [:pointer, :pointer], :int
attach_function :SetConsoleCursorInfo, [:pointer, :pointer], :int
end

module CRT
extend FFI::Library
ffi_convention :cdecl
ffi_lib 'msvcrt'
attach_function :_kbhit, [], :int
end

GetStdHandle = Native.method(:GetStdHandle)
GetConsoleMode = Native.method(:GetConsoleMode)
SetConsoleMode = Native.method(:SetConsoleMode)
WaitForSingleObject = Native.method(:WaitForSingleObject)
ReadConsoleInputW = Native.method(:ReadConsoleInputW)
GetFileType = Native.method(:GetFileType)
GetFileInformationByHandleEx = Native.method(:GetFileInformationByHandleEx)
GetConsoleScreenBufferInfo = Native.method(:GetConsoleScreenBufferInfo)
SetConsoleCursorPosition = Native.method(:SetConsoleCursorPosition)
FillConsoleOutputCharacter = Native.method(:FillConsoleOutputCharacterW)
FillConsoleOutputAttribute = Native.method(:FillConsoleOutputAttribute)
GetConsoleCursorInfo = Native.method(:GetConsoleCursorInfo)
SetConsoleCursorInfo = Native.method(:SetConsoleCursorInfo)
Kbhit = CRT.method(:_kbhit)

INPUT_HANDLE = GetStdHandle.call(STD_INPUT_HANDLE)
OUTPUT_HANDLE = GetStdHandle.call(STD_OUTPUT_HANDLE)

module_function

def handle(io)
io.equal?(STDIN) ? INPUT_HANDLE : OUTPUT_HANDLE
end

def console_mode(io)
buffer = "\0" * 4
raise SystemCallError, 'GetConsoleMode' if GetConsoleMode.call(handle(io), buffer) == 0
buffer.unpack1('L')
end

def set_console_mode(io, mode)
raise SystemCallError, 'SetConsoleMode' if SetConsoleMode.call(handle(io), mode) == 0
end

def screen_buffer_info(io)
buffer = "\0" * 22
raise SystemCallError, 'GetConsoleScreenBufferInfo' if GetConsoleScreenBufferInfo.call(handle(io), buffer) == 0
buffer.unpack('s9')
end

def coordinate(x, y)
(y & 0xffff) << 16 | (x & 0xffff)
end
end

module IO::Console::Windows::TTY
def tty?(*types)
return super() if types.empty?

default = msys = cygwin = false
types.each do |type|
case type
when nil
default = true
when :any
default = msys = cygwin = true
when :msys
msys = true
when :cygwin
cygwin = true
when Symbol
raise ArgumentError, "unknown tty type: #{type.inspect}"
else
raise TypeError, "expected Symbol, got #{type.class}"
end
end

return true if default && super()
(msys || cygwin) && msys_tty?(msys, cygwin)
end
alias isatty tty?

private def msys_tty?(msys, cygwin)
windows = IO::Console::Windows
handle = windows.handle(self)
return false unless windows::GetFileType.call(handle) == 3
buffer = "\0" * 1024
return false if windows::GetFileInformationByHandleEx.call(handle, 2, buffer, 1022) == 0
length = buffer.unpack1('L')
name = buffer[4, length].encode(Encoding::UTF_8, Encoding::UTF_16LE, invalid: :replace)
return false unless (msys && name.start_with?('\\msys-')) || (cygwin && name.start_with?('\\cygwin-'))
name.include?('-pty')
end
end

IO.prepend(IO::Console::Windows::TTY)

class IO::ConsoleMode
def initialize(mode)
@mode = mode
end

def virtual_terminal_processing?
@mode & IO::Console::Windows::ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0
end

def virtual_terminal_processing=(enabled)
set_flag(IO::Console::Windows::ENABLE_VIRTUAL_TERMINAL_PROCESSING, enabled)
end

def wrap_at_eol_output?
@mode & IO::Console::Windows::ENABLE_WRAP_AT_EOL_OUTPUT != 0
end

def wrap_at_eol_output=(enabled)
set_flag(IO::Console::Windows::ENABLE_WRAP_AT_EOL_OUTPUT, enabled)
end

private def set_flag(flag, enabled)
enabled ? @mode |= flag : @mode &= ~flag
self
end

private def to_i
@mode
end
end

class IO
def console_mode
IO::ConsoleMode.new(IO::Console::Windows.console_mode(self))
end

def console_mode=(mode)
IO::Console::Windows.set_console_mode(self, mode.__send__(:to_i))
mode
end

def input_pending?
windows = IO::Console::Windows
return windows::Kbhit.call != 0 if windows::GetConsoleMode.call(windows.handle(self), "\0" * 4) != 0
respond_to?(:wait_readable) && !!wait_readable(0)
end

def console_input_events(max_events = 1, timeout: nil)
raise ArgumentError, 'max_events must be positive' unless max_events > 0
raise ArgumentError, 'time interval must not be negative' if timeout && timeout < 0

deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout if timeout
loop do
wait = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
return [] if wait && wait <= 0
milliseconds = wait ? [[(wait * 1000).ceil, 100].min, 0].max : 100
windows = IO::Console::Windows
result = windows::WaitForSingleObject.call(windows.handle(self), milliseconds)
break if result == windows::WAIT_OBJECT_0
return [] if result != windows::WAIT_TIMEOUT
return [] if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
end

records = "\0" * 20 * max_events
count = "\0" * 4
windows = IO::Console::Windows
if windows::ReadConsoleInputW.call(windows.handle(self), records, max_events, count) == 0
raise SystemCallError, 'ReadConsoleInputW'
end
count.unpack1('L').times.map do |index|
record = records[index * 20, 20]
event_type = record.unpack1('S')
case event_type
when 1
key_down, repeat_count, virtual_key_code, virtual_scan_code,
unicode_char, control_key_state = record[4, 16].unpack('LS4L')
{
type: :key, key_down: key_down != 0, repeat_count: repeat_count,
virtual_key_code: virtual_key_code, virtual_scan_code: virtual_scan_code,
unicode_char: unicode_char, control_key_state: control_key_state,
}
when 2
x, y, button_state, control_key_state, event_flags = record[4, 16].unpack('s2L3')
{type: :mouse, position: [y, x], button_state: button_state,
control_key_state: control_key_state, event_flags: event_flags}
when 4
x, y = record[4, 4].unpack('s2')
{type: :window_buffer_size, size: [y, x]}
when 8
{type: :menu, command_id: record[4, 4].unpack1('L')}
when 16
{type: :focus, set_focus: record[4, 4].unpack1('L') != 0}
else
{type: event_type}
end
end
end

def winsize
width, _, _, _, _, _, top, _, bottom = IO::Console::Windows.screen_buffer_info(self)
[bottom - top + 1, width]
end

def cursor
_, _, x, y, _, _, top, = IO::Console::Windows.screen_buffer_info(self)
[y - top, x]
end

def goto(row, column)
windows = IO::Console::Windows
_, _, _, _, _, _, top, = windows.screen_buffer_info(self)
position = windows.coordinate(column, row + top)
raise SystemCallError, 'SetConsoleCursorPosition' if windows::SetConsoleCursorPosition.call(windows.handle(self), position) == 0
self
end

def goto_column(column)
row, = cursor
goto(row, column)
end

def erase_line(mode)
raise ArgumentError, 'invalid line erase mode' unless (0..2).cover?(mode)
windows = IO::Console::Windows
width, _, x, y, attributes, = windows.screen_buffer_info(self)
start = mode == 0 ? x : 0
length = mode == 0 ? width - x : mode == 1 ? x + 1 : width
position = windows.coordinate(start, y)
written = "\0" * 4
windows::FillConsoleOutputCharacter.call(windows.handle(self), 0x20, length, position, written)
windows::FillConsoleOutputAttribute.call(windows.handle(self), attributes, length, position, written)
self
end

def clear_screen
windows = IO::Console::Windows
width, _, _, _, attributes, _, top, _, bottom = windows.screen_buffer_info(self)
length = width * (bottom - top + 1)
position = windows.coordinate(0, top)
written = "\0" * 4
windows::FillConsoleOutputCharacter.call(windows.handle(self), 0x20, length, position, written)
windows::FillConsoleOutputAttribute.call(windows.handle(self), attributes, length, position, written)
windows::SetConsoleCursorPosition.call(windows.handle(self), position)
self
end

def hide_cursor
set_cursor_visibility(false)
end

def show_cursor
set_cursor_visibility(true)
end

private def set_cursor_visibility(visible)
info = "\0" * 8
windows = IO::Console::Windows
handle = windows.handle(self)
raise SystemCallError, 'GetConsoleCursorInfo' if windows::GetConsoleCursorInfo.call(handle, info) == 0
size, = info.unpack('L2')
info = [size, visible ? 1 : 0].pack('L2')
raise SystemCallError, 'SetConsoleCursorInfo' if windows::SetConsoleCursorInfo.call(handle, info) == 0
self
end

end
26 changes: 26 additions & 0 deletions lib/ffi/io/console/windows_constants.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
module IO::Console::Windows
VK_TAB = 0x09
VK_RETURN = 0x0d
VK_SHIFT = 0x10
VK_CONTROL = 0x11
VK_MENU = 0x12
VK_END = 0x23
VK_HOME = 0x24
VK_LEFT = 0x25
VK_UP = 0x26
VK_RIGHT = 0x27
VK_DOWN = 0x28
VK_DELETE = 0x2e
VK_DIVIDE = 0x6f
VK_LMENU = 0xa4

RIGHT_ALT_PRESSED = 0x0001
LEFT_ALT_PRESSED = 0x0002
RIGHT_CTRL_PRESSED = 0x0004
LEFT_CTRL_PRESSED = 0x0008
SHIFT_PRESSED = 0x0010
NUMLOCK_ON = 0x0020
SCROLLLOCK_ON = 0x0040
CAPSLOCK_ON = 0x0080
ENHANCED_KEY = 0x0100
end
Loading
Loading