Module: Rcurses

Defined in:
lib/rcurses.rb,
lib/rcurses/pane.rb,
lib/rcurses/input.rb,
lib/rcurses/cursor.rb,
lib/rcurses/general.rb

Defined Under Namespace

Modules: Cursor, Input Classes: Pane

Constant Summary collapse

@@terminal_state_saved =
false
@@original_stty_state =
nil

Class Method Summary collapse

Class Method Details

.cleanup!Object

Public: Restore terminal to normal mode, clear screen, show cursor. Idempotent: subsequent calls do nothing.



76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/rcurses.rb', line 76

def cleanup!
  return if @cleaned_up

  # Restore terminal to normal mode
  begin
    if @using_stty
      # If we used stty for init, use it for cleanup
      system("stty sane 2>/dev/null")
    elsif RUBY_VERSION >= "3.4.0"
      # Ruby 3.4+ with timeout protection
      begin
        Timeout::timeout(0.5) do
          $stdin.cooked!
          $stdin.echo = true
        end
      rescue Timeout::Error
        # Fallback if cooked! hangs
        system("stty sane 2>/dev/null")
      end
    else
      # Original code for Ruby < 3.4
      $stdin.cooked!
      $stdin.echo = true
    end
  rescue => e
    # Last resort fallback
    system("stty sane 2>/dev/null")
  end
  
  # Only clear screen if there's no error to display
  # This preserves the error context on screen
  if @error_to_display.nil?
    Rcurses.clear_screen
  else
    # Just move cursor to bottom of screen without clearing
    print "\e[999;1H"  # Move to bottom-left
    print "\e[K"       # Clear current line
  end
  
  Cursor.show

  @cleaned_up = true
  
  # Display any captured error after terminal is restored
  if @error_to_display
    display_error(@error_to_display)
  end
end

.clear_screenObject



5
6
7
8
# File 'lib/rcurses/general.rb', line 5

def self.clear_screen
  # ANSI code \e[2J clears the screen, and \e[H moves the cursor to the top left.
  print "\e[2J\e[H"
end

.display_error(error) ⇒ Object

Private: Display error information after terminal cleanup



126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/rcurses.rb', line 126

def display_error(error)
  # Log error to file if RCURSES_ERROR_LOG is set
  log_error_to_file(error) if ENV['RCURSES_ERROR_LOG'] == '1'

  # Only display if we're in a TTY and not in a test environment
  return unless $stdout.tty?

  # Add some spacing and make the error very visible
  puts "\n\n\e[41;37m                    APPLICATION CRASHED                    \e[0m"
  puts "\e[31m═══════════════════════════════════════════════════════════\e[0m"
  puts "\e[33mError Type:\e[0m #{error.class}"
  puts "\e[33mMessage:\e[0m    #{error.message}"

  # Show backtrace if debug mode is enabled
  if ENV['DEBUG'] || ENV['RCURSES_DEBUG']
    puts "\n\e[33mBacktrace:\e[0m"
    error.backtrace.first(15).each do |line|
      puts "  \e[90m#{line}\e[0m"
    end
  else
    puts "\n\e[90mTip: Set DEBUG=1 or RCURSES_DEBUG=1 to see the full backtrace\e[0m"
  end

  puts "\e[31m═══════════════════════════════════════════════════════════\e[0m"
  puts "\e[33mNote:\e[0m The application state above shows where the error occurred."

  # Show log file location if logging is enabled
  if ENV['RCURSES_ERROR_LOG'] == '1'
    puts "\e[33mError logged to:\e[0m /tmp/rcurses_errors_#{Process.pid}.log"
  end
  puts ""
end

.display_width(str) ⇒ Object

Simple, fast display_width function (like original 4.8.3)



62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/rcurses/general.rb', line 62

def self.display_width(str)
  return 0 if str.nil? || str.empty?
  
  width = 0
  str.each_char do |char|
    cp = char.ord
    if cp == 0
      # NUL – no width
    elsif cp < 32 || (cp >= 0x7F && cp < 0xA0)
      # Control characters: no width
      width += 0
    # Approximate common wide ranges:
    elsif (cp >= 0x1100 && cp <= 0x115F) ||
          cp == 0x2329 || cp == 0x232A ||
          (cp >= 0x2E80 && cp <= 0xA4CF) ||
          (cp >= 0xAC00 && cp <= 0xD7A3) ||
          (cp >= 0xF900 && cp <= 0xFAFF) ||
          (cp >= 0xFE10 && cp <= 0xFE19) ||
          (cp >= 0xFE30 && cp <= 0xFE6F) ||
          (cp >= 0xFF00 && cp <= 0xFF60) ||
          (cp >= 0xFFE0 && cp <= 0xFFE6)
      width += 2
    else
      width += 1
    end
  end
  width
end

.display_width_unicode(str) ⇒ Object

Comprehensive Unicode display width (available but not used in performance-critical paths)



92
93
94
95
96
97
98
# File 'lib/rcurses/general.rb', line 92

def self.display_width_unicode(str)
  return 0 if str.nil? || str.empty?
  
  # ... full Unicode implementation available when needed ...
  # For now, just delegate to the simple version
  display_width(str)
end

.init!Object

Public: Initialize Rcurses. Switches terminal into raw/no-echo and registers cleanup handlers. Idempotent.



24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/rcurses.rb', line 24

def init!
  return if @initialized
  return unless $stdin.tty?

  # enter raw mode, disable echo
  # Ruby 3.4+ compatibility: Handle potential blocking in raw!
  begin
    if RUBY_VERSION >= "3.4.0"
      # Flush outputs before changing terminal mode (Ruby 3.4+ requirement)
      $stdout.flush if $stdout.respond_to?(:flush)
      $stderr.flush if $stderr.respond_to?(:flush)
      
      # Use timeout to detect hanging raw! call
      begin
        Timeout::timeout(0.5) do
          $stdin.raw!
          $stdin.echo = false
        end
      rescue Timeout::Error
        # Fallback to stty for Ruby 3.4+ if raw! hangs
        system("stty raw -echo 2>/dev/null")
        @using_stty = true
      end
    else
      # Original code for Ruby < 3.4
      $stdin.raw!
      $stdin.echo = false
    end
  rescue Errno::ENOTTY, Errno::ENODEV
    # Not a real terminal
    return
  end

  # ensure cleanup on normal exit
  at_exit do
    # Capture any unhandled exception
    if $! && !$!.is_a?(SystemExit) && !$!.is_a?(Interrupt)
      @error_to_display = $!
    end
    cleanup!
  end

  # ensure cleanup on signals
  %w[INT TERM].each do |sig|
    trap(sig) { cleanup!; exit }
  end

  @initialized = true
end

.log_error_to_file(error) ⇒ Object

Private: Log error details to a PID-specific file



160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/rcurses.rb', line 160

def log_error_to_file(error)
  return unless ENV['RCURSES_ERROR_LOG'] == '1'

  log_file = "/tmp/rcurses_errors_#{Process.pid}.log"
  timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S")

  begin
    File.open(log_file, "a") do |f|
      f.puts "=" * 60
      f.puts "RCURSES ERROR LOG - #{timestamp}"
      f.puts "PID: #{Process.pid}"
      f.puts "Program: #{$0}"
      f.puts "Working Directory: #{Dir.pwd}"
      f.puts "Ruby Version: #{RUBY_VERSION}"
      f.puts "Rcurses Version: 6.1.1"
      f.puts "=" * 60
      f.puts "Error Class: #{error.class}"
      f.puts "Error Message: #{error.message}"
      f.puts
      f.puts "Full Backtrace:"
      error.backtrace.each_with_index do |line, i|
        f.puts "  #{i}: #{line}"
      end
      f.puts
      f.puts "Environment Variables:"
      ENV.select { |k, v| k.start_with?('RCURSES') || k == 'DEBUG' }.each do |k, v|
        f.puts "  #{k}=#{v}"
      end
      f.puts "=" * 60
      f.puts
    end
  rescue => file_error
    # Silently fail if we can't write to log file - don't interfere with main error display
    # This prevents cascading errors that could hide the original problem
  end
end

.restore_terminal_stateObject



18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# File 'lib/rcurses/general.rb', line 18

def self.restore_terminal_state
  if @@terminal_state_saved && @@original_stty_state
    begin
      # Restore terminal settings
      system("stty #{@@original_stty_state} 2>/dev/null")
      # Reset terminal
      print "\e[0m\e[?25h\e[?7h\e[r"
      STDOUT.flush
    rescue
      # Fallback restoration
      begin
        STDIN.cooked! rescue nil
        STDIN.echo = true rescue nil
      rescue
      end
    end
  end
  @@terminal_state_saved = false
end

.run(&block) ⇒ Object

Public: Run a block with proper error handling and terminal cleanup This ensures errors are displayed after terminal is restored



199
200
201
202
203
204
205
206
207
208
209
# File 'lib/rcurses.rb', line 199

def run(&block)
  init!
  begin
    yield
  rescue StandardError => e
    @error_to_display = e
    raise
  ensure
    # cleanup! will be called by at_exit handler
  end
end

.save_terminal_state(install_handlers = false) ⇒ Object



10
11
12
13
14
15
16
# File 'lib/rcurses/general.rb', line 10

def self.save_terminal_state(install_handlers = false)
  unless @@terminal_state_saved
    @@original_stty_state = `stty -g 2>/dev/null`.chomp rescue nil
    @@terminal_state_saved = true
    setup_signal_handlers if install_handlers
  end
end

.setup_signal_handlersObject



38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/rcurses/general.rb', line 38

def self.setup_signal_handlers
  ['TERM', 'INT', 'QUIT', 'HUP'].each do |signal|
    Signal.trap(signal) do
      restore_terminal_state
      exit(1)
    end
  end
  
  # Handle WINCH (window size change) gracefully
  Signal.trap('WINCH') do
    # Just ignore for now - applications should handle this themselves
  end
end

.with_terminal_protection(install_handlers = true) ⇒ Object



52
53
54
55
56
57
58
59
# File 'lib/rcurses/general.rb', line 52

def self.with_terminal_protection(install_handlers = true)
  save_terminal_state(install_handlers)
  begin
    yield
  ensure
    restore_terminal_state
  end
end