From 22048817d4e7d0469944a18e23c68fd32d3f6216 Mon Sep 17 00:00:00 2001 From: Lars Kanis Date: Mon, 12 Jan 2026 07:01:25 +0100 Subject: [PATCH] Add mouse event handling to classic terminal and avoid threads Using `ReadConsoleInput` the mouse inputs work for conhost.exe as well. Stopping the threads when calling out per `Kernel.system` is a problem, since they continue to run and eat the input characters of sub-processes. This can be avoided by not using threads in favour of a single main_loop. --- lib/ruby_installer/runtime/console_ui.rb | 172 ++++++++++++++++------- resources/files/startmenu.rb | 2 +- 2 files changed, 120 insertions(+), 54 deletions(-) diff --git a/lib/ruby_installer/runtime/console_ui.rb b/lib/ruby_installer/runtime/console_ui.rb index 948567a8..cc4c9d32 100644 --- a/lib/ruby_installer/runtime/console_ui.rb +++ b/lib/ruby_installer/runtime/console_ui.rb @@ -1,3 +1,4 @@ +require "stringio" require "io/console" require "fiddle" require "fiddle/import" @@ -175,6 +176,9 @@ def repaint(width: @con.winsize[1], height: @con.winsize[0]) ENABLE_QUICK_EDIT_MODE = 0x0040 ENABLE_EXTENDED_FLAGS = 0x0080 ENABLE_VIRTUAL_TERMINAL_INPUT = 0x200 + KEY_EVENT = 0x01 + MOUSE_EVENT = 0x02 + WINDOW_BUFFER_SIZE_EVENT = 0x04 attr_accessor :widget @@ -182,16 +186,16 @@ def initialize @GetStdHandle = Win32API.new('kernel32', 'GetStdHandle', ['L'], 'L') @GetConsoleMode = Win32API.new('kernel32', 'GetConsoleMode', ['L', 'P'], 'L') @SetConsoleMode = Win32API.new('kernel32', 'SetConsoleMode', ['L', 'L'], 'L') + @ReadConsoleInputW = Win32API.new('kernel32', 'ReadConsoleInputW', ['L', 'P', 'L', 'P'], 'L') + @GetConsoleScreenBufferInfo = Win32API.new('kernel32', 'GetConsoleScreenBufferInfo', ['L', 'P'], 'L') @hConsoleHandle = @GetStdHandle.call(STD_INPUT_HANDLE) - @ev_r, @ev_w = IO.pipe.map(&:binmode) - @read_request_queue = Thread::Queue.new + @hConsoleOutHandle = @GetStdHandle.call(STD_OUTPUT_HANDLE) + @mouse_state = 0 + @old_winsize = IO.console.winsize set_consolemode - register_term_size_change - register_stdin - at_exit do unset_consolemode end @@ -240,49 +244,90 @@ def unset_consolemode call_with_console_handle(@SetConsoleMode, mode) end - private def register_term_size_change - if RUBY_PLATFORM =~ /mingw|mswin/ - con = IO.console - old_size = con.winsize - Thread.new do - loop do - new_size = con.winsize - if old_size != new_size - old_size = new_size - @ev_w.write "\x01" - end - sleep 1 - 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(@hConsoleOutHandle, csbi) != 0 + # returns [width, height, x, y, attributes, left, top, right, bottom] + csbi.unpack("s9") else - Signal.trap('SIGWINCH') do - @ev_w.write "\x01" - end + return nil end end - private def register_stdin - Thread.new do - str = +"" - @read_request_queue.shift - c = IO.console - while char=c.read(1) - str << char - next if !str.valid_encoding? || - str == "\e" || - str == "\e[" || - str == "\xE0" || - str.match(/\A\e\x5b<[0-9;]*\z/) + private def winsize_changed? + con = IO.console + new_size = con.winsize + if @old_winsize != new_size + @old_winsize = new_size + true + else + false + end + end - @ev_w.write [2, str.size, str].pack("CCa*") - str = +"" - @read_request_queue.shift + def read_input_event + # Wait for reception of at least one event + input_records = 0.chr * 20 * 1 + read_event = 0.chr * 4 + + if @ReadConsoleInputW.(@hConsoleHandle, input_records, 1, read_event) != 0 + read_events = read_event.unpack1('L') + 0.upto(read_events-1) do |idx| + input_record = input_records[idx * 20, 20] + event = input_record[0, 2].unpack1('s*') + case event + 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 + # p [repeat_count, virtual_key_code, virtual_scan_code, char_code, control_key_state] + + return char_code.chr + end + when MOUSE_EVENT + click_x, click_y, state = input_record[4, 8].unpack("ssL") + if @mouse_state != state + # click state changed + @mouse_state = state + csbi = get_console_screen_buffer_info || raise("error at GetConsoleScreenBufferInfo") + click_y -= csbi[6] + # p mouse: [click_x, click_y, state] + + if state == 1 + # mouse button down + return "\e\x5b<0;#{click_x};#{click_y}M" + else + # mouse button up + return "\e\x5b<0;#{click_x};#{click_y}m" + end + end + when WINDOW_BUFFER_SIZE_EVENT + return :winsize_changed + end end end + false end - private def request_read - @read_request_queue.push true + private def windows_terminal? + !!ENV["WT_SESSION"] end private def handle_key_input(str) @@ -299,14 +344,14 @@ def unset_consolemode unset_consolemode do widget.select end - when /\e\x5b<0;(\d+);(\d+)m/ # Mouse left button up + when /\A\e\x5b<0;(\d+);(\d+)m\z/ # Mouse left button up if widget.click($1.to_i - 1, $2.to_i - 2) widget.repaint unset_consolemode do widget.select end end - when /\e\x5b<\d+;(\d+);(\d+)[Mm]/ # other mouse events + when /\A\e\x5b<\d+;(\d+);(\d+)[Mm]\z/ # other mouse events return # no repaint end widget.repaint @@ -314,20 +359,41 @@ def unset_consolemode private def main_loop str = +"" - request_read - while char=@ev_r.read(1) - case char - when "\x01" - widget.repaint - when "\x02" - strlen = @ev_r.read(1).unpack1("C") - str = @ev_r.read(strlen) - - handle_key_input(str) + console_buffer = StringIO.new + loop do + if windows_terminal? + c = IO.console + + rs, = IO.select([c], [], [], 0.5) + if rs + char = c.read(1) + break unless char + else + # timeout -> check windows size change + widget.repaint if winsize_changed? + end else - raise "unexpected event: #{char.inspect}" + if console_buffer.eof? + input = read_input_event + if input == :winsize_changed + widget.repaint if winsize_changed? + elsif input + console_buffer = StringIO.new(input) + end + end + char = console_buffer.read(1) end - request_read + next unless char + str << char + + next if !str.valid_encoding? || + str == "\e" || + str == "\e[" || + str == "\xE0" || + str.match(/\A\e\x5b<[0-9;]*\z/) + + handle_key_input(str) + str = +"" end end diff --git a/resources/files/startmenu.rb b/resources/files/startmenu.rb index c4fb0b00..fcef51d2 100644 --- a/resources/files/startmenu.rb +++ b/resources/files/startmenu.rb @@ -2,7 +2,7 @@ app = RubyInstaller::Runtime::ConsoleUi.new bm = RubyInstaller::Runtime::ConsoleUi::ButtonMatrix.new ncols: 3 -bm.headline = "Ruby startmenu - Choose item by #{ENV["WT_SESSION"] && "mouse or "}cursor keys and press Enter" +bm.headline = "Ruby startmenu - Choose item by mouse or cursor keys and press Enter" bt = <<~EOT irb:>