From 4628d7590e5f0847593af0fe4e8cafc2667c7c79 Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Tue, 4 Aug 2026 10:08:03 +0900 Subject: [PATCH] Use io-console for Windows console support Remove Fiddle bindings by using `io-console` for Windows input and screen operations. --- Gemfile | 3 - lib/reline/io/windows.rb | 352 +++++-------------- reline.gemspec | 2 +- test/reline/windows/test_key_event_record.rb | 58 +++ 4 files changed, 139 insertions(+), 276 deletions(-) diff --git a/Gemfile b/Gemfile index b694335031..d9a2876369 100644 --- a/Gemfile +++ b/Gemfile @@ -17,6 +17,3 @@ gem 'test-unit-ruby-core' # Only used in ci to run readline-ext test using Reline as Readline gem 'readline' - -# Only used in windows -gem 'fiddle' diff --git a/lib/reline/io/windows.rb b/lib/reline/io/windows.rb index 8e15d6f485..e7be59d714 100644 --- a/lib/reline/io/windows.rb +++ b/lib/reline/io/windows.rb @@ -1,73 +1,21 @@ -require 'fiddle/import' - class Reline::Windows < Reline::IO + console = IO::Console::Windows + console.constants(false).grep(/\AVK_|_(?:KEY|ON|OFF|PRESSED)\z/).each do |name| + const_set(name, console.const_get(name)) + end + attr_writer :output def initialize @input_buf = [] @output_buf = [] + @input = STDIN @output = STDOUT + @console_output = STDOUT @hsg = nil - @getwch = Win32API.new('msvcrt', '_getwch', [], 'I') - @kbhit = Win32API.new('msvcrt', '_kbhit', [], 'I') - @GetKeyState = Win32API.new('user32', 'GetKeyState', ['L'], 'L') - @GetConsoleScreenBufferInfo = Win32API.new('kernel32', 'GetConsoleScreenBufferInfo', ['L', 'P'], 'L') - @SetConsoleCursorPosition = Win32API.new('kernel32', 'SetConsoleCursorPosition', ['L', 'L'], 'L') - @GetStdHandle = Win32API.new('kernel32', 'GetStdHandle', ['L'], 'L') - @FillConsoleOutputCharacter = Win32API.new('kernel32', 'FillConsoleOutputCharacter', ['L', 'L', 'L', 'L', 'P'], 'L') - @ScrollConsoleScreenBuffer = Win32API.new('kernel32', 'ScrollConsoleScreenBuffer', ['L', 'P', 'P', 'L', 'P'], 'L') - @hConsoleHandle = @GetStdHandle.call(STD_OUTPUT_HANDLE) - @hConsoleInputHandle = @GetStdHandle.call(STD_INPUT_HANDLE) - @GetNumberOfConsoleInputEvents = Win32API.new('kernel32', 'GetNumberOfConsoleInputEvents', ['L', 'P'], 'L') - @ReadConsoleInputW = Win32API.new('kernel32', 'ReadConsoleInputW', ['L', 'P', 'L', 'P'], 'L') - @GetFileType = Win32API.new('kernel32', 'GetFileType', ['L'], 'L') - @GetFileInformationByHandleEx = Win32API.new('kernel32', 'GetFileInformationByHandleEx', ['L', 'I', 'P', 'L'], 'I') - @FillConsoleOutputAttribute = Win32API.new('kernel32', 'FillConsoleOutputAttribute', ['L', 'L', 'L', 'L', 'P'], 'L') - @SetConsoleCursorInfo = Win32API.new('kernel32', 'SetConsoleCursorInfo', ['L', 'P'], 'L') - - @GetConsoleMode = Win32API.new('kernel32', 'GetConsoleMode', ['L', 'P'], 'L') - @SetConsoleMode = Win32API.new('kernel32', 'SetConsoleMode', ['L', 'L'], 'L') - @WaitForSingleObject = Win32API.new('kernel32', 'WaitForSingleObject', ['L', 'L'], 'L') - - # Win32API does not have pointer size integer. - # Current process pseudo handle (-1)LL seems to fail to be passed to DuplicateHandle. - # @GetCurrentProcess = Win32API.new('kernel32', 'GetCurrentProcess', [], 'L') - @GetCurrentProcessId = Win32API.new('kernel32', 'GetCurrentProcessId', [], 'L') - @OpenProcess = Win32API.new('kernel32', 'OpenProcess', ['L', 'L', 'L'], 'L') - @CloseHandle = Win32API.new('kernel32', 'CloseHandle', ['L'], 'L') - @DuplicateHandle = Win32API.new('kernel32', 'DuplicateHandle', ['L', 'L', 'L', 'P', 'I', 'I', 'I'], 'L') - - current_process_handle = @OpenProcess.call( - 0x0040, # PROCESS_DUP_HANDLE - 0, # bInheritHandle - @GetCurrentProcessId.call() - ) - duplicate_handle = proc { |handle| - dupHandle = "\0" * 8 - @DuplicateHandle.call( - current_process_handle, - handle, - current_process_handle, - dupHandle, - 0, # dwDesiredAccess - 0, # bInheritHandle - 2 # dwOptions = DUPLICATE_SAME_ACCESS - ) - dupHandle.unpack1("J") - } - if (dup = duplicate_handle.call(@hConsoleHandle)) != 0 - @hConsoleHandle = dup - at_exit { @CloseHandle.call(@hConsoleHandle) } - end - if (dup = duplicate_handle.call(@hConsoleInputHandle)) != 0 - @hConsoleInputHandle = dup - at_exit { @CloseHandle.call(@hConsoleInputHandle) } - end - @CloseHandle.call(current_process_handle) - - @legacy_console = getconsolemode & ENABLE_VIRTUAL_TERMINAL_PROCESSING == 0 + @legacy_console = legacy_console? end def encoding @@ -120,121 +68,33 @@ def set_default_key_bindings(config) end end - if defined? JRUBY_VERSION - require 'win32api' - else - class Win32API - DLL = {} - TYPEMAP = {"0" => Fiddle::TYPE_VOID, "S" => Fiddle::TYPE_VOIDP, "I" => Fiddle::TYPE_LONG} - POINTER_TYPE = Fiddle::SIZEOF_VOIDP == Fiddle::SIZEOF_LONG_LONG ? 'q*' : 'l!*' - - WIN32_TYPES = "VPpNnLlIi" - DL_TYPES = "0SSI" - - def initialize(dllname, func, import, export = "0", calltype = :stdcall) - @proto = [import].join.tr(WIN32_TYPES, DL_TYPES).sub(/^(.)0*$/, '\1') - import = @proto.chars.map {|win_type| TYPEMAP[win_type.tr(WIN32_TYPES, DL_TYPES)]} - export = TYPEMAP[export.tr(WIN32_TYPES, DL_TYPES)] - calltype = Fiddle::Importer.const_get(:CALL_TYPE_TO_ABI)[calltype] - - handle = DLL[dllname] ||= - begin - Fiddle.dlopen(dllname) - rescue Fiddle::DLError - raise unless File.extname(dllname).empty? - Fiddle.dlopen(dllname + ".dll") - end - - @func = Fiddle::Function.new(handle[func], import, export, calltype) - rescue Fiddle::DLError => e - raise LoadError, e.message, e.backtrace - end + private def legacy_console? + return false unless @console_output.tty?(nil) - def call(*args) - import = @proto.split("") - args.each_with_index do |x, i| - args[i], = [x == 0 ? nil : +x].pack("p").unpack(POINTER_TYPE) if import[i] == "S" - args[i], = [x].pack("I").unpack("i") if import[i] == "I" - end - ret, = @func.call(*args) - return ret || 0 - end - end + legacy_console_mode?(console_mode) + rescue SystemCallError + false end - VK_RETURN = 0x0D - VK_MENU = 0x12 # ALT key - VK_LMENU = 0xA4 - VK_CONTROL = 0x11 - VK_SHIFT = 0x10 - VK_DIVIDE = 0x6F - - KEY_EVENT = 0x01 - WINDOW_BUFFER_SIZE_EVENT = 0x04 - - CAPSLOCK_ON = 0x0080 - ENHANCED_KEY = 0x0100 - LEFT_ALT_PRESSED = 0x0002 - LEFT_CTRL_PRESSED = 0x0008 - NUMLOCK_ON = 0x0020 - RIGHT_ALT_PRESSED = 0x0001 - RIGHT_CTRL_PRESSED = 0x0004 - SCROLLLOCK_ON = 0x0040 - SHIFT_PRESSED = 0x0010 - - VK_TAB = 0x09 - VK_END = 0x23 - VK_HOME = 0x24 - VK_LEFT = 0x25 - VK_UP = 0x26 - VK_RIGHT = 0x27 - VK_DOWN = 0x28 - VK_DELETE = 0x2E - - STD_INPUT_HANDLE = -10 - STD_OUTPUT_HANDLE = -11 - FILE_TYPE_PIPE = 0x0003 - FILE_NAME_INFO = 2 - ENABLE_WRAP_AT_EOL_OUTPUT = 2 - ENABLE_VIRTUAL_TERMINAL_PROCESSING = 4 - - private def getconsolemode - mode = +"\0\0\0\0" - @GetConsoleMode.call(@hConsoleHandle, mode) - mode.unpack1('L') - end - - private def setconsolemode(mode) - @SetConsoleMode.call(@hConsoleHandle, mode) - end - - #if @legacy_console - # setconsolemode(getconsolemode() | ENABLE_VIRTUAL_TERMINAL_PROCESSING) - # @legacy_console = (getconsolemode() & ENABLE_VIRTUAL_TERMINAL_PROCESSING == 0) - #end - - def msys_tty?(io = @hConsoleInputHandle) - # check if fd is a pipe - if @GetFileType.call(io) != FILE_TYPE_PIPE - return false - end + private def legacy_console_mode?(mode) + mode ? !mode.virtual_terminal_processing? : false + end - bufsize = 1024 - p_buffer = "\0" * bufsize - res = @GetFileInformationByHandleEx.call(io, FILE_NAME_INFO, p_buffer, bufsize - 2) - return false if res == 0 + private def console_mode + @console_output.console_mode + rescue SystemCallError + nil + end - # get pipe name: p_buffer layout is: - # struct _FILE_NAME_INFO { - # DWORD FileNameLength; - # WCHAR FileName[1]; - # } FILE_NAME_INFO - len = p_buffer[0, 4].unpack1("L") - name = p_buffer[4, len].encode(Encoding::UTF_8, Encoding::UTF_16LE, invalid: :replace) + private def set_console_mode(mode) + @console_output.console_mode = mode + true + rescue SystemCallError + false + end - # Check if this could be a MSYS2 pty pipe ('\msys-XXXX-ptyN-XX') - # or a cygwin pty pipe ('\cygwin-XXXX-ptyN-XX') - name =~ /(msys-|cygwin-).*-pty/ ? true : false + def msys_tty? + @input.tty?(:msys, :cygwin) end KEY_MAP = [ @@ -296,36 +156,27 @@ def process_key_event(repeat_count, virtual_key_code, virtual_scan_code, char_co end def check_input_event - num_of_events = 0.chr * 8 while @output_buf.empty? Reline.core.line_editor.handle_signal - if @WaitForSingleObject.(@hConsoleInputHandle, 100) != 0 # max 0.1 sec + events = @input.console_input_events(80, timeout: 0.1) + if events.empty? # prevent for background consolemode change - @legacy_console = getconsolemode & ENABLE_VIRTUAL_TERMINAL_PROCESSING == 0 + @legacy_console = legacy_console? next end - next if @GetNumberOfConsoleInputEvents.(@hConsoleInputHandle, num_of_events) == 0 or num_of_events.unpack1('L') == 0 - input_records = 0.chr * 20 * 80 - read_event = 0.chr * 4 - if @ReadConsoleInputW.(@hConsoleInputHandle, input_records, 80, read_event) != 0 - read_events = read_event.unpack1('L') - 0.upto(read_events) do |idx| - input_record = input_records[idx * 20, 20] - event = input_record[0, 2].unpack1('s*') - case event - when WINDOW_BUFFER_SIZE_EVENT - @winch_handler.() - when KEY_EVENT - key_down = input_record[4, 4].unpack1('l*') - repeat_count = input_record[8, 2].unpack1('s*') - virtual_key_code = input_record[10, 2].unpack1('s*') - virtual_scan_code = input_record[12, 2].unpack1('s*') - char_code = input_record[14, 2].unpack1('S*') - control_key_state = input_record[16, 2].unpack1('S*') - is_key_down = key_down.zero? ? false : true - if is_key_down - process_key_event(repeat_count, virtual_key_code, virtual_scan_code, char_code, control_key_state) - end + events.each do |event| + case event[:type] + when :window_buffer_size + @winch_handler.() + when :key + if event[:key_down] + process_key_event( + event[:repeat_count], + event[:virtual_key_code], + event[:virtual_scan_code], + event[:unicode_char], + event[:control_key_state] + ) end end end @@ -358,85 +209,51 @@ def in_pasting? end def empty_buffer? - if not @output_buf.empty? - false - elsif @kbhit.call == 0 - true - else - false - end - end - - def get_console_screen_buffer_info - # CONSOLE_SCREEN_BUFFER_INFO - # [ 0,2] dwSize.X - # [ 2,2] dwSize.Y - # [ 4,2] dwCursorPositions.X - # [ 6,2] dwCursorPositions.Y - # [ 8,2] wAttributes - # [10,2] srWindow.Left - # [12,2] srWindow.Top - # [14,2] srWindow.Right - # [16,2] srWindow.Bottom - # [18,2] dwMaximumWindowSize.X - # [20,2] dwMaximumWindowSize.Y - csbi = 0.chr * 22 - if @GetConsoleScreenBufferInfo.call(@hConsoleHandle, csbi) != 0 - # returns [width, height, x, y, attributes, left, top, right, bottom] - csbi.unpack("s9") - else - return nil - end + @output_buf.empty? && !@input.input_pending? end - ALTERNATIVE_CSBI = [80, 24, 0, 0, 7, 0, 0, 79, 23].freeze - def get_screen_size - width, _, _, _, _, _, top, _, bottom = get_console_screen_buffer_info || ALTERNATIVE_CSBI - [bottom - top + 1, width] + @console_output.winsize + rescue SystemCallError + [24, 80] end def cursor_pos - _, _, x, y, _, _, top, = get_console_screen_buffer_info || ALTERNATIVE_CSBI - Reline::CursorPos.new(x, y - top) + row, column = @console_output.cursor + Reline::CursorPos.new(column, row) + rescue SystemCallError + Reline::CursorPos.new(0, 0) end def move_cursor_column(val) - _, _, _, y, = get_console_screen_buffer_info - @SetConsoleCursorPosition.call(@hConsoleHandle, y * 65536 + val) if y + @console_output.goto_column(val) + rescue SystemCallError end def move_cursor_up(val) if val > 0 - _, _, x, y, _, _, top, = get_console_screen_buffer_info - return unless y - y = (y - top) - val - y = 0 if y < 0 - @SetConsoleCursorPosition.call(@hConsoleHandle, (y + top) * 65536 + x) + row, column = @console_output.cursor + @console_output.goto([row - val, 0].max, column) elsif val < 0 move_cursor_down(-val) end + rescue SystemCallError end def move_cursor_down(val) if val > 0 - _, _, x, y, _, _, top, _, bottom = get_console_screen_buffer_info - return unless y - screen_height = bottom - top - y = (y - top) + val - y = screen_height if y > screen_height - @SetConsoleCursorPosition.call(@hConsoleHandle, (y + top) * 65536 + x) + row, column = @console_output.cursor + rows, = @console_output.winsize + @console_output.goto([row + val, rows - 1].min, column) elsif val < 0 move_cursor_up(-val) end + rescue SystemCallError end def erase_after_cursor - width, _, x, y, attributes, = get_console_screen_buffer_info - return unless x - written = 0.chr * 4 - @FillConsoleOutputCharacter.call(@hConsoleHandle, 0x20, width - x, y * 65536 + x, written) - @FillConsoleOutputAttribute.call(@hConsoleHandle, attributes, width - x, y * 65536 + x, written) + @console_output.erase_line(0) + rescue SystemCallError end # This only works when the cursor is at the bottom of the scroll range @@ -449,17 +266,11 @@ def scroll_down(x) def clear_screen if @legacy_console - width, _, _, _, attributes, _, top, _, bottom = get_console_screen_buffer_info - return unless width - fill_length = width * (bottom - top + 1) - screen_topleft = top * 65536 - written = 0.chr * 4 - @FillConsoleOutputCharacter.call(@hConsoleHandle, 0x20, fill_length, screen_topleft, written) - @FillConsoleOutputAttribute.call(@hConsoleHandle, attributes, fill_length, screen_topleft, written) - @SetConsoleCursorPosition.call(@hConsoleHandle, screen_topleft) + @console_output.clear_screen else @output.write "\e[2J" "\e[H" end + rescue SystemCallError end def set_screen_size(rows, columns) @@ -467,17 +278,13 @@ def set_screen_size(rows, columns) end def hide_cursor - size = 100 - visible = 0 # 0 means false - cursor_info = [size, visible].pack('Li') - @SetConsoleCursorInfo.call(@hConsoleHandle, cursor_info) + @console_output.hide_cursor + rescue SystemCallError end def show_cursor - size = 100 - visible = 1 # 1 means true - cursor_info = [size, visible].pack('Li') - @SetConsoleCursorInfo.call(@hConsoleHandle, cursor_info) + @console_output.show_cursor + rescue SystemCallError end def set_winch_handler(&handler) @@ -494,21 +301,22 @@ def deprep(otio) end def disable_auto_linewrap(setting = true, &block) - mode = getconsolemode - if 0 == (mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) + mode = console_mode + if legacy_console_mode?(mode) if block + wrap_at_eol = mode.wrap_at_eol_output? + mode.wrap_at_eol_output = false + return block.call unless set_console_mode(mode) + begin - setconsolemode(mode & ~ENABLE_WRAP_AT_EOL_OUTPUT) block.call ensure - setconsolemode(mode | ENABLE_WRAP_AT_EOL_OUTPUT) + mode.wrap_at_eol_output = wrap_at_eol + set_console_mode(mode) end else - if setting - setconsolemode(mode & ~ENABLE_WRAP_AT_EOL_OUTPUT) - else - setconsolemode(mode | ENABLE_WRAP_AT_EOL_OUTPUT) - end + mode.wrap_at_eol_output = !setting + set_console_mode(mode) end else block.call if block diff --git a/reline.gemspec b/reline.gemspec index 53fd2bfbf7..483f7584c0 100644 --- a/reline.gemspec +++ b/reline.gemspec @@ -33,5 +33,5 @@ Gem::Specification.new do |spec| spec.required_ruby_version = Gem::Requirement.new('>= 2.6') - spec.add_dependency 'io-console', '~> 0.5' + spec.add_dependency 'io-console', '~> 0.9' end diff --git a/test/reline/windows/test_key_event_record.rb b/test/reline/windows/test_key_event_record.rb index 25c860606a..f9aef9b1e6 100644 --- a/test/reline/windows/test_key_event_record.rb +++ b/test/reline/windows/test_key_event_record.rb @@ -2,6 +2,64 @@ return unless Reline.const_defined?(:Windows) class Reline::Windows + class Test < Reline::TestCase + def test_does_not_define_win32api + refute Reline::Windows.const_defined?(:Win32API, false) + end + + def test_empty_buffer_uses_input_pending + windows = Reline::Windows.allocate + input = Object.new + input.define_singleton_method(:input_pending?) { false } + windows.instance_variable_set(:@input, input) + windows.instance_variable_set(:@output_buf, []) + assert windows.empty_buffer? + + input.define_singleton_method(:input_pending?) { true } + refute windows.empty_buffer? + end + + def test_screen_operations_ignore_system_call_errors + windows = Reline::Windows.allocate + console_output = Object.new + console_output.define_singleton_method(:method_missing) { |*| raise Errno::EIO } + windows.instance_variable_set(:@console_output, console_output) + windows.instance_variable_set(:@legacy_console, true) + + assert_nothing_raised(SystemCallError) do + windows.move_cursor_column(1) + windows.move_cursor_up(1) + windows.move_cursor_down(1) + windows.erase_after_cursor + windows.clear_screen + windows.hide_cursor + windows.show_cursor + end + end + + def test_legacy_console_is_false_on_system_call_error + windows = Reline::Windows.allocate + console_output = Object.new + console_output.define_singleton_method(:tty?) { |*| true } + console_output.define_singleton_method(:console_mode) { raise Errno::EIO } + windows.instance_variable_set(:@console_output, console_output) + + refute windows.send(:legacy_console?) + end + + def test_disable_auto_linewrap_yields_on_system_call_error + windows = Reline::Windows.allocate + console_output = Object.new + console_output.define_singleton_method(:console_mode) { raise Errno::EIO } + windows.instance_variable_set(:@console_output, console_output) + calls = 0 + + windows.disable_auto_linewrap { calls += 1 } + + assert_equal 1, calls + end + end + class KeyEventRecord::Test < Reline::TestCase def setup