ratatui_ruby 0.5.0 → 0.6.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 +6 -0
- data/CHANGELOG.md +44 -7
- data/README.md +11 -4
- data/REUSE.toml +2 -7
- data/doc/application_architecture.md +84 -10
- data/doc/application_testing.md +75 -29
- data/doc/contributors/design/ruby_frontend.md +39 -3
- data/doc/contributors/design/rust_backend.md +1 -0
- data/doc/contributors/developing_examples.md +129 -44
- data/doc/contributors/examples_audit/p1_high.md +21 -0
- data/doc/contributors/examples_audit/p2_moderate.md +81 -0
- data/doc/contributors/examples_audit.md +41 -0
- data/doc/event_handling.md +11 -3
- data/doc/images/app_all_events.png +0 -0
- data/doc/images/app_color_picker.png +0 -0
- data/doc/images/app_login_form.png +0 -0
- data/doc/images/app_stateful_interaction.png +0 -0
- data/doc/images/verify_quickstart_dsl.png +0 -0
- data/doc/images/verify_quickstart_layout.png +0 -0
- data/doc/images/verify_quickstart_lifecycle.png +0 -0
- data/doc/images/verify_readme_usage.png +0 -0
- data/doc/images/widget_barchart_demo.png +0 -0
- data/doc/images/widget_block_demo.png +0 -0
- data/doc/images/widget_canvas_demo.png +0 -0
- data/doc/images/widget_cell_demo.png +0 -0
- data/doc/images/widget_center_demo.png +0 -0
- data/doc/images/widget_chart_demo.png +0 -0
- data/doc/images/widget_list_demo.png +0 -0
- data/doc/images/widget_overlay_demo.png +0 -0
- data/doc/images/widget_render.png +0 -0
- data/doc/images/widget_rich_text.png +0 -0
- data/doc/images/widget_scroll_text.png +0 -0
- data/doc/images/widget_sparkline_demo.png +0 -0
- data/doc/images/widget_table_demo.png +0 -0
- data/doc/images/widget_tabs_demo.png +0 -0
- data/doc/images/widget_text_width.png +0 -0
- data/doc/quickstart.md +69 -76
- data/doc/terminal_limitations.md +92 -0
- data/examples/app_all_events/README.md +45 -27
- data/examples/app_all_events/app.rb +38 -35
- data/examples/app_all_events/model/app_model.rb +157 -0
- data/examples/app_all_events/model/event_entry.rb +17 -0
- data/examples/app_all_events/model/msg.rb +37 -0
- data/examples/app_all_events/update.rb +73 -0
- data/examples/app_all_events/view/app_view.rb +8 -8
- data/examples/app_all_events/view/controls_view.rb +8 -6
- data/examples/app_all_events/view/counts_view.rb +12 -8
- data/examples/app_all_events/view/live_view.rb +8 -7
- data/examples/app_all_events/view/log_view.rb +10 -15
- data/examples/app_color_picker/README.md +84 -44
- data/examples/app_color_picker/app.rb +24 -62
- data/examples/app_color_picker/controls.rb +90 -0
- data/examples/app_color_picker/copy_dialog.rb +45 -49
- data/examples/app_color_picker/export_pane.rb +126 -0
- data/examples/app_color_picker/input.rb +99 -67
- data/examples/app_color_picker/main_container.rb +178 -0
- data/examples/app_color_picker/palette.rb +55 -26
- data/examples/app_login_form/README.md +47 -0
- data/examples/app_login_form/app.rb +2 -3
- data/examples/app_stateful_interaction/README.md +31 -0
- data/examples/app_stateful_interaction/app.rb +272 -0
- data/examples/timeout_demo.rb +43 -0
- data/examples/verify_quickstart_dsl/README.md +48 -0
- data/examples/verify_quickstart_dsl/app.rb +2 -0
- data/examples/verify_quickstart_layout/README.md +71 -0
- data/examples/verify_quickstart_layout/app.rb +2 -0
- data/examples/verify_quickstart_lifecycle/README.md +56 -0
- data/examples/verify_quickstart_lifecycle/app.rb +8 -2
- data/examples/verify_readme_usage/README.md +43 -0
- data/examples/verify_readme_usage/app.rb +8 -2
- data/examples/widget_barchart_demo/README.md +49 -0
- data/examples/widget_barchart_demo/app.rb +5 -5
- data/examples/widget_block_demo/README.md +34 -0
- data/examples/widget_block_demo/app.rb +256 -0
- data/examples/widget_box_demo/README.md +45 -0
- data/examples/widget_calendar_demo/README.md +39 -0
- data/examples/widget_canvas_demo/README.md +27 -0
- data/examples/widget_canvas_demo/app.rb +123 -0
- data/examples/widget_cell_demo/README.md +36 -0
- data/examples/widget_cell_demo/app.rb +31 -24
- data/examples/widget_center_demo/README.md +29 -0
- data/examples/widget_center_demo/app.rb +116 -0
- data/examples/widget_chart_demo/README.md +41 -0
- data/examples/widget_chart_demo/app.rb +7 -2
- data/examples/widget_gauge_demo/README.md +41 -0
- data/examples/widget_layout_split/README.md +44 -0
- data/examples/widget_line_gauge_demo/README.md +41 -0
- data/examples/widget_list_demo/README.md +49 -0
- data/examples/widget_list_demo/app.rb +91 -107
- data/examples/widget_map_demo/README.md +39 -0
- data/examples/{app_map_demo → widget_map_demo}/app.rb +2 -2
- data/examples/widget_overlay_demo/app.rb +248 -0
- data/examples/widget_popup_demo/README.md +36 -0
- data/examples/widget_ratatui_logo_demo/README.md +34 -0
- data/examples/widget_ratatui_mascot_demo/README.md +34 -0
- data/examples/widget_rect/README.md +38 -0
- data/examples/widget_render/README.md +37 -0
- data/examples/widget_rich_text/README.md +35 -0
- data/examples/widget_rich_text/app.rb +62 -33
- data/examples/widget_scroll_text/README.md +37 -0
- data/examples/widget_scroll_text/app.rb +0 -1
- data/examples/widget_scrollbar_demo/README.md +37 -0
- data/examples/widget_sparkline_demo/README.md +42 -0
- data/examples/widget_sparkline_demo/app.rb +4 -3
- data/examples/widget_style_colors/README.md +34 -0
- data/examples/widget_table_demo/README.md +48 -0
- data/examples/{app_table_select → widget_table_demo}/app.rb +46 -8
- data/examples/widget_tabs_demo/README.md +41 -0
- data/examples/widget_tabs_demo/app.rb +15 -1
- data/examples/widget_text_width/README.md +35 -0
- data/examples/widget_text_width/app.rb +106 -0
- data/exe/.gitkeep +0 -0
- data/ext/ratatui_ruby/Cargo.lock +11 -4
- data/ext/ratatui_ruby/Cargo.toml +2 -1
- data/ext/ratatui_ruby/src/events.rs +238 -26
- data/ext/ratatui_ruby/src/frame.rs +113 -1
- data/ext/ratatui_ruby/src/lib.rs +34 -4
- data/ext/ratatui_ruby/src/string_width.rs +101 -0
- data/ext/ratatui_ruby/src/terminal.rs +39 -15
- data/ext/ratatui_ruby/src/text.rs +1 -1
- data/ext/ratatui_ruby/src/widgets/barchart.rs +24 -6
- data/ext/ratatui_ruby/src/widgets/gauge.rs +9 -2
- data/ext/ratatui_ruby/src/widgets/line_gauge.rs +9 -2
- data/ext/ratatui_ruby/src/widgets/list.rs +179 -3
- data/ext/ratatui_ruby/src/widgets/list_state.rs +137 -0
- data/ext/ratatui_ruby/src/widgets/mod.rs +3 -0
- data/ext/ratatui_ruby/src/widgets/scrollbar.rs +93 -1
- data/ext/ratatui_ruby/src/widgets/scrollbar_state.rs +169 -0
- data/ext/ratatui_ruby/src/widgets/table.rs +113 -1
- data/ext/ratatui_ruby/src/widgets/table_state.rs +121 -0
- data/lib/ratatui_ruby/cell.rb +4 -4
- data/lib/ratatui_ruby/event/key/character.rb +35 -0
- data/lib/ratatui_ruby/event/key/media.rb +44 -0
- data/lib/ratatui_ruby/event/key/modifier.rb +95 -0
- data/lib/ratatui_ruby/event/key/navigation.rb +55 -0
- data/lib/ratatui_ruby/event/key/system.rb +45 -0
- data/lib/ratatui_ruby/event/key.rb +111 -51
- data/lib/ratatui_ruby/event/mouse.rb +3 -3
- data/lib/ratatui_ruby/event/paste.rb +1 -1
- data/lib/ratatui_ruby/frame.rb +96 -0
- data/lib/ratatui_ruby/list_state.rb +88 -0
- data/lib/ratatui_ruby/schema/bar_chart/bar.rb +2 -2
- data/lib/ratatui_ruby/schema/cursor.rb +5 -0
- data/lib/ratatui_ruby/schema/gauge.rb +3 -1
- data/lib/ratatui_ruby/schema/line_gauge.rb +2 -2
- data/lib/ratatui_ruby/schema/list.rb +25 -4
- data/lib/ratatui_ruby/schema/list_item.rb +41 -0
- data/lib/ratatui_ruby/schema/rect.rb +43 -0
- data/lib/ratatui_ruby/schema/style.rb +24 -4
- data/lib/ratatui_ruby/schema/table.rb +21 -3
- data/lib/ratatui_ruby/schema/text.rb +69 -1
- data/lib/ratatui_ruby/scrollbar_state.rb +112 -0
- data/lib/ratatui_ruby/session/autodoc.rb +65 -0
- data/lib/ratatui_ruby/session.rb +22 -7
- data/lib/ratatui_ruby/table_state.rb +90 -0
- data/lib/ratatui_ruby/test_helper/event_injection.rb +169 -0
- data/lib/ratatui_ruby/test_helper/snapshot.rb +390 -0
- data/lib/ratatui_ruby/test_helper/style_assertions.rb +351 -0
- data/lib/ratatui_ruby/test_helper/terminal.rb +127 -0
- data/lib/ratatui_ruby/test_helper/test_doubles.rb +68 -0
- data/lib/ratatui_ruby/test_helper.rb +65 -358
- data/lib/ratatui_ruby/version.rb +1 -1
- data/lib/ratatui_ruby.rb +42 -19
- data/sig/examples/app_stateful_interaction/app.rbs +33 -0
- data/sig/examples/widget_block_demo/app.rbs +32 -0
- data/sig/examples/{app_map_demo → widget_map_demo}/app.rbs +2 -2
- data/sig/examples/{app_table_select → widget_table_demo}/app.rbs +2 -2
- data/sig/examples/{widget_table_flex → widget_text_width}/app.rbs +2 -3
- data/sig/ratatui_ruby/event.rbs +11 -1
- data/sig/ratatui_ruby/frame.rbs +2 -0
- data/sig/ratatui_ruby/list_state.rbs +13 -0
- data/sig/ratatui_ruby/ratatui_ruby.rbs +2 -2
- data/sig/ratatui_ruby/schema/bar_chart/bar.rbs +3 -3
- data/sig/ratatui_ruby/schema/gauge.rbs +2 -2
- data/sig/ratatui_ruby/schema/line_gauge.rbs +2 -2
- data/sig/ratatui_ruby/schema/list.rbs +4 -2
- data/sig/ratatui_ruby/schema/list_item.rbs +10 -0
- data/sig/ratatui_ruby/schema/rect.rbs +3 -0
- data/sig/ratatui_ruby/schema/style.rbs +3 -3
- data/sig/ratatui_ruby/schema/table.rbs +3 -1
- data/sig/ratatui_ruby/schema/text.rbs +8 -6
- data/sig/ratatui_ruby/scrollbar_state.rbs +18 -0
- data/sig/ratatui_ruby/session.rbs +13 -0
- data/sig/ratatui_ruby/table_state.rbs +15 -0
- data/sig/ratatui_ruby/test_helper/event_injection.rbs +16 -0
- data/sig/ratatui_ruby/test_helper/snapshot.rbs +12 -0
- data/sig/ratatui_ruby/test_helper/style_assertions.rbs +64 -0
- data/sig/ratatui_ruby/test_helper/terminal.rbs +14 -0
- data/sig/ratatui_ruby/test_helper/test_doubles.rbs +22 -0
- data/sig/ratatui_ruby/test_helper.rbs +5 -4
- data/tasks/autodoc/examples.rb +79 -0
- data/tasks/autodoc/inventory.rb +9 -7
- data/tasks/autodoc.rake +11 -5
- data/tasks/bump/changelog.rb +3 -3
- data/tasks/bump/links.rb +67 -0
- data/tasks/sourcehut.rake +61 -21
- data/tasks/terminal_preview/app_screenshot.rb +13 -3
- data/tasks/terminal_preview/saved_screenshot.rb +4 -3
- metadata +111 -37
- data/doc/images/app_table_select.png +0 -0
- data/doc/images/widget_block_padding.png +0 -0
- data/doc/images/widget_block_titles.png +0 -0
- data/doc/images/widget_list_styles.png +0 -0
- data/examples/app_all_events/model/events.rb +0 -180
- data/examples/app_all_events/model/highlight.rb +0 -57
- data/examples/app_all_events/test/snapshots/after_focus_lost.txt +0 -24
- data/examples/app_all_events/test/snapshots/after_focus_regained.txt +0 -24
- data/examples/app_all_events/test/snapshots/after_horizontal_resize.txt +0 -24
- data/examples/app_all_events/test/snapshots/after_key_a.txt +0 -24
- data/examples/app_all_events/test/snapshots/after_key_ctrl_x.txt +0 -24
- data/examples/app_all_events/test/snapshots/after_mouse_click.txt +0 -24
- data/examples/app_all_events/test/snapshots/after_mouse_drag.txt +0 -24
- data/examples/app_all_events/test/snapshots/after_multiple_events.txt +0 -24
- data/examples/app_all_events/test/snapshots/after_paste.txt +0 -24
- data/examples/app_all_events/test/snapshots/after_resize.txt +0 -24
- data/examples/app_all_events/test/snapshots/after_right_click.txt +0 -24
- data/examples/app_all_events/test/snapshots/after_vertical_resize.txt +0 -24
- data/examples/app_all_events/test/snapshots/initial_state.txt +0 -24
- data/examples/app_all_events/view_state.rb +0 -42
- data/examples/app_color_picker/scene.rb +0 -201
- data/examples/widget_block_padding/app.rb +0 -67
- data/examples/widget_block_titles/app.rb +0 -69
- data/examples/widget_list_styles/app.rb +0 -141
- data/examples/widget_table_flex/app.rb +0 -95
- data/sig/examples/widget_block_padding/app.rbs +0 -11
- data/sig/examples/widget_block_titles/app.rbs +0 -11
- data/sig/examples/widget_list_styles/app.rbs +0 -11
- data/tasks/bump/comparison_links.rb +0 -41
- /data/doc/images/{app_map_demo.png → widget_map_demo.png} +0 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
|
|
6
|
+
module RatatuiRuby
|
|
7
|
+
# Mutable state object for Table widgets.
|
|
8
|
+
#
|
|
9
|
+
# When using {Frame#render_stateful_widget}, the State object is the
|
|
10
|
+
# *single source of truth* for selection and scroll offset. Widget
|
|
11
|
+
# properties (+selected_row+, +selected_column+, +offset+) are *ignored*
|
|
12
|
+
# in stateful mode.
|
|
13
|
+
#
|
|
14
|
+
# == Example
|
|
15
|
+
#
|
|
16
|
+
# @table_state = RatatuiRuby::TableState.new
|
|
17
|
+
# @table_state.select(1) # Select second row
|
|
18
|
+
# @table_state.select_column(0) # Select first column
|
|
19
|
+
#
|
|
20
|
+
# RatatuiRuby.draw do |frame|
|
|
21
|
+
# table = RatatuiRuby::Table.new(rows: [...], widths: [...])
|
|
22
|
+
# frame.render_stateful_widget(table, frame.area, @table_state)
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
class TableState
|
|
26
|
+
##
|
|
27
|
+
# :method: new
|
|
28
|
+
# :call-seq: new(selected = nil) -> TableState
|
|
29
|
+
#
|
|
30
|
+
# Creates a new TableState with optional initial row selection.
|
|
31
|
+
#
|
|
32
|
+
# (Native method implemented in Rust)
|
|
33
|
+
|
|
34
|
+
##
|
|
35
|
+
# :method: select
|
|
36
|
+
# :call-seq: select(index) -> nil
|
|
37
|
+
#
|
|
38
|
+
# Sets the selected row index. Pass +nil+ to deselect.
|
|
39
|
+
#
|
|
40
|
+
# (Native method implemented in Rust)
|
|
41
|
+
|
|
42
|
+
##
|
|
43
|
+
# :method: selected
|
|
44
|
+
# :call-seq: selected() -> Integer or nil
|
|
45
|
+
#
|
|
46
|
+
# Returns the currently selected row index.
|
|
47
|
+
#
|
|
48
|
+
# (Native method implemented in Rust)
|
|
49
|
+
|
|
50
|
+
##
|
|
51
|
+
# :method: select_column
|
|
52
|
+
# :call-seq: select_column(index) -> nil
|
|
53
|
+
#
|
|
54
|
+
# Sets the selected column index. Pass +nil+ to deselect.
|
|
55
|
+
#
|
|
56
|
+
# (Native method implemented in Rust)
|
|
57
|
+
|
|
58
|
+
##
|
|
59
|
+
# :method: selected_column
|
|
60
|
+
# :call-seq: selected_column() -> Integer or nil
|
|
61
|
+
#
|
|
62
|
+
# Returns the currently selected column index.
|
|
63
|
+
#
|
|
64
|
+
# (Native method implemented in Rust)
|
|
65
|
+
|
|
66
|
+
##
|
|
67
|
+
# :method: offset
|
|
68
|
+
# :call-seq: offset() -> Integer
|
|
69
|
+
#
|
|
70
|
+
# Returns the current scroll offset.
|
|
71
|
+
#
|
|
72
|
+
# (Native method implemented in Rust)
|
|
73
|
+
|
|
74
|
+
##
|
|
75
|
+
# :method: scroll_down_by
|
|
76
|
+
# :call-seq: scroll_down_by(n) -> nil
|
|
77
|
+
#
|
|
78
|
+
# Scrolls down by +n+ rows.
|
|
79
|
+
#
|
|
80
|
+
# (Native method implemented in Rust)
|
|
81
|
+
|
|
82
|
+
##
|
|
83
|
+
# :method: scroll_up_by
|
|
84
|
+
# :call-seq: scroll_up_by(n) -> nil
|
|
85
|
+
#
|
|
86
|
+
# Scrolls up by +n+ rows.
|
|
87
|
+
#
|
|
88
|
+
# (Native method implemented in Rust)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
|
|
6
|
+
module RatatuiRuby
|
|
7
|
+
module TestHelper
|
|
8
|
+
##
|
|
9
|
+
# Event injection helpers for testing TUI interactions.
|
|
10
|
+
#
|
|
11
|
+
# Testing keyboard navigation and mouse clicks requires simulating user input.
|
|
12
|
+
# Constructing event objects by hand for every test is verbose and repetitive.
|
|
13
|
+
#
|
|
14
|
+
# This mixin provides convenience methods to inject keys, clicks, and other events
|
|
15
|
+
# into the test terminal's event queue. Events are consumed by the next
|
|
16
|
+
# <tt>poll_event</tt> call.
|
|
17
|
+
#
|
|
18
|
+
# Use it to simulate user interactions: typing, clicking, dragging, pasting.
|
|
19
|
+
#
|
|
20
|
+
# === Examples
|
|
21
|
+
#
|
|
22
|
+
# with_test_terminal do
|
|
23
|
+
# inject_keys("h", "e", "l", "l", "o")
|
|
24
|
+
# inject_keys(:enter, :ctrl_s)
|
|
25
|
+
# inject_click(x: 10, y: 5)
|
|
26
|
+
# inject_event(RatatuiRuby::Event::Paste.new(content: "pasted text"))
|
|
27
|
+
#
|
|
28
|
+
# @app.run
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
module EventInjection
|
|
32
|
+
##
|
|
33
|
+
# Injects an event into the test terminal's event queue.
|
|
34
|
+
#
|
|
35
|
+
# Pass any <tt>RatatuiRuby::Event</tt> object. The event is returned by
|
|
36
|
+
# the next <tt>poll_event</tt> call.
|
|
37
|
+
#
|
|
38
|
+
# Raises <tt>RuntimeError</tt> if called outside a <tt>with_test_terminal</tt> block.
|
|
39
|
+
#
|
|
40
|
+
# === Examples
|
|
41
|
+
#
|
|
42
|
+
# inject_event(RatatuiRuby::Event::Key.new(code: "q"))
|
|
43
|
+
# inject_event(RatatuiRuby::Event::Mouse.new(kind: "down", button: "left", x: 10, y: 5))
|
|
44
|
+
# inject_event(RatatuiRuby::Event::Paste.new(content: "Hello"))
|
|
45
|
+
#
|
|
46
|
+
# [event] A <tt>RatatuiRuby::Event</tt> object.
|
|
47
|
+
def inject_event(event)
|
|
48
|
+
unless @_ratatui_test_terminal_active
|
|
49
|
+
raise "Events must be injected inside a `with_test_terminal` block. " \
|
|
50
|
+
"Calling this method outside the block causes a race condition where the event " \
|
|
51
|
+
"is flushed before the application starts."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
case event
|
|
55
|
+
when RatatuiRuby::Event::Key
|
|
56
|
+
RatatuiRuby.inject_test_event("key", { code: event.code, modifiers: event.modifiers })
|
|
57
|
+
when RatatuiRuby::Event::Mouse
|
|
58
|
+
RatatuiRuby.inject_test_event("mouse", {
|
|
59
|
+
kind: event.kind,
|
|
60
|
+
button: event.button,
|
|
61
|
+
x: event.x,
|
|
62
|
+
y: event.y,
|
|
63
|
+
modifiers: event.modifiers,
|
|
64
|
+
})
|
|
65
|
+
when RatatuiRuby::Event::Resize
|
|
66
|
+
RatatuiRuby.inject_test_event("resize", { width: event.width, height: event.height })
|
|
67
|
+
when RatatuiRuby::Event::Paste
|
|
68
|
+
RatatuiRuby.inject_test_event("paste", { content: event.content })
|
|
69
|
+
when RatatuiRuby::Event::FocusGained
|
|
70
|
+
RatatuiRuby.inject_test_event("focus_gained", {})
|
|
71
|
+
when RatatuiRuby::Event::FocusLost
|
|
72
|
+
RatatuiRuby.inject_test_event("focus_lost", {})
|
|
73
|
+
else
|
|
74
|
+
raise ArgumentError, "Unknown event type: #{event.class}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
##
|
|
79
|
+
# Injects a mouse event.
|
|
80
|
+
#
|
|
81
|
+
# === Example
|
|
82
|
+
#
|
|
83
|
+
# inject_mouse(x: 10, y: 5, kind: :down, button: :left)
|
|
84
|
+
#
|
|
85
|
+
# [x] Integer x-coordinate.
|
|
86
|
+
# [y] Integer y-coordinate.
|
|
87
|
+
# [kind] Symbol <tt>:down</tt>, <tt>:up</tt>, or <tt>:drag</tt>.
|
|
88
|
+
# [button] Symbol <tt>:left</tt>, <tt>:right</tt>, or <tt>:middle</tt>.
|
|
89
|
+
# [modifiers] Array of modifier strings.
|
|
90
|
+
def inject_mouse(x:, y:, kind: :down, modifiers: [], button: :left)
|
|
91
|
+
event = RatatuiRuby::Event::Mouse.new(
|
|
92
|
+
kind: kind.to_s,
|
|
93
|
+
x:,
|
|
94
|
+
y:,
|
|
95
|
+
button: button.to_s,
|
|
96
|
+
modifiers:
|
|
97
|
+
)
|
|
98
|
+
inject_event(event)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
##
|
|
102
|
+
# Injects a left mouse click.
|
|
103
|
+
#
|
|
104
|
+
# === Example
|
|
105
|
+
#
|
|
106
|
+
# inject_click(x: 10, y: 5)
|
|
107
|
+
def inject_click(x:, y:, modifiers: [])
|
|
108
|
+
inject_mouse(x:, y:, kind: :down, modifiers:, button: :left)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
##
|
|
112
|
+
# Injects a right mouse click.
|
|
113
|
+
#
|
|
114
|
+
# === Example
|
|
115
|
+
#
|
|
116
|
+
# inject_right_click(x: 10, y: 5)
|
|
117
|
+
def inject_right_click(x:, y:, modifiers: [])
|
|
118
|
+
inject_mouse(x:, y:, kind: :down, modifiers:, button: :right)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
##
|
|
122
|
+
# Injects a mouse drag event.
|
|
123
|
+
#
|
|
124
|
+
# === Example
|
|
125
|
+
#
|
|
126
|
+
# inject_drag(x: 10, y: 5)
|
|
127
|
+
def inject_drag(x:, y:, modifiers: [], button: :left)
|
|
128
|
+
inject_mouse(x:, y:, kind: :drag, modifiers:, button:)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
##
|
|
132
|
+
# Injects one or more key events.
|
|
133
|
+
#
|
|
134
|
+
# Accepts multiple formats for convenience:
|
|
135
|
+
# - String: Character key (e.g., <tt>"a"</tt>, <tt>"q"</tt>)
|
|
136
|
+
# - Symbol: Named key or modifier combo (e.g., <tt>:enter</tt>, <tt>:ctrl_c</tt>)
|
|
137
|
+
# - Hash: Passed to <tt>Key.new</tt>
|
|
138
|
+
# - Key: Passed directly
|
|
139
|
+
#
|
|
140
|
+
# === Examples
|
|
141
|
+
#
|
|
142
|
+
# inject_keys("a", "b", "c")
|
|
143
|
+
# inject_keys(:enter, :esc)
|
|
144
|
+
# inject_keys(:ctrl_c, :alt_shift_left)
|
|
145
|
+
# inject_keys("j", { code: "k", modifiers: ["ctrl"] })
|
|
146
|
+
def inject_keys(*args)
|
|
147
|
+
args.each do |arg|
|
|
148
|
+
event = case arg
|
|
149
|
+
when String
|
|
150
|
+
RatatuiRuby::Event::Key.new(code: arg)
|
|
151
|
+
when Symbol
|
|
152
|
+
parts = arg.to_s.split("_")
|
|
153
|
+
code = parts.pop
|
|
154
|
+
modifiers = parts
|
|
155
|
+
RatatuiRuby::Event::Key.new(code:, modifiers:)
|
|
156
|
+
when Hash
|
|
157
|
+
RatatuiRuby::Event::Key.new(**arg)
|
|
158
|
+
when RatatuiRuby::Event::Key
|
|
159
|
+
arg
|
|
160
|
+
else
|
|
161
|
+
raise ArgumentError, "Invalid key argument: #{arg.inspect}. Expected String, Symbol, Hash, or Key event."
|
|
162
|
+
end
|
|
163
|
+
inject_event(event)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
alias inject_key inject_keys
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module RatatuiRuby
|
|
9
|
+
module TestHelper
|
|
10
|
+
##
|
|
11
|
+
# Snapshot testing assertions for terminal UIs.
|
|
12
|
+
#
|
|
13
|
+
# Verifying every character of a TUI screen by hand is tedious. Snapshots let you
|
|
14
|
+
# capture the screen once and compare against it in future runs.
|
|
15
|
+
#
|
|
16
|
+
# This mixin provides <tt>assert_snapshot</tt> for plain text and
|
|
17
|
+
# <tt>assert_rich_snapshot</tt> for styled ANSI output. Both auto-create
|
|
18
|
+
# snapshot files on first run.
|
|
19
|
+
#
|
|
20
|
+
# Use it to verify complex layouts, styles, and interactions without manual assertions.
|
|
21
|
+
#
|
|
22
|
+
# === Snapshot Files
|
|
23
|
+
#
|
|
24
|
+
# Snapshots live in a <tt>snapshots/</tt> subdirectory next to your test file:
|
|
25
|
+
#
|
|
26
|
+
# test/examples/my_app/test_app.rb
|
|
27
|
+
# test/examples/my_app/snapshots/initial_render.txt
|
|
28
|
+
# test/examples/my_app/snapshots/initial_render.ansi
|
|
29
|
+
#
|
|
30
|
+
# === Creating and Updating Snapshots
|
|
31
|
+
#
|
|
32
|
+
# Run tests with <tt>UPDATE_SNAPSHOTS=1</tt> to create or refresh snapshots:
|
|
33
|
+
#
|
|
34
|
+
# UPDATE_SNAPSHOTS=1 bundle exec rake test
|
|
35
|
+
#
|
|
36
|
+
# === Seeding Random Data
|
|
37
|
+
#
|
|
38
|
+
# Random data (scatter plots, generated content) breaks snapshot stability.
|
|
39
|
+
# Use a seeded <tt>Random</tt> instance instead of <tt>Kernel.rand</tt>:
|
|
40
|
+
#
|
|
41
|
+
# class MyApp
|
|
42
|
+
# def initialize(seed: nil)
|
|
43
|
+
# @rng = seed ? Random.new(seed) : Random.new
|
|
44
|
+
# end
|
|
45
|
+
#
|
|
46
|
+
# def generate_data
|
|
47
|
+
# (0..20).map { @rng.rand(0.0..10.0) }
|
|
48
|
+
# end
|
|
49
|
+
# end
|
|
50
|
+
#
|
|
51
|
+
# # In your test
|
|
52
|
+
# def setup
|
|
53
|
+
# @app = MyApp.new(seed: 42)
|
|
54
|
+
# end
|
|
55
|
+
#
|
|
56
|
+
# For libraries like Faker, see their docs on deterministic random:
|
|
57
|
+
# https://github.com/faker-ruby/faker#deterministic-random
|
|
58
|
+
#
|
|
59
|
+
# === Normalization Blocks
|
|
60
|
+
#
|
|
61
|
+
# Mask dynamic content (timestamps, IDs) with a normalization block:
|
|
62
|
+
#
|
|
63
|
+
# assert_snapshot("dashboard") do |lines|
|
|
64
|
+
# lines.map { |l| l.gsub(/\d{4}-\d{2}-\d{2}/, "YYYY-MM-DD") }
|
|
65
|
+
# end
|
|
66
|
+
#
|
|
67
|
+
module Snapshot
|
|
68
|
+
##
|
|
69
|
+
# Asserts that the current screen content matches a stored snapshot.
|
|
70
|
+
#
|
|
71
|
+
# This method simplifies snapshot testing by automatically resolving the snapshot path
|
|
72
|
+
# relative to the test file calling this method. It assumes a "snapshots" directory
|
|
73
|
+
# exists in the same directory as the test file.
|
|
74
|
+
#
|
|
75
|
+
# # In test/test_login.rb
|
|
76
|
+
# assert_snapshot("login_screen")
|
|
77
|
+
# # Look for: test/snapshots/login_screen.txt
|
|
78
|
+
#
|
|
79
|
+
# # With normalization block
|
|
80
|
+
# assert_snapshot("clock") do |actual|
|
|
81
|
+
# actual.map { |l| l.gsub(/\d{2}:\d{2}/, "XX:XX") }
|
|
82
|
+
# end
|
|
83
|
+
#
|
|
84
|
+
# [name] String name of the snapshot (without extension).
|
|
85
|
+
# [msg] String optional failure message.
|
|
86
|
+
def assert_snapshot(name, msg = nil, &)
|
|
87
|
+
# Get the path of the test file calling this method
|
|
88
|
+
caller_path = caller_locations(1, 1).first.path
|
|
89
|
+
snapshot_dir = File.join(File.dirname(caller_path), "snapshots")
|
|
90
|
+
snapshot_path = File.join(snapshot_dir, "#{name}.txt")
|
|
91
|
+
|
|
92
|
+
assert_screen_matches(snapshot_path, msg, &)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
##
|
|
96
|
+
# Asserts that the current screen content matches the expected content.
|
|
97
|
+
#
|
|
98
|
+
# Users need to verify that the entire TUI screen looks exactly as expected.
|
|
99
|
+
# Manually checking every cell or line is tedious and error-prone.
|
|
100
|
+
#
|
|
101
|
+
# This helper compares the current buffer content against an expected string (file path)
|
|
102
|
+
# or array of strings. It supports automatic snapshot creation and updating via
|
|
103
|
+
# the +UPDATE_SNAPSHOTS+ environment variable.
|
|
104
|
+
#
|
|
105
|
+
# Use it to verify complex UI states, layouts, and renderings.
|
|
106
|
+
#
|
|
107
|
+
# == Usage
|
|
108
|
+
#
|
|
109
|
+
# # Direct comparison
|
|
110
|
+
# assert_screen_matches(["Line 1", "Line 2"])
|
|
111
|
+
#
|
|
112
|
+
# # File comparison
|
|
113
|
+
# assert_screen_matches("test/snapshots/login.txt")
|
|
114
|
+
#
|
|
115
|
+
# # With normalization (e.g., masking dynamic data)
|
|
116
|
+
# assert_screen_matches("test/snapshots/dashboard.txt") do |lines|
|
|
117
|
+
# lines.map { |l| l.gsub(/User ID: \d+/, "User ID: XXX") }
|
|
118
|
+
# end
|
|
119
|
+
#
|
|
120
|
+
# [expected] String (file path) or Array<String> (content).
|
|
121
|
+
# [msg] String optional failure message.
|
|
122
|
+
#
|
|
123
|
+
# == Non-Determinism
|
|
124
|
+
#
|
|
125
|
+
# To prevent flaky tests, this assertion performs a "Flakiness Check" when creating or updating
|
|
126
|
+
# snapshots. It captures the screen content, immediately re-renders the buffer, and compares
|
|
127
|
+
# the two results.
|
|
128
|
+
#
|
|
129
|
+
# Ensure your render logic is deterministic by seeding random number generators and stubbing
|
|
130
|
+
# time where necessary.
|
|
131
|
+
def assert_screen_matches(expected, msg = nil)
|
|
132
|
+
actual_lines = buffer_content
|
|
133
|
+
|
|
134
|
+
if block_given?
|
|
135
|
+
actual_lines = yield(actual_lines)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
if expected.is_a?(String)
|
|
139
|
+
# Snapshot file mode
|
|
140
|
+
snapshot_path = expected
|
|
141
|
+
update_snapshots = ENV["UPDATE_SNAPSHOTS"] == "1" || ENV["UPDATE_SNAPSHOTS"] == "true"
|
|
142
|
+
|
|
143
|
+
if !File.exist?(snapshot_path) || update_snapshots
|
|
144
|
+
FileUtils.mkdir_p(File.dirname(snapshot_path))
|
|
145
|
+
|
|
146
|
+
content_to_write = "#{actual_lines.join("\n")}\n"
|
|
147
|
+
|
|
148
|
+
begin
|
|
149
|
+
# Delete old file first to avoid git index stale-read issues
|
|
150
|
+
FileUtils.rm_f(snapshot_path)
|
|
151
|
+
|
|
152
|
+
# Write with explicit mode to ensure clean write
|
|
153
|
+
File.write(snapshot_path, content_to_write, mode: "w")
|
|
154
|
+
|
|
155
|
+
# Flush filesystem buffers to ensure durability
|
|
156
|
+
File.open(snapshot_path, "r", &:fsync) if File.exist?(snapshot_path)
|
|
157
|
+
rescue => e
|
|
158
|
+
warn "Failed to write snapshot #{snapshot_path}: #{e.message}"
|
|
159
|
+
raise
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
if update_snapshots
|
|
163
|
+
puts "Updated snapshot: #{snapshot_path}"
|
|
164
|
+
else
|
|
165
|
+
puts "Created snapshot: #{snapshot_path}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
end
|
|
169
|
+
expected_lines = File.readlines(snapshot_path, chomp: true)
|
|
170
|
+
else
|
|
171
|
+
# Direct comparison mode
|
|
172
|
+
expected_lines = expected
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
msg ||= "Screen content mismatch"
|
|
176
|
+
|
|
177
|
+
assert_equal expected_lines.size, actual_lines.size, "#{msg}: Line count mismatch"
|
|
178
|
+
|
|
179
|
+
expected_lines.each_with_index do |expected_line, i|
|
|
180
|
+
actual_line = actual_lines[i]
|
|
181
|
+
assert_equal expected_line, actual_line,
|
|
182
|
+
"#{msg}: Line #{i + 1} mismatch.\nExpected: #{expected_line.inspect}\nActual: #{actual_line.inspect}"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
##
|
|
187
|
+
# Asserts that the current screen content (including colors!) matches a stored ANSI snapshot.
|
|
188
|
+
#
|
|
189
|
+
# Generates/Compares against a file with <tt>.ansi</tt> extension.
|
|
190
|
+
# You can <tt>cat</tt> this file to see exactly what the screen looked like.
|
|
191
|
+
#
|
|
192
|
+
# assert_rich_snapshot("login_screen")
|
|
193
|
+
#
|
|
194
|
+
# # With normalization
|
|
195
|
+
# assert_rich_snapshot("log_view") do |lines|
|
|
196
|
+
# lines.map { |l| l.gsub(/\d{2}:\d{2}:\d{2}/, "HH:MM:SS") }
|
|
197
|
+
# end
|
|
198
|
+
#
|
|
199
|
+
# [name] String snapshot name.
|
|
200
|
+
# [msg] String optional failure message.
|
|
201
|
+
def assert_rich_snapshot(name, msg = nil)
|
|
202
|
+
caller_path = caller_locations(1, 1).first.path
|
|
203
|
+
snapshot_dir = File.join(File.dirname(caller_path), "snapshots")
|
|
204
|
+
snapshot_path = File.join(snapshot_dir, "#{name}.ansi")
|
|
205
|
+
|
|
206
|
+
actual_content = _render_buffer_with_ansi
|
|
207
|
+
|
|
208
|
+
if block_given?
|
|
209
|
+
lines = actual_content.split("\n")
|
|
210
|
+
# Yield lines to user block for modification (e.g. masking IDs/Times)
|
|
211
|
+
lines = yield(lines)
|
|
212
|
+
actual_content = "#{lines.join("\n")}\n"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
update_snapshots = ENV["UPDATE_SNAPSHOTS"] == "1" || ENV["UPDATE_SNAPSHOTS"] == "true"
|
|
216
|
+
|
|
217
|
+
if !File.exist?(snapshot_path) || update_snapshots
|
|
218
|
+
FileUtils.mkdir_p(File.dirname(snapshot_path))
|
|
219
|
+
|
|
220
|
+
begin
|
|
221
|
+
# Delete old file first to avoid git index stale-read issues
|
|
222
|
+
FileUtils.rm_f(snapshot_path)
|
|
223
|
+
|
|
224
|
+
# Write with explicit mode to ensure clean write
|
|
225
|
+
File.write(snapshot_path, actual_content, mode: "w")
|
|
226
|
+
|
|
227
|
+
# Flush filesystem buffers to ensure durability
|
|
228
|
+
File.open(snapshot_path, "r", &:fsync) if File.exist?(snapshot_path)
|
|
229
|
+
rescue => e
|
|
230
|
+
warn "Failed to write rich snapshot #{snapshot_path}: #{e.message}"
|
|
231
|
+
raise
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
puts (update_snapshots ? "Updated" : "Created") + " rich snapshot: #{snapshot_path}"
|
|
235
|
+
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
expected_content = File.read(snapshot_path)
|
|
239
|
+
|
|
240
|
+
# Compare byte-for-byte first
|
|
241
|
+
if expected_content != actual_content
|
|
242
|
+
# Fallback to line-by-line diff for better error messages
|
|
243
|
+
expected_lines = expected_content.split("\n")
|
|
244
|
+
actual_lines = actual_content.split("\n")
|
|
245
|
+
|
|
246
|
+
assert_equal expected_lines.size, actual_lines.size, "#{msg}: Line count mismatch"
|
|
247
|
+
|
|
248
|
+
expected_lines.each_with_index do |exp, i|
|
|
249
|
+
act = actual_lines[i]
|
|
250
|
+
assert_equal exp, act, "#{msg}: Rich content mismatch at line #{i + 1}"
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
private def _render_buffer_with_ansi
|
|
256
|
+
RatatuiRuby.get_buffer_content # Ensure buffer is fresh if needed
|
|
257
|
+
|
|
258
|
+
lines = buffer_content
|
|
259
|
+
height = lines.size
|
|
260
|
+
width = lines.first&.length || 0
|
|
261
|
+
|
|
262
|
+
output = String.new
|
|
263
|
+
|
|
264
|
+
(0...height).each do |y|
|
|
265
|
+
current_fg = nil
|
|
266
|
+
current_bg = nil
|
|
267
|
+
current_modifiers = []
|
|
268
|
+
|
|
269
|
+
# Reset at start of line
|
|
270
|
+
output << "\e[0m"
|
|
271
|
+
|
|
272
|
+
(0...width).each do |x|
|
|
273
|
+
cell = RatatuiRuby.get_cell_at(x, y)
|
|
274
|
+
char = cell.char || " "
|
|
275
|
+
|
|
276
|
+
# Check for changes
|
|
277
|
+
fg_changed = cell.fg != current_fg
|
|
278
|
+
bg_changed = cell.bg != current_bg
|
|
279
|
+
mod_changed = cell.modifiers != current_modifiers
|
|
280
|
+
|
|
281
|
+
if fg_changed || bg_changed || mod_changed
|
|
282
|
+
# If modifiers change, easiest is to reset and re-apply everything
|
|
283
|
+
# because removing a modifier (e.g. bold) requires reset usually.
|
|
284
|
+
if mod_changed
|
|
285
|
+
output << "\e[0m"
|
|
286
|
+
output << _ansi_for_modifiers(cell.modifiers)
|
|
287
|
+
# Force re-apply colors after reset
|
|
288
|
+
output << _ansi_for_color(cell.fg, :fg)
|
|
289
|
+
output << _ansi_for_color(cell.bg, :bg)
|
|
290
|
+
else
|
|
291
|
+
# Modifiers same, just update colors if needed
|
|
292
|
+
output << _ansi_for_color(cell.fg, :fg) if fg_changed
|
|
293
|
+
output << _ansi_for_color(cell.bg, :bg) if bg_changed
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
current_fg = cell.fg
|
|
297
|
+
current_bg = cell.bg
|
|
298
|
+
current_modifiers = cell.modifiers
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
output << char
|
|
302
|
+
rescue
|
|
303
|
+
output << " "
|
|
304
|
+
end
|
|
305
|
+
output << "\e[0m\n" # Reset at end of line
|
|
306
|
+
end
|
|
307
|
+
output
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
private def _ansi_for_color(color, layer)
|
|
311
|
+
return "" if color.nil?
|
|
312
|
+
|
|
313
|
+
base = (layer == :fg) ? 38 : 48
|
|
314
|
+
|
|
315
|
+
case color
|
|
316
|
+
when Symbol
|
|
317
|
+
if color.to_s.start_with?("indexed_")
|
|
318
|
+
# Extracted indexed color :indexed_5 -> 5
|
|
319
|
+
idx = color.to_s.split("_").last.to_i
|
|
320
|
+
"\e[#{base};5;#{idx}m"
|
|
321
|
+
else
|
|
322
|
+
# Named colors
|
|
323
|
+
_ansi_named_color(color, layer == :fg)
|
|
324
|
+
end
|
|
325
|
+
when String
|
|
326
|
+
if color.start_with?("#")
|
|
327
|
+
# Hex color: #RRGGBB -> r;g;b
|
|
328
|
+
r = color[1..2].to_i(16)
|
|
329
|
+
g = color[3..4].to_i(16)
|
|
330
|
+
b = color[5..6].to_i(16)
|
|
331
|
+
"\e[#{base};2;#{r};#{g};#{b}m"
|
|
332
|
+
else
|
|
333
|
+
""
|
|
334
|
+
end
|
|
335
|
+
else
|
|
336
|
+
""
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
private def _ansi_named_color(name, is_fg)
|
|
341
|
+
# Map symbol to standard ANSI code offset
|
|
342
|
+
# FG: 30-37 (dim), 90-97 (bright)
|
|
343
|
+
# BG: 40-47 (dim), 100-107 (bright)
|
|
344
|
+
|
|
345
|
+
offset = is_fg ? 30 : 40
|
|
346
|
+
|
|
347
|
+
case name
|
|
348
|
+
when :black then "\e[#{offset}m"
|
|
349
|
+
when :red then "\e[#{offset + 1}m"
|
|
350
|
+
when :green then "\e[#{offset + 2}m"
|
|
351
|
+
when :yellow then "\e[#{offset + 3}m"
|
|
352
|
+
when :blue then "\e[#{offset + 4}m"
|
|
353
|
+
when :magenta then "\e[#{offset + 5}m"
|
|
354
|
+
when :cyan then "\e[#{offset + 6}m"
|
|
355
|
+
when :gray then is_fg ? "\e[90m" : "\e[100m" # Dark gray usually
|
|
356
|
+
when :dark_gray then is_fg ? "\e[90m" : "\e[100m"
|
|
357
|
+
when :light_red then "\e[#{offset + 60 + 1}m"
|
|
358
|
+
when :light_green then "\e[#{offset + 60 + 2}m"
|
|
359
|
+
when :light_yellow then "\e[#{offset + 60 + 3}m"
|
|
360
|
+
when :light_blue then "\e[#{offset + 60 + 4}m"
|
|
361
|
+
when :light_magenta then "\e[#{offset + 60 + 5}m"
|
|
362
|
+
when :light_cyan then "\e[#{offset + 60 + 6}m"
|
|
363
|
+
when :white then "\e[#{offset + 60 + 7}m"
|
|
364
|
+
else ""
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
private def _ansi_for_modifiers(modifiers)
|
|
369
|
+
return "" if modifiers.nil? || modifiers.empty?
|
|
370
|
+
|
|
371
|
+
seq = []
|
|
372
|
+
seq << "1" if modifiers.include?(:bold)
|
|
373
|
+
seq << "2" if modifiers.include?(:dim)
|
|
374
|
+
seq << "3" if modifiers.include?(:italic)
|
|
375
|
+
seq << "4" if modifiers.include?(:underlined)
|
|
376
|
+
seq << "5" if modifiers.include?(:slow_blink)
|
|
377
|
+
seq << "6" if modifiers.include?(:rapid_blink)
|
|
378
|
+
seq << "7" if modifiers.include?(:reversed)
|
|
379
|
+
seq << "8" if modifiers.include?(:hidden)
|
|
380
|
+
seq << "9" if modifiers.include?(:crossed_out)
|
|
381
|
+
|
|
382
|
+
if seq.any?
|
|
383
|
+
"\e[#{seq.join(';')}m"
|
|
384
|
+
else
|
|
385
|
+
""
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|