Module: Rcurses

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

Defined Under Namespace

Modules: Cursor, EmojiPicker, Input Classes: Pane, Popup

Constant Summary collapse

ANSI_RE =
/\e\[[0-9;]*m/.freeze
ANSI_CSI_RE =
/\e\[[0-9;]*[A-HJKSTfhlnr]/.freeze
DW_CACHE_MAX =
2000
@@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.



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
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/rcurses.rb', line 85

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
  
  # Restore main screen buffer (leave alternate screen)
  print "\e[?1049l"

  # 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_dw_cacheObject



65
66
67
# File 'lib/rcurses/general.rb', line 65

def self.clear_dw_cache
  @dw_cache.clear
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



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/rcurses.rb', line 138

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 (cached)



70
71
72
73
74
75
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
124
125
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
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/rcurses/general.rb', line 70

def self.display_width(str)
  return 0 if str.nil? || str.empty?
  cached = @dw_cache[str]
  return cached if cached

  width = 0
  prev_regional = false
  after_zwj = false       # Next visible char is joined (zero-width)
  last_was_narrow = false # Previous visible char was 1-wide (for ZWJ promotion)
  str.each_char do |char|
    cp = char.ord

    # Variation selectors: always zero-width
    # FE0F/FE0E behavior is terminal-dependent; treating as zero-width
    # matches libc wcwidth and avoids grid misalignment.
    next if cp == 0xFE0F || cp == 0xFE0E

    # Zero-width: skin tones, tags, combining marks, keycaps
    if (cp >= 0x1F3FB && cp <= 0x1F3FF) ||       # Skin tone modifiers
       (cp >= 0xE0020 && cp <= 0xE007F) ||       # Tag characters (flag subdivisions)
       cp == 0xE0001 || cp == 0x200C ||           # Other zero-width
       (cp >= 0x20D0 && cp <= 0x20FF) ||         # Combining diacritical marks for symbols
       cp == 0x20E3                               # Combining enclosing keycap
      next
    end

    # ZWJ: next visible character merges into current glyph
    if cp == 0x200D
      # ZWJ sequences always render as 2-wide emoji.
      # If the base character was narrow (e.g. ❤ in ❤️‍🔥), promote to 2.
      if last_was_narrow
        width += 1
        last_was_narrow = false
      end
      after_zwj = true
      next
    end

    if cp == 0 || cp < 32 || (cp >= 0x7F && cp < 0xA0)
      # NUL and control characters: no width
      last_was_narrow = false
    # Regional indicator symbols (flags): pair = one 2-wide glyph
    elsif cp >= 0x1F1E6 && cp <= 0x1F1FF
      if prev_regional
        prev_regional = false
        after_zwj = false
        next
      else
        prev_regional = true
        width += 2 unless after_zwj
        after_zwj = false
        next
      end
    # Wide character ranges (CJK, emoji, etc):
    elsif (cp >= 0x1100 && cp <= 0x115F) ||
          cp == 0x2329 || cp == 0x232A ||
          (cp >= 0x231A && cp <= 0x231B) ||   # Watch, hourglass
          (cp >= 0x23E9 && cp <= 0x23F3) ||   # Various symbols
          (cp >= 0x23F8 && cp <= 0x23FA) ||   # Play/pause symbols
          (cp >= 0x25FD && cp <= 0x25FE) ||   # Medium squares
          (cp >= 0x2614 && cp <= 0x2615) ||   # Umbrella, hot beverage
          (cp >= 0x2648 && cp <= 0x2653) ||   # Zodiac signs
          cp == 0x267F || cp == 0x2693 ||     # Wheelchair, anchor
          cp == 0x26A1 || cp == 0x26AA ||     # High voltage, circles
          cp == 0x26AB || cp == 0x26BD ||
          cp == 0x26BE || cp == 0x26C4 ||
          cp == 0x26C5 || cp == 0x26CE ||
          cp == 0x26D4 || cp == 0x26EA ||
          (cp >= 0x26F2 && cp <= 0x26F3) ||
          cp == 0x26F5 || cp == 0x26FA ||
          cp == 0x26FD ||
          cp == 0x2705 ||                            # ✅
          (cp >= 0x270A && cp <= 0x270B) ||          # ✊✋
          cp == 0x2728 ||                            # ✨
          cp == 0x274C || cp == 0x274E ||            # ❌❎
          (cp >= 0x2753 && cp <= 0x2755) ||          # ❓❔❕
          cp == 0x2757 ||                            # ❗
          (cp >= 0x2795 && cp <= 0x2797) ||          # ➕➖➗
          cp == 0x27B0 || cp == 0x27BF ||            # ➰➿
          (cp >= 0x2B1B && cp <= 0x2B1C) ||          # ⬛⬜
          cp == 0x2B50 || cp == 0x2B55 ||            # ⭐⭕
          cp == 0x3030 || cp == 0x303D ||
          cp == 0x3297 || cp == 0x3299 ||
          (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) ||
          (cp >= 0x1F000 && cp <= 0x1FFFF) ||  # All emoji blocks
          (cp >= 0x20000 && cp <= 0x2FFFF)     # CJK Extension B+
      width += 2 unless after_zwj
      last_was_narrow = false
    else
      unless after_zwj
        width += 1
        last_was_narrow = true
      else
        last_was_narrow = false
      end
    end
    prev_regional = false
    after_zwj = false
  end
  @dw_cache.clear if @dw_cache.size >= DW_CACHE_MAX
  @dw_cache[str] = width
  width
end

.display_width_unicode(str) ⇒ Object

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



181
182
183
184
185
186
187
# File 'lib/rcurses/general.rb', line 181

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.



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
73
74
75
76
77
78
79
80
81
# File 'lib/rcurses.rb', line 25

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

  # Switch to alternate screen buffer (like vim, mutt, less, htop).
  # This isolates the TUI from stray output by other processes.
  print "\e[?1049h"

  # Flush any pending stdin data (terminal position responses, etc.)
  # This prevents cursor query responses from blocking the first getchr
  $stdin.getc while $stdin.wait_readable(0)

  @initialized = true
end

.log_error_to_file(error) ⇒ Object

Private: Log error details to a PID-specific file



172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/rcurses.rb', line 172

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.2.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 StandardError
      # Fallback restoration
      begin
        STDIN.cooked! rescue nil
        STDIN.echo = true rescue nil
      rescue StandardError
      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



211
212
213
214
215
216
217
218
219
220
221
# File 'lib/rcurses.rb', line 211

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