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.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/.builds/ruby-3.2.yml +1 -1
  3. data/.builds/ruby-3.3.yml +1 -1
  4. data/.builds/ruby-3.4.yml +1 -1
  5. data/.builds/ruby-4.0.0.yml +1 -1
  6. data/AGENTS.md +3 -2
  7. data/CHANGELOG.md +33 -7
  8. data/Steepfile +1 -0
  9. data/doc/concepts/application_testing.md +5 -5
  10. data/doc/concepts/event_handling.md +1 -1
  11. data/doc/contributors/design/ruby_frontend.md +40 -12
  12. data/doc/contributors/design/rust_backend.md +13 -1
  13. data/doc/contributors/releasing.md +215 -0
  14. data/doc/contributors/todo/align/api_completeness_audit-finished.md +6 -0
  15. data/doc/contributors/todo/align/api_completeness_audit-unfinished.md +1 -7
  16. data/doc/contributors/todo/align/term.md +351 -0
  17. data/doc/contributors/upstream_requests/paragraph_span_rects.md +259 -0
  18. data/doc/getting_started/quickstart.md +1 -1
  19. data/doc/getting_started/why.md +3 -3
  20. data/doc/images/app_external_editor.gif +0 -0
  21. data/doc/index.md +1 -6
  22. data/examples/app_external_editor/README.md +62 -0
  23. data/examples/app_external_editor/app.rb +344 -0
  24. data/examples/widget_list/app.rb +2 -4
  25. data/examples/widget_table/app.rb +8 -2
  26. data/ext/ratatui_ruby/Cargo.lock +1 -1
  27. data/ext/ratatui_ruby/Cargo.toml +1 -1
  28. data/ext/ratatui_ruby/src/events.rs +171 -203
  29. data/ext/ratatui_ruby/src/lib.rs +36 -0
  30. data/ext/ratatui_ruby/src/lib_header.rs +11 -0
  31. data/ext/ratatui_ruby/src/terminal/capabilities.rs +46 -0
  32. data/ext/ratatui_ruby/src/terminal/init.rs +92 -0
  33. data/ext/ratatui_ruby/src/terminal/mod.rs +12 -3
  34. data/ext/ratatui_ruby/src/terminal/queries.rs +15 -0
  35. data/ext/ratatui_ruby/src/terminal/query.rs +64 -2
  36. data/lib/ratatui_ruby/backend/window_size.rb +50 -0
  37. data/lib/ratatui_ruby/backend.rb +59 -0
  38. data/lib/ratatui_ruby/event/key/navigation.rb +10 -1
  39. data/lib/ratatui_ruby/event/key.rb +84 -0
  40. data/lib/ratatui_ruby/event/mouse.rb +95 -3
  41. data/lib/ratatui_ruby/event/resize.rb +45 -3
  42. data/lib/ratatui_ruby/layout/alignment.rb +91 -0
  43. data/lib/ratatui_ruby/layout/layout.rb +1 -2
  44. data/lib/ratatui_ruby/layout/size.rb +10 -3
  45. data/lib/ratatui_ruby/layout.rb +4 -0
  46. data/lib/ratatui_ruby/terminal/capabilities.rb +316 -0
  47. data/lib/ratatui_ruby/terminal/viewport.rb +1 -1
  48. data/lib/ratatui_ruby/terminal.rb +66 -0
  49. data/lib/ratatui_ruby/test_helper/global_state.rb +111 -0
  50. data/lib/ratatui_ruby/test_helper.rb +3 -0
  51. data/lib/ratatui_ruby/version.rb +1 -1
  52. data/lib/ratatui_ruby/widgets/table.rb +2 -2
  53. data/lib/ratatui_ruby.rb +25 -4
  54. data/sig/examples/app_external_editor/app.rbs +12 -0
  55. data/sig/generated/event_key_predicates.rbs +1348 -0
  56. data/sig/ratatui_ruby/backend/window_size.rbs +17 -0
  57. data/sig/ratatui_ruby/backend.rbs +12 -0
  58. data/sig/ratatui_ruby/event.rbs +7 -0
  59. data/sig/ratatui_ruby/layout/alignment.rbs +26 -0
  60. data/sig/ratatui_ruby/ratatui_ruby.rbs +2 -0
  61. data/sig/ratatui_ruby/terminal/capabilities.rbs +38 -0
  62. data/sig/ratatui_ruby/terminal/viewport.rbs +15 -1
  63. data/tasks/bump/bump_workflow.rb +49 -0
  64. data/tasks/bump/changelog.rb +57 -0
  65. data/tasks/bump/patch_release.rb +19 -0
  66. data/tasks/bump/release_branch.rb +17 -0
  67. data/tasks/bump/release_from_trunk.rb +49 -0
  68. data/tasks/bump/repository.rb +54 -0
  69. data/tasks/bump/ruby_gem.rb +6 -26
  70. data/tasks/bump/sem_ver.rb +4 -0
  71. data/tasks/bump/unreleased_section.rb +17 -0
  72. data/tasks/bump.rake +21 -11
  73. data/tasks/doc/documentation.rb +59 -0
  74. data/tasks/doc/link/file_url.rb +30 -0
  75. data/tasks/doc/link/relative_path.rb +61 -0
  76. data/tasks/doc/link/web_url.rb +55 -0
  77. data/tasks/doc/link.rb +52 -0
  78. data/tasks/doc/link_audit.rb +116 -0
  79. data/tasks/doc/problem.rb +40 -0
  80. data/tasks/doc/source_file.rb +93 -0
  81. data/tasks/doc.rake +18 -0
  82. data/tasks/rbs_predicates/predicate_catalog.rb +52 -0
  83. data/tasks/rbs_predicates/predicate_tests.rb +124 -0
  84. data/tasks/rbs_predicates/rbs_signature.rb +63 -0
  85. data/tasks/rbs_predicates.rake +31 -0
  86. data/tasks/test.rake +3 -0
  87. data/tasks/website/version.rb +23 -28
  88. 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
- /// Parses a `snake_case` string to `MediaKeyCode`.
34
- ///
35
- /// Parses a `snake_case` string to `MediaKeyCode`.
36
- ///
37
- /// Only accepts the `media_`-prefixed codes (canonical). Legacy unprefixed codes are no longer supported.
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
- /// Parses a `snake_case` string to `ModifierKeyCode`.
60
- fn parse_modifier_key(s: &str) -> Option<ratatui::crossterm::event::ModifierKeyCode> {
61
- use ratatui::crossterm::event::ModifierKeyCode;
62
- match s {
63
- "left_shift" => Some(ModifierKeyCode::LeftShift),
64
- "left_control" => Some(ModifierKeyCode::LeftControl),
65
- "left_alt" => Some(ModifierKeyCode::LeftAlt),
66
- "left_super" => Some(ModifierKeyCode::LeftSuper),
67
- "left_hyper" => Some(ModifierKeyCode::LeftHyper),
68
- "left_meta" => Some(ModifierKeyCode::LeftMeta),
69
- "right_shift" => Some(ModifierKeyCode::RightShift),
70
- "right_control" => Some(ModifierKeyCode::RightControl),
71
- "right_alt" => Some(ModifierKeyCode::RightAlt),
72
- "right_super" => Some(ModifierKeyCode::RightSuper),
73
- "right_hyper" => Some(ModifierKeyCode::RightHyper),
74
- "right_meta" => Some(ModifierKeyCode::RightMeta),
75
- "iso_level3_shift" => Some(ModifierKeyCode::IsoLevel3Shift),
76
- "iso_level5_shift" => Some(ModifierKeyCode::IsoLevel5Shift),
77
- _ => None,
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
- let code = match code_str.as_str() {
92
- // Arrow keys
93
- "up" => KeyCode::Up,
94
- "down" => KeyCode::Down,
95
- "left" => KeyCode::Left,
96
- "right" => KeyCode::Right,
97
- // Common keys
98
- "enter" => KeyCode::Enter,
99
- "esc" => KeyCode::Esc,
100
- "backspace" => KeyCode::Backspace,
101
- "tab" => KeyCode::Tab,
102
- "back_tab" => KeyCode::BackTab,
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 = ratatui::crossterm::event::KeyModifiers::empty();
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
- match m.as_str() {
155
- "ctrl" => modifiers |= ratatui::crossterm::event::KeyModifiers::CONTROL,
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
- /// Converts `MediaKeyCode` to `snake_case` string.
331
- ///
332
- /// All media keys are consistently prefixed with `media_` to reflect that they
333
- /// belong to the `KeyCode::Media(_)` variant in Crossterm. This allows Ruby's
334
- /// "Smart Predicates" to provide DWIM behavior (e.g., `pause?` matching both
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
- /// Converts `ModifierKeyCode` to `snake_case` string.
356
- fn modifier_key_to_string(m: ratatui::crossterm::event::ModifierKeyCode) -> &'static str {
357
- use ratatui::crossterm::event::ModifierKeyCode;
358
- match m {
359
- ModifierKeyCode::LeftShift => "left_shift",
360
- ModifierKeyCode::LeftControl => "left_control",
361
- ModifierKeyCode::LeftAlt => "left_alt",
362
- ModifierKeyCode::LeftSuper => "left_super",
363
- ModifierKeyCode::LeftHyper => "left_hyper",
364
- ModifierKeyCode::LeftMeta => "left_meta",
365
- ModifierKeyCode::RightShift => "right_shift",
366
- ModifierKeyCode::RightControl => "right_control",
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 = match key.code {
419
- // Characters
420
- KeyCode::Char(c) => c.to_string(),
421
- // Arrow keys
422
- KeyCode::Up => "up".to_string(),
423
- KeyCode::Down => "down".to_string(),
424
- KeyCode::Left => "left".to_string(),
425
- KeyCode::Right => "right".to_string(),
426
- // Common keys
427
- KeyCode::Enter => "enter".to_string(),
428
- KeyCode::Esc => "esc".to_string(),
429
- KeyCode::Backspace => "backspace".to_string(),
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
- if key
461
- .modifiers
462
- .contains(ratatui::crossterm::event::KeyModifiers::CONTROL)
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)?;
@@ -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
+ }