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 +4 -4
- data/README.md +81 -16
- data/img/rcurses-emoji.png +0 -0
- data/img/rcurses-logo.png +0 -0
- data/lib/rcurses/emoji.rb +293 -0
- data/lib/rcurses/general.rb +88 -11
- data/lib/rcurses/input.rb +1 -1
- data/lib/rcurses/pane.rb +69 -20
- data/lib/rcurses/popup.rb +115 -0
- data/lib/rcurses.rb +7 -2
- metadata +8 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 83520fbe28d15e3132827b2487cf90c386bd94362bcd77561a6361bf34a155fc
|
|
4
|
+
data.tar.gz: 22be19a0d1625c0e264372dbfa9fd9031016400a3859119158a783a620e3c412
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
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
|
-
|
|
21
|
-
- **
|
|
22
|
-
- **
|
|
23
|
-
- **
|
|
24
|
-
- **
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
data/lib/rcurses/general.rb
CHANGED
|
@@ -58,32 +58,109 @@ module Rcurses
|
|
|
58
58
|
end
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
# Simple, fast display_width function
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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, :
|
|
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)
|
|
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
|
-
|
|
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
|
|
635
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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-
|
|
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.
|
|
33
|
-
|
|
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:
|