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
-
.cleanup! ⇒ Object
Public: Restore terminal to normal mode, clear screen, show cursor.
- .clear_dw_cache ⇒ Object
- .clear_screen ⇒ Object
-
.display_error(error) ⇒ Object
Private: Display error information after terminal cleanup.
-
.display_width(str) ⇒ Object
Simple, fast display_width function (cached).
-
.display_width_unicode(str) ⇒ Object
Comprehensive Unicode display width (available but not used in performance-critical paths).
-
.init! ⇒ Object
Public: Initialize Rcurses.
-
.log_error_to_file(error) ⇒ Object
Private: Log error details to a PID-specific file.
- .restore_terminal_state ⇒ Object
-
.run(&block) ⇒ Object
Public: Run a block with proper error handling and terminal cleanup This ensures errors are displayed after terminal is restored.
- .save_terminal_state(install_handlers = false) ⇒ Object
- .setup_signal_handlers ⇒ Object
- .with_terminal_protection(install_handlers = true) ⇒ Object
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_cache ⇒ Object
65 66 67 |
# File 'lib/rcurses/general.rb', line 65 def self.clear_dw_cache @dw_cache.clear end |
.clear_screen ⇒ Object
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" = 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_state ⇒ Object
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_handlers ⇒ Object
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 |