ratatui_ruby 1.0.0 → 1.1.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/.builds/ruby-3.2.yml +1 -1
- data/.builds/ruby-3.3.yml +1 -1
- data/.builds/ruby-3.4.yml +1 -1
- data/.builds/ruby-4.0.0.yml +1 -1
- data/AGENTS.md +3 -2
- data/CHANGELOG.md +33 -7
- data/Steepfile +1 -0
- data/doc/concepts/application_testing.md +5 -5
- data/doc/concepts/event_handling.md +1 -1
- data/doc/contributors/design/ruby_frontend.md +40 -12
- data/doc/contributors/design/rust_backend.md +13 -1
- data/doc/contributors/releasing.md +215 -0
- data/doc/contributors/todo/align/api_completeness_audit-finished.md +6 -0
- data/doc/contributors/todo/align/api_completeness_audit-unfinished.md +1 -7
- data/doc/contributors/todo/align/term.md +351 -0
- data/doc/contributors/upstream_requests/paragraph_span_rects.md +259 -0
- data/doc/getting_started/quickstart.md +1 -1
- data/doc/getting_started/why.md +3 -3
- data/doc/images/app_external_editor.gif +0 -0
- data/doc/index.md +1 -6
- data/examples/app_external_editor/README.md +62 -0
- data/examples/app_external_editor/app.rb +344 -0
- data/examples/widget_list/app.rb +2 -4
- data/examples/widget_table/app.rb +8 -2
- data/ext/ratatui_ruby/Cargo.lock +1 -1
- data/ext/ratatui_ruby/Cargo.toml +1 -1
- data/ext/ratatui_ruby/src/events.rs +171 -203
- data/ext/ratatui_ruby/src/lib.rs +36 -0
- data/ext/ratatui_ruby/src/lib_header.rs +11 -0
- data/ext/ratatui_ruby/src/terminal/capabilities.rs +46 -0
- data/ext/ratatui_ruby/src/terminal/init.rs +92 -0
- data/ext/ratatui_ruby/src/terminal/mod.rs +12 -3
- data/ext/ratatui_ruby/src/terminal/queries.rs +15 -0
- data/ext/ratatui_ruby/src/terminal/query.rs +64 -2
- data/lib/ratatui_ruby/backend/window_size.rb +50 -0
- data/lib/ratatui_ruby/backend.rb +59 -0
- data/lib/ratatui_ruby/event/key/navigation.rb +10 -1
- data/lib/ratatui_ruby/event/key.rb +84 -0
- data/lib/ratatui_ruby/event/mouse.rb +95 -3
- data/lib/ratatui_ruby/event/resize.rb +45 -3
- data/lib/ratatui_ruby/layout/alignment.rb +91 -0
- data/lib/ratatui_ruby/layout/layout.rb +1 -2
- data/lib/ratatui_ruby/layout/size.rb +10 -3
- data/lib/ratatui_ruby/layout.rb +4 -0
- data/lib/ratatui_ruby/terminal/capabilities.rb +316 -0
- data/lib/ratatui_ruby/terminal/viewport.rb +1 -1
- data/lib/ratatui_ruby/terminal.rb +66 -0
- data/lib/ratatui_ruby/test_helper/global_state.rb +111 -0
- data/lib/ratatui_ruby/test_helper.rb +3 -0
- data/lib/ratatui_ruby/version.rb +1 -1
- data/lib/ratatui_ruby/widgets/table.rb +2 -2
- data/lib/ratatui_ruby.rb +25 -4
- data/sig/examples/app_external_editor/app.rbs +12 -0
- data/sig/generated/event_key_predicates.rbs +1348 -0
- data/sig/ratatui_ruby/backend/window_size.rbs +17 -0
- data/sig/ratatui_ruby/backend.rbs +12 -0
- data/sig/ratatui_ruby/event.rbs +7 -0
- data/sig/ratatui_ruby/layout/alignment.rbs +26 -0
- data/sig/ratatui_ruby/ratatui_ruby.rbs +2 -0
- data/sig/ratatui_ruby/terminal/capabilities.rbs +38 -0
- data/sig/ratatui_ruby/terminal/viewport.rbs +15 -1
- data/tasks/bump/bump_workflow.rb +49 -0
- data/tasks/bump/changelog.rb +57 -0
- data/tasks/bump/patch_release.rb +19 -0
- data/tasks/bump/release_branch.rb +17 -0
- data/tasks/bump/release_from_trunk.rb +49 -0
- data/tasks/bump/repository.rb +54 -0
- data/tasks/bump/ruby_gem.rb +6 -26
- data/tasks/bump/sem_ver.rb +4 -0
- data/tasks/bump/unreleased_section.rb +17 -0
- data/tasks/bump.rake +21 -11
- data/tasks/doc/documentation.rb +59 -0
- data/tasks/doc/link/file_url.rb +30 -0
- data/tasks/doc/link/relative_path.rb +61 -0
- data/tasks/doc/link/web_url.rb +55 -0
- data/tasks/doc/link.rb +52 -0
- data/tasks/doc/link_audit.rb +116 -0
- data/tasks/doc/problem.rb +40 -0
- data/tasks/doc/source_file.rb +93 -0
- data/tasks/doc.rake +18 -0
- data/tasks/rbs_predicates/predicate_catalog.rb +52 -0
- data/tasks/rbs_predicates/predicate_tests.rb +124 -0
- data/tasks/rbs_predicates/rbs_signature.rb +63 -0
- data/tasks/rbs_predicates.rake +31 -0
- data/tasks/test.rake +3 -0
- data/tasks/website/version.rb +23 -28
- metadata +38 -1
|
@@ -8,6 +8,101 @@ thread_local! {
|
|
|
8
8
|
static EVENT_QUEUE: RefCell<Vec<ratatui::crossterm::event::Event>> = const { RefCell::new(Vec::new()) };
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
use ratatui::crossterm::event::{KeyCode, KeyModifiers, MediaKeyCode, ModifierKeyCode};
|
|
12
|
+
|
|
13
|
+
/// Single source of truth for base key code mappings.
|
|
14
|
+
const BASE_KEY_MAPPINGS: &[(&str, KeyCode)] = &[
|
|
15
|
+
// Arrow keys
|
|
16
|
+
("up", KeyCode::Up),
|
|
17
|
+
("down", KeyCode::Down),
|
|
18
|
+
("left", KeyCode::Left),
|
|
19
|
+
("right", KeyCode::Right),
|
|
20
|
+
// Common keys
|
|
21
|
+
("enter", KeyCode::Enter),
|
|
22
|
+
("esc", KeyCode::Esc),
|
|
23
|
+
("backspace", KeyCode::Backspace),
|
|
24
|
+
("tab", KeyCode::Tab),
|
|
25
|
+
("back_tab", KeyCode::BackTab),
|
|
26
|
+
("null", KeyCode::Null),
|
|
27
|
+
// Navigation keys
|
|
28
|
+
("home", KeyCode::Home),
|
|
29
|
+
("end", KeyCode::End),
|
|
30
|
+
("page_up", KeyCode::PageUp),
|
|
31
|
+
("page_down", KeyCode::PageDown),
|
|
32
|
+
("insert", KeyCode::Insert),
|
|
33
|
+
("delete", KeyCode::Delete),
|
|
34
|
+
// Lock keys
|
|
35
|
+
("caps_lock", KeyCode::CapsLock),
|
|
36
|
+
("scroll_lock", KeyCode::ScrollLock),
|
|
37
|
+
("num_lock", KeyCode::NumLock),
|
|
38
|
+
// System keys
|
|
39
|
+
("print_screen", KeyCode::PrintScreen),
|
|
40
|
+
("pause", KeyCode::Pause),
|
|
41
|
+
("menu", KeyCode::Menu),
|
|
42
|
+
("keypad_begin", KeyCode::KeypadBegin),
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/// Single source of truth for media key mappings.
|
|
46
|
+
const MEDIA_KEY_MAPPINGS: &[(&str, MediaKeyCode)] = &[
|
|
47
|
+
("media_play", MediaKeyCode::Play),
|
|
48
|
+
("media_pause", MediaKeyCode::Pause),
|
|
49
|
+
("media_play_pause", MediaKeyCode::PlayPause),
|
|
50
|
+
("media_reverse", MediaKeyCode::Reverse),
|
|
51
|
+
("media_stop", MediaKeyCode::Stop),
|
|
52
|
+
("media_fast_forward", MediaKeyCode::FastForward),
|
|
53
|
+
("media_rewind", MediaKeyCode::Rewind),
|
|
54
|
+
("media_track_next", MediaKeyCode::TrackNext),
|
|
55
|
+
("media_track_previous", MediaKeyCode::TrackPrevious),
|
|
56
|
+
("media_record", MediaKeyCode::Record),
|
|
57
|
+
("media_lower_volume", MediaKeyCode::LowerVolume),
|
|
58
|
+
("media_raise_volume", MediaKeyCode::RaiseVolume),
|
|
59
|
+
("media_mute_volume", MediaKeyCode::MuteVolume),
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/// Single source of truth for modifier key mappings.
|
|
63
|
+
const MODIFIER_KEY_MAPPINGS: &[(&str, ModifierKeyCode)] = &[
|
|
64
|
+
("left_shift", ModifierKeyCode::LeftShift),
|
|
65
|
+
("left_control", ModifierKeyCode::LeftControl),
|
|
66
|
+
("left_alt", ModifierKeyCode::LeftAlt),
|
|
67
|
+
("left_super", ModifierKeyCode::LeftSuper),
|
|
68
|
+
("left_hyper", ModifierKeyCode::LeftHyper),
|
|
69
|
+
("left_meta", ModifierKeyCode::LeftMeta),
|
|
70
|
+
("right_shift", ModifierKeyCode::RightShift),
|
|
71
|
+
("right_control", ModifierKeyCode::RightControl),
|
|
72
|
+
("right_alt", ModifierKeyCode::RightAlt),
|
|
73
|
+
("right_super", ModifierKeyCode::RightSuper),
|
|
74
|
+
("right_hyper", ModifierKeyCode::RightHyper),
|
|
75
|
+
("right_meta", ModifierKeyCode::RightMeta),
|
|
76
|
+
("iso_level3_shift", ModifierKeyCode::IsoLevel3Shift),
|
|
77
|
+
("iso_level5_shift", ModifierKeyCode::IsoLevel5Shift),
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/// Single source of truth for keyboard modifier flag mappings.
|
|
81
|
+
const KEYBOARD_MODIFIER_MAPPINGS: &[(&str, KeyModifiers)] = &[
|
|
82
|
+
("ctrl", KeyModifiers::CONTROL),
|
|
83
|
+
("alt", KeyModifiers::ALT),
|
|
84
|
+
("shift", KeyModifiers::SHIFT),
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
/// Returns all supported key codes for RBS generation.
|
|
88
|
+
pub fn all_key_codes() -> magnus::RHash {
|
|
89
|
+
let ruby = magnus::Ruby::get().unwrap();
|
|
90
|
+
let hash = ruby.hash_new();
|
|
91
|
+
|
|
92
|
+
let base: Vec<&str> = BASE_KEY_MAPPINGS.iter().map(|(s, _)| *s).collect();
|
|
93
|
+
let media: Vec<&str> = MEDIA_KEY_MAPPINGS.iter().map(|(s, _)| *s).collect();
|
|
94
|
+
let modifier_keys: Vec<&str> = MODIFIER_KEY_MAPPINGS.iter().map(|(s, _)| *s).collect();
|
|
95
|
+
let keyboard_modifiers: Vec<&str> =
|
|
96
|
+
KEYBOARD_MODIFIER_MAPPINGS.iter().map(|(s, _)| *s).collect();
|
|
97
|
+
|
|
98
|
+
let _ = hash.aset(ruby.to_symbol("base_keys"), base);
|
|
99
|
+
let _ = hash.aset(ruby.to_symbol("media_keys"), media);
|
|
100
|
+
let _ = hash.aset(ruby.to_symbol("modifier_keys"), modifier_keys);
|
|
101
|
+
let _ = hash.aset(ruby.to_symbol("keyboard_modifiers"), keyboard_modifiers);
|
|
102
|
+
|
|
103
|
+
hash
|
|
104
|
+
}
|
|
105
|
+
|
|
11
106
|
#[allow(clippy::needless_pass_by_value)]
|
|
12
107
|
pub fn inject_test_event(event_type: String, data: magnus::RHash) -> Result<(), Error> {
|
|
13
108
|
let ruby = magnus::Ruby::get().unwrap();
|
|
@@ -30,132 +125,67 @@ pub fn inject_test_event(event_type: String, data: magnus::RHash) -> Result<(),
|
|
|
30
125
|
Ok(())
|
|
31
126
|
}
|
|
32
127
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
fn parse_media_key(s: &str) -> Option<ratatui::crossterm::event::MediaKeyCode> {
|
|
39
|
-
use ratatui::crossterm::event::MediaKeyCode;
|
|
40
|
-
match s {
|
|
41
|
-
// New canonical codes (media_ prefix)
|
|
42
|
-
"media_play" => Some(MediaKeyCode::Play),
|
|
43
|
-
"media_pause" => Some(MediaKeyCode::Pause),
|
|
44
|
-
"media_play_pause" => Some(MediaKeyCode::PlayPause),
|
|
45
|
-
"media_reverse" => Some(MediaKeyCode::Reverse),
|
|
46
|
-
"media_stop" => Some(MediaKeyCode::Stop),
|
|
47
|
-
"media_fast_forward" => Some(MediaKeyCode::FastForward),
|
|
48
|
-
"media_rewind" => Some(MediaKeyCode::Rewind),
|
|
49
|
-
"media_track_next" => Some(MediaKeyCode::TrackNext),
|
|
50
|
-
"media_track_previous" => Some(MediaKeyCode::TrackPrevious),
|
|
51
|
-
"media_record" => Some(MediaKeyCode::Record),
|
|
52
|
-
"media_lower_volume" => Some(MediaKeyCode::LowerVolume),
|
|
53
|
-
"media_raise_volume" => Some(MediaKeyCode::RaiseVolume),
|
|
54
|
-
"media_mute_volume" => Some(MediaKeyCode::MuteVolume),
|
|
55
|
-
_ => None,
|
|
56
|
-
}
|
|
128
|
+
fn parse_base_key(s: &str) -> Option<KeyCode> {
|
|
129
|
+
BASE_KEY_MAPPINGS
|
|
130
|
+
.iter()
|
|
131
|
+
.find(|(key, _)| *key == s)
|
|
132
|
+
.map(|(_, code)| *code)
|
|
57
133
|
}
|
|
58
134
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
_
|
|
78
|
-
}
|
|
135
|
+
fn parse_media_key(s: &str) -> Option<MediaKeyCode> {
|
|
136
|
+
MEDIA_KEY_MAPPINGS
|
|
137
|
+
.iter()
|
|
138
|
+
.find(|(key, _)| *key == s)
|
|
139
|
+
.map(|(_, code)| *code)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
fn parse_modifier_key(s: &str) -> Option<ModifierKeyCode> {
|
|
143
|
+
MODIFIER_KEY_MAPPINGS
|
|
144
|
+
.iter()
|
|
145
|
+
.find(|(key, _)| *key == s)
|
|
146
|
+
.map(|(_, code)| *code)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
fn parse_keyboard_modifier(s: &str) -> Option<KeyModifiers> {
|
|
150
|
+
KEYBOARD_MODIFIER_MAPPINGS
|
|
151
|
+
.iter()
|
|
152
|
+
.find(|(key, _)| *key == s)
|
|
153
|
+
.map(|(_, mods)| *mods)
|
|
79
154
|
}
|
|
80
155
|
|
|
81
156
|
fn parse_key_event(
|
|
82
157
|
data: magnus::RHash,
|
|
83
158
|
ruby: &magnus::Ruby,
|
|
84
159
|
) -> Result<ratatui::crossterm::event::Event, Error> {
|
|
85
|
-
use ratatui::crossterm::event::KeyCode;
|
|
86
|
-
|
|
87
160
|
let code_val: Value = data
|
|
88
161
|
.get(ruby.to_symbol("code"))
|
|
89
162
|
.ok_or_else(|| Error::new(ruby.exception_arg_error(), "Missing 'code' in key event"))?;
|
|
90
163
|
let code_str: String = String::try_convert(code_val)?;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// Navigation keys
|
|
104
|
-
"home" => KeyCode::Home,
|
|
105
|
-
"end" => KeyCode::End,
|
|
106
|
-
"page_up" => KeyCode::PageUp,
|
|
107
|
-
"page_down" => KeyCode::PageDown,
|
|
108
|
-
"insert" => KeyCode::Insert,
|
|
109
|
-
"delete" => KeyCode::Delete,
|
|
110
|
-
// Lock keys
|
|
111
|
-
"caps_lock" => KeyCode::CapsLock,
|
|
112
|
-
"scroll_lock" => KeyCode::ScrollLock,
|
|
113
|
-
"num_lock" => KeyCode::NumLock,
|
|
114
|
-
// System keys
|
|
115
|
-
"print_screen" => KeyCode::PrintScreen,
|
|
116
|
-
"pause" => KeyCode::Pause,
|
|
117
|
-
"menu" => KeyCode::Menu,
|
|
118
|
-
"keypad_begin" => KeyCode::KeypadBegin,
|
|
119
|
-
"null" => KeyCode::Null,
|
|
120
|
-
// Dynamic parsing for media, modifiers, function keys, and characters
|
|
121
|
-
s => {
|
|
122
|
-
// Media keys (check first - "fast_forward" starts with 'f' but isn't F-key)
|
|
123
|
-
if let Some(m) = parse_media_key(s) {
|
|
124
|
-
KeyCode::Media(m)
|
|
125
|
-
}
|
|
126
|
-
// Modifier keys
|
|
127
|
-
else if let Some(m) = parse_modifier_key(s) {
|
|
128
|
-
KeyCode::Modifier(m)
|
|
129
|
-
}
|
|
130
|
-
// Function keys: f1, f2, ..., f12, etc.
|
|
131
|
-
else if let Some(num_str) = s.strip_prefix('f') {
|
|
132
|
-
if let Ok(n) = num_str.parse::<u8>() {
|
|
133
|
-
KeyCode::F(n)
|
|
134
|
-
} else {
|
|
135
|
-
// "f" alone or invalid suffix - treat as character
|
|
136
|
-
KeyCode::Char(s.chars().next().unwrap_or('\0'))
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
// Single character
|
|
140
|
-
else if s.len() == 1 {
|
|
141
|
-
KeyCode::Char(s.chars().next().unwrap())
|
|
142
|
-
}
|
|
143
|
-
// Unknown - default to Null
|
|
144
|
-
else {
|
|
145
|
-
KeyCode::Null
|
|
146
|
-
}
|
|
164
|
+
|
|
165
|
+
let code = if let Some(kc) = parse_base_key(&code_str) {
|
|
166
|
+
kc
|
|
167
|
+
} else if let Some(m) = parse_media_key(&code_str) {
|
|
168
|
+
KeyCode::Media(m)
|
|
169
|
+
} else if let Some(m) = parse_modifier_key(&code_str) {
|
|
170
|
+
KeyCode::Modifier(m)
|
|
171
|
+
} else if let Some(num_str) = code_str.strip_prefix('f') {
|
|
172
|
+
if let Ok(n) = num_str.parse::<u8>() {
|
|
173
|
+
KeyCode::F(n)
|
|
174
|
+
} else {
|
|
175
|
+
KeyCode::Char(code_str.chars().next().unwrap_or('\0'))
|
|
147
176
|
}
|
|
177
|
+
} else if code_str.len() == 1 {
|
|
178
|
+
KeyCode::Char(code_str.chars().next().unwrap())
|
|
179
|
+
} else {
|
|
180
|
+
KeyCode::Null
|
|
148
181
|
};
|
|
149
182
|
|
|
150
|
-
let mut modifiers =
|
|
183
|
+
let mut modifiers = KeyModifiers::empty();
|
|
151
184
|
if let Some(mods_val) = data.get(ruby.to_symbol("modifiers")) {
|
|
152
185
|
let mods: Vec<String> = Vec::try_convert(mods_val)?;
|
|
153
186
|
for m in mods {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
"alt" => modifiers |= ratatui::crossterm::event::KeyModifiers::ALT,
|
|
157
|
-
"shift" => modifiers |= ratatui::crossterm::event::KeyModifiers::SHIFT,
|
|
158
|
-
_ => {}
|
|
187
|
+
if let Some(mod_flag) = parse_keyboard_modifier(&m) {
|
|
188
|
+
modifiers |= mod_flag;
|
|
159
189
|
}
|
|
160
190
|
}
|
|
161
191
|
}
|
|
@@ -327,50 +357,25 @@ fn handle_event(event: ratatui::crossterm::event::Event) -> Result<Value, Error>
|
|
|
327
357
|
}
|
|
328
358
|
}
|
|
329
359
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
/// system and media pause).
|
|
336
|
-
fn media_key_to_string(m: ratatui::crossterm::event::MediaKeyCode) -> &'static str {
|
|
337
|
-
use ratatui::crossterm::event::MediaKeyCode;
|
|
338
|
-
match m {
|
|
339
|
-
MediaKeyCode::Play => "media_play",
|
|
340
|
-
MediaKeyCode::Pause => "media_pause",
|
|
341
|
-
MediaKeyCode::PlayPause => "media_play_pause",
|
|
342
|
-
MediaKeyCode::Reverse => "media_reverse",
|
|
343
|
-
MediaKeyCode::Stop => "media_stop",
|
|
344
|
-
MediaKeyCode::FastForward => "media_fast_forward",
|
|
345
|
-
MediaKeyCode::Rewind => "media_rewind",
|
|
346
|
-
MediaKeyCode::TrackNext => "media_track_next",
|
|
347
|
-
MediaKeyCode::TrackPrevious => "media_track_previous",
|
|
348
|
-
MediaKeyCode::Record => "media_record",
|
|
349
|
-
MediaKeyCode::LowerVolume => "media_lower_volume",
|
|
350
|
-
MediaKeyCode::RaiseVolume => "media_raise_volume",
|
|
351
|
-
MediaKeyCode::MuteVolume => "media_mute_volume",
|
|
352
|
-
}
|
|
360
|
+
fn media_key_to_string(m: MediaKeyCode) -> &'static str {
|
|
361
|
+
MEDIA_KEY_MAPPINGS
|
|
362
|
+
.iter()
|
|
363
|
+
.find(|(_, code)| *code == m)
|
|
364
|
+
.map_or("unknown", |(s, _)| *s)
|
|
353
365
|
}
|
|
354
366
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
ModifierKeyCode::RightAlt => "right_alt",
|
|
368
|
-
ModifierKeyCode::RightSuper => "right_super",
|
|
369
|
-
ModifierKeyCode::RightHyper => "right_hyper",
|
|
370
|
-
ModifierKeyCode::RightMeta => "right_meta",
|
|
371
|
-
ModifierKeyCode::IsoLevel3Shift => "iso_level3_shift",
|
|
372
|
-
ModifierKeyCode::IsoLevel5Shift => "iso_level5_shift",
|
|
373
|
-
}
|
|
367
|
+
fn modifier_key_to_string(m: ModifierKeyCode) -> &'static str {
|
|
368
|
+
MODIFIER_KEY_MAPPINGS
|
|
369
|
+
.iter()
|
|
370
|
+
.find(|(_, code)| *code == m)
|
|
371
|
+
.map_or("unknown", |(s, _)| *s)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
fn base_key_to_string(kc: KeyCode) -> Option<&'static str> {
|
|
375
|
+
BASE_KEY_MAPPINGS
|
|
376
|
+
.iter()
|
|
377
|
+
.find(|(_, code)| *code == kc)
|
|
378
|
+
.map(|(s, _)| *s)
|
|
374
379
|
}
|
|
375
380
|
|
|
376
381
|
fn handle_key_event(key: ratatui::crossterm::event::KeyEvent) -> Result<Value, Error> {
|
|
@@ -415,65 +420,28 @@ fn handle_key_event(key: ratatui::crossterm::event::KeyEvent) -> Result<Value, E
|
|
|
415
420
|
| KeyCode::KeypadBegin => "system",
|
|
416
421
|
};
|
|
417
422
|
|
|
418
|
-
let code =
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
KeyCode::Tab => "tab".to_string(),
|
|
431
|
-
KeyCode::BackTab => "back_tab".to_string(),
|
|
432
|
-
// Navigation keys
|
|
433
|
-
KeyCode::Home => "home".to_string(),
|
|
434
|
-
KeyCode::End => "end".to_string(),
|
|
435
|
-
KeyCode::PageUp => "page_up".to_string(),
|
|
436
|
-
KeyCode::PageDown => "page_down".to_string(),
|
|
437
|
-
KeyCode::Insert => "insert".to_string(),
|
|
438
|
-
KeyCode::Delete => "delete".to_string(),
|
|
439
|
-
// Function keys
|
|
440
|
-
KeyCode::F(n) => format!("f{n}"),
|
|
441
|
-
// Lock keys
|
|
442
|
-
KeyCode::CapsLock => "caps_lock".to_string(),
|
|
443
|
-
KeyCode::ScrollLock => "scroll_lock".to_string(),
|
|
444
|
-
KeyCode::NumLock => "num_lock".to_string(),
|
|
445
|
-
// System keys
|
|
446
|
-
KeyCode::PrintScreen => "print_screen".to_string(),
|
|
447
|
-
KeyCode::Pause => "pause".to_string(),
|
|
448
|
-
KeyCode::Menu => "menu".to_string(),
|
|
449
|
-
KeyCode::KeypadBegin => "keypad_begin".to_string(),
|
|
450
|
-
KeyCode::Null => "null".to_string(),
|
|
451
|
-
// Compound variants
|
|
452
|
-
KeyCode::Media(m) => media_key_to_string(m).to_string(),
|
|
453
|
-
KeyCode::Modifier(m) => modifier_key_to_string(m).to_string(),
|
|
423
|
+
let code = if let KeyCode::Char(c) = key.code {
|
|
424
|
+
c.to_string()
|
|
425
|
+
} else if let KeyCode::F(n) = key.code {
|
|
426
|
+
format!("f{n}")
|
|
427
|
+
} else if let KeyCode::Media(m) = key.code {
|
|
428
|
+
media_key_to_string(m).to_string()
|
|
429
|
+
} else if let KeyCode::Modifier(m) = key.code {
|
|
430
|
+
modifier_key_to_string(m).to_string()
|
|
431
|
+
} else if let Some(s) = base_key_to_string(key.code) {
|
|
432
|
+
s.to_string()
|
|
433
|
+
} else {
|
|
434
|
+
"unknown".to_string()
|
|
454
435
|
};
|
|
455
436
|
|
|
456
437
|
hash.aset(ruby.to_symbol("code"), code)?;
|
|
457
438
|
hash.aset(ruby.to_symbol("kind"), ruby.to_symbol(kind))?;
|
|
458
439
|
|
|
459
440
|
let mut modifiers = Vec::new();
|
|
460
|
-
|
|
461
|
-
.modifiers
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
modifiers.push("ctrl");
|
|
465
|
-
}
|
|
466
|
-
if key
|
|
467
|
-
.modifiers
|
|
468
|
-
.contains(ratatui::crossterm::event::KeyModifiers::ALT)
|
|
469
|
-
{
|
|
470
|
-
modifiers.push("alt");
|
|
471
|
-
}
|
|
472
|
-
if key
|
|
473
|
-
.modifiers
|
|
474
|
-
.contains(ratatui::crossterm::event::KeyModifiers::SHIFT)
|
|
475
|
-
{
|
|
476
|
-
modifiers.push("shift");
|
|
441
|
+
for (name, flag) in KEYBOARD_MODIFIER_MAPPINGS {
|
|
442
|
+
if key.modifiers.contains(*flag) {
|
|
443
|
+
modifiers.push(*name);
|
|
444
|
+
}
|
|
477
445
|
}
|
|
478
446
|
if !modifiers.is_empty() {
|
|
479
447
|
hash.aset(ruby.to_symbol("modifiers"), modifiers)?;
|
data/ext/ratatui_ruby/src/lib.rs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
#![allow(clippy::missing_errors_doc)]
|
|
10
10
|
#![allow(clippy::missing_panics_doc)]
|
|
11
11
|
#![allow(clippy::module_name_repetitions)]
|
|
12
|
+
#![allow(clippy::non_std_lazy_statics)]
|
|
12
13
|
|
|
13
14
|
mod color;
|
|
14
15
|
mod errors;
|
|
@@ -166,6 +167,36 @@ fn test_panic(_ruby: &magnus::Ruby) {
|
|
|
166
167
|
panic!("Test panic triggered by RatatuiRuby._test_panic");
|
|
167
168
|
}
|
|
168
169
|
|
|
170
|
+
/// Register the Terminal class with FFI methods.
|
|
171
|
+
fn register_terminal_class(ruby: &Ruby, m: magnus::RModule) -> Result<(), Error> {
|
|
172
|
+
let terminal_class = m.define_class("Terminal", ruby.class_object())?;
|
|
173
|
+
terminal_class.define_singleton_method(
|
|
174
|
+
"_init_test_terminal_instance",
|
|
175
|
+
function!(terminal::init_test_terminal_instance, 4),
|
|
176
|
+
)?;
|
|
177
|
+
terminal_class.define_singleton_method(
|
|
178
|
+
"_get_terminal_size_instance",
|
|
179
|
+
function!(terminal::get_terminal_size_instance, 1),
|
|
180
|
+
)?;
|
|
181
|
+
terminal_class.define_singleton_method(
|
|
182
|
+
"_available_color_count",
|
|
183
|
+
function!(terminal::available_color_count, 0),
|
|
184
|
+
)?;
|
|
185
|
+
terminal_class.define_singleton_method(
|
|
186
|
+
"_supports_keyboard_enhancement",
|
|
187
|
+
function!(terminal::supports_keyboard_enhancement, 0),
|
|
188
|
+
)?;
|
|
189
|
+
terminal_class.define_singleton_method(
|
|
190
|
+
"_terminal_window_size",
|
|
191
|
+
function!(terminal::terminal_window_size, 0),
|
|
192
|
+
)?;
|
|
193
|
+
terminal_class.define_singleton_method(
|
|
194
|
+
"_force_color_output",
|
|
195
|
+
function!(terminal::force_color_output, 1),
|
|
196
|
+
)?;
|
|
197
|
+
Ok(())
|
|
198
|
+
}
|
|
199
|
+
|
|
169
200
|
#[magnus::init]
|
|
170
201
|
fn init() -> Result<(), Error> {
|
|
171
202
|
let ruby = magnus::Ruby::get().unwrap();
|
|
@@ -196,6 +227,7 @@ fn init() -> Result<(), Error> {
|
|
|
196
227
|
m.define_module_function("_poll_event", function!(events::poll_event, 1))?;
|
|
197
228
|
m.define_module_function("inject_test_event", function!(events::inject_test_event, 2))?;
|
|
198
229
|
m.define_module_function("clear_events", function!(events::clear_events, 0))?;
|
|
230
|
+
m.define_module_function("_all_key_codes", function!(events::all_key_codes, 0))?;
|
|
199
231
|
|
|
200
232
|
// Register State classes
|
|
201
233
|
widgets::list_state::register(&ruby, m)?;
|
|
@@ -234,6 +266,10 @@ fn init() -> Result<(), Error> {
|
|
|
234
266
|
"_get_viewport_type",
|
|
235
267
|
function!(terminal::get_viewport_type, 0),
|
|
236
268
|
)?;
|
|
269
|
+
m.define_module_function("_frame_count", function!(terminal::frame_count, 0))?;
|
|
270
|
+
|
|
271
|
+
// Register Terminal class with instance-specific FFI methods
|
|
272
|
+
register_terminal_class(&ruby, m)?;
|
|
237
273
|
|
|
238
274
|
// Register Layout.split on the Layout::Layout class (inside the Layout module)
|
|
239
275
|
let layout_mod = m.const_get::<_, magnus::RModule>("Layout")?;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
// Require SAFETY comments on all unsafe blocks
|
|
5
|
+
#![warn(clippy::undocumented_unsafe_blocks)]
|
|
6
|
+
// Enable pedantic lints for stricter code quality
|
|
7
|
+
#![warn(clippy::pedantic)]
|
|
8
|
+
// Allow certain pedantic lints that are too noisy for FFI code
|
|
9
|
+
#![allow(clippy::missing_errors_doc)]
|
|
10
|
+
#![allow(clippy::missing_panics_doc)]
|
|
11
|
+
#![allow(clippy::non_std_lazy_statics)]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
//! Terminal capability detection functions.
|
|
5
|
+
|
|
6
|
+
use magnus::{Error, Ruby};
|
|
7
|
+
|
|
8
|
+
/// Returns color support level (8, 256, or `u16::MAX` for truecolor)
|
|
9
|
+
///
|
|
10
|
+
/// Wraps `crossterm::style::available_color_count()` which checks COLORTERM and TERM env vars.
|
|
11
|
+
pub fn available_color_count() -> u16 {
|
|
12
|
+
ratatui::crossterm::style::available_color_count()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/// Query if terminal supports Kitty keyboard protocol
|
|
16
|
+
///
|
|
17
|
+
/// Note: This requires raw mode and may return errors in some environments.
|
|
18
|
+
pub fn supports_keyboard_enhancement() -> Result<bool, Error> {
|
|
19
|
+
ratatui::crossterm::terminal::supports_keyboard_enhancement().map_err(|e| {
|
|
20
|
+
Error::new(
|
|
21
|
+
Ruby::get().unwrap().exception_runtime_error(),
|
|
22
|
+
e.to_string(),
|
|
23
|
+
)
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// Query terminal window size in characters and pixels
|
|
28
|
+
///
|
|
29
|
+
/// Wraps `crossterm::terminal::window_size()`. Returns
|
|
30
|
+
/// Some((columns, rows, `pixel_width`, `pixel_height`)) or None if query fails.
|
|
31
|
+
/// Note: Pixel dimensions may be 0 on some systems (marked as "unused" by Unix drivers,
|
|
32
|
+
/// not implemented on Windows).
|
|
33
|
+
pub fn terminal_window_size() -> Option<(u16, u16, u16, u16)> {
|
|
34
|
+
match ratatui::crossterm::terminal::window_size() {
|
|
35
|
+
Ok(size) => Some((size.columns, size.rows, size.width, size.height)),
|
|
36
|
+
Err(_) => None,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Globally override `NO_COLOR` detection
|
|
41
|
+
///
|
|
42
|
+
/// Wraps `crossterm::style::force_color_output()`. When enabled, color output will
|
|
43
|
+
/// be forced even if `NO_COLOR` is set. Useful for `--color=always` flags.
|
|
44
|
+
pub fn force_color_output(enable: bool) {
|
|
45
|
+
ratatui::crossterm::style::force_color_output(enable);
|
|
46
|
+
}
|
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
|
|
4
4
|
//! Terminal initialization and restoration functions.
|
|
5
5
|
|
|
6
|
+
use magnus::value::ReprValue;
|
|
6
7
|
use magnus::{Error, Module};
|
|
7
8
|
use ratatui::{
|
|
8
9
|
backend::{CrosstermBackend, TestBackend},
|
|
9
10
|
Terminal, TerminalOptions, Viewport,
|
|
10
11
|
};
|
|
12
|
+
use std::collections::HashMap;
|
|
11
13
|
use std::io;
|
|
14
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
12
15
|
|
|
13
16
|
use super::TerminalWrapper;
|
|
14
17
|
|
|
@@ -17,6 +20,13 @@ thread_local! {
|
|
|
17
20
|
static IS_FULLSCREEN: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
|
18
21
|
}
|
|
19
22
|
|
|
23
|
+
// Instance-based terminal tracking (Proposal 1 from terminal.md)
|
|
24
|
+
thread_local! {
|
|
25
|
+
static TERMINAL_INSTANCES: std::cell::RefCell<HashMap<u64, TerminalWrapper>> =
|
|
26
|
+
std::cell::RefCell::new(HashMap::new());
|
|
27
|
+
}
|
|
28
|
+
static NEXT_TERMINAL_ID: AtomicU64 = AtomicU64::new(1);
|
|
29
|
+
|
|
20
30
|
#[allow(clippy::needless_pass_by_value)] // Magnus FFI requires owned String, not &str
|
|
21
31
|
pub fn init_terminal(
|
|
22
32
|
focus_events: bool,
|
|
@@ -110,6 +120,45 @@ pub fn init_test_terminal(
|
|
|
110
120
|
Ok(())
|
|
111
121
|
}
|
|
112
122
|
|
|
123
|
+
// Instance-based terminal initialization (Proposal 1 from terminal.md)
|
|
124
|
+
// Returns terminal ID for Ruby to store
|
|
125
|
+
#[allow(clippy::needless_pass_by_value)]
|
|
126
|
+
pub fn init_test_terminal_instance(
|
|
127
|
+
width: u16,
|
|
128
|
+
height: u16,
|
|
129
|
+
viewport_type: String,
|
|
130
|
+
viewport_height: Option<u16>,
|
|
131
|
+
) -> Result<u64, Error> {
|
|
132
|
+
let ruby = magnus::Ruby::get().unwrap();
|
|
133
|
+
let backend = TestBackend::new(width, height);
|
|
134
|
+
let module = ruby.define_module("RatatuiRuby")?;
|
|
135
|
+
let error_base = module.const_get::<_, magnus::RClass>("Error")?;
|
|
136
|
+
let error_class = error_base.const_get("Terminal")?;
|
|
137
|
+
|
|
138
|
+
// Parse viewport type
|
|
139
|
+
let viewport = match viewport_type.as_ref() {
|
|
140
|
+
"inline" => {
|
|
141
|
+
let vp_height = viewport_height.unwrap_or(height);
|
|
142
|
+
Viewport::Inline(vp_height)
|
|
143
|
+
}
|
|
144
|
+
_ => Viewport::Fullscreen,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
let options = TerminalOptions { viewport };
|
|
148
|
+
let terminal = Terminal::with_options(backend, options)
|
|
149
|
+
.map_err(|e| Error::new(error_class, e.to_string()))?;
|
|
150
|
+
|
|
151
|
+
// Generate unique ID and store instance
|
|
152
|
+
let id = NEXT_TERMINAL_ID.fetch_add(1, Ordering::SeqCst);
|
|
153
|
+
TERMINAL_INSTANCES.with(|instances| {
|
|
154
|
+
instances
|
|
155
|
+
.borrow_mut()
|
|
156
|
+
.insert(id, TerminalWrapper::Test(terminal));
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
Ok(id)
|
|
160
|
+
}
|
|
161
|
+
|
|
113
162
|
pub fn restore_terminal() {
|
|
114
163
|
if let Some(wrapper) = super::take_terminal() {
|
|
115
164
|
match wrapper {
|
|
@@ -139,3 +188,46 @@ pub fn restore_terminal() {
|
|
|
139
188
|
}
|
|
140
189
|
}
|
|
141
190
|
}
|
|
191
|
+
|
|
192
|
+
/// Get terminal size for a specific instance.
|
|
193
|
+
/// Returns `Layout::Rect` object (not hash!)
|
|
194
|
+
pub fn get_terminal_size_instance(terminal_id: u64) -> Result<magnus::Value, Error> {
|
|
195
|
+
let ruby = magnus::Ruby::get().unwrap();
|
|
196
|
+
|
|
197
|
+
TERMINAL_INSTANCES.with(|instances| {
|
|
198
|
+
let instances = instances.borrow();
|
|
199
|
+
if let Some(wrapper) = instances.get(&terminal_id) {
|
|
200
|
+
let size = match wrapper {
|
|
201
|
+
TerminalWrapper::Crossterm(term) => term.size().map_err(|e| {
|
|
202
|
+
let module = ruby.define_module("RatatuiRuby").unwrap();
|
|
203
|
+
let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
|
|
204
|
+
let error_class = error_base.const_get("Terminal").unwrap();
|
|
205
|
+
Error::new(error_class, e.to_string())
|
|
206
|
+
})?,
|
|
207
|
+
TerminalWrapper::Test(term) => term.size().unwrap_or_default(),
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// Construct Layout::Rect object in Rust (NOT a hash!)
|
|
211
|
+
let module = ruby.define_module("RatatuiRuby")?;
|
|
212
|
+
let layout_mod = module.const_get::<_, magnus::RModule>("Layout")?;
|
|
213
|
+
let rect_class = layout_mod.const_get::<_, magnus::RClass>("Rect")?;
|
|
214
|
+
|
|
215
|
+
// Create hash with keyword args for Rect.new
|
|
216
|
+
let args = ruby.hash_new();
|
|
217
|
+
args.aset(ruby.to_symbol("x"), 0)?;
|
|
218
|
+
args.aset(ruby.to_symbol("y"), 0)?;
|
|
219
|
+
args.aset(ruby.to_symbol("width"), size.width)?;
|
|
220
|
+
args.aset(ruby.to_symbol("height"), size.height)?;
|
|
221
|
+
|
|
222
|
+
rect_class.funcall_public("new", (0, 0, size.width, size.height))
|
|
223
|
+
} else {
|
|
224
|
+
let module = ruby.define_module("RatatuiRuby")?;
|
|
225
|
+
let error_base = module.const_get::<_, magnus::RClass>("Error")?;
|
|
226
|
+
let error_class = error_base.const_get("Terminal")?;
|
|
227
|
+
Err(Error::new(
|
|
228
|
+
error_class,
|
|
229
|
+
format!("Terminal instance {terminal_id} not found"),
|
|
230
|
+
))
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
}
|