rcurses 6.1.8 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 72260982576477690897f35d9b879ec6ebe2ce96d8ed9890eb17f4a450a3761a
4
- data.tar.gz: ede3452eb593c4a15d86a1305aa58f672fc60a8247f31788d6cc406c93cfa73c
3
+ metadata.gz: 83520fbe28d15e3132827b2487cf90c386bd94362bcd77561a6361bf34a155fc
4
+ data.tar.gz: 22be19a0d1625c0e264372dbfa9fd9031016400a3859119158a783a620e3c412
5
5
  SHA512:
6
- metadata.gz: '0686e0adcbe24f0ad7a5c3d0a5655e0edacb93aa7b6f46e71bc5665000379d8a7529d5d15ed79c1d3afb7e0d2e567c93c235fe116b1dcfadc4fb397bfb7b0360'
7
- data.tar.gz: 1657bc094efdef6af6665aec14a77d98b9b56b02eb4a6bcbdd10cfb816dfaacfc9a4b8c57f77eba0208c6df5c30909e589a52df0eeb03bff34beb3d7324bd748
6
+ metadata.gz: 523564a95aaf527d1df0f03056d6f2776f66e5d2aacb7bf70f81381d1f7cee5fc2f575cb6196126dcf990d6fde4db11d357f60dfb85112ef3f1e8fee038e9bee
7
+ data.tar.gz: 670f44738dab604557da61576ec048634efbef7d3a6adbac84bba5ace294fe2a246c7c25a438798e70012a45ab4263a311ee6abb8026943be9b4e746c068ce4a
data/README.md CHANGED
@@ -10,24 +10,20 @@ Here's a somewhat simple example of a TUI program using rcurses: The [T-REX](htt
10
10
 
11
11
  And here's a much more involved example: The [RTFM](https://github.com/isene/RTFM) terminal file manager.
12
12
 
13
- # NOTE: Version 6.1.1 adds optional error logging!
14
- - **Error logging** - Set `RCURSES_ERROR_LOG=1` to log crashes to `/tmp/rcurses_errors_PID.log`
15
- - **Race-condition free** - Uses process-specific filenames to avoid conflicts
16
- - **Stack trace preservation** - Full backtraces logged even when screen is cleared
17
- - **Completely opt-in** - Zero impact unless explicitly enabled
18
- - **Full backward compatibility** - All existing applications work unchanged
13
+ # What's new in 6.2.0
19
14
 
20
- Previous improvements in 6.1.0:
21
- - **Safe regex substitution** - New `safe_gsub` methods prevent ANSI code corruption
22
- - **ANSI detection** - Check if strings contain ANSI codes with `has_ansi?`
23
- - **Visible length calculation** - Get true text length with `visible_length`
24
- - **Conditional coloring** - Apply colors only when needed with `safe_fg`/`safe_bg`
15
+ - **Popup widget** - New `Rcurses::Popup` class for modal dialogs with auto-centering, border, scrolling, and built-in key handling (see Popup section below)
16
+ - **Built-in emoji picker** - Panes with `emoji = true` get a `Ctrl-E` shortcut in editline that opens an emoji overlay with category tabs, search, and cursor-positioned grid rendering
17
+ - **Pane color cache invalidation** - Changing `fg` or `bg` on a Pane now forces a full repaint automatically (no more stale diff-rendering artifacts)
18
+ - **Startup stdin flush** - `Rcurses.init!` now flushes pending stdin data (cursor position responses) to prevent first-keypress blocking
19
+ - **Pane update flag** - Set `pane.update = false` to skip rendering during batch operations
20
+ - **Wider ESC timeout** - Escape sequence detection timeout increased from 50ms to 150ms for better terminal compatibility
21
+ - **Unicode display_width fixes** - Improved handling of FE0F variation selectors, ZWJ sequences, and dingbat characters
22
+ - **Editline improvements** - Unicode-aware padding, content truncation, multiline paste joins all lines
25
23
 
26
- Previous major improvements in 5.0.0:
27
- - Memory leak fixes, terminal state protection, enhanced Unicode support
28
- - Error handling improvements and performance optimizations
24
+ <img src="img/rcurses-emoji.png" width="400">
29
25
 
30
- Version 4.5 gave full RGB support in addition to 256-colors. Just write a color as a string - e.g. `"d533e0"` for a hexadecimal RGB color (or use the terminal 256 colors by supplying an integer in the range 0-255)
26
+ Previous notable versions: 6.1.1 (error logging), 6.1.0 (safe ANSI methods), 6.0.0 (explicit init!), 5.0.0 (memory fixes), 4.5 (RGB colors)
31
27
 
32
28
  # Why?
33
29
  Having struggled with the venerable curses library and the ruby interface to it for many years, I finally got around to write an alternative - in pure Ruby.
@@ -96,6 +92,9 @@ align | Text alignment in the Pane: "l" = lefts aligned, "c" = center,
96
92
  prompt | The prompt to print at the beginning of a one-liner Pane used as an input box
97
93
  moreup | Set to true when there is more text above what is shown (top scroll bar i showing)
98
94
  moredown | Set to true when there is more text below what is shown (bottom scroll bar i showing)
95
+ update | When false, refresh is a no-op (default: true). Useful during batch operations
96
+ emoji | When true, Ctrl-E in editline opens the built-in emoji picker (default: false)
97
+ emoji_refresh | Optional lambda/proc called after emoji picker closes, to redraw the UI
99
98
 
100
99
  The methods for Pane:
101
100
 
@@ -119,6 +118,57 @@ lineup | Scroll up one line in the text
119
118
  bottom | Scroll to the bottom of the text in the pane
120
119
  top | Scroll to the top of the text in the pane
121
120
 
121
+ # class Popup
122
+
123
+ A reusable popup overlay widget for modal dialogs, selection menus, and informational displays. Extends `Pane` with auto-centering, built-in scrolling, and keyboard handling.
124
+
125
+ ```ruby
126
+ # Auto-centered popup with default size
127
+ popup = Rcurses::Popup.new(w: 50, h: 20)
128
+
129
+ # Explicit position
130
+ popup = Rcurses::Popup.new(x: 10, y: 5, w: 50, h: 20, fg: 255, bg: 236)
131
+ ```
132
+
133
+ **Modal usage** (blocks until ESC or ENTER):
134
+ ```ruby
135
+ result = popup.modal(content_string)
136
+ # Returns selected line index on ENTER, or nil on ESC
137
+ # Built-in: UP/DOWN/PgUP/PgDN/HOME/END for scrolling
138
+ ```
139
+
140
+ **Custom key handling**:
141
+ ```ruby
142
+ result = popup.modal(content) do |chr, index|
143
+ case chr
144
+ when 'd'
145
+ delete_item(index)
146
+ :dismiss # Close popup
147
+ when 'e'
148
+ "edit:#{index}" # Return custom value
149
+ end
150
+ end
151
+ ```
152
+
153
+ **Manual control** (non-blocking):
154
+ ```ruby
155
+ popup.show(content_string)
156
+ # ... your own input loop ...
157
+ popup.dismiss(refresh_panes: [pane1, pane2]) # Clears area, refreshes underlying panes
158
+ ```
159
+
160
+ # Emoji Picker
161
+
162
+ Panes can opt in to a built-in emoji picker that opens with `Ctrl-E` during editline:
163
+
164
+ ```ruby
165
+ pane.emoji = true # Enable Ctrl-E shortcut
166
+ pane.emoji_refresh = -> { render } # Optional: redraw UI after picker closes
167
+ pane.editline # Ctrl-E now opens emoji overlay
168
+ ```
169
+
170
+ The picker features category tabs (Tab/Shift-Tab), arrow navigation, search-by-keyword, and cursor-positioned grid rendering for pixel-perfect alignment regardless of Unicode width variations.
171
+
122
172
  # class String extensions
123
173
  Method extensions provided for the class String.
124
174
 
@@ -366,7 +416,12 @@ end
366
416
  **Problem:** `wait_readable` method not found.
367
417
  **Solution:** Always include `require 'io/wait'` for stdin flush.
368
418
 
369
- ### 5. Border Changes Not Visible
419
+ ### 5. `String#b` Overrides Ruby's Built-in Binary Method
420
+ **Problem:** rcurses defines `String#b` for bold formatting, but Ruby's built-in `String#b` returns a binary-encoded copy of the string. Any code using `''.b` or `str.b` for binary I/O will silently get ANSI bold codes instead.
421
+ **Impact:** Binary protocol parsers, socket readers, and encoding-sensitive code will break when rcurses is loaded.
422
+ **Workaround:** Use `String.new(encoding: 'ASCII-8BIT')` instead of `''.b` when you need binary strings in code that coexists with rcurses.
423
+
424
+ ### 6. Border Changes Not Visible
370
425
  **Problem:** Changing pane border property doesn't show visual changes.
371
426
  **Cause:** Border changes require explicit refresh to be displayed.
372
427
  **Solution:** Use `border_refresh` method after changing border property.
@@ -405,6 +460,16 @@ And - try running the example file `rcurses_example.rb`.
405
460
 
406
461
  # Version History
407
462
 
463
+ ## v6.2.0
464
+ - New `Popup` class for modal dialogs with auto-centering, border, scrolling, and key handling
465
+ - Built-in emoji picker with category tabs, search, and cursor-positioned grid rendering
466
+ - Pane `fg`/`bg` setters now invalidate diff-rendering cache (fixes color change artifacts)
467
+ - `Rcurses.init!` flushes pending stdin data to prevent startup keypress blocking
468
+ - New pane attributes: `update` (skip rendering), `emoji` (enable Ctrl-E picker), `emoji_refresh`
469
+ - ESC sequence timeout widened from 50ms to 150ms for better terminal compatibility
470
+ - Unicode `display_width` fixes: FE0F/FE0E treated as zero-width, improved ZWJ promotion
471
+ - Editline: Unicode-aware padding, content truncation, multiline paste joins all lines
472
+
408
473
  ## v6.1.8
409
474
  - Added `scroll_fg` pane attribute for custom scroll indicator (∆/∇) color
410
475
  - When set, scroll markers use this color instead of the pane's foreground color
Binary file
Binary file
@@ -0,0 +1,293 @@
1
+ module Rcurses
2
+ module EmojiPicker
3
+ extend Rcurses::Input
4
+
5
+ CATEGORIES = {
6
+ "Smileys" => [
7
+ ["😀","grin"],["😃","smiley"],["😄","smile"],["😁","beaming"],["😆","laughing"],
8
+ ["😅","sweat smile"],["🤣","rofl"],["😂","joy"],["🙂","slight smile"],["😉","wink"],
9
+ ["😊","blush"],["😇","innocent"],["🥰","love face"],["😍","heart eyes"],["🤩","starstruck"],
10
+ ["😘","kiss"],["😗","kissing"],["😚","kiss closed"],["😙","kiss smile"],["🥲","happy tear"],
11
+ ["😋","yum"],["😛","tongue"],["😜","tongue wink"],["🤪","zany"],["😝","tongue closed"],
12
+ ["🤑","money"],["🤗","hug"],["🤭","hand mouth"],["🤫","shush"],["🤔","thinking"],
13
+ ["🫡","salute"],["🤐","zipper"],["🤨","raised brow"],["😐","neutral"],["😑","expressionless"],
14
+ ["😶","no mouth"],["🫥","dotted face"],["😏","smirk"],["😒","unamused"],["🙄","roll eyes"],
15
+ ["😬","grimace"],["😮‍💨","exhale"],["🤥","liar"],["😌","relieved"],["😔","pensive"],
16
+ ["😪","sleepy"],["🤤","drool"],["😴","sleep"],["😷","mask"],["🤒","sick"],
17
+ ["🤕","bandage"],["🤢","nausea"],["🤮","vomit"],["🥵","hot"],["🥶","cold"],
18
+ ["🥴","woozy"],["😵","dizzy"],["🤯","exploding"],["🤠","cowboy"],["🥳","party"],
19
+ ["🥸","disguise"],["😎","cool"],["🤓","nerd"],["🧐","monocle"],["😕","confused"],
20
+ ["🫤","diagonal mouth"],["😟","worried"],["🙁","frown"],["😮","open mouth"],["😯","hushed"],
21
+ ["😲","astonished"],["😳","flushed"],["🥺","pleading"],["🥹","holding tears"],["😦","frowning"],
22
+ ["😧","anguished"],["😨","fearful"],["😰","anxious"],["😥","sad relief"],["😢","cry"],
23
+ ["😭","sob"],["😱","scream"],["😖","confounded"],["😣","persevere"],["😞","disappointed"],
24
+ ["😓","downcast"],["😩","weary"],["😫","tired"],["🥱","yawn"],["😤","triumph"],
25
+ ["😡","rage"],["😠","angry"],["🤬","swearing"],["😈","devil smile"],["👿","devil"],
26
+ ["💀","skull"],["☠️","crossbones"],["💩","poop"],["🤡","clown"],["👹","ogre"],
27
+ ["👻","ghost"],["👽","alien"],["🤖","robot"],["💋","kiss mark"],["❤️","heart"],
28
+ ["🧡","orange heart"],["💛","yellow heart"],["💚","green heart"],["💙","blue heart"],
29
+ ["💜","purple heart"],["🖤","black heart"],["🤍","white heart"],["🤎","brown heart"],
30
+ ["💔","broken heart"],["❤️‍🔥","fire heart"],["💕","two hearts"],["💞","revolving hearts"],
31
+ ["💓","heartbeat"],["💗","growing heart"],["💖","sparkling heart"],["💘","cupid"],
32
+ ["💝","ribbon heart"],["💟","heart decoration"],["🫶","heart hands"]
33
+ ],
34
+ "People" => [
35
+ ["👋","wave"],["🤚","raised back"],["🖐️","splayed hand"],["✋","raised hand"],
36
+ ["🖖","vulcan"],["🫱","right hand"],["🫲","left hand"],["🫳","palm down"],["🫴","palm up"],
37
+ ["👌","ok"],["🤌","pinched"],["🤏","pinch"],["✌️","peace"],["🤞","crossed fingers"],
38
+ ["🫰","index thumb"],["🤟","love you"],["🤘","rock"],["🤙","call me"],
39
+ ["👈","left point"],["👉","right point"],["👆","up point"],["🖕","middle finger"],
40
+ ["👇","down point"],["☝️","index up"],["🫵","point at viewer"],["👍","thumbsup"],
41
+ ["👎","thumbsdown"],["✊","fist"],["👊","punch"],["🤛","left fist"],["🤜","right fist"],
42
+ ["👏","clap"],["🙌","raised hands"],["🫶","heart hands"],["👐","open hands"],
43
+ ["🤲","palms up"],["🤝","handshake"],["🙏","pray"],["✍️","writing"],["💪","muscle"],
44
+ ["🦾","mechanical arm"],["🧠","brain"],["👀","eyes"],["👁️","eye"],["👅","tongue"],
45
+ ["👄","lips"],["🫦","biting lip"],["👶","baby"],["🧒","child"],["👦","boy"],
46
+ ["👧","girl"],["🧑","person"],["👱","blond"],["👨","man"],["🧔","beard"],
47
+ ["👩","woman"],["🧓","older person"],["👴","old man"],["👵","old woman"]
48
+ ],
49
+ "Animals" => [
50
+ ["🐶","dog"],["🐱","cat"],["🐭","mouse"],["🐹","hamster"],["🐰","rabbit"],
51
+ ["🦊","fox"],["🐻","bear"],["🐼","panda"],["🐻‍❄️","polar bear"],["🐨","koala"],
52
+ ["🐯","tiger"],["🦁","lion"],["🐮","cow"],["🐷","pig"],["🐸","frog"],
53
+ ["🐵","monkey"],["🐔","chicken"],["🐧","penguin"],["🐦","bird"],["🐤","chick"],
54
+ ["🦆","duck"],["🦅","eagle"],["🦉","owl"],["🦇","bat"],["🐺","wolf"],
55
+ ["🐗","boar"],["🐴","horse"],["🦄","unicorn"],["🐝","bee"],["🐛","bug"],
56
+ ["🦋","butterfly"],["🐌","snail"],["🐞","ladybug"],["🐜","ant"],["🪲","beetle"],
57
+ ["🐢","turtle"],["🐍","snake"],["🦎","lizard"],["🦂","scorpion"],["🐙","octopus"],
58
+ ["🦑","squid"],["🐠","fish"],["🐟","tropical fish"],["🐡","blowfish"],["🐬","dolphin"],
59
+ ["🐳","whale"],["🐋","humpback"],["🦈","shark"],["🐊","crocodile"],["🐅","tiger2"],
60
+ ["🐆","leopard"],["🦓","zebra"],["🦍","gorilla"],["🦧","orangutan"],["🐘","elephant"],
61
+ ["🦛","hippo"],["🦏","rhino"],["🐪","camel"],["🐫","two hump camel"],["🦒","giraffe"],
62
+ ["🦘","kangaroo"],["🦬","bison"],["🐃","water buffalo"],["🐂","ox"],["🐄","dairy cow"]
63
+ ],
64
+ "Food" => [
65
+ ["🍎","apple"],["🍐","pear"],["🍊","orange"],["🍋","lemon"],["🍌","banana"],
66
+ ["🍉","watermelon"],["🍇","grapes"],["🍓","strawberry"],["🫐","blueberry"],["🍈","melon"],
67
+ ["🍒","cherry"],["🍑","peach"],["🥭","mango"],["🍍","pineapple"],["🥥","coconut"],
68
+ ["🥝","kiwi"],["🍅","tomato"],["🥑","avocado"],["🌽","corn"],["🌶️","pepper"],
69
+ ["🥒","cucumber"],["🥕","carrot"],["🧄","garlic"],["🧅","onion"],["🥔","potato"],
70
+ ["🍞","bread"],["🥐","croissant"],["🥖","baguette"],["🧀","cheese"],["🥚","egg"],
71
+ ["🍳","cooking"],["🥞","pancakes"],["🧇","waffle"],["🥓","bacon"],["🍔","burger"],
72
+ ["🍟","fries"],["🍕","pizza"],["🌭","hotdog"],["🥪","sandwich"],["🌮","taco"],
73
+ ["🌯","burrito"],["🫔","tamale"],["🥗","salad"],["🍝","spaghetti"],["🍜","ramen"],
74
+ ["🍲","stew"],["🍛","curry"],["🍣","sushi"],["🍱","bento"],["🥟","dumpling"],
75
+ ["🍩","donut"],["🍪","cookie"],["🎂","cake"],["🍰","shortcake"],["🧁","cupcake"],
76
+ ["🍫","chocolate"],["🍬","candy"],["🍭","lollipop"],["🍮","custard"],["🍯","honey"],
77
+ ["☕","coffee"],["🍵","tea"],["🧃","juice box"],["🍺","beer"],["🍻","cheers"],
78
+ ["🥂","champagne"],["🍷","wine"],["🍸","cocktail"],["🍹","tropical drink"],["🧊","ice"]
79
+ ],
80
+ "Travel" => [
81
+ ["🏠","house"],["🏡","garden house"],["🏢","office"],["🏥","hospital"],["🏦","bank"],
82
+ ["🏨","hotel"],["🏪","store"],["🏫","school"],["🏬","department store"],["🏛️","classical"],
83
+ ["⛪","church"],["🕌","mosque"],["🕍","synagogue"],["⛩️","shinto"],["🕋","kaaba"],
84
+ ["⛲","fountain"],["🏕️","camping"],["🏖️","beach"],["🏜️","desert"],["🏝️","island"],
85
+ ["🏔️","mountain"],["⛰️","mountain2"],["🌋","volcano"],["🗻","fuji"],["🗼","tokyo tower"],
86
+ ["🗽","statue liberty"],["✈️","airplane"],["🛩️","small plane"],["🚀","rocket"],
87
+ ["🛸","ufo"],["🚁","helicopter"],["🚂","train"],["🚗","car"],["🚕","taxi"],
88
+ ["🚌","bus"],["🚲","bike"],["🛵","scooter"],["🚤","speedboat"],["⛵","sailboat"],
89
+ ["🚢","ship"],["⚓","anchor"],["🗺️","world map"],["🧭","compass"],["🌍","earth"],
90
+ ["🌎","earth americas"],["🌏","earth asia"],["🌙","moon"],["⭐","star"],["🌟","glowing star"],
91
+ ["☀️","sun"],["🌤️","partly sunny"],["⛅","cloudy"],["🌧️","rain"],["⛈️","storm"],
92
+ ["🌈","rainbow"],["☔","umbrella rain"],["❄️","snowflake"],["⛄","snowman"],["🔥","fire"],
93
+ ["💧","drop"],["🌊","wave"]
94
+ ],
95
+ "Objects" => [
96
+ ["⌚","watch"],["📱","phone"],["💻","laptop"],["⌨️","keyboard"],["🖥️","desktop"],
97
+ ["🖨️","printer"],["🖱️","mouse"],["💾","floppy"],["💿","cd"],["📷","camera"],
98
+ ["🎥","movie camera"],["📺","tv"],["📻","radio"],["🔔","bell"],["🔕","no bell"],
99
+ ["📢","megaphone"],["📣","horn"],["⏰","alarm"],["⏳","hourglass"],["🔋","battery"],
100
+ ["🔌","plug"],["💡","bulb"],["🔦","flashlight"],["🕯️","candle"],["🗑️","trash"],
101
+ ["🔑","key"],["🗝️","old key"],["🔒","lock"],["🔓","unlock"],["🔨","hammer"],
102
+ ["🪓","axe"],["⛏️","pick"],["🔧","wrench"],["🔩","nut bolt"],["⚙️","gear"],
103
+ ["📎","paperclip"],["✂️","scissors"],["📌","pin"],["📍","round pin"],["🖊️","pen"],
104
+ ["✏️","pencil"],["📝","memo"],["📁","folder"],["📂","open folder"],["📅","calendar"],
105
+ ["📊","chart"],["📈","chart up"],["📉","chart down"],["🗂️","card index"],
106
+ ["💰","money bag"],["💵","dollar"],["💴","yen"],["💶","euro"],["💷","pound"],
107
+ ["💎","gem"],["⚖️","scales"],["🧰","toolbox"],["🧲","magnet"],["🔗","link"],
108
+ ["📦","package"],["📫","mailbox"],["📬","mailbox flag"],["📧","email"],["📩","envelope arrow"],
109
+ ["🎁","gift"],["🎀","ribbon"],["🏷️","label"],["🔖","bookmark"]
110
+ ],
111
+ "Symbols" => [
112
+ ["✅","check"],["❌","cross"],["❓","question"],["❗","exclamation"],["‼️","double excl"],
113
+ ["⭕","circle"],["🚫","prohibited"],["🔴","red circle"],["🟠","orange circle"],
114
+ ["🟡","yellow circle"],["🟢","green circle"],["🔵","blue circle"],["🟣","purple circle"],
115
+ ["⚫","black circle"],["⚪","white circle"],["🟥","red square"],["🟧","orange square"],
116
+ ["🟨","yellow square"],["🟩","green square"],["🟦","blue square"],["🟪","purple square"],
117
+ ["⬛","black square"],["⬜","white square"],["🔶","orange diamond"],["🔷","blue diamond"],
118
+ ["🔺","red triangle"],["🔻","red triangle down"],["💠","diamond dot"],["🔘","radio button"],
119
+ ["✨","sparkles"],["⚡","zap"],["💥","boom"],["🎵","music"],["🎶","notes"],
120
+ ["➡️","right arrow"],["⬅️","left arrow"],["⬆️","up arrow"],["⬇️","down arrow"],
121
+ ["↗️","upper right"],["↘️","lower right"],["↙️","lower left"],["↖️","upper left"],
122
+ ["🔀","shuffle"],["🔁","repeat"],["🔂","repeat one"],["▶️","play"],["⏸️","pause"],
123
+ ["⏹️","stop"],["⏺️","record"],["⏭️","next track"],["⏮️","prev track"],
124
+ ["🔊","loud"],["🔇","mute"],["🔔","bell2"],["📌","pushpin"],
125
+ ["♻️","recycle"],["🏁","checkered flag"],["🚩","flag"],["🏳️","white flag"],
126
+ ["🏴","black flag"],["⚠️","warning"],["🛑","stop sign"],["⛔","no entry"],
127
+ ["♾️","infinity"],["💯","hundred"],["🆗","ok button"],["🆕","new"],["🆓","free"],
128
+ ["ℹ️","info"],["🔤","abc"],["🔢","numbers"],["#️⃣","hash"],["*️⃣","asterisk"],
129
+ ["0️⃣","zero"],["1️⃣","one"],["2️⃣","two"],["3️⃣","three"],["©️","copyright"],
130
+ ["®️","registered"],["™️","trademark"]
131
+ ],
132
+ "Flags" => [
133
+ ["🇳🇴","norway"],["🇸🇪","sweden"],["🇩🇰","denmark"],["🇫🇮","finland"],["🇮🇸","iceland"],
134
+ ["🇬🇧","uk"],["🇺🇸","usa"],["🇨🇦","canada"],["🇦🇺","australia"],["🇳🇿","new zealand"],
135
+ ["🇩🇪","germany"],["🇫🇷","france"],["🇪🇸","spain"],["🇮🇹","italy"],["🇵🇹","portugal"],
136
+ ["🇳🇱","netherlands"],["🇧🇪","belgium"],["🇨🇭","switzerland"],["🇦🇹","austria"],
137
+ ["🇵🇱","poland"],["🇨🇿","czech"],["🇬🇷","greece"],["🇹🇷","turkey"],["🇷🇺","russia"],
138
+ ["🇺🇦","ukraine"],["🇯🇵","japan"],["🇰🇷","south korea"],["🇨🇳","china"],["🇮🇳","india"],
139
+ ["🇧🇷","brazil"],["🇲🇽","mexico"],["🇦🇷","argentina"],["🇿🇦","south africa"],
140
+ ["🇪🇬","egypt"],["🇮🇱","israel"],["🇸🇦","saudi"],["🇦🇪","uae"],["🇹🇭","thailand"],
141
+ ["🇻🇳","vietnam"],["🇮🇩","indonesia"],["🇵🇭","philippines"],["🇲🇾","malaysia"],
142
+ ["🇸🇬","singapore"],["🇭🇰","hong kong"],["🇹🇼","taiwan"],["🏳️‍🌈","rainbow flag"],
143
+ ["🏴‍☠️","pirate flag"],["🎌","crossed flags"],["🏁","checkered"]
144
+ ]
145
+ }.freeze
146
+
147
+ CELL_W = 4 # Each emoji cell = 4 terminal columns (space + emoji + space)
148
+
149
+ # Main entry point: opens picker overlay, returns emoji string or nil
150
+ def self.pick(parent_pane)
151
+ max_h, max_w = IO.console ? IO.console.winsize : [24, 80]
152
+
153
+ cols = 11
154
+ ow = cols * CELL_W + 4
155
+ oh = [max_h - 4, 22].min
156
+ return nil if oh < 8
157
+
158
+ ox = (max_w - ow) / 2 + 1
159
+ oy = (max_h - oh) / 2 + 1
160
+
161
+ overlay = Rcurses::Popup.new(x: ox, y: oy, w: ow, h: oh, fg: 255, bg: 236)
162
+ overlay.scroll = false
163
+
164
+ categories = CATEGORIES.keys
165
+ cat_idx = 0
166
+ sel_idx = 0
167
+ search = ""
168
+
169
+ fmt = "255,236"
170
+
171
+ begin
172
+ loop do
173
+ # Determine items for current category or search
174
+ if search.empty?
175
+ items = CATEGORIES[categories[cat_idx]]
176
+ else
177
+ items = CATEGORIES.values.flatten(1).select { |_e, k|
178
+ k.include?(search.downcase)
179
+ }
180
+ end
181
+
182
+ total = items.size
183
+ sel_idx = sel_idx.clamp(0, [total - 1, 0].max)
184
+
185
+ # === Build pane text (header + blank grid area + footer) ===
186
+ lines = []
187
+
188
+ # Header: category tabs (wrapped to fit)
189
+ header_lines = 0
190
+ if search.empty?
191
+ tab_rows = [[]]
192
+ row_w = 0
193
+ categories.each_with_index do |c, i|
194
+ tab = " #{c} "
195
+ tw = tab.length
196
+ if row_w + tw > ow && !tab_rows.last.empty?
197
+ tab_rows << []
198
+ row_w = 0
199
+ end
200
+ tab_rows.last << (i == cat_idx ? tab.b.r : tab)
201
+ row_w += tw
202
+ end
203
+ tab_rows.each { |tr| lines << tr.join("") }
204
+ header_lines = tab_rows.size
205
+ else
206
+ lines << " Search: #{search}".b
207
+ header_lines = 1
208
+ end
209
+ lines << ""
210
+ header_lines += 1 # blank separator
211
+
212
+ # Blank lines for grid area (pane fills bg color)
213
+ grid_rows = (total + cols - 1) / cols
214
+ grid_rows.times { lines << "" }
215
+
216
+ # Pad remaining space
217
+ while lines.size < oh - 3
218
+ lines << ""
219
+ end
220
+
221
+ # Footer
222
+ lines << ""
223
+ if total > 0 && items[sel_idx]
224
+ lines << " :#{items[sel_idx][1]}:".fg(245)
225
+ else
226
+ lines << ""
227
+ end
228
+
229
+ overlay.text = lines.join("\n")
230
+ overlay.ix = 0
231
+ overlay.full_refresh
232
+ overlay.border_refresh
233
+
234
+ # === Render emoji grid at explicit cursor positions ===
235
+ # This bypasses display_width entirely for grid alignment
236
+ flat_i = 0
237
+ items.each_slice(cols) do |row|
238
+ grid_row = flat_i / cols
239
+ abs_row = oy + header_lines + grid_row
240
+ row.each_with_index do |(emoji, _keyword), col_i|
241
+ abs_col = ox + col_i * CELL_W
242
+ STDOUT.print "\e[#{abs_row};#{abs_col}H"
243
+ if flat_i == sel_idx
244
+ STDOUT.print " #{emoji} ".c("236,255") # Inverted colors
245
+ else
246
+ STDOUT.print " #{emoji} ".c(fmt)
247
+ end
248
+ flat_i += 1
249
+ end
250
+ end
251
+ STDOUT.flush
252
+
253
+ # Input
254
+ chr = getchr(flush: false)
255
+ case chr
256
+ when 'ESC'
257
+ return nil
258
+ when 'ENTER'
259
+ return (total > 0 && items[sel_idx]) ? items[sel_idx][0] : nil
260
+ when 'RIGHT'
261
+ sel_idx = [sel_idx + 1, total - 1].min if total > 0
262
+ when 'LEFT'
263
+ sel_idx = [sel_idx - 1, 0].max
264
+ when 'UP'
265
+ sel_idx = [sel_idx - cols, 0].max
266
+ when 'DOWN'
267
+ sel_idx = [sel_idx + cols, total - 1].min if total > 0
268
+ when 'TAB'
269
+ if search.empty?
270
+ cat_idx = (cat_idx + 1) % categories.size
271
+ sel_idx = 0
272
+ end
273
+ when 'S-TAB'
274
+ if search.empty?
275
+ cat_idx = (cat_idx - 1) % categories.size
276
+ sel_idx = 0
277
+ end
278
+ when 'BACK'
279
+ if search.length > 0
280
+ search = search[0..-2]
281
+ sel_idx = 0
282
+ end
283
+ when /^.$/
284
+ search << chr
285
+ sel_idx = 0
286
+ end
287
+ end
288
+ ensure
289
+ overlay.cleanup if overlay.respond_to?(:cleanup)
290
+ end
291
+ end
292
+ end
293
+ end
@@ -58,32 +58,109 @@ module Rcurses
58
58
  end
59
59
  end
60
60
 
61
- # Simple, fast display_width function (like original 4.8.3)
61
+ # Simple, fast display_width function
62
62
  def self.display_width(str)
63
63
  return 0 if str.nil? || str.empty?
64
-
64
+
65
65
  width = 0
66
+ prev_regional = false
67
+ after_zwj = false # Next visible char is joined (zero-width)
68
+ last_was_narrow = false # Previous visible char was 1-wide (for ZWJ promotion)
66
69
  str.each_char do |char|
67
70
  cp = char.ord
68
- if cp == 0
69
- # NUL no width
70
- elsif cp < 32 || (cp >= 0x7F && cp < 0xA0)
71
- # Control characters: no width
72
- width += 0
73
- # Approximate common wide ranges:
71
+
72
+ # Variation selectors: always zero-width
73
+ # FE0F/FE0E behavior is terminal-dependent; treating as zero-width
74
+ # matches libc wcwidth and avoids grid misalignment.
75
+ next if cp == 0xFE0F || cp == 0xFE0E
76
+
77
+ # Zero-width: skin tones, tags, combining marks, keycaps
78
+ if (cp >= 0x1F3FB && cp <= 0x1F3FF) || # Skin tone modifiers
79
+ (cp >= 0xE0020 && cp <= 0xE007F) || # Tag characters (flag subdivisions)
80
+ cp == 0xE0001 || cp == 0x200C || # Other zero-width
81
+ (cp >= 0x20D0 && cp <= 0x20FF) || # Combining diacritical marks for symbols
82
+ cp == 0x20E3 # Combining enclosing keycap
83
+ next
84
+ end
85
+
86
+ # ZWJ: next visible character merges into current glyph
87
+ if cp == 0x200D
88
+ # ZWJ sequences always render as 2-wide emoji.
89
+ # If the base character was narrow (e.g. ❤ in ❤️‍🔥), promote to 2.
90
+ if last_was_narrow
91
+ width += 1
92
+ last_was_narrow = false
93
+ end
94
+ after_zwj = true
95
+ next
96
+ end
97
+
98
+ if cp == 0 || cp < 32 || (cp >= 0x7F && cp < 0xA0)
99
+ # NUL and control characters: no width
100
+ last_was_narrow = false
101
+ # Regional indicator symbols (flags): pair = one 2-wide glyph
102
+ elsif cp >= 0x1F1E6 && cp <= 0x1F1FF
103
+ if prev_regional
104
+ prev_regional = false
105
+ after_zwj = false
106
+ next
107
+ else
108
+ prev_regional = true
109
+ width += 2 unless after_zwj
110
+ after_zwj = false
111
+ next
112
+ end
113
+ # Wide character ranges (CJK, emoji, etc):
74
114
  elsif (cp >= 0x1100 && cp <= 0x115F) ||
75
115
  cp == 0x2329 || cp == 0x232A ||
116
+ (cp >= 0x231A && cp <= 0x231B) || # Watch, hourglass
117
+ (cp >= 0x23E9 && cp <= 0x23F3) || # Various symbols
118
+ (cp >= 0x23F8 && cp <= 0x23FA) || # Play/pause symbols
119
+ (cp >= 0x25FD && cp <= 0x25FE) || # Medium squares
120
+ (cp >= 0x2614 && cp <= 0x2615) || # Umbrella, hot beverage
121
+ (cp >= 0x2648 && cp <= 0x2653) || # Zodiac signs
122
+ cp == 0x267F || cp == 0x2693 || # Wheelchair, anchor
123
+ cp == 0x26A1 || cp == 0x26AA || # High voltage, circles
124
+ cp == 0x26AB || cp == 0x26BD ||
125
+ cp == 0x26BE || cp == 0x26C4 ||
126
+ cp == 0x26C5 || cp == 0x26CE ||
127
+ cp == 0x26D4 || cp == 0x26EA ||
128
+ (cp >= 0x26F2 && cp <= 0x26F3) ||
129
+ cp == 0x26F5 || cp == 0x26FA ||
130
+ cp == 0x26FD ||
131
+ cp == 0x2705 || # ✅
132
+ (cp >= 0x270A && cp <= 0x270B) || # ✊✋
133
+ cp == 0x2728 || # ✨
134
+ cp == 0x274C || cp == 0x274E || # ❌❎
135
+ (cp >= 0x2753 && cp <= 0x2755) || # ❓❔❕
136
+ cp == 0x2757 || # ❗
137
+ (cp >= 0x2795 && cp <= 0x2797) || # ➕➖➗
138
+ cp == 0x27B0 || cp == 0x27BF || # ➰➿
139
+ (cp >= 0x2B1B && cp <= 0x2B1C) || # ⬛⬜
140
+ cp == 0x2B50 || cp == 0x2B55 || # ⭐⭕
141
+ cp == 0x3030 || cp == 0x303D ||
142
+ cp == 0x3297 || cp == 0x3299 ||
76
143
  (cp >= 0x2E80 && cp <= 0xA4CF) ||
77
144
  (cp >= 0xAC00 && cp <= 0xD7A3) ||
78
145
  (cp >= 0xF900 && cp <= 0xFAFF) ||
79
146
  (cp >= 0xFE10 && cp <= 0xFE19) ||
80
147
  (cp >= 0xFE30 && cp <= 0xFE6F) ||
81
148
  (cp >= 0xFF00 && cp <= 0xFF60) ||
82
- (cp >= 0xFFE0 && cp <= 0xFFE6)
83
- width += 2
149
+ (cp >= 0xFFE0 && cp <= 0xFFE6) ||
150
+ (cp >= 0x1F000 && cp <= 0x1FFFF) || # All emoji blocks
151
+ (cp >= 0x20000 && cp <= 0x2FFFF) # CJK Extension B+
152
+ width += 2 unless after_zwj
153
+ last_was_narrow = false
84
154
  else
85
- width += 1
155
+ unless after_zwj
156
+ width += 1
157
+ last_was_narrow = true
158
+ else
159
+ last_was_narrow = false
160
+ end
86
161
  end
162
+ prev_regional = false
163
+ after_zwj = false
87
164
  end
88
165
  width
89
166
  end
data/lib/rcurses/input.rb CHANGED
@@ -13,7 +13,7 @@ module Rcurses
13
13
  # 2) If it's ESC, grab any quick trailing bytes
14
14
  seq = c
15
15
  if c == "\e"
16
- if IO.select([$stdin], nil, nil, 0.05)
16
+ if IO.select([$stdin], nil, nil, 0.15)
17
17
  begin
18
18
  seq << $stdin.read_nonblock(16)
19
19
  rescue IO::WaitReadable, EOFError
data/lib/rcurses/pane.rb CHANGED
@@ -3,11 +3,15 @@ module Rcurses
3
3
  require 'clipboard' # Ensure the 'clipboard' gem is installed
4
4
  include Cursor
5
5
  include Input
6
- attr_accessor :x, :y, :w, :h, :fg, :bg, :scroll_fg
6
+ attr_accessor :x, :y, :w, :h, :scroll_fg
7
+ attr_reader :fg, :bg
7
8
  attr_accessor :border, :scroll, :text, :ix, :index, :align, :prompt
8
9
  attr_accessor :moreup, :moredown
9
10
  attr_accessor :record, :history
10
11
  attr_accessor :multiline_buffer
12
+ attr_accessor :update
13
+ attr_accessor :emoji
14
+ attr_accessor :emoji_refresh # Optional callback to redraw UI after emoji picker closes
11
15
 
12
16
  def initialize(x = 1, y = 1, w = 1, h = 1, fg = nil, bg = nil)
13
17
  @max_h, @max_w = IO.console ? IO.console.winsize : [24, 80]
@@ -29,10 +33,27 @@ module Rcurses
29
33
  @max_history_size = 100 # Limit history to prevent memory leaks
30
34
  @scroll_fg = nil # Scroll indicator color (defaults to @fg)
31
35
  @multiline_buffer = [] # Buffer to store lines from multi-line paste
36
+ @update = true # When false, refresh is a no-op
37
+ @emoji = false # Opt-in: C-E opens emoji picker in editline
38
+ @emoji_refresh = nil # Optional lambda to redraw UI after emoji picker
32
39
 
33
40
  ObjectSpace.define_finalizer(self, self.class.finalizer_proc)
34
41
  end
35
42
 
43
+ def fg=(value)
44
+ if @fg != value
45
+ @fg = value
46
+ @prev_frame = nil # Force full repaint on color change
47
+ end
48
+ end
49
+
50
+ def bg=(value)
51
+ if @bg != value
52
+ @bg = value
53
+ @prev_frame = nil # Force full repaint on color change
54
+ end
55
+ end
56
+
36
57
  def text=(new_text)
37
58
  if @record && @text
38
59
  @history << @text
@@ -176,6 +197,7 @@ module Rcurses
176
197
  # Diff-based refresh that minimizes flicker.
177
198
  # In this updated version we lazily process only the raw lines required to fill the pane.
178
199
  def refresh(cont = @text)
200
+ return @prev_frame&.join("\n") || "" unless @update
179
201
  begin
180
202
  @max_h, @max_w = IO.console.winsize
181
203
  rescue => e
@@ -253,7 +275,7 @@ module Rcurses
253
275
 
254
276
  raw_line = @raw_txt[@lazy_index]
255
277
  # If the raw line is short, no wrapping is needed.
256
- if raw_line.respond_to?(:pure) && Rcurses.display_width(raw_line.pure) < @w
278
+ if raw_line.respond_to?(:pure) && Rcurses.display_width(raw_line.pure) <= @w
257
279
  processed = [raw_line]
258
280
  else
259
281
  processed = split_line_with_ansi(raw_line, @w)
@@ -344,19 +366,8 @@ module Rcurses
344
366
  # restore wrap, then also reset SGR and scroll-region one more time
345
367
  diff_buf << "\e[#{o_row};#{o_col}H\e[?7h\e[0m\e[r"
346
368
  begin
347
- # Debug: check what's actually being printed
348
- if diff_buf.include?("Purpose") && diff_buf.include?("[38;5;")
349
- File.open("/tmp/rcurses_debug.log", "a") do |f|
350
- f.puts "=== PRINT DEBUG ==="
351
- f.puts "diff_buf sample: #{diff_buf[0..200].inspect}"
352
- f.puts "Has escape byte 27: #{diff_buf.bytes.include?(27)}"
353
- f.puts "Escape count: #{diff_buf.bytes.count(27)}"
354
- end
355
- end
356
-
357
369
  print diff_buf
358
370
  rescue => e
359
- # If printing fails, at least try to restore terminal state
360
371
  begin
361
372
  print "\e[0m\e[?25h\e[?7h"
362
373
  rescue
@@ -618,6 +629,7 @@ module Rcurses
618
629
  print @prompt.c(fmt)
619
630
  prompt_len = @prompt.pure.length
620
631
  content_len = @w - prompt_len
632
+ return nil if content_len < 1
621
633
  cont = @text.pure.slice(0, content_len)
622
634
  @pos = cont.length
623
635
  chr = ''
@@ -626,15 +638,31 @@ module Rcurses
626
638
 
627
639
  while chr != 'ESC'
628
640
  col(@x + prompt_len)
629
- cont = cont.slice(0, content_len)
641
+ # Truncate content to fit display width
642
+ disp_w = Rcurses.display_width(cont)
643
+ while disp_w > content_len && cont.length > 0
644
+ cont = cont[0..-2]
645
+ disp_w = Rcurses.display_width(cont)
646
+ end
647
+ @pos = cont.length if @pos > cont.length
630
648
  # Show indicator if multiline content detected
631
649
  display_cont = cont
632
650
  if @multiline_buffer && !@multiline_buffer.empty?
633
651
  lines_indicator = " [+#{@multiline_buffer.size} lines]"
634
- available = content_len - lines_indicator.length
635
- display_cont = cont.slice(0, [available, 0].max) + lines_indicator if available > 0
652
+ available = content_len - Rcurses.display_width(lines_indicator)
653
+ if available > 0
654
+ # Truncate cont to fit indicator
655
+ trunc = cont
656
+ while Rcurses.display_width(trunc) > available && trunc.length > 0
657
+ trunc = trunc[0..-2]
658
+ end
659
+ display_cont = trunc + lines_indicator
660
+ end
636
661
  end
637
- print display_cont.ljust(content_len).c(fmt)
662
+ # Pad to full width using spaces (each space = 1 column)
663
+ pad_needed = content_len - Rcurses.display_width(display_cont)
664
+ padded = display_cont + (" " * [pad_needed, 0].max)
665
+ print padded.c(fmt)
638
666
  # Calculate display width up to cursor position
639
667
  display_pos = @pos > 0 ? Rcurses.display_width(cont[0...@pos]) : 0
640
668
  col(@x + prompt_len + display_pos)
@@ -664,11 +692,10 @@ module Rcurses
664
692
  cont = ''
665
693
  @pos = 0
666
694
  when 'ENTER'
667
- # If there are lines in multiline_buffer, handle multiline paste
695
+ # If there are lines in multiline_buffer, join all pasted lines
668
696
  if @multiline_buffer && !@multiline_buffer.empty?
669
- # Add current line if it's not empty
670
697
  @multiline_buffer << cont.dup unless cont.empty?
671
- @text = @multiline_buffer.shift # Return first line as @text
698
+ @text = @multiline_buffer.join("\n")
672
699
  else
673
700
  @text = cont
674
701
  end
@@ -689,6 +716,21 @@ module Rcurses
689
716
  cont = ""
690
717
  @pos = 0
691
718
  end
719
+ when 'C-E'
720
+ if @emoji
721
+ require_relative 'emoji' unless defined?(Rcurses::EmojiPicker)
722
+ picked = Rcurses::EmojiPicker.pick(self)
723
+ if picked
724
+ cont.insert(@pos, picked)
725
+ @pos += picked.length
726
+ end
727
+ # Redraw UI behind the overlay (full refresh to clear overlay artifacts)
728
+ @emoji_refresh.call if @emoji_refresh
729
+ # Redraw prompt line
730
+ row(@y)
731
+ col(@x)
732
+ print @prompt.c(fmt)
733
+ end
692
734
  when /^.$/
693
735
  if @pos < content_len
694
736
  cont.insert(@pos, chr)
@@ -696,9 +738,16 @@ module Rcurses
696
738
  end
697
739
  end
698
740
 
741
+ last_was_cr = false
699
742
  while IO.select([$stdin], nil, nil, 0)
700
743
  chr = $stdin.read_nonblock(1) rescue nil
701
744
  break unless chr
745
+ # Normalize \r\n to a single newline: skip \n after \r
746
+ if chr == "\n" && last_was_cr
747
+ last_was_cr = false
748
+ next
749
+ end
750
+ last_was_cr = (chr == "\r")
702
751
  # Handle newlines in multi-line paste (\n or \r)
703
752
  if chr == "\n" || chr == "\r"
704
753
  @multiline_buffer << cont.dup unless cont.empty?
@@ -0,0 +1,115 @@
1
+ module Rcurses
2
+ class Popup < Pane
3
+ # Creates a popup overlay. Defaults to centered, bordered, dark background.
4
+ #
5
+ # Usage:
6
+ # popup = Rcurses::Popup.new(w: 50, h: 20) # auto-centered
7
+ # popup = Rcurses::Popup.new(x: 10, y: 5, w: 50, h: 20) # explicit position
8
+ #
9
+ # # Simple modal (blocks until ESC/ENTER, returns selected line index or nil)
10
+ # result = popup.modal(content_string)
11
+ #
12
+ # # Manual control
13
+ # popup.show(content_string)
14
+ # # ... your own input loop ...
15
+ # popup.dismiss(refresh_panes: [pane1, pane2])
16
+ #
17
+ def initialize(x: nil, y: nil, w: 40, h: 15, fg: 255, bg: 236)
18
+ max_h, max_w = IO.console ? IO.console.winsize : [24, 80]
19
+ # Auto-center if position not specified
20
+ px = x || ((max_w - w) / 2 + 1)
21
+ py = y || ((max_h - h) / 2 + 1)
22
+ super(px, py, w, h, fg, bg)
23
+ @border = true
24
+ @scroll = true
25
+ end
26
+
27
+ # Show content in the popup (non-blocking, just renders)
28
+ def show(content)
29
+ @text = content
30
+ @ix = 0
31
+ full_refresh
32
+ end
33
+
34
+ # Modal: show content, handle scroll/navigation, return on ESC or ENTER.
35
+ # Returns the selected line index on ENTER, or nil on ESC.
36
+ # Optional block receives each keypress for custom handling;
37
+ # return :dismiss from the block to close, or a String to return that value.
38
+ def modal(content, &on_key)
39
+ @text = content
40
+ @ix = 0
41
+ @index = 0
42
+
43
+ loop do
44
+ full_refresh
45
+
46
+ chr = getchr(flush: false)
47
+ case chr
48
+ when 'ESC'
49
+ return nil
50
+ when 'ENTER'
51
+ return @index
52
+ when 'UP', 'k'
53
+ @index = [@index - 1, 0].max
54
+ scroll_to_index
55
+ when 'DOWN', 'j'
56
+ total = (@text || "").split("\n").size
57
+ @index = [@index + 1, total - 1].min
58
+ scroll_to_index
59
+ when 'PgUP'
60
+ @index = [@index - @h + 1, 0].max
61
+ scroll_to_index
62
+ when 'PgDOWN'
63
+ total = (@text || "").split("\n").size
64
+ @index = [@index + @h - 1, total - 1].min
65
+ scroll_to_index
66
+ when 'HOME'
67
+ @index = 0
68
+ @ix = 0
69
+ when 'END'
70
+ total = (@text || "").split("\n").size
71
+ @index = [total - 1, 0].max
72
+ scroll_to_index
73
+ end
74
+
75
+ # Custom key handler
76
+ if block_given?
77
+ result = on_key.call(chr, @index)
78
+ return nil if result == :dismiss
79
+ return result if result.is_a?(String)
80
+ end
81
+ end
82
+ end
83
+
84
+ # Clear the popup area and optionally refresh underlying panes
85
+ def dismiss(refresh_panes: [])
86
+ clear_area
87
+ refresh_panes.each do |p|
88
+ p.full_refresh if p.respond_to?(:full_refresh)
89
+ end
90
+ end
91
+
92
+ # Blank the screen region occupied by this popup (including border)
93
+ def clear_area
94
+ top = @y - (@border ? 1 : 0)
95
+ bot = @y + @h - 1 + (@border ? 1 : 0)
96
+ left = @x - (@border ? 1 : 0)
97
+ width = @w + (@border ? 2 : 0)
98
+ (top..bot).each do |row|
99
+ STDOUT.print "\e[#{row};#{left}H\e[0m#{' ' * width}"
100
+ end
101
+ STDOUT.flush
102
+ end
103
+
104
+ private
105
+
106
+ def scroll_to_index
107
+ # Keep selected index visible in the pane
108
+ if @index < @ix
109
+ @ix = @index
110
+ elsif @index >= @ix + @h
111
+ @ix = @index - @h + 1
112
+ end
113
+ end
114
+ end
115
+ end
data/lib/rcurses.rb CHANGED
@@ -5,7 +5,7 @@
5
5
  # Web_site: http://isene.com/
6
6
  # Github: https://github.com/isene/rcurses
7
7
  # License: Public domain
8
- # Version: 6.1.1: Added optional error logging with RCURSES_ERROR_LOG=1
8
+ # Version: 6.2.0: Popup widget, emoji picker, pane color cache, stdin flush
9
9
 
10
10
  require 'io/console' # Basic gem for rcurses
11
11
  require 'io/wait' # stdin handling
@@ -16,6 +16,7 @@ require_relative 'rcurses/general'
16
16
  require_relative 'rcurses/cursor'
17
17
  require_relative 'rcurses/input'
18
18
  require_relative 'rcurses/pane'
19
+ require_relative 'rcurses/popup'
19
20
 
20
21
  module Rcurses
21
22
  class << self
@@ -68,6 +69,10 @@ module Rcurses
68
69
  trap(sig) { cleanup!; exit }
69
70
  end
70
71
 
72
+ # Flush any pending stdin data (terminal position responses, etc.)
73
+ # This prevents cursor query responses from blocking the first getchr
74
+ $stdin.getc while $stdin.wait_readable(0)
75
+
71
76
  @initialized = true
72
77
  end
73
78
 
@@ -171,7 +176,7 @@ module Rcurses
171
176
  f.puts "Program: #{$0}"
172
177
  f.puts "Working Directory: #{Dir.pwd}"
173
178
  f.puts "Ruby Version: #{RUBY_VERSION}"
174
- f.puts "Rcurses Version: 6.1.1"
179
+ f.puts "Rcurses Version: 6.2.0"
175
180
  f.puts "=" * 60
176
181
  f.puts "Error Class: #{error.class}"
177
182
  f.puts "Error Message: #{error.message}"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rcurses
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.1.8
4
+ version: 6.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Geir Isene
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-03 00:00:00.000000000 Z
11
+ date: 2026-03-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: clipboard
@@ -29,9 +29,8 @@ description: 'Create curses applications for the terminal easier than ever. Crea
29
29
  up text (in panes or anywhere in the terminal) in bold, italic, underline, reverse
30
30
  color, blink and in any 256 terminal colors for foreground and background. Use a
31
31
  simple editor to let users edit text in panes. Left, right or center align text
32
- in panes. Cursor movement around the terminal. VERSION 6.0.0 BREAKING CHANGE: Apps
33
- must now explicitly call Rcurses.init! - auto-initialization has been removed for
34
- Ruby 3.4+ compatibility.'
32
+ in panes. Cursor movement around the terminal. 6.2.0: Popup widget, emoji picker,
33
+ pane color cache invalidation, stdin flush, Unicode display_width fixes.'
35
34
  email: g@isene.com
36
35
  executables: []
37
36
  extensions: []
@@ -41,11 +40,15 @@ files:
41
40
  - README.md
42
41
  - examples/basic_panes.rb
43
42
  - examples/focus_panes.rb
43
+ - img/rcurses-emoji.png
44
+ - img/rcurses-logo.png
44
45
  - lib/rcurses.rb
45
46
  - lib/rcurses/cursor.rb
47
+ - lib/rcurses/emoji.rb
46
48
  - lib/rcurses/general.rb
47
49
  - lib/rcurses/input.rb
48
50
  - lib/rcurses/pane.rb
51
+ - lib/rcurses/popup.rb
49
52
  - lib/string_extensions.rb
50
53
  homepage: https://isene.com/
51
54
  licenses: