diff --git a/ext/io/console/console.c b/ext/io/console/console.c index 05a4d68..098a802 100644 --- a/ext/io/console/console.c +++ b/ext/io/console/console.c @@ -56,6 +56,13 @@ typedef struct sgttyb conmode; #include typedef DWORD conmode; +#ifndef ENABLE_WRAP_AT_EOL_OUTPUT +# define ENABLE_WRAP_AT_EOL_OUTPUT 0x0002 +#endif +#ifndef ENABLE_VIRTUAL_TERMINAL_PROCESSING +# define ENABLE_VIRTUAL_TERMINAL_PROCESSING 0x0004 +#endif + #define LAST_ERROR rb_w32_map_errno(GetLastError()) #define SET_LAST_ERROR (errno = LAST_ERROR, 0) @@ -81,7 +88,7 @@ getattr(int fd, conmode *t) #define CSI "\x1b\x5b" -static ID id_getc, id_close; +static ID id_getc, id_close, id_timeout; static ID id_gets, id_flush, id_chomp_bang; #ifndef HAVE_RB_INTERNED_STR_CSTR @@ -89,6 +96,10 @@ static ID id_gets, id_flush, id_chomp_bang; # define rb_interned_str_cstr(str) rb_str_freeze(rb_usascii_str_new_cstr(str)) #endif +#if !defined(HAVE_RB_CATEGORY_WARN) || !defined(HAVE_CONST_RB_WARN_CATEGORY_DEPRECATED) +# define rb_category_warn(category, ...) rb_warn(__VA_ARGS__) +#endif + #if defined HAVE_RUBY_FIBER_SCHEDULER_H # include "ruby/fiber/scheduler.h" #elif defined HAVE_RB_SCHEDULER_TIMEOUT @@ -619,6 +630,43 @@ console_getch(int argc, VALUE *argv, VALUE io) #endif } +/* + * call-seq: + * io.input_pending? -> true or false + * + * Returns whether input can be read without blocking. + * + * You must require 'io/console' to use this method. + */ +static VALUE +console_input_pending_p(VALUE io) +{ + rb_io_t *fptr; + + GetOpenFile(io, fptr); + if (rb_io_read_pending(fptr)) return Qtrue; +#ifdef _WIN32 + { + DWORD mode; + HANDLE h = (HANDLE)rb_w32_get_osfhandle(GetReadFD(io)); + + if (GetConsoleMode(h, &mode)) return _kbhit() ? Qtrue : Qfalse; + } +#endif +#if defined HAVE_RB_IO_WAIT + return RTEST(rb_io_wait(io, RB_INT2NUM(RUBY_IO_READABLE), INT2FIX(0))) ? Qtrue : Qfalse; +#else + { + struct timeval timeout = {0, 0}; + int result; + + result = rb_wait_for_single_fd(fptr->fd, RB_WAITFD_IN, &timeout); + if (result < 0) sys_fail(io); + return (result & RB_WAITFD_IN) ? Qtrue : Qfalse; + } +#endif +} + /* * call-seq: * io.noecho {|io| } @@ -746,6 +794,70 @@ conmode_raw_new(int argc, VALUE *argv, VALUE obj) return conmode_new(rb_obj_class(obj), &t); } +#ifdef _WIN32 +/* + * call-seq: + * mode.virtual_terminal_processing? -> true or false + * + * Returns whether virtual terminal sequences are processed on output. + */ +static VALUE +conmode_virtual_terminal_processing_p(VALUE obj) +{ + conmode *t = rb_check_typeddata(obj, &conmode_type); + return (*t & ENABLE_VIRTUAL_TERMINAL_PROCESSING) ? Qtrue : Qfalse; +} + +/* + * call-seq: + * mode.virtual_terminal_processing = enabled + * + * Enables or disables virtual terminal sequence processing in +mode+. + * Assign +mode+ to IO#console_mode= to apply the change. + */ +static VALUE +conmode_set_virtual_terminal_processing(VALUE obj, VALUE enabled) +{ + conmode *t = rb_check_typeddata(obj, &conmode_type); + if (RTEST(enabled)) + *t |= ENABLE_VIRTUAL_TERMINAL_PROCESSING; + else + *t &= ~ENABLE_VIRTUAL_TERMINAL_PROCESSING; + return obj; +} + +/* + * call-seq: + * mode.wrap_at_eol_output? -> true or false + * + * Returns whether output wraps at the end of a line. + */ +static VALUE +conmode_wrap_at_eol_output_p(VALUE obj) +{ + conmode *t = rb_check_typeddata(obj, &conmode_type); + return (*t & ENABLE_WRAP_AT_EOL_OUTPUT) ? Qtrue : Qfalse; +} + +/* + * call-seq: + * mode.wrap_at_eol_output = enabled + * + * Enables or disables wrapping at the end of a line in +mode+. + * Assign +mode+ to IO#console_mode= to apply the change. + */ +static VALUE +conmode_set_wrap_at_eol_output(VALUE obj, VALUE enabled) +{ + conmode *t = rb_check_typeddata(obj, &conmode_type); + if (RTEST(enabled)) + *t |= ENABLE_WRAP_AT_EOL_OUTPUT; + else + *t &= ~ENABLE_WRAP_AT_EOL_OUTPUT; + return obj; +} +#endif + /* * call-seq: * io.console_mode -> mode @@ -946,12 +1058,207 @@ console_set_winsize(VALUE io, VALUE size) #endif #ifdef _WIN32 +enum console_input_handle_index { + console_input_handle, + console_input_wakeup, + console_input_handle_count +}; + +typedef struct { + HANDLE handles[console_input_handle_count]; + INPUT_RECORD *records; + DWORD length; + DWORD count; + DWORD timeout; + DWORD wait_result; + DWORD error; + BOOL result; +} read_console_input_args_t; + +static void * +nogvl_read_console_input(void *ptr) +{ + read_console_input_args_t *args = ptr; + + args->wait_result = WaitForMultipleObjects(console_input_handle_count, + args->handles, FALSE, args->timeout); + if (args->wait_result == WAIT_OBJECT_0 + console_input_handle) { + args->result = ReadConsoleInputW(args->handles[console_input_handle], + args->records, args->length, &args->count); + if (!args->result) args->error = GetLastError(); + } + else if (args->wait_result == WAIT_FAILED) { + args->error = GetLastError(); + } + return 0; +} + +static void +ubf_console_input(void *ptr) +{ + read_console_input_args_t *args = ptr; + SetEvent(args->handles[console_input_wakeup]); +} + +static void +console_input_event_set(VALUE event, const char *name, VALUE value) +{ + rb_hash_aset(event, ID2SYM(rb_intern(name)), value); +} + +static VALUE +console_input_event(const INPUT_RECORD *record) +{ + VALUE event = rb_hash_new(); + + switch (record->EventType) { + case KEY_EVENT: + console_input_event_set(event, "type", ID2SYM(rb_intern("key"))); + console_input_event_set(event, "key_down", record->Event.KeyEvent.bKeyDown ? Qtrue : Qfalse); + console_input_event_set(event, "repeat_count", UINT2NUM(record->Event.KeyEvent.wRepeatCount)); + console_input_event_set(event, "virtual_key_code", UINT2NUM(record->Event.KeyEvent.wVirtualKeyCode)); + console_input_event_set(event, "virtual_scan_code", UINT2NUM(record->Event.KeyEvent.wVirtualScanCode)); + console_input_event_set(event, "unicode_char", UINT2NUM(record->Event.KeyEvent.uChar.UnicodeChar)); + console_input_event_set(event, "control_key_state", UINT2NUM(record->Event.KeyEvent.dwControlKeyState)); + break; + case MOUSE_EVENT: + console_input_event_set(event, "type", ID2SYM(rb_intern("mouse"))); + console_input_event_set(event, "position", rb_assoc_new( + INT2NUM(record->Event.MouseEvent.dwMousePosition.Y), + INT2NUM(record->Event.MouseEvent.dwMousePosition.X))); + console_input_event_set(event, "button_state", UINT2NUM(record->Event.MouseEvent.dwButtonState)); + console_input_event_set(event, "control_key_state", UINT2NUM(record->Event.MouseEvent.dwControlKeyState)); + console_input_event_set(event, "event_flags", UINT2NUM(record->Event.MouseEvent.dwEventFlags)); + break; + case WINDOW_BUFFER_SIZE_EVENT: + console_input_event_set(event, "type", ID2SYM(rb_intern("window_buffer_size"))); + console_input_event_set(event, "size", rb_assoc_new( + INT2NUM(record->Event.WindowBufferSizeEvent.dwSize.Y), + INT2NUM(record->Event.WindowBufferSizeEvent.dwSize.X))); + break; + case MENU_EVENT: + console_input_event_set(event, "type", ID2SYM(rb_intern("menu"))); + console_input_event_set(event, "command_id", UINT2NUM(record->Event.MenuEvent.dwCommandId)); + break; + case FOCUS_EVENT: + console_input_event_set(event, "type", ID2SYM(rb_intern("focus"))); + console_input_event_set(event, "set_focus", record->Event.FocusEvent.bSetFocus ? Qtrue : Qfalse); + break; + default: + console_input_event_set(event, "type", UINT2NUM(record->EventType)); + break; + } + + return event; +} + +static VALUE +console_input_events_read(VALUE vargs) +{ + read_console_input_args_t *args = (read_console_input_args_t *)vargs; + VALUE events; + DWORD i; + + rb_thread_call_without_gvl(nogvl_read_console_input, args, + ubf_console_input, args); + if (args->wait_result == WAIT_TIMEOUT) return rb_ary_new(); + if (args->wait_result != WAIT_OBJECT_0 + console_input_handle || + !args->result) { + rb_syserr_fail(rb_w32_map_errno(args->error), 0); + } + + events = rb_ary_new_capa(args->count); + for (i = 0; i < args->count; ++i) { + rb_ary_push(events, console_input_event(&args->records[i])); + } + return events; +} + +static VALUE +console_input_events_ensure(VALUE vargs) +{ + read_console_input_args_t *args = (read_console_input_args_t *)vargs; + + CloseHandle(args->handles[console_input_wakeup]); + xfree(args->records); + return Qnil; +} + +/* + * call-seq: + * io.console_input_events([max_events], timeout: nil) -> array + * + * Reads up to +max_events+ console input events, preserving their order. + * The default is one event. Blocks until at least one event is available, + * or for +timeout+ seconds if specified. Returns an empty Array on timeout. + * + * Each event is returned as a Hash. The +:type+ and remaining keys are: + * + * - +:key+ : +:key_down+, +:repeat_count+, +:virtual_key_code+, + * +:virtual_scan_code+, +:unicode_char+, and +:control_key_state+. + * - +:mouse+ : +:position+ ([row, column]), +:button_state+, + * +:control_key_state+, and +:event_flags+. + * - +:window_buffer_size+ : +:size+ ([rows, columns]). + * - +:menu+ : +:command_id+. + * - +:focus+ : +:set_focus+. + * + * This method is Windows only. + * + * You must require 'io/console' to use this method. + */ +static VALUE +console_input_events(int argc, VALUE *argv, VALUE io) +{ + VALUE vmax = Qnil, vopts = Qnil, vtimeout = Qundef; + VALUE values[1]; + ID keywords[1] = {id_timeout}; + DWORD max_events = 1; + read_console_input_args_t args; + + rb_scan_args(argc, argv, "01:", &vmax, &vopts); + if (rb_get_kwargs(vopts, keywords, 0, 1, values)) { + vtimeout = values[0]; + } + if (!NIL_P(vmax)) { + max_events = NUM2UINT(vmax); + if (max_events == 0) rb_raise(rb_eArgError, "max_events must be positive"); + } + + args.timeout = INFINITE; + if (!NIL_OR_UNDEF_P(vtimeout)) { + struct timeval timeout = rb_time_interval(vtimeout); + uint64_t milliseconds = (uint64_t)timeout.tv_sec * 1000; + milliseconds += ((uint64_t)timeout.tv_usec + 999) / 1000; + args.timeout = milliseconds < INFINITE ? (DWORD)milliseconds : INFINITE - 1; + } + + args.handles[console_input_handle] = + (HANDLE)rb_w32_get_osfhandle(GetReadFD(io)); + args.records = ALLOC_N(INPUT_RECORD, max_events); + args.handles[console_input_wakeup] = CreateEvent(NULL, FALSE, FALSE, NULL); + if (!args.handles[console_input_wakeup]) { + int error = LAST_ERROR; + xfree(args.records); + rb_syserr_fail(error, 0); + } + args.length = max_events; + args.count = 0; + args.wait_result = WAIT_FAILED; + args.error = ERROR_SUCCESS; + args.result = FALSE; + return rb_ensure(console_input_events_read, (VALUE)&args, + console_input_events_ensure, (VALUE)&args); +} + /* * call-seq: * io.check_winsize_changed { ... } -> io * * Yields while console input events are queued. * + * Deprecated because it discards queued input events other than window buffer + * size changes. Use IO#console_input_events instead to preserve all events. + * * This method is Windows only. * * You must require 'io/console' to use this method. @@ -962,6 +1269,9 @@ console_check_winsize_changed(VALUE io) HANDLE h; DWORD num; + rb_category_warn(RB_WARN_CATEGORY_DEPRECATED, + "IO#check_winsize_changed is deprecated; " + "use IO#console_input_events instead"); h = (HANDLE)rb_w32_get_osfhandle(GetReadFD(io)); while (GetNumberOfConsoleInputEvents(h, &num) && num > 0) { INPUT_RECORD rec; @@ -974,6 +1284,7 @@ console_check_winsize_changed(VALUE io) return io; } #else +#define console_input_events rb_f_notimplement #define console_check_winsize_changed rb_f_notimplement #endif @@ -1274,6 +1585,54 @@ console_cursor_pos(VALUE io) #endif } +static VALUE +console_cursor_visibility(VALUE io, int visible) +{ +#ifdef _WIN32 + HANDLE h = (HANDLE)rb_w32_get_osfhandle(GetWriteFD(io)); + CONSOLE_CURSOR_INFO info; + + if (!GetConsoleCursorInfo(h, &info)) { + rb_syserr_fail(LAST_ERROR, 0); + } + info.bVisible = visible; + if (!SetConsoleCursorInfo(h, &info)) { + rb_syserr_fail(LAST_ERROR, 0); + } +#else + rb_io_write(io, rb_str_new_cstr(visible ? CSI "?25h" : CSI "?25l")); +#endif + return io; +} + +/* + * call-seq: + * io.hide_cursor -> io + * + * Hides the cursor. + * + * You must require 'io/console' to use this method. + */ +static VALUE +console_hide_cursor(VALUE io) +{ + return console_cursor_visibility(io, 0); +} + +/* + * call-seq: + * io.show_cursor -> io + * + * Shows the cursor. + * + * You must require 'io/console' to use this method. + */ +static VALUE +console_show_cursor(VALUE io) +{ + return console_cursor_visibility(io, 1); +} + /* * call-seq: * io.goto(line, column) -> io @@ -2032,6 +2391,7 @@ Init_console(void) id_flush = rb_intern("flush"); id_chomp_bang = rb_intern("chomp!"); id_close = rb_intern("close"); + id_timeout = rb_intern("timeout"); #define init_rawmode_opt_id(name) \ rawmode_opt_ids[kwd_##name] = rb_intern(#name) init_rawmode_opt_id(min); @@ -2046,11 +2406,54 @@ Init_console(void) void InitVM_console(void) { + /* + * Document-module: IO::Console + * + * Namespace for console-specific classes and constants. + */ + /* + * Document-module: IO::Console::Windows + * + * Windows console constants. + */ +#ifdef _WIN32 + VALUE mConsole = rb_define_module_under(rb_cIO, "Console"); + VALUE mWindows = rb_define_module_under(mConsole, "Windows"); +#define define_win32_const(name) rb_define_const(mWindows, #name, UINT2NUM(name)) + define_win32_const(VK_TAB); + define_win32_const(VK_RETURN); + define_win32_const(VK_SHIFT); + define_win32_const(VK_CONTROL); + define_win32_const(VK_MENU); + define_win32_const(VK_END); + define_win32_const(VK_HOME); + define_win32_const(VK_LEFT); + define_win32_const(VK_UP); + define_win32_const(VK_RIGHT); + define_win32_const(VK_DOWN); + define_win32_const(VK_DELETE); + define_win32_const(VK_DIVIDE); + define_win32_const(VK_LMENU); + define_win32_const(RIGHT_ALT_PRESSED); + define_win32_const(LEFT_ALT_PRESSED); + define_win32_const(RIGHT_CTRL_PRESSED); + define_win32_const(LEFT_CTRL_PRESSED); + define_win32_const(SHIFT_PRESSED); + define_win32_const(NUMLOCK_ON); + define_win32_const(SCROLLLOCK_ON); + define_win32_const(CAPSLOCK_ON); + define_win32_const(ENHANCED_KEY); +#undef define_win32_const +#else + rb_define_module_under(rb_cIO, "Console"); +#endif + rb_define_method(rb_cIO, "raw", console_raw, -1); rb_define_method(rb_cIO, "raw!", console_set_raw, -1); rb_define_method(rb_cIO, "cooked", console_cooked, 0); rb_define_method(rb_cIO, "cooked!", console_set_cooked, 0); rb_define_method(rb_cIO, "getch", console_getch, -1); + rb_define_method(rb_cIO, "input_pending?", console_input_pending_p, 0); rb_define_method(rb_cIO, "echo=", console_set_echo, 1); rb_define_method(rb_cIO, "echo?", console_echo_p, 0); rb_define_method(rb_cIO, "console_mode", console_conmode_get, 0); @@ -2065,6 +2468,8 @@ InitVM_console(void) rb_define_method(rb_cIO, "goto", console_goto, 2); rb_define_method(rb_cIO, "cursor", console_cursor_pos, 0); rb_define_method(rb_cIO, "cursor=", console_cursor_set, 1); + rb_define_method(rb_cIO, "hide_cursor", console_hide_cursor, 0); + rb_define_method(rb_cIO, "show_cursor", console_show_cursor, 0); rb_define_method(rb_cIO, "cursor_up", console_cursor_up, 1); rb_define_method(rb_cIO, "cursor_down", console_cursor_down, 1); rb_define_method(rb_cIO, "cursor_left", console_cursor_left, 1); @@ -2076,6 +2481,7 @@ InitVM_console(void) rb_define_method(rb_cIO, "scroll_backward", console_scroll_backward, 1); rb_define_method(rb_cIO, "clear_screen", console_clear_screen, 0); rb_define_method(rb_cIO, "pressed?", console_key_pressed_p, 1); + rb_define_method(rb_cIO, "console_input_events", console_input_events, -1); rb_define_method(rb_cIO, "check_winsize_changed", console_check_winsize_changed, 0); rb_define_method(rb_cIO, "getpass", console_getpass, -1); rb_define_method(rb_cIO, "ttyname", console_ttyname, 0); @@ -2110,5 +2516,11 @@ InitVM_console(void) rb_define_method(cConmode, "echo=", conmode_set_echo, 1); rb_define_method(cConmode, "raw!", conmode_set_raw, -1); rb_define_method(cConmode, "raw", conmode_raw_new, -1); +#ifdef _WIN32 + rb_define_method(cConmode, "virtual_terminal_processing?", conmode_virtual_terminal_processing_p, 0); + rb_define_method(cConmode, "virtual_terminal_processing=", conmode_set_virtual_terminal_processing, 1); + rb_define_method(cConmode, "wrap_at_eol_output?", conmode_wrap_at_eol_output_p, 0); + rb_define_method(cConmode, "wrap_at_eol_output=", conmode_set_wrap_at_eol_output, 1); +#endif } } diff --git a/ext/io/console/extconf.rb b/ext/io/console/extconf.rb index 95680dc..d10bdcf 100644 --- a/ext/io/console/extconf.rb +++ b/ext/io/console/extconf.rb @@ -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| diff --git a/io-console.gemspec b/io-console.gemspec index 0a19992..8a5093c 100644 --- a/io-console.gemspec +++ b/io-console.gemspec @@ -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 diff --git a/lib/ffi/io/console.rb b/lib/ffi/io/console.rb index 91d6632..26a4cfc 100644 --- a/lib/ffi/io/console.rb +++ b/lib/ffi/io/console.rb @@ -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 diff --git a/lib/ffi/io/console/common.rb b/lib/ffi/io/console/common.rb index 832889a..d6953ac 100644 --- a/lib/ffi/io/console/common.rb +++ b/lib/ffi/io/console/common.rb @@ -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) @@ -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" diff --git a/lib/ffi/io/console/windows_console.rb b/lib/ffi/io/console/windows_console.rb new file mode 100644 index 0000000..787a5b6 --- /dev/null +++ b/lib/ffi/io/console/windows_console.rb @@ -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 diff --git a/lib/ffi/io/console/windows_constants.rb b/lib/ffi/io/console/windows_constants.rb new file mode 100644 index 0000000..d5813e2 --- /dev/null +++ b/lib/ffi/io/console/windows_constants.rb @@ -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 diff --git a/test/io/console/test_io_console.rb b/test/io/console/test_io_console.rb index 1ce1de0..c7675c1 100644 --- a/test/io/console/test_io_console.rb +++ b/test/io/console/test_io_console.rb @@ -7,6 +7,10 @@ end class TestIO_Console < Test::Unit::TestCase + def test_console_namespace + assert_kind_of(Module, IO::Console) + end unless RUBY_ENGINE == "jruby" && RbConfig::CONFIG["host_os"] !~ /mswin|mingw/ + HOST_OS = RbConfig::CONFIG['host_os'] private def host_os?(os) HOST_OS =~ os @@ -410,6 +414,28 @@ def test_cursor_position end end + def test_cursor_visibility + run_pty(<<~'RUBY') do |r, _, _| + con = IO.console + abort unless con.hide_cursor.equal?(con) + abort unless con.show_cursor.equal?(con) + RUBY + assert_equal("\e[?25l\e[?25h", r.read(12)) + end + end unless RbConfig::CONFIG["host_os"] =~ /mswin|mingw/ + + def test_input_pending + IO.pipe do |read, write| + assert_false(read.input_pending?) + write.write("ab") + assert_true(read.input_pending?) + assert_equal("a", read.getc) + assert_true(read.input_pending?) + assert_equal("b", read.getc) + assert_false(read.input_pending?) + end + end unless RbConfig::CONFIG["host_os"] =~ /mswin|mingw/ || RUBY_ENGINE == "jruby" + def assert_ctrl(expect, cc, r, w) sleep 0.1 w.print cc @@ -664,6 +690,204 @@ def test_pressed_invalid end end +RbConfig::CONFIG["host_os"] =~ /mswin|mingw/ and TestIO_Console.class_eval do + def test_virtual_key_constants + { + 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, + }.each do |name, value| + assert_equal(value, IO::Console::Windows.const_get(name, false)) + assert_false(IO::Console.const_defined?(name, false)) + end + end +end + +RbConfig::CONFIG["host_os"] =~ /mswin|mingw/ and defined?(IO.console) and IO.console and \ +TestIO_Console.class_eval do + def test_output_console_mode + require "fiddle/import" + + kernel32 = Module.new do + extend Fiddle::Importer + dlload "kernel32.dll" + extern "void *CreateFileW(void *, long, long, void *, long, long, void *)" + extern "int CloseHandle(void *)" + extern "int GetConsoleMode(void *, void *)" + end + File.open("CONOUT$", "r+") do |output| + path = "CONOUT$\0".encode("UTF-16LE") + handle = kernel32.CreateFileW(path, -0x40000000, 3, nil, 3, 0, nil) + buffer = [0].pack("L<") + assert_not_equal(0, kernel32.GetConsoleMode(handle, buffer)) + original = buffer.unpack1("L<") + mode = output.console_mode + begin + assert_equal((original & 4) != 0, mode.virtual_terminal_processing?) + assert_equal((original & 2) != 0, mode.wrap_at_eol_output?) + + mode.virtual_terminal_processing = (original & 4) == 0 + mode.wrap_at_eol_output = (original & 2) == 0 + output.console_mode = mode + assert_not_equal(0, kernel32.GetConsoleMode(handle, buffer)) + assert_equal(original ^ 6, buffer.unpack1("L<")) + ensure + mode.virtual_terminal_processing = (original & 4) != 0 + mode.wrap_at_eol_output = (original & 2) != 0 + output.console_mode = mode + kernel32.CloseHandle(handle) + end + end + end + + def test_cursor_visibility + require "fiddle/import" + + kernel32 = Module.new do + extend Fiddle::Importer + dlload "kernel32.dll" + extern "void *CreateFileW(void *, long, long, void *, long, long, void *)" + extern "int CloseHandle(void *)" + extern "int GetConsoleCursorInfo(void *, void *)" + end + File.open("CONOUT$", "r+") do |output| + info = [0, 0].pack("L