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
@@ -7,6 +7,7 @@
7
7
  //! during `draw()` callbacks return data from a pre-captured snapshot,
8
8
  //! avoiding reentrancy issues with the terminal lock.
9
9
 
10
+ mod capabilities;
10
11
  mod init;
11
12
  mod mutations;
12
13
  mod queries;
@@ -21,13 +22,21 @@ pub use storage::{
21
22
  pub use wrapper::TerminalWrapper;
22
23
 
23
24
  // Init/restore functions
24
- pub use init::{init_terminal, init_test_terminal, restore_terminal};
25
+ pub use init::{
26
+ get_terminal_size_instance, init_terminal, init_test_terminal, init_test_terminal_instance,
27
+ restore_terminal,
28
+ };
25
29
 
26
30
  // Query functions
27
31
  pub use queries::{
28
- get_buffer_content, get_cell_at, get_cursor_position, get_terminal_area, get_terminal_size,
29
- get_viewport_type,
32
+ frame_count, get_buffer_content, get_cell_at, get_cursor_position, get_terminal_area,
33
+ get_terminal_size, get_viewport_type,
30
34
  };
31
35
 
32
36
  // Mutation functions
33
37
  pub use mutations::{insert_before, resize_terminal, set_cursor_position};
38
+
39
+ // Capability detection
40
+ pub use capabilities::{
41
+ available_color_count, force_color_output, supports_keyboard_enhancement, terminal_window_size,
42
+ };
@@ -214,3 +214,18 @@ fn modifiers_to_value(modifier: ratatui::style::Modifier) -> Value {
214
214
 
215
215
  ary.as_value()
216
216
  }
217
+
218
+ /// Returns the number of frames that have been drawn
219
+ pub fn frame_count() -> Result<usize, Error> {
220
+ let ruby = magnus::Ruby::get().unwrap();
221
+
222
+ crate::terminal::with_query(|q| Ok(q.frame_count())).unwrap_or_else(|| {
223
+ let module = ruby.define_module("RatatuiRuby").unwrap();
224
+ let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
225
+ let error_class = error_base.const_get("Invariant").unwrap();
226
+ Err(Error::new(
227
+ error_class,
228
+ "Cannot query frame_count: terminal not initialized. Use RatatuiRuby.run or call init_terminal first.",
229
+ ))
230
+ })
231
+ }
@@ -19,6 +19,7 @@ pub trait TerminalQuery {
19
19
  fn is_test_mode(&self) -> bool;
20
20
  fn cursor_position(&self) -> Option<(u16, u16)>;
21
21
  fn cell_at(&self, x: u16, y: u16) -> Option<Cell>;
22
+ fn frame_count(&self) -> usize;
22
23
  }
23
24
 
24
25
  /// Snapshot of terminal state captured before draw.
@@ -30,6 +31,7 @@ pub struct DrawSnapshot {
30
31
  pub is_test_mode: bool,
31
32
  pub cursor_position: Option<(u16, u16)>,
32
33
  pub buffer: Option<ratatui::buffer::Buffer>,
34
+ pub frame_count: usize,
33
35
  }
34
36
 
35
37
  impl DrawSnapshot {
@@ -38,18 +40,23 @@ impl DrawSnapshot {
38
40
  match wrapper {
39
41
  super::wrapper::TerminalWrapper::Crossterm(t) => {
40
42
  let size = t.size().unwrap_or_default();
41
- let viewport = t.get_frame().area();
43
+ let frame = t.get_frame();
44
+ let viewport = frame.area();
45
+ let count = frame.count();
42
46
  Self {
43
47
  size: Rect::new(0, 0, size.width, size.height),
44
48
  viewport_area: viewport,
45
49
  is_test_mode: false,
46
50
  cursor_position: None,
47
51
  buffer: None,
52
+ frame_count: count,
48
53
  }
49
54
  }
50
55
  super::wrapper::TerminalWrapper::Test(t) => {
51
56
  let size = t.size().unwrap_or_default();
52
- let viewport = t.get_frame().area();
57
+ let frame = t.get_frame();
58
+ let viewport = frame.area();
59
+ let count = frame.count();
53
60
  let cursor = t.get_cursor_position().ok().map(Into::into);
54
61
  let buffer = t.backend().buffer().clone();
55
62
  Self {
@@ -58,6 +65,7 @@ impl DrawSnapshot {
58
65
  is_test_mode: true,
59
66
  cursor_position: cursor,
60
67
  buffer: Some(buffer),
68
+ frame_count: count,
61
69
  }
62
70
  }
63
71
  }
@@ -80,6 +88,9 @@ impl TerminalQuery for DrawSnapshot {
80
88
  fn cell_at(&self, x: u16, y: u16) -> Option<Cell> {
81
89
  self.buffer.as_ref()?.cell((x, y)).cloned()
82
90
  }
91
+ fn frame_count(&self) -> usize {
92
+ self.frame_count
93
+ }
83
94
  }
84
95
 
85
96
  /// Live queries against actual terminal (outside draw).
@@ -129,6 +140,12 @@ impl TerminalQuery for LiveTerminal<'_> {
129
140
  super::wrapper::TerminalWrapper::Crossterm(_) => None,
130
141
  }
131
142
  }
143
+ fn frame_count(&self) -> usize {
144
+ match *self.0.borrow_mut() {
145
+ super::wrapper::TerminalWrapper::Crossterm(ref mut t) => t.get_frame().count(),
146
+ super::wrapper::TerminalWrapper::Test(ref mut t) => t.get_frame().count(),
147
+ }
148
+ }
132
149
  }
133
150
 
134
151
  #[cfg(test)]
@@ -156,6 +173,9 @@ mod tests {
156
173
  fn cell_at(&self, _x: u16, _y: u16) -> Option<Cell> {
157
174
  None
158
175
  }
176
+ fn frame_count(&self) -> usize {
177
+ 0
178
+ }
159
179
  }
160
180
 
161
181
  #[test]
@@ -174,6 +194,7 @@ mod tests {
174
194
  is_test_mode: false,
175
195
  cursor_position: None,
176
196
  buffer: None,
197
+ frame_count: 0,
177
198
  };
178
199
  assert_eq!(snapshot.size(), Rect::new(0, 0, 120, 40));
179
200
  }
@@ -186,6 +207,7 @@ mod tests {
186
207
  is_test_mode: false,
187
208
  cursor_position: None,
188
209
  buffer: None,
210
+ frame_count: 0,
189
211
  };
190
212
  // This should return the stored viewport_area, not Rect::default()
191
213
  assert_eq!(snapshot.viewport_area(), Rect::new(5, 10, 80, 20));
@@ -199,6 +221,7 @@ mod tests {
199
221
  is_test_mode: true, // TRUE!
200
222
  cursor_position: None,
201
223
  buffer: None,
224
+ frame_count: 0,
202
225
  };
203
226
  assert!(snapshot.is_test_mode());
204
227
  }
@@ -211,6 +234,7 @@ mod tests {
211
234
  is_test_mode: false,
212
235
  cursor_position: Some((15, 20)), // Not None!
213
236
  buffer: None,
237
+ frame_count: 0,
214
238
  };
215
239
  assert_eq!(snapshot.cursor_position(), Some((15, 20)));
216
240
  }
@@ -229,6 +253,7 @@ mod tests {
229
253
  is_test_mode: true,
230
254
  cursor_position: None,
231
255
  buffer: Some(buffer),
256
+ frame_count: 0,
232
257
  };
233
258
 
234
259
  let cell = snapshot.cell_at(5, 5);
@@ -335,4 +360,41 @@ mod tests {
335
360
  assert_eq!(viewport.width, 80);
336
361
  assert_eq!(viewport.height, 5);
337
362
  }
363
+
364
+ #[test]
365
+ fn test_live_terminal_frame_count_increases_after_draw() {
366
+ use crate::terminal::TerminalWrapper;
367
+ use ratatui::{backend::TestBackend, Terminal};
368
+
369
+ let backend = TestBackend::new(80, 24);
370
+ let mut terminal = Terminal::new(backend).unwrap();
371
+
372
+ // Draw once to increment the counter
373
+ terminal.draw(|_frame| {}).unwrap();
374
+
375
+ let mut wrapper = TerminalWrapper::Test(terminal);
376
+ let live = super::LiveTerminal::new(&mut wrapper);
377
+
378
+ // Frame count should be 1 after one draw, NOT 0!
379
+ assert_eq!(live.frame_count(), 1);
380
+ }
381
+
382
+ #[test]
383
+ fn test_live_terminal_frame_count_increases_with_multiple_draws() {
384
+ use crate::terminal::TerminalWrapper;
385
+ use ratatui::{backend::TestBackend, Terminal};
386
+
387
+ let backend = TestBackend::new(80, 24);
388
+ let mut terminal = Terminal::new(backend).unwrap();
389
+
390
+ // Draw twice
391
+ terminal.draw(|_frame| {}).unwrap();
392
+ terminal.draw(|_frame| {}).unwrap();
393
+
394
+ let mut wrapper = TerminalWrapper::Test(terminal);
395
+ let live = super::LiveTerminal::new(&mut wrapper);
396
+
397
+ // Frame count should be 2 after two draws!
398
+ assert_eq!(live.frame_count(), 2);
399
+ }
338
400
  }
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ module RatatuiRuby
9
+ module Backend
10
+ # Terminal window dimensions in characters and pixels.
11
+ #
12
+ # Some operations need both character grid size and pixel dimensions.
13
+ # Sixel graphics, image rendering, and precise layout calculations all
14
+ # benefit from knowing both measurements at once.
15
+ #
16
+ # This struct bundles both sizes together. It matches upstream Ratatui's
17
+ # <tt>backend::WindowSize</tt> struct exactly.
18
+ #
19
+ # Both fields are <tt>Layout::Size</tt> instances. This reuses the same
20
+ # type for character and pixel dimensions, matching upstream design.
21
+ #
22
+ # Note: Pixel dimensions may be zero on some systems. Unix marks these
23
+ # fields "unused" in TIOCGWINSZ. Windows does not implement them.
24
+ #
25
+ # === Example
26
+ #
27
+ #--
28
+ # SPDX-SnippetBegin
29
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
30
+ # SPDX-License-Identifier: MIT-0
31
+ #++
32
+ # ws = RatatuiRuby::Terminal.window_size
33
+ # if ws
34
+ # puts "#{ws.columns_rows.width}x#{ws.columns_rows.height} chars"
35
+ # puts "#{ws.pixels.width}x#{ws.pixels.height} pixels"
36
+ # end
37
+ #--
38
+ # SPDX-SnippetEnd
39
+ #++
40
+ class WindowSize < Data.define(:columns_rows, :pixels)
41
+ ##
42
+ # :attr_reader: columns_rows
43
+ # Size of the window in characters (columns/rows) as <tt>Layout::Size</tt>.
44
+
45
+ ##
46
+ # :attr_reader: pixels
47
+ # Size of the window in pixels as <tt>Layout::Size</tt>.
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ module RatatuiRuby
9
+ # Backend abstractions for terminal rendering.
10
+ #
11
+ # This module contains types related to terminal backend operations.
12
+ # It mirrors upstream Ratatui's <tt>backend</tt> module structure.
13
+ module Backend
14
+ class << self
15
+ # Queries terminal window size in characters and pixels.
16
+ #
17
+ # Some operations need both the character grid and pixel dimensions.
18
+ # Querying them separately wastes syscalls. Most backends fetch both
19
+ # at once anyway.
20
+ #
21
+ # This method queries crossterm for window dimensions. It returns a
22
+ # <tt>Backend::WindowSize</tt> with <tt>columns_rows</tt> and
23
+ # <tt>pixels</tt> fields, each as <tt>Layout::Size</tt> instances.
24
+ # Returns <tt>nil</tt> if the query fails.
25
+ #
26
+ # Note: Pixel dimensions may be zero on some systems. Unix marks
27
+ # these fields "unused" in TIOCGWINSZ. Windows does not implement them.
28
+ #
29
+ # === Example
30
+ #
31
+ #--
32
+ # SPDX-SnippetBegin
33
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
34
+ # SPDX-License-Identifier: MIT-0
35
+ #++
36
+ # ws = RatatuiRuby::Backend.window_size
37
+ # if ws
38
+ # puts "#{ws.columns_rows.width}x#{ws.columns_rows.height} chars"
39
+ # puts "#{ws.pixels.width}x#{ws.pixels.height} pixels"
40
+ # end
41
+ #--
42
+ # SPDX-SnippetEnd
43
+ #++
44
+ def window_size
45
+ window_size = Terminal._terminal_window_size
46
+ return nil unless window_size
47
+ columns, rows, px_width, px_height = window_size
48
+ WindowSize.new(
49
+ columns_rows: Layout::Size.new(width: columns, height: rows),
50
+ pixels: Layout::Size.new(width: px_width, height: px_height)
51
+ )
52
+ rescue
53
+ nil
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ require_relative "backend/window_size"
@@ -39,9 +39,18 @@ module RatatuiRuby
39
39
  return true if @code == "back_tab"
40
40
  return true if @code == "tab" && @modifiers.include?("shift")
41
41
  end
42
-
43
42
  # DWIM: Check explicit aliases
44
43
  navigation_aliases = {
44
+ # Arrow key aliases (disambiguate from Mouse#up? and Mouse#down?)
45
+ arrow_up: "up",
46
+ up_arrow: "up",
47
+ arrow_down: "down",
48
+ down_arrow: "down",
49
+ arrow_left: "left",
50
+ left_arrow: "left",
51
+ arrow_right: "right",
52
+ right_arrow: "right",
53
+ # Other navigation aliases
45
54
  return: "enter",
46
55
  back: "backspace",
47
56
  del: "delete",
@@ -327,6 +327,60 @@ module RatatuiRuby
327
327
  #++
328
328
  # event.media_pause? # => true ONLY for media pause
329
329
  # event.code == "pause" # => true ONLY for system pause
330
+ #
331
+ #--
332
+ # SPDX-SnippetEnd
333
+ #++
334
+ # === Arrow Key Aliases
335
+ #
336
+ # Arrow keys respond to <tt>arrow_up?</tt> and <tt>up_arrow?</tt> variants.
337
+ # This disambiguates from Mouse events, which also respond to <tt>up?</tt>
338
+ # and <tt>down?</tt>:
339
+ #
340
+ #--
341
+ # SPDX-SnippetBegin
342
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
343
+ # SPDX-License-Identifier: MIT-0
344
+ #++
345
+ # event.arrow_up? # => true for up arrow key
346
+ # event.up_arrow? # => true for up arrow key
347
+ # event.arrow_down? # => true for down arrow key
348
+ #
349
+ #--
350
+ # SPDX-SnippetEnd
351
+ #++
352
+ # === Key Prefix and Suffix
353
+ #
354
+ # Predicates accept <tt>key_</tt> prefix or <tt>_key</tt> suffix for explicit
355
+ # key event matching in mixed event contexts:
356
+ #
357
+ #--
358
+ # SPDX-SnippetBegin
359
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
360
+ # SPDX-License-Identifier: MIT-0
361
+ #++
362
+ # event.key_up? # => true for up arrow key
363
+ # event.key_q? # => true for "q" key
364
+ # event.q_key? # => true for "q" key
365
+ # event.enter_key? # => true for enter key
366
+ #
367
+ #--
368
+ # SPDX-SnippetEnd
369
+ #++
370
+ # === Capital Letters and Shift
371
+ #
372
+ # Capital letter predicates match shifted keys naturally. The terminal reports
373
+ # the produced character with shift in the modifiers:
374
+ #
375
+ #--
376
+ # SPDX-SnippetBegin
377
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
378
+ # SPDX-License-Identifier: MIT-0
379
+ #++
380
+ # event.G? # => true for code="G" modifiers=["shift"]
381
+ # event.shift_g? # => true for code="G" modifiers=["shift"]
382
+ # event.alt_B? # => true for code="B" modifiers=["alt", "shift"]
383
+ #
330
384
  #--
331
385
  # SPDX-SnippetEnd
332
386
  #++
@@ -345,6 +399,36 @@ module RatatuiRuby
345
399
  return true if match_navigation_dwim?(key_name, key_sym)
346
400
  return true if match_system_dwim?(key_name, key_sym)
347
401
 
402
+ # DWIM: key_ prefix and _key suffix (disambiguate from mouse events)
403
+ # key_up? → up?, q_key? → q?, etc.
404
+ key_name = key_name.delete_prefix("key_").delete_suffix("_key")
405
+
406
+ # Fast path after prefix/suffix stripping
407
+ return true if self == key_name.to_sym
408
+
409
+ # DWIM: Single character codes match even with shift modifier present
410
+ # G? matches code="G" modifiers=["shift"], B? matches code="B" modifiers=["alt","shift"]
411
+ # @? matches code="@" modifiers=["shift"]
412
+ # The terminal reports the produced character, shift is implicit for these
413
+ if key_name.length == 1 && @code == key_name && @modifiers.include?("shift")
414
+ return true
415
+ end
416
+
417
+ # DWIM: Uppercase in predicate implies shift, so alt_B? matches alt_shift_B
418
+ # Parse predicate to extract modifiers and final letter
419
+ if key_name.match?(/\A([a-z_]+_)?([A-Z])\z/)
420
+ pred_letter = key_name[-1]
421
+ pred_mods = key_name.chop.delete_suffix("_").split("_").reject(&:empty?)
422
+ expected_mods = (pred_mods + ["shift"]).sort
423
+ return true if @code == pred_letter && @modifiers == expected_mods
424
+ end
425
+
426
+ # DWIM: Case-insensitive letter matching with modifiers
427
+ # shift_g? matches code="G" modifiers=["shift"]
428
+ if @code.length == 1 && @code.match?(/[A-Za-z]/) && (to_sym.to_s.downcase == key_name.downcase)
429
+ return true
430
+ end
431
+
348
432
  # DWIM: Universal underscore-insensitivity
349
433
  # Normalize both predicate and code by stripping underscores
350
434
  normalized_predicate = key_name.delete("_")
@@ -110,6 +110,9 @@ module RatatuiRuby
110
110
  @kind == "up"
111
111
  end
112
112
 
113
+ alias mouse_down? down?
114
+ alias mouse_up? up?
115
+
113
116
  # Returns true if mouse is being dragged.
114
117
  def drag?
115
118
  @kind == "drag"
@@ -137,6 +140,25 @@ module RatatuiRuby
137
140
  @kind == "scroll_down"
138
141
  end
139
142
 
143
+ # Returns true if event involves the left mouse button.
144
+ def left?
145
+ @button == "left"
146
+ end
147
+
148
+ # Returns true if event involves the right mouse button.
149
+ def right?
150
+ @button == "right"
151
+ end
152
+
153
+ # Returns true if event involves the middle mouse button.
154
+ def middle?
155
+ @button == "middle"
156
+ end
157
+
158
+ alias left_button? left?
159
+ alias right_button? right?
160
+ alias middle_button? middle?
161
+
140
162
  # Deconstructs the event for pattern matching.
141
163
  #
142
164
  #--
@@ -156,10 +178,80 @@ module RatatuiRuby
156
178
  end
157
179
 
158
180
  ##
159
- # Compares this event with another for equality.
181
+ # Converts the event to a Symbol representation.
182
+ #
183
+ # The format varies by event type:
184
+ #
185
+ # [Left Button]
186
+ # <tt>:mouse_left_down</tt>, <tt>:mouse_left_up</tt>, <tt>:mouse_left_drag</tt>
187
+ # [Right Button]
188
+ # <tt>:mouse_right_down</tt>, <tt>:mouse_right_up</tt>, <tt>:mouse_right_drag</tt>
189
+ # [Middle Button]
190
+ # <tt>:mouse_middle_down</tt>, <tt>:mouse_middle_up</tt>, <tt>:mouse_middle_drag</tt>
191
+ # [Scroll]
192
+ # <tt>:scroll_up</tt>, <tt>:scroll_down</tt>
193
+ # [Move]
194
+ #--
195
+ # SPDX-SnippetBegin
196
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
197
+ # SPDX-License-Identifier: MIT-0
198
+ #++
199
+ # <tt>:mouse_moved</tt>
200
+ #
201
+ #--
202
+ # SPDX-SnippetEnd
203
+ #++
204
+ # === Example
205
+ #
206
+ #--
207
+ # SPDX-SnippetBegin
208
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
209
+ # SPDX-License-Identifier: MIT-0
210
+ #++
211
+ # event = Event::Mouse.new(kind: "down", x: 10, y: 5, button: "left")
212
+ # event.to_sym # => :mouse_left_down
213
+ #
214
+ # scroll = Event::Mouse.new(kind: "scroll_up", x: 0, y: 0, button: "none")
215
+ # scroll.to_sym # => :scroll_up
216
+ #--
217
+ # SPDX-SnippetEnd
218
+ #++
219
+ def to_sym
220
+ if @kind.start_with?("scroll")
221
+ @kind.to_sym
222
+ elsif @button == "none"
223
+ :"mouse_#{@kind}"
224
+ else
225
+ :"mouse_#{@button}_#{@kind}"
226
+ end
227
+ end
228
+
229
+ ##
230
+ # Compares the event with another object.
231
+ #
232
+ # - If +other+ is a +Symbol+, compares against #to_sym.
233
+ # - If +other+ is a +Mouse+, compares as a value object.
234
+ # - Otherwise, returns +false+.
235
+ #
236
+ # === Example
237
+ #
238
+ #--
239
+ # SPDX-SnippetBegin
240
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
241
+ # SPDX-License-Identifier: MIT-0
242
+ #++
243
+ # if event == :mouse_left_down
244
+ # handle_click(event)
245
+ # end
246
+ #--
247
+ # SPDX-SnippetEnd
248
+ #++
160
249
  def ==(other)
161
- return false unless other.is_a?(Mouse)
162
- kind == other.kind && x == other.x && y == other.y && button == other.button && modifiers == other.modifiers
250
+ case other
251
+ when Symbol then to_sym == other
252
+ when Mouse then kind == other.kind && x == other.x && y == other.y && button == other.button && modifiers == other.modifiers
253
+ else false
254
+ end
163
255
  end
164
256
  end
165
257
  end
@@ -104,10 +104,52 @@ module RatatuiRuby
104
104
  end
105
105
 
106
106
  ##
107
- # Compares this event with another for equality.
107
+ # Converts the event to a Symbol representation.
108
+ #
109
+ # Always returns <tt>:resize</tt>.
110
+ #
111
+ # === Example
112
+ #
113
+ #--
114
+ # SPDX-SnippetBegin
115
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
116
+ # SPDX-License-Identifier: MIT-0
117
+ #++
118
+ # event = Event::Resize.new(width: 80, height: 24)
119
+ # event.to_sym # => :resize
120
+ #--
121
+ # SPDX-SnippetEnd
122
+ #++
123
+ def to_sym
124
+ :resize
125
+ end
126
+
127
+ ##
128
+ # Compares the event with another object.
129
+ #
130
+ # - If +other+ is a +Symbol+, compares against #to_sym.
131
+ # - If +other+ is a +Resize+, compares as a value object.
132
+ # - Otherwise, returns +false+.
133
+ #
134
+ # === Example
135
+ #
136
+ #--
137
+ # SPDX-SnippetBegin
138
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
139
+ # SPDX-License-Identifier: MIT-0
140
+ #++
141
+ # if event == :resize
142
+ # handle_resize(event)
143
+ # end
144
+ #--
145
+ # SPDX-SnippetEnd
146
+ #++
108
147
  def ==(other)
109
- return false unless other.is_a?(Resize)
110
- width == other.width && height == other.height
148
+ case other
149
+ when Symbol then to_sym == other
150
+ when Resize then width == other.width && height == other.height
151
+ else false
152
+ end
111
153
  end
112
154
  end
113
155
  end