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 +4 -4
- data/README.md +87 -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 +73 -22
- 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.
|
|
@@ -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.
|
|
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
|
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]
|
|
@@ -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)
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
633
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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-
|
|
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:
|