rcurses 6.1.7 → 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: 0c6cd88e7945b9df842bb148be5d71b6e04bf912bbe16ab676bab21b940479ce
4
- data.tar.gz: 71bc49050001d78f38f2d749c74c2648c9278ccf7dc5b33f576a9d4f4b3ab639
3
+ metadata.gz: 83520fbe28d15e3132827b2487cf90c386bd94362bcd77561a6361bf34a155fc
4
+ data.tar.gz: 22be19a0d1625c0e264372dbfa9fd9031016400a3859119158a783a620e3c412
5
5
  SHA512:
6
- metadata.gz: 7c6f5962a5ae1899560583246982bdaea68191fa9c71dde9e1b51c57b641dfd3a4e39aacaedbb4e7df6e97b460189abac7cda426f32fc57d0d03c9d737d28acf
7
- data.tar.gz: d88ded84e69538f801f6e01f32fcfd4a664b05df2ee01cd92868b399228bc034c0883b822e34c0ad6ccf652bfe4fa7ab41213f7c4628048862dfed3c45443a89
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.
@@ -88,6 +84,7 @@ fg | Foreground color for the Pane
88
84
  bg | Background color for the Pane
89
85
  border | Draw border around the Pane (=true) or not (=false), default being false
90
86
  scroll | Whether to indicate more text to be shown above/below the Pane, default is true
87
+ scroll_fg | Color for scroll indicators (∆/∇), defaults to pane fg color if nil
91
88
  text | The text/content of the Pane
92
89
  ix | The line number at the top of the Pane, starts at 0, the first line of text in the Pane
93
90
  index | An attribute that can be used to track the selected line/element in the pane
@@ -95,6 +92,9 @@ align | Text alignment in the Pane: "l" = lefts aligned, "c" = center,
95
92
  prompt | The prompt to print at the beginning of a one-liner Pane used as an input box
96
93
  moreup | Set to true when there is more text above what is shown (top scroll bar i showing)
97
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
98
98
 
99
99
  The methods for Pane:
100
100
 
@@ -118,6 +118,57 @@ lineup | Scroll up one line in the text
118
118
  bottom | Scroll to the bottom of the text in the pane
119
119
  top | Scroll to the top of the text in the pane
120
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
+
121
172
  # class String extensions
122
173
  Method extensions provided for the class String.
123
174
 
@@ -365,7 +416,12 @@ end
365
416
  **Problem:** `wait_readable` method not found.
366
417
  **Solution:** Always include `require 'io/wait'` for stdin flush.
367
418
 
368
- ### 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
369
425
  **Problem:** Changing pane border property doesn't show visual changes.
370
426
  **Cause:** Border changes require explicit refresh to be displayed.
371
427
  **Solution:** Use `border_refresh` method after changing border property.
@@ -404,6 +460,21 @@ And - try running the example file `rcurses_example.rb`.
404
460
 
405
461
  # Version History
406
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
+
473
+ ## v6.1.8
474
+ - Added `scroll_fg` pane attribute for custom scroll indicator (∆/∇) color
475
+ - When set, scroll markers use this color instead of the pane's foreground color
476
+ - Defaults to nil (existing behavior unchanged)
477
+
407
478
  ## v5.1.2 (2025-08-13)
408
479
  - Added comprehensive scrolling best practices documentation (SCROLLING_BEST_PRACTICES.md)
409
480
  - Documentation improvements for developers implementing scrollable panes
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
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]
@@ -27,11 +31,29 @@ module Rcurses
27
31
  @record = false # Don't record history unless explicitly set to true
28
32
  @history = [] # History array
29
33
  @max_history_size = 100 # Limit history to prevent memory leaks
34
+ @scroll_fg = nil # Scroll indicator color (defaults to @fg)
30
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
31
39
 
32
40
  ObjectSpace.define_finalizer(self, self.class.finalizer_proc)
33
41
  end
34
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
+
35
57
  def text=(new_text)
36
58
  if @record && @text
37
59
  @history << @text
@@ -175,6 +197,7 @@ module Rcurses
175
197
  # Diff-based refresh that minimizes flicker.
176
198
  # In this updated version we lazily process only the raw lines required to fill the pane.
177
199
  def refresh(cont = @text)
200
+ return @prev_frame&.join("\n") || "" unless @update
178
201
  begin
179
202
  @max_h, @max_w = IO.console.winsize
180
203
  rescue => e
@@ -252,7 +275,7 @@ module Rcurses
252
275
 
253
276
  raw_line = @raw_txt[@lazy_index]
254
277
  # If the raw line is short, no wrapping is needed.
255
- 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
256
279
  processed = [raw_line]
257
280
  else
258
281
  processed = split_line_with_ansi(raw_line, @w)
@@ -343,19 +366,8 @@ module Rcurses
343
366
  # restore wrap, then also reset SGR and scroll-region one more time
344
367
  diff_buf << "\e[#{o_row};#{o_col}H\e[?7h\e[0m\e[r"
345
368
  begin
346
- # Debug: check what's actually being printed
347
- if diff_buf.include?("Purpose") && diff_buf.include?("[38;5;")
348
- File.open("/tmp/rcurses_debug.log", "a") do |f|
349
- f.puts "=== PRINT DEBUG ==="
350
- f.puts "diff_buf sample: #{diff_buf[0..200].inspect}"
351
- f.puts "Has escape byte 27: #{diff_buf.bytes.include?(27)}"
352
- f.puts "Escape count: #{diff_buf.bytes.count(27)}"
353
- end
354
- end
355
-
356
369
  print diff_buf
357
370
  rescue => e
358
- # If printing fails, at least try to restore terminal state
359
371
  begin
360
372
  print "\e[0m\e[?25h\e[?7h"
361
373
  rescue
@@ -366,13 +378,14 @@ module Rcurses
366
378
  # Draw scroll markers after printing the frame.
367
379
  if @scroll
368
380
  marker_col = @x + @w - 1
381
+ sfmt = @scroll_fg ? [(@scroll_fg).to_s, @bg.to_s].join(',') : fmt
369
382
  if @ix > 0
370
- print "\e[#{@y};#{marker_col}H" + "∆".c(fmt)
383
+ print "\e[#{@y};#{marker_col}H" + "∆".c(sfmt)
371
384
  end
372
385
  # If there are more processed lines than fit in the pane
373
386
  # OR there remain raw lines to process, show the down marker.
374
387
  if (@txt.length - @ix) > @h || (@lazy_index < @raw_txt.size)
375
- print "\e[#{@y + @h - 1};#{marker_col}H" + "∇".c(fmt)
388
+ print "\e[#{@y + @h - 1};#{marker_col}H" + "∇".c(sfmt)
376
389
  end
377
390
  end
378
391
 
@@ -616,6 +629,7 @@ module Rcurses
616
629
  print @prompt.c(fmt)
617
630
  prompt_len = @prompt.pure.length
618
631
  content_len = @w - prompt_len
632
+ return nil if content_len < 1
619
633
  cont = @text.pure.slice(0, content_len)
620
634
  @pos = cont.length
621
635
  chr = ''
@@ -624,15 +638,31 @@ module Rcurses
624
638
 
625
639
  while chr != 'ESC'
626
640
  col(@x + prompt_len)
627
- 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
628
648
  # Show indicator if multiline content detected
629
649
  display_cont = cont
630
650
  if @multiline_buffer && !@multiline_buffer.empty?
631
651
  lines_indicator = " [+#{@multiline_buffer.size} lines]"
632
- available = content_len - lines_indicator.length
633
- 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
634
661
  end
635
- 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)
636
666
  # Calculate display width up to cursor position
637
667
  display_pos = @pos > 0 ? Rcurses.display_width(cont[0...@pos]) : 0
638
668
  col(@x + prompt_len + display_pos)
@@ -662,11 +692,10 @@ module Rcurses
662
692
  cont = ''
663
693
  @pos = 0
664
694
  when 'ENTER'
665
- # If there are lines in multiline_buffer, handle multiline paste
695
+ # If there are lines in multiline_buffer, join all pasted lines
666
696
  if @multiline_buffer && !@multiline_buffer.empty?
667
- # Add current line if it's not empty
668
697
  @multiline_buffer << cont.dup unless cont.empty?
669
- @text = @multiline_buffer.shift # Return first line as @text
698
+ @text = @multiline_buffer.join("\n")
670
699
  else
671
700
  @text = cont
672
701
  end
@@ -687,6 +716,21 @@ module Rcurses
687
716
  cont = ""
688
717
  @pos = 0
689
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
690
734
  when /^.$/
691
735
  if @pos < content_len
692
736
  cont.insert(@pos, chr)
@@ -694,9 +738,16 @@ module Rcurses
694
738
  end
695
739
  end
696
740
 
741
+ last_was_cr = false
697
742
  while IO.select([$stdin], nil, nil, 0)
698
743
  chr = $stdin.read_nonblock(1) rescue nil
699
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")
700
751
  # Handle newlines in multi-line paste (\n or \r)
701
752
  if chr == "\n" || chr == "\r"
702
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.7
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-02-24 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: