ratatui_ruby 1.0.0.pre.beta.2 → 1.0.0.pre.beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 69c2adadc9306ebeb02ffefa11e56abe7db2f0ef1c361a2edaf1cf5a3f9b9fee
4
- data.tar.gz: c8e07b3361a46597f957fa6a4ade8e9eef426fa85a0df8650f7188a306384d5a
3
+ metadata.gz: db0cf2519be48abadebb47b0ff1473739692a8b0556add21c774420db188c392
4
+ data.tar.gz: 9454ead9e7cbe09a886b33f437833cd753ceaca565400554056c26eb6110e1ed
5
5
  SHA512:
6
- metadata.gz: eb439fe49d032bf2d7221b4771b2b6b0156d9b5373d32fe6710ff0077046263fc5e719c1d91d1ed271b3cb58c18cf9f31ef6d366daed59b6f4413ac66db82447
7
- data.tar.gz: dc395bd562a70d0bc8a9d6eafa4ca0b488ef3ce450a3f3d65b125550ed616c1516b2b5301bb0b634028433f90d4306885ba8f756542241fb9d5e0c4fa25d6644
6
+ metadata.gz: c96411ef407f2d9e54535239af7121b3acdfed15cf72ec1d146166b8b0fbaa0320aadc0c5aaba6b2d47dcb9ecb59bf508a5ebe658327e05a12190029b010d823
7
+ data.tar.gz: d418563a2fa210c302c83ced44095af3a37453b1e129367ab532291eeb9574db077782228ba7f2cac3ea1cadba90dbdf1c7f2a8d0924615dd4f629ef0f849a6c
data/.builds/ruby-3.2.yml CHANGED
@@ -16,7 +16,7 @@ packages:
16
16
  - clang
17
17
  - git
18
18
  artifacts:
19
- - ratatui_ruby/pkg/ratatui_ruby-1.0.0.pre.beta.2.gem
19
+ - ratatui_ruby/pkg/ratatui_ruby-1.0.0.pre.beta.3.gem
20
20
  sources:
21
21
  - https://git.sr.ht/~kerrick/ratatui_ruby
22
22
  tasks:
data/.builds/ruby-3.3.yml CHANGED
@@ -16,7 +16,7 @@ packages:
16
16
  - clang
17
17
  - git
18
18
  artifacts:
19
- - ratatui_ruby/pkg/ratatui_ruby-1.0.0.pre.beta.2.gem
19
+ - ratatui_ruby/pkg/ratatui_ruby-1.0.0.pre.beta.3.gem
20
20
  sources:
21
21
  - https://git.sr.ht/~kerrick/ratatui_ruby
22
22
  tasks:
data/.builds/ruby-3.4.yml CHANGED
@@ -16,7 +16,7 @@ packages:
16
16
  - clang
17
17
  - git
18
18
  artifacts:
19
- - ratatui_ruby/pkg/ratatui_ruby-1.0.0.pre.beta.2.gem
19
+ - ratatui_ruby/pkg/ratatui_ruby-1.0.0.pre.beta.3.gem
20
20
  sources:
21
21
  - https://git.sr.ht/~kerrick/ratatui_ruby
22
22
  tasks:
@@ -16,7 +16,7 @@ packages:
16
16
  - clang
17
17
  - git
18
18
  artifacts:
19
- - ratatui_ruby/pkg/ratatui_ruby-1.0.0.pre.beta.2.gem
19
+ - ratatui_ruby/pkg/ratatui_ruby-1.0.0.pre.beta.3.gem
20
20
  sources:
21
21
  - https://git.sr.ht/~kerrick/ratatui_ruby
22
22
  tasks:
data/CHANGELOG.md CHANGED
@@ -18,6 +18,21 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
18
18
 
19
19
  ### Removed
20
20
 
21
+ ## [1.0.0-beta.3] - 2026-01-24
22
+
23
+ ### Added
24
+
25
+ ### Changed
26
+
27
+ ### Fixed
28
+
29
+ - **Terminal Queries During Draw (Deadlock)**: Calling `viewport_area`, `terminal_size`, `get_cell_at`, `poll_event`, `get_cursor_position`, or `get_viewport_type` from inside a `draw()` block previously caused the application to freeze forever. The thread blocked on a Mutex. Ruby's `Timeout` could not interrupt it. The only recovery was `kill -9`. These reads now work via a snapshot captured before rendering.
30
+ - **Terminal Mutations During Draw (Deadlock)**: Calling `insert_before`, `set_cursor_position`, or `resize_terminal` from inside a `draw()` block previously caused the same silent freeze. These writes now raise `Error::Invariant` with a clear message.
31
+ - **Nested Draw Calls (Deadlock)**: Calling `draw()` from inside another `draw()` block previously froze the application. Nested draws now raise `Error::Invariant`.
32
+ - **Event Injection During Draw (Deadlock)**: Calling `inject_test_event` from inside a `draw()` block previously froze the application. Event injection now works correctly during draw.
33
+
34
+ ### Removed
35
+
21
36
  ## [1.0.0-beta.2] - 2026-01-20
22
37
 
23
38
  ### Added
@@ -26,6 +41,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
26
41
 
27
42
  ### Fixed
28
43
 
44
+ - **Terminal Queries During Draw (Deadlock)**: Calling `viewport_area`, `terminal_size`, `get_cell_at`, `poll_event`, `get_cursor_position`, or `get_viewport_type` from inside a `draw()` block previously caused the application to freeze forever. The thread blocked on a Mutex. Ruby's `Timeout` could not interrupt it. The only recovery was `kill -9`. These reads now work via a snapshot captured before rendering.
45
+ - **Terminal Mutations During Draw (Deadlock)**: Calling `insert_before`, `set_cursor_position`, or `resize_terminal` from inside a `draw()` block previously caused the same silent freeze. These writes now raise `Error::Invariant` with a clear message.
46
+ - **Nested Draw Calls (Deadlock)**: Calling `draw()` from inside another `draw()` block previously froze the application. Nested draws now raise `Error::Invariant`.
47
+ - **Event Injection During Draw (Deadlock)**: Calling `inject_test_event` from inside a `draw()` block previously froze the application. Event injection now works correctly during draw.
29
48
  - **TableState Row Navigation Methods**: Added missing row navigation methods (`select_next`, `select_previous`, `select_first`, `select_last`) that should have been included alongside the column navigation methods added in v0.10.0. These methods match `ListState`'s navigation API and are used in the `app_stateful_interaction` example.
30
49
 
31
50
  ### Removed
@@ -682,6 +701,7 @@ This release is functionally equivalent to v0.10.3. The version bump signals the
682
701
  - **Testing Support**: Included `RatatuiRuby::TestHelper` and RSpec integration to make testing your TUI applications possible.
683
702
 
684
703
  [Unreleased]: https://git.sr.ht/~kerrick/ratatui_ruby/refs/HEAD
704
+ [1.0.0-beta.3]: https://git.sr.ht/~kerrick/ratatui_ruby/refs/v1.0.0-beta.3
685
705
  [1.0.0-beta.2]: https://git.sr.ht/~kerrick/ratatui_ruby/refs/v1.0.0-beta.2
686
706
  [1.0.0-beta.1]: https://git.sr.ht/~kerrick/ratatui_ruby/refs/v1.0.0-beta.1
687
707
  [0.10.3]: https://git.sr.ht/~kerrick/ratatui_ruby/refs/v0.10.3
@@ -1059,7 +1059,7 @@ dependencies = [
1059
1059
 
1060
1060
  [[package]]
1061
1061
  name = "ratatui_ruby"
1062
- version = "1.0.0-beta.2"
1062
+ version = "1.0.0-beta.3"
1063
1063
  dependencies = [
1064
1064
  "bumpalo",
1065
1065
  "lazy_static",
@@ -3,7 +3,7 @@
3
3
 
4
4
  [package]
5
5
  name = "ratatui_ruby"
6
- version = "1.0.0-beta.2"
6
+ version = "1.0.0-beta.3"
7
7
  edition = "2021"
8
8
 
9
9
  [lib]
@@ -2,9 +2,11 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
4
  use magnus::{Error, IntoValue, TryConvert, Value};
5
- use std::sync::Mutex;
5
+ use std::cell::RefCell;
6
6
 
7
- static EVENT_QUEUE: Mutex<Vec<ratatui::crossterm::event::Event>> = Mutex::new(Vec::new());
7
+ thread_local! {
8
+ static EVENT_QUEUE: RefCell<Vec<ratatui::crossterm::event::Event>> = const { RefCell::new(Vec::new()) };
9
+ }
8
10
 
9
11
  #[allow(clippy::needless_pass_by_value)]
10
12
  pub fn inject_test_event(event_type: String, data: magnus::RHash) -> Result<(), Error> {
@@ -24,7 +26,7 @@ pub fn inject_test_event(event_type: String, data: magnus::RHash) -> Result<(),
24
26
  }
25
27
  };
26
28
 
27
- EVENT_QUEUE.lock().unwrap().push(event);
29
+ EVENT_QUEUE.with(|q| q.borrow_mut().push(event));
28
30
  Ok(())
29
31
  }
30
32
 
@@ -271,30 +273,24 @@ fn parse_paste_event(
271
273
  }
272
274
 
273
275
  pub fn clear_events() {
274
- EVENT_QUEUE.lock().unwrap().clear();
276
+ EVENT_QUEUE.with(|q| q.borrow_mut().clear());
275
277
  }
276
278
 
277
279
  pub fn poll_event(ruby: &magnus::Ruby, timeout_val: Option<f64>) -> Result<Value, Error> {
278
- let event = {
279
- let mut queue = EVENT_QUEUE.lock().unwrap();
280
+ let event = EVENT_QUEUE.with(|q| {
281
+ let mut queue = q.borrow_mut();
280
282
  if queue.is_empty() {
281
283
  None
282
284
  } else {
283
285
  Some(queue.remove(0))
284
286
  }
285
- };
287
+ });
286
288
 
287
289
  if let Some(e) = event {
288
290
  return handle_event(e);
289
291
  }
290
292
 
291
- let is_test_mode = {
292
- let term_lock = crate::terminal::TERMINAL.lock().unwrap();
293
- matches!(
294
- term_lock.as_ref(),
295
- Some(crate::terminal::TerminalWrapper::Test(_))
296
- )
297
- };
293
+ let is_test_mode = crate::terminal::with_query(|q| q.is_test_mode()).unwrap_or(false);
298
294
 
299
295
  if is_test_mode {
300
296
  return Ok(ruby.qnil().into_value_with(ruby));
@@ -17,13 +17,13 @@ mod frame;
17
17
  mod rendering;
18
18
  mod string_width;
19
19
  mod style;
20
- mod terminal;
20
+ mod terminal; // New module with TerminalQuery trait
21
21
  mod text;
22
22
  mod widgets;
23
23
 
24
24
  use frame::RubyFrame;
25
25
  use magnus::{function, method, Error, Module, Object, Ruby, Value};
26
- use terminal::{init_terminal, restore_terminal, TERMINAL};
26
+ use terminal::{init_terminal, restore_terminal};
27
27
 
28
28
  /// Draw to the terminal.
29
29
  ///
@@ -63,63 +63,78 @@ fn draw(args: &[Value]) -> Result<(), Error> {
63
63
  ));
64
64
  }
65
65
 
66
- let mut term_lock = TERMINAL.lock().unwrap();
67
- let mut render_error: Option<Error> = None;
68
-
69
- // Helper closure to execute the draw callback logic for either terminal type
70
- let mut draw_callback = |f: &mut ratatui::Frame<'_>| {
71
- if block_given {
72
- // New API: yield RubyFrame to block
73
- // Create validity flag set to true while the block is executing
74
- let active = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
66
+ // Nested draw() calls are not allowed - would deadlock
67
+ if terminal::is_in_draw_mode() {
68
+ let module = ruby.define_module("RatatuiRuby")?;
69
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
70
+ let error_class = error_base.const_get("Invariant")?;
71
+ return Err(Error::new(
72
+ error_class,
73
+ "draw cannot be called during another draw (nested draws not allowed)",
74
+ ));
75
+ }
75
76
 
76
- let ruby_frame = RubyFrame::new(f, active.clone());
77
- if let Err(e) = ruby.yield_value::<_, Value>(ruby_frame) {
78
- render_error = Some(e);
77
+ // Use lend_for_draw which handles snapshot capture/cleanup automatically
78
+ let result = terminal::lend_for_draw(|wrapper| {
79
+ let ruby = magnus::Ruby::get().expect("Ruby must be initialized");
80
+ let mut render_error: Option<Error> = None;
81
+
82
+ // Helper closure to execute the draw callback logic for either terminal type
83
+ let mut draw_callback = |f: &mut ratatui::Frame<'_>| {
84
+ if block_given {
85
+ // New API: yield RubyFrame to block
86
+ let active = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
87
+
88
+ let ruby_frame = RubyFrame::new(f, active.clone());
89
+ if let Err(e) = ruby.yield_value::<_, Value>(ruby_frame) {
90
+ render_error = Some(e);
91
+ }
92
+
93
+ // Invalidate frame immediately after block returns
94
+ active.store(false, std::sync::atomic::Ordering::Relaxed);
95
+ } else if let Some(tree_value) = tree {
96
+ // Legacy API: render tree to full area
97
+ let area = f.area();
98
+ if let Err(e) = rendering::render_node(f.buffer_mut(), area, tree_value) {
99
+ render_error = Some(e);
100
+ }
79
101
  }
102
+ };
80
103
 
81
- // Invalidate frame immediately after block returns
82
- // This prevents use-after-free if user stored the frame object
83
- active.store(false, std::sync::atomic::Ordering::Relaxed);
84
- } else if let Some(tree_value) = tree {
85
- // Legacy API: render tree to full area
86
- let area = f.area();
87
- if let Err(e) = rendering::render_node(f.buffer_mut(), area, tree_value) {
88
- render_error = Some(e);
89
- }
90
- }
91
- };
92
-
93
- if let Some(wrapper) = term_lock.as_mut() {
94
104
  match wrapper {
95
105
  terminal::TerminalWrapper::Crossterm(term) => {
96
- let module = ruby.define_module("RatatuiRuby")?;
97
- let error_base = module.const_get::<_, magnus::RClass>("Error")?;
98
- let error_class = error_base.const_get("Terminal")?;
106
+ let module = ruby.define_module("RatatuiRuby").unwrap();
107
+ let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
108
+ let error_class = error_base.const_get("Terminal").unwrap();
99
109
  term.draw(&mut draw_callback)
100
110
  .map_err(|e| Error::new(error_class, e.to_string()))?;
101
111
  }
102
112
  terminal::TerminalWrapper::Test(term) => {
103
- let module = ruby.define_module("RatatuiRuby")?;
104
- let error_base = module.const_get::<_, magnus::RClass>("Error")?;
105
- let error_class = error_base.const_get("Terminal")?;
113
+ let module = ruby.define_module("RatatuiRuby").unwrap();
114
+ let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
115
+ let error_class = error_base.const_get("Terminal").unwrap();
106
116
  term.draw(&mut draw_callback)
107
117
  .map_err(|e| Error::new(error_class, e.to_string()))?;
108
118
  }
109
119
  }
110
- } else {
111
- eprintln!("Terminal is None!");
112
- }
113
120
 
114
- if let Some(e) = render_error {
115
- return Err(e);
116
- }
121
+ if let Some(e) = render_error {
122
+ return Err(e);
123
+ }
117
124
 
118
- Ok(())
125
+ Ok(())
126
+ });
127
+
128
+ result.unwrap_or_else(|| {
129
+ eprintln!("Terminal is None!");
130
+ Ok(())
131
+ })
119
132
  }
120
133
 
121
- /// Storage for the last panic info, to be retrieved and printed after terminal restore.
122
- static LAST_PANIC: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
134
+ // Storage for the last panic info, to be retrieved and printed after terminal restore.
135
+ thread_local! {
136
+ static LAST_PANIC: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
137
+ }
123
138
 
124
139
  /// Enables Rust backtraces and installs a custom panic hook.
125
140
  ///
@@ -131,9 +146,9 @@ fn enable_rust_backtrace(_ruby: &magnus::Ruby) {
131
146
  std::panic::set_hook(Box::new(|info| {
132
147
  let backtrace = std::backtrace::Backtrace::force_capture();
133
148
  let message = format!("Rust panic: {info}\n{backtrace}");
134
- if let Ok(mut guard) = LAST_PANIC.lock() {
135
- *guard = Some(message);
136
- }
149
+ LAST_PANIC.with(|p| {
150
+ *p.borrow_mut() = Some(message);
151
+ });
137
152
  }));
138
153
  }
139
154
 
@@ -141,11 +156,7 @@ fn enable_rust_backtrace(_ruby: &magnus::Ruby) {
141
156
  ///
142
157
  /// Call this after terminal restoration to get deferred panic output.
143
158
  fn get_last_panic(_ruby: &magnus::Ruby) -> Option<String> {
144
- if let Ok(mut guard) = LAST_PANIC.lock() {
145
- guard.take()
146
- } else {
147
- None
148
- }
159
+ LAST_PANIC.with(|p| p.borrow_mut().take())
149
160
  }
150
161
 
151
162
  /// Intentionally panics to test backtrace output.
@@ -0,0 +1,141 @@
1
+ // SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ //! Terminal initialization and restoration functions.
5
+
6
+ use magnus::{Error, Module};
7
+ use ratatui::{
8
+ backend::{CrosstermBackend, TestBackend},
9
+ Terminal, TerminalOptions, Viewport,
10
+ };
11
+ use std::io;
12
+
13
+ use super::TerminalWrapper;
14
+
15
+ // Track whether we're using fullscreen viewport (for restore_terminal)
16
+ thread_local! {
17
+ static IS_FULLSCREEN: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
18
+ }
19
+
20
+ #[allow(clippy::needless_pass_by_value)] // Magnus FFI requires owned String, not &str
21
+ pub fn init_terminal(
22
+ focus_events: bool,
23
+ bracketed_paste: bool,
24
+ viewport_type: String,
25
+ viewport_height: Option<u16>,
26
+ ) -> Result<(), Error> {
27
+ let ruby = magnus::Ruby::get().unwrap();
28
+
29
+ // Check if already initialized
30
+ if super::is_initialized() {
31
+ return Ok(());
32
+ }
33
+
34
+ let module = ruby.define_module("RatatuiRuby")?;
35
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
36
+ let error_class = error_base.const_get("Terminal")?;
37
+
38
+ // Parse viewport type
39
+ let viewport = match viewport_type.as_ref() {
40
+ "inline" => {
41
+ let height = viewport_height.unwrap_or(8);
42
+ Viewport::Inline(height)
43
+ }
44
+ _ => Viewport::Fullscreen,
45
+ };
46
+
47
+ ratatui::crossterm::terminal::enable_raw_mode()
48
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
49
+ let mut stdout = io::stdout();
50
+
51
+ // Only enter alternate screen for fullscreen viewports
52
+ if matches!(viewport, Viewport::Fullscreen) {
53
+ ratatui::crossterm::execute!(stdout, ratatui::crossterm::terminal::EnterAlternateScreen)
54
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
55
+ }
56
+
57
+ ratatui::crossterm::execute!(stdout, ratatui::crossterm::event::EnableMouseCapture)
58
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
59
+
60
+ if focus_events {
61
+ ratatui::crossterm::execute!(stdout, ratatui::crossterm::event::EnableFocusChange)
62
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
63
+ }
64
+ if bracketed_paste {
65
+ ratatui::crossterm::execute!(stdout, ratatui::crossterm::event::EnableBracketedPaste)
66
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
67
+ }
68
+
69
+ let backend = CrosstermBackend::new(stdout);
70
+
71
+ // Store whether we're using fullscreen for restore_terminal (before moving viewport)
72
+ let is_fullscreen = matches!(viewport, Viewport::Fullscreen);
73
+ IS_FULLSCREEN.with(|f| f.set(is_fullscreen));
74
+
75
+ let options = TerminalOptions { viewport };
76
+ let terminal = Terminal::with_options(backend, options)
77
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
78
+
79
+ super::set_terminal(TerminalWrapper::Crossterm(terminal));
80
+ Ok(())
81
+ }
82
+
83
+ #[allow(clippy::needless_pass_by_value)] // Magnus FFI requires owned String, not &str
84
+ pub fn init_test_terminal(
85
+ width: u16,
86
+ height: u16,
87
+ viewport_type: String,
88
+ viewport_height: Option<u16>,
89
+ ) -> Result<(), Error> {
90
+ let ruby = magnus::Ruby::get().unwrap();
91
+ let backend = TestBackend::new(width, height);
92
+ let module = ruby.define_module("RatatuiRuby")?;
93
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
94
+ let error_class = error_base.const_get("Terminal")?;
95
+
96
+ // Parse viewport type (same as init_terminal)
97
+ let viewport = match viewport_type.as_ref() {
98
+ "inline" => {
99
+ let vp_height = viewport_height.unwrap_or(height);
100
+ Viewport::Inline(vp_height)
101
+ }
102
+ _ => Viewport::Fullscreen,
103
+ };
104
+
105
+ let options = TerminalOptions { viewport };
106
+ let terminal = Terminal::with_options(backend, options)
107
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
108
+
109
+ super::set_terminal(TerminalWrapper::Test(terminal));
110
+ Ok(())
111
+ }
112
+
113
+ pub fn restore_terminal() {
114
+ if let Some(wrapper) = super::take_terminal() {
115
+ match wrapper {
116
+ TerminalWrapper::Crossterm(mut t) => {
117
+ let _ = ratatui::crossterm::terminal::disable_raw_mode();
118
+
119
+ // Only leave alternate screen if we were in fullscreen mode
120
+ let is_fullscreen = IS_FULLSCREEN.with(std::cell::Cell::get);
121
+ if is_fullscreen {
122
+ let _ = ratatui::crossterm::execute!(
123
+ t.backend_mut(),
124
+ ratatui::crossterm::terminal::LeaveAlternateScreen,
125
+ ratatui::crossterm::event::DisableMouseCapture,
126
+ ratatui::crossterm::event::DisableFocusChange,
127
+ ratatui::crossterm::event::DisableBracketedPaste
128
+ );
129
+ } else {
130
+ let _ = ratatui::crossterm::execute!(
131
+ t.backend_mut(),
132
+ ratatui::crossterm::event::DisableMouseCapture,
133
+ ratatui::crossterm::event::DisableFocusChange,
134
+ ratatui::crossterm::event::DisableBracketedPaste
135
+ );
136
+ }
137
+ }
138
+ TerminalWrapper::Test(_) => {}
139
+ }
140
+ }
141
+ }
@@ -0,0 +1,33 @@
1
+ // SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ //! Terminal management module.
5
+ //!
6
+ //! Provides thread-local terminal storage with safe accessors. Queries
7
+ //! during `draw()` callbacks return data from a pre-captured snapshot,
8
+ //! avoiding reentrancy issues with the terminal lock.
9
+
10
+ mod init;
11
+ mod mutations;
12
+ mod queries;
13
+ mod query;
14
+ mod storage;
15
+ mod wrapper;
16
+
17
+ pub use storage::{
18
+ is_in_draw_mode, is_initialized, lend_for_draw, set_terminal, take_terminal, with_query,
19
+ with_terminal_mut,
20
+ };
21
+ pub use wrapper::TerminalWrapper;
22
+
23
+ // Init/restore functions
24
+ pub use init::{init_terminal, init_test_terminal, restore_terminal};
25
+
26
+ // Query functions
27
+ pub use queries::{
28
+ get_buffer_content, get_cell_at, get_cursor_position, get_terminal_area, get_terminal_size,
29
+ get_viewport_type,
30
+ };
31
+
32
+ // Mutation functions
33
+ pub use mutations::{insert_before, resize_terminal, set_cursor_position};
@@ -0,0 +1,158 @@
1
+ // SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ //! Terminal mutation functions (write operations).
5
+
6
+ use magnus::{Error, Module};
7
+
8
+ use super::TerminalWrapper;
9
+
10
+ pub fn insert_before(height: u16, widget: magnus::Value) -> Result<(), Error> {
11
+ let ruby = magnus::Ruby::get().unwrap();
12
+
13
+ // with_terminal_mut returns None during draw mode, so we handle that case
14
+ crate::terminal::with_terminal_mut(|wrapper| {
15
+ let module = ruby.define_module("RatatuiRuby").unwrap();
16
+ let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
17
+ let error_class = error_base.const_get("Terminal").unwrap();
18
+
19
+ match wrapper {
20
+ TerminalWrapper::Crossterm(term) => {
21
+ // Capture rendering error since closure can't return Result
22
+ let mut render_error: Option<String> = None;
23
+
24
+ let result = term.insert_before(height, |buf| {
25
+ let area = buf.area();
26
+ let area_copy = *area; // Copy rect before closure capture
27
+
28
+ // Render widget to buffer using centralized dispatch
29
+ let render_result =
30
+ crate::rendering::render_widget_to_buffer(buf, area_copy, widget);
31
+
32
+ if let Err(e) = render_result {
33
+ render_error = Some(e.to_string());
34
+ }
35
+ });
36
+
37
+ // Handle insert_before error
38
+ result.map_err(|e| Error::new(error_class, e.to_string()))?;
39
+
40
+ // Handle rendering error
41
+ if let Some(err_msg) = render_error {
42
+ return Err(Error::new(error_class, err_msg));
43
+ }
44
+ }
45
+ TerminalWrapper::Test(term) => {
46
+ // Capture rendering error since closure can't return Result
47
+ let mut render_error: Option<String> = None;
48
+
49
+ let result = term.insert_before(height, |buf| {
50
+ let area = buf.area();
51
+ let area_copy = *area; // Copy rect before closure capture
52
+
53
+ // Render widget to buffer using centralized dispatch
54
+ let render_result =
55
+ crate::rendering::render_widget_to_buffer(buf, area_copy, widget);
56
+
57
+ if let Err(e) = render_result {
58
+ render_error = Some(e.to_string());
59
+ }
60
+ });
61
+
62
+ // Handle insert_before error
63
+ result.map_err(|e| Error::new(error_class, e.to_string()))?;
64
+
65
+ // Handle rendering error
66
+ if let Some(err_msg) = render_error {
67
+ return Err(Error::new(error_class, err_msg));
68
+ }
69
+ }
70
+ }
71
+ Ok(())
72
+ })
73
+ .unwrap_or_else(|| {
74
+ // During draw or terminal not initialized
75
+ let module = ruby.define_module("RatatuiRuby").unwrap();
76
+ let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
77
+ if crate::terminal::is_in_draw_mode() {
78
+ let error_class = error_base.const_get("Invariant").unwrap();
79
+ Err(Error::new(
80
+ error_class,
81
+ "insert_before cannot be called during draw",
82
+ ))
83
+ } else {
84
+ let error_class = error_base.const_get("Terminal").unwrap();
85
+ Err(Error::new(error_class, "Terminal not initialized"))
86
+ }
87
+ })
88
+ }
89
+
90
+ pub fn set_cursor_position(x: u16, y: u16) -> Result<(), Error> {
91
+ let ruby = magnus::Ruby::get().unwrap();
92
+
93
+ crate::terminal::with_terminal_mut(|wrapper| {
94
+ let module = ruby.define_module("RatatuiRuby").unwrap();
95
+ let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
96
+ let error_class = error_base.const_get("Terminal").unwrap();
97
+
98
+ match wrapper {
99
+ TerminalWrapper::Crossterm(term) => {
100
+ term.set_cursor_position((x, y))
101
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
102
+ }
103
+ TerminalWrapper::Test(term) => {
104
+ term.set_cursor_position((x, y))
105
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
106
+ }
107
+ }
108
+ Ok(())
109
+ })
110
+ .unwrap_or_else(|| {
111
+ let module = ruby.define_module("RatatuiRuby").unwrap();
112
+ let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
113
+ if crate::terminal::is_in_draw_mode() {
114
+ let error_class = error_base.const_get("Invariant").unwrap();
115
+ Err(Error::new(
116
+ error_class,
117
+ "set_cursor_position cannot be called during draw",
118
+ ))
119
+ } else {
120
+ let error_class = error_base.const_get("Terminal").unwrap();
121
+ Err(Error::new(error_class, "Terminal is not initialized"))
122
+ }
123
+ })
124
+ }
125
+
126
+ pub fn resize_terminal(width: u16, height: u16) -> Result<(), Error> {
127
+ let ruby = magnus::Ruby::get().unwrap();
128
+
129
+ crate::terminal::with_terminal_mut(|wrapper| {
130
+ match wrapper {
131
+ TerminalWrapper::Crossterm(_) => {}
132
+ TerminalWrapper::Test(terminal) => {
133
+ terminal.backend_mut().resize(width, height);
134
+ if let Err(e) = terminal.resize(ratatui::layout::Rect::new(0, 0, width, height)) {
135
+ let module = ruby.define_module("RatatuiRuby").unwrap();
136
+ let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
137
+ let error_class = error_base.const_get("Terminal").unwrap();
138
+ return Err(Error::new(error_class, e.to_string()));
139
+ }
140
+ }
141
+ }
142
+ Ok(())
143
+ })
144
+ .unwrap_or_else(|| {
145
+ let module = ruby.define_module("RatatuiRuby").unwrap();
146
+ let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
147
+ if crate::terminal::is_in_draw_mode() {
148
+ let error_class = error_base.const_get("Invariant").unwrap();
149
+ Err(Error::new(
150
+ error_class,
151
+ "resize_terminal cannot be called during draw",
152
+ ))
153
+ } else {
154
+ // No terminal initialized is OK for resize - just no-op
155
+ Ok(())
156
+ }
157
+ })
158
+ }