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
|
@@ -5,27 +5,30 @@
|
|
|
5
5
|
|
|
6
6
|
require_relative "color"
|
|
7
7
|
|
|
8
|
-
#
|
|
8
|
+
# A self-contained text input component for color entry.
|
|
9
9
|
#
|
|
10
10
|
# Users type color values. They make mistakes—typos, invalid formats. The app
|
|
11
|
-
# needs to validate their input and show helpful error messages.
|
|
12
|
-
# tracking input state, validation, and error messages across renders is
|
|
13
|
-
# cumbersome and error-prone.
|
|
11
|
+
# needs to validate their input and show helpful error messages.
|
|
14
12
|
#
|
|
15
|
-
# This
|
|
16
|
-
#
|
|
17
|
-
#
|
|
13
|
+
# This component encapsulates rendering, state, and event handling. It draws
|
|
14
|
+
# itself into the provided area, caches that area for hit testing, and handles
|
|
15
|
+
# keyboard events internally.
|
|
18
16
|
#
|
|
19
|
-
#
|
|
17
|
+
# === Component Contract
|
|
18
|
+
#
|
|
19
|
+
# - `render(tui, frame, area)`: Draws the input field; stores `area` for hit testing
|
|
20
|
+
# - `handle_event(event) -> Symbol | nil`: Returns `:consumed`, `:submitted`, or `nil`
|
|
20
21
|
#
|
|
21
22
|
# === Example
|
|
22
23
|
#
|
|
23
24
|
# input = Input.new
|
|
24
|
-
# input.
|
|
25
|
-
#
|
|
26
|
-
# input.
|
|
27
|
-
#
|
|
28
|
-
#
|
|
25
|
+
# input.render(tui, frame, area)
|
|
26
|
+
#
|
|
27
|
+
# result = input.handle_event(event)
|
|
28
|
+
# case result
|
|
29
|
+
# when :submitted
|
|
30
|
+
# palette.update_color(input.parsed_color)
|
|
31
|
+
# end
|
|
29
32
|
class Input
|
|
30
33
|
PRINTABLE_PATTERN = /[\w#,().\s%]/
|
|
31
34
|
|
|
@@ -35,93 +38,112 @@ class Input
|
|
|
35
38
|
def initialize(initial_value = "#F96302")
|
|
36
39
|
@value = initial_value
|
|
37
40
|
@error = ""
|
|
41
|
+
@parsed_color = nil
|
|
42
|
+
@area = nil
|
|
38
43
|
end
|
|
39
44
|
|
|
40
45
|
# Current input string.
|
|
41
|
-
|
|
42
|
-
# === Example
|
|
43
|
-
#
|
|
44
|
-
# input = Input.new
|
|
45
|
-
# input.value # => "#F96302"
|
|
46
|
-
def value
|
|
47
|
-
@value
|
|
48
|
-
end
|
|
46
|
+
attr_reader :value
|
|
49
47
|
|
|
50
48
|
# Error message from the last failed parse, or empty string.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
#
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
end
|
|
49
|
+
attr_reader :error
|
|
50
|
+
|
|
51
|
+
# The last successfully parsed Color, or nil.
|
|
52
|
+
attr_reader :parsed_color
|
|
53
|
+
|
|
54
|
+
# The cached render area, for hit testing.
|
|
55
|
+
attr_reader :area
|
|
59
56
|
|
|
60
57
|
# Clears the current error message.
|
|
61
58
|
def clear_error
|
|
62
59
|
@error = ""
|
|
63
60
|
end
|
|
64
61
|
|
|
65
|
-
#
|
|
62
|
+
# Renders the input widget into the given area.
|
|
63
|
+
#
|
|
64
|
+
# Caches `area` for hit testing. Shows the current input value and positions
|
|
65
|
+
# the terminal's blinking cursor at the end of the text using
|
|
66
|
+
# `frame.set_cursor_position`. Displays the error message in red if set.
|
|
67
|
+
#
|
|
68
|
+
# [tui] Session or TUI factory object
|
|
69
|
+
# [frame] Frame object from RatatuiRuby.draw block
|
|
70
|
+
# [area] Rect area to draw into
|
|
71
|
+
#
|
|
72
|
+
# === Example
|
|
73
|
+
#
|
|
74
|
+
# input.render(tui, frame, input_area)
|
|
75
|
+
def render(tui, frame, area)
|
|
76
|
+
@area = area
|
|
77
|
+
widget = build_widget(tui)
|
|
78
|
+
frame.render_widget(widget, area)
|
|
79
|
+
|
|
80
|
+
# Position real blinking cursor at end of input text
|
|
81
|
+
cursor_x, cursor_y = cursor_position_in(area)
|
|
82
|
+
frame.set_cursor_position(cursor_x, cursor_y)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Processes a keyboard event and updates internal state.
|
|
86
|
+
#
|
|
87
|
+
# Returns:
|
|
88
|
+
# - `:submitted` when Enter is pressed (caller should read `parsed_color`)
|
|
89
|
+
# - `:consumed` when the event was handled (typing, backspace)
|
|
90
|
+
# - `nil` when the event was ignored
|
|
66
91
|
#
|
|
67
|
-
#
|
|
68
|
-
# letters, digits, hash, comma, parentheses, dot, space, and percent.
|
|
92
|
+
# [event] Event from RatatuiRuby.poll_event
|
|
69
93
|
#
|
|
70
|
-
#
|
|
71
|
-
|
|
94
|
+
# === Example
|
|
95
|
+
#
|
|
96
|
+
# result = input.handle_event(event)
|
|
97
|
+
# if result == :submitted
|
|
98
|
+
# palette.update_color(input.parsed_color)
|
|
99
|
+
# end
|
|
100
|
+
def handle_event(event)
|
|
101
|
+
case event
|
|
102
|
+
in { type: :key, code: "enter" }
|
|
103
|
+
parse
|
|
104
|
+
:submitted
|
|
105
|
+
in { type: :key, code: "backspace" }
|
|
106
|
+
delete_char
|
|
107
|
+
:consumed
|
|
108
|
+
in { type: :paste, content: }
|
|
109
|
+
set(content)
|
|
110
|
+
parse
|
|
111
|
+
:submitted
|
|
112
|
+
in { type: :key, code: code }
|
|
113
|
+
append_char(code)
|
|
114
|
+
:consumed
|
|
115
|
+
else
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private def append_char(char)
|
|
72
121
|
@value += char if char.length == 1 && char.match?(PRINTABLE_PATTERN)
|
|
73
122
|
end
|
|
74
123
|
|
|
75
|
-
|
|
76
|
-
def delete_char
|
|
124
|
+
private def delete_char
|
|
77
125
|
@value = @value[0...-1]
|
|
78
126
|
end
|
|
79
127
|
|
|
80
|
-
|
|
81
|
-
#
|
|
82
|
-
# [text] String new input value
|
|
83
|
-
def set(text)
|
|
128
|
+
private def set(text)
|
|
84
129
|
@value = text
|
|
85
130
|
end
|
|
86
131
|
|
|
87
|
-
|
|
88
|
-
#
|
|
89
|
-
# Returns a Color if valid; nil otherwise. Sets the error message on failure.
|
|
90
|
-
# Clears the error message on success.
|
|
91
|
-
#
|
|
92
|
-
# === Example
|
|
93
|
-
#
|
|
94
|
-
# input = Input.new("#FF0000")
|
|
95
|
-
# color = input.parse # => Color
|
|
96
|
-
# input.error # => ""
|
|
97
|
-
def parse
|
|
132
|
+
private def parse
|
|
98
133
|
color = Color.parse(@value)
|
|
99
134
|
if color
|
|
100
135
|
clear_error
|
|
101
|
-
color
|
|
136
|
+
@parsed_color = color
|
|
102
137
|
else
|
|
103
138
|
@error = "Invalid color format. Try: #ff0000, rgb(255,0,0), red"
|
|
104
|
-
nil
|
|
139
|
+
@parsed_color = nil
|
|
105
140
|
end
|
|
106
141
|
end
|
|
107
142
|
|
|
108
|
-
|
|
109
|
-
#
|
|
110
|
-
# Shows the current input value with a cursor. Displays the error message
|
|
111
|
-
# in red if one is set.
|
|
112
|
-
#
|
|
113
|
-
# [tui] Session or TUI factory object
|
|
114
|
-
#
|
|
115
|
-
# === Example
|
|
116
|
-
#
|
|
117
|
-
# input = Input.new
|
|
118
|
-
# widget = input.render(tui)
|
|
119
|
-
# frame.render_widget(widget, area)
|
|
120
|
-
def render(tui)
|
|
143
|
+
private def build_widget(tui)
|
|
121
144
|
input_lines = [
|
|
122
145
|
tui.text_line(spans: [
|
|
123
146
|
tui.text_span(content: @value),
|
|
124
|
-
tui.text_span(content: "_", style: tui.style(modifiers: [:reversed])),
|
|
125
147
|
]),
|
|
126
148
|
]
|
|
127
149
|
|
|
@@ -139,4 +161,14 @@ class Input
|
|
|
139
161
|
]
|
|
140
162
|
)
|
|
141
163
|
end
|
|
164
|
+
|
|
165
|
+
# Calculates cursor position within the input area.
|
|
166
|
+
#
|
|
167
|
+
# Accounts for block border (1 cell) and current text length.
|
|
168
|
+
private def cursor_position_in(area)
|
|
169
|
+
# Border takes 1 cell on left, cursor goes after last character
|
|
170
|
+
x = area.x + 1 + @value.length
|
|
171
|
+
y = area.y + 1 # First line inside border
|
|
172
|
+
[x, y]
|
|
173
|
+
end
|
|
142
174
|
end
|
|
@@ -0,0 +1,178 @@
|
|
|
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_relative "input"
|
|
7
|
+
require_relative "palette"
|
|
8
|
+
require_relative "export_pane"
|
|
9
|
+
require_relative "controls"
|
|
10
|
+
require_relative "clipboard"
|
|
11
|
+
require_relative "copy_dialog"
|
|
12
|
+
|
|
13
|
+
# The root container that owns all child components and orchestrates the UI.
|
|
14
|
+
#
|
|
15
|
+
# Building a complete color picker UI involves layout calculation, widget
|
|
16
|
+
# composition, event routing, and cross-component communication. The Container
|
|
17
|
+
# pattern centralizes this orchestration while keeping components decoupled.
|
|
18
|
+
#
|
|
19
|
+
# This container:
|
|
20
|
+
# - **Layout Phase**: Calculates Rects using tui.layout_split
|
|
21
|
+
# - **Delegation Phase**: Calls child.render(tui, frame, area) for each component
|
|
22
|
+
# - **Event Routing (Chain of Responsibility)**: Delegates events front-to-back
|
|
23
|
+
# - **Mediator Pattern**: Manages cross-component communication via symbolic signals
|
|
24
|
+
#
|
|
25
|
+
# === Component Contract
|
|
26
|
+
#
|
|
27
|
+
# - `render(tui, frame, area)`: Lays out and renders all children
|
|
28
|
+
# - `handle_event(event) -> Symbol | nil`: Routes events to children
|
|
29
|
+
# - `tick`: Delegates lifecycle updates (clipboard timer)
|
|
30
|
+
#
|
|
31
|
+
# === Example
|
|
32
|
+
#
|
|
33
|
+
# container = MainContainer.new(tui)
|
|
34
|
+
# container.render(tui, frame, frame.area)
|
|
35
|
+
# result = container.handle_event(event)
|
|
36
|
+
# container.tick
|
|
37
|
+
class MainContainer
|
|
38
|
+
def initialize(tui)
|
|
39
|
+
@tui = tui
|
|
40
|
+
@input = Input.new
|
|
41
|
+
@palette = Palette.new(@input.parsed_color)
|
|
42
|
+
@export_pane = ExportPane.new
|
|
43
|
+
@controls = Controls.new
|
|
44
|
+
@clipboard = Clipboard.new
|
|
45
|
+
@dialog = CopyDialog.new(@clipboard)
|
|
46
|
+
|
|
47
|
+
# Parse initial color
|
|
48
|
+
initial_result = simulate_initial_parse
|
|
49
|
+
@palette.update_color(initial_result) if initial_result
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Renders all child components into the given area.
|
|
53
|
+
#
|
|
54
|
+
# Calculates layout once per frame. Delegates rendering to each component.
|
|
55
|
+
# Renders the dialog overlay last for z-ordering.
|
|
56
|
+
#
|
|
57
|
+
# [tui] Session or TUI factory object
|
|
58
|
+
# [frame] Frame object from RatatuiRuby.draw block
|
|
59
|
+
# [area] Rect area to draw into
|
|
60
|
+
#
|
|
61
|
+
# === Example
|
|
62
|
+
#
|
|
63
|
+
# tui.draw { |frame| container.render(tui, frame, frame.area) }
|
|
64
|
+
def render(tui, frame, area)
|
|
65
|
+
# Layout Phase: calculate all areas
|
|
66
|
+
input_area, rest = tui.layout_split(
|
|
67
|
+
area,
|
|
68
|
+
direction: :vertical,
|
|
69
|
+
constraints: [
|
|
70
|
+
tui.constraint_length(3),
|
|
71
|
+
tui.constraint_fill(1),
|
|
72
|
+
]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
color_area, control_area = tui.layout_split(
|
|
76
|
+
rest,
|
|
77
|
+
direction: :vertical,
|
|
78
|
+
constraints: [
|
|
79
|
+
tui.constraint_length(14),
|
|
80
|
+
tui.constraint_fill(1),
|
|
81
|
+
]
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
harmony_area, export_area = tui.layout_split(
|
|
85
|
+
color_area,
|
|
86
|
+
direction: :vertical,
|
|
87
|
+
constraints: [
|
|
88
|
+
tui.constraint_length(7),
|
|
89
|
+
tui.constraint_fill(1),
|
|
90
|
+
]
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Delegation Phase: render each component
|
|
94
|
+
@input.render(tui, frame, input_area)
|
|
95
|
+
@palette.render(tui, frame, harmony_area)
|
|
96
|
+
@export_pane.render(tui, frame, export_area, palette: @palette)
|
|
97
|
+
@controls.render(tui, frame, control_area, clipboard: @clipboard)
|
|
98
|
+
|
|
99
|
+
# Overlay Logic: dialog rendered last for z-ordering
|
|
100
|
+
if @dialog.active?
|
|
101
|
+
dialog_area = calculate_center_area(area, 40, 8)
|
|
102
|
+
frame.render_widget(tui.clear, area)
|
|
103
|
+
@dialog.render(tui, frame, dialog_area)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Routes events to child components in visual order (front-to-back).
|
|
108
|
+
#
|
|
109
|
+
# Implements Chain of Responsibility:
|
|
110
|
+
# 1. If dialog is active, offer it the event first
|
|
111
|
+
# 2. Then Input, ExportPane (which may trigger dialog)
|
|
112
|
+
# 3. Mediator pattern: interprets symbolic signals for cross-component effects
|
|
113
|
+
#
|
|
114
|
+
# Returns:
|
|
115
|
+
# - `:consumed` when any component handled the event
|
|
116
|
+
# - `nil` when no component handled the event
|
|
117
|
+
#
|
|
118
|
+
# [event] Event from RatatuiRuby.poll_event
|
|
119
|
+
#
|
|
120
|
+
# === Example
|
|
121
|
+
#
|
|
122
|
+
# result = container.handle_event(event)
|
|
123
|
+
def handle_event(event)
|
|
124
|
+
# Clear input error when not in dialog mode
|
|
125
|
+
@input.clear_error unless @dialog.active?
|
|
126
|
+
|
|
127
|
+
# Front-to-back: dialog has priority when active
|
|
128
|
+
if @dialog.active?
|
|
129
|
+
result = @dialog.handle_event(event)
|
|
130
|
+
return :consumed if result == :consumed
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Input component
|
|
134
|
+
result = @input.handle_event(event)
|
|
135
|
+
case result
|
|
136
|
+
when :submitted
|
|
137
|
+
# Mediator: sync Input -> Palette
|
|
138
|
+
@palette.update_color(@input.parsed_color)
|
|
139
|
+
return :consumed
|
|
140
|
+
when :consumed
|
|
141
|
+
return :consumed
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# ExportPane: may request copy dialog
|
|
145
|
+
result = @export_pane.handle_event(event)
|
|
146
|
+
if result == :copy_requested && @palette.main
|
|
147
|
+
@dialog.open(@palette.main.hex)
|
|
148
|
+
return :consumed
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Palette and Controls are display-only
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Delegates lifecycle tick to time-sensitive components.
|
|
156
|
+
#
|
|
157
|
+
# Currently handles clipboard feedback timer.
|
|
158
|
+
#
|
|
159
|
+
# === Example
|
|
160
|
+
#
|
|
161
|
+
# container.tick
|
|
162
|
+
def tick
|
|
163
|
+
@controls.tick(@clipboard)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private def calculate_center_area(parent_area, width, height)
|
|
167
|
+
x = (parent_area.width - width) / 2
|
|
168
|
+
y = (parent_area.height - height) / 2
|
|
169
|
+
@tui.rect(x:, y:, width:, height:)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Simulates the initial parse that happens when the app starts.
|
|
173
|
+
# Input is initialized with a default color, so we need to parse it.
|
|
174
|
+
private def simulate_initial_parse
|
|
175
|
+
require_relative "color"
|
|
176
|
+
Color.parse(@input.value)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -5,66 +5,95 @@
|
|
|
5
5
|
|
|
6
6
|
require_relative "color"
|
|
7
7
|
|
|
8
|
-
#
|
|
8
|
+
# A self-contained component displaying a color palette with harmonies.
|
|
9
9
|
#
|
|
10
|
-
# Color pickers need to show related colors: shades, tints, complements.
|
|
11
|
-
#
|
|
12
|
-
# rendering pipelines is awkward.
|
|
10
|
+
# Color pickers need to show related colors: shades, tints, complements. This
|
|
11
|
+
# component owns a primary color and renders its harmonies.
|
|
13
12
|
#
|
|
14
|
-
#
|
|
15
|
-
# provides accessor methods and rendering helpers.
|
|
13
|
+
# === Component Contract
|
|
16
14
|
#
|
|
17
|
-
#
|
|
15
|
+
# - `render(tui, frame, area)`: Draws the harmony blocks; stores `area`
|
|
16
|
+
# - `handle_event(event) -> nil`: Display-only, always returns nil
|
|
17
|
+
# - `update_color(color)`: Updates the primary color (called by MainContainer)
|
|
18
18
|
#
|
|
19
19
|
# === Example
|
|
20
20
|
#
|
|
21
|
-
#
|
|
22
|
-
# palette
|
|
23
|
-
# palette.
|
|
24
|
-
# palette.all # => [Harmony, Harmony, ...]
|
|
25
|
-
# blocks = palette.as_blocks(tui) # => [Block, Block, ...]
|
|
21
|
+
# palette = Palette.new
|
|
22
|
+
# palette.update_color(Color.parse("#FF0000"))
|
|
23
|
+
# palette.render(tui, frame, palette_area)
|
|
26
24
|
class Palette
|
|
27
|
-
def initialize(primary_color)
|
|
25
|
+
def initialize(primary_color = nil)
|
|
28
26
|
@primary = primary_color
|
|
27
|
+
@area = nil
|
|
29
28
|
end
|
|
30
29
|
|
|
30
|
+
# The cached render area.
|
|
31
|
+
attr_reader :area
|
|
32
|
+
|
|
31
33
|
# The primary (main) color, or nil if no color is set.
|
|
32
34
|
#
|
|
33
35
|
# === Example
|
|
34
36
|
#
|
|
35
|
-
# palette = Palette.new(color)
|
|
36
37
|
# palette.main.hex # => "#FF0000"
|
|
37
38
|
def main
|
|
38
39
|
@primary
|
|
39
40
|
end
|
|
40
41
|
|
|
41
|
-
#
|
|
42
|
+
# Updates the primary color.
|
|
42
43
|
#
|
|
43
|
-
#
|
|
44
|
+
# Called by the MainContainer when Input submits a new color.
|
|
44
45
|
#
|
|
45
|
-
#
|
|
46
|
+
# [color] Color object or nil
|
|
47
|
+
def update_color(color)
|
|
48
|
+
@primary = color
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# All harmonies: main, shade, tint, complement, split 1, split 2, split-complement.
|
|
46
52
|
#
|
|
47
|
-
#
|
|
48
|
-
# palette.all.size # => 7
|
|
53
|
+
# Returns an empty array if no primary color is set.
|
|
49
54
|
def all
|
|
50
55
|
return [] if @primary.nil?
|
|
51
56
|
|
|
52
57
|
@primary.harmonies
|
|
53
58
|
end
|
|
54
59
|
|
|
55
|
-
# Renders
|
|
60
|
+
# Renders the palette into the given area.
|
|
56
61
|
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
62
|
+
# Shows all harmony blocks in a horizontal layout. If no color is set,
|
|
63
|
+
# displays a placeholder message.
|
|
59
64
|
#
|
|
60
65
|
# [tui] Session or TUI factory object
|
|
66
|
+
# [frame] Frame object from RatatuiRuby.draw block
|
|
67
|
+
# [area] Rect area to draw into
|
|
61
68
|
#
|
|
62
69
|
# === Example
|
|
63
70
|
#
|
|
64
|
-
# palette
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
71
|
+
# palette.render(tui, frame, palette_area)
|
|
72
|
+
def render(tui, frame, area)
|
|
73
|
+
@area = area
|
|
74
|
+
widget = build_widget(tui)
|
|
75
|
+
frame.render_widget(widget, area)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Display-only component; always returns nil.
|
|
79
|
+
def handle_event(_event)
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private def build_widget(tui)
|
|
84
|
+
if @primary.nil?
|
|
85
|
+
tui.paragraph(text: "No color selected")
|
|
86
|
+
else
|
|
87
|
+
blocks = as_blocks(tui)
|
|
88
|
+
tui.layout(
|
|
89
|
+
direction: :horizontal,
|
|
90
|
+
constraints: Array.new(blocks.size) { tui.constraint_fill(1) },
|
|
91
|
+
children: blocks
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private def as_blocks(tui)
|
|
68
97
|
return [] if @primary.nil?
|
|
69
98
|
|
|
70
99
|
all.map do |harmony|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
|
|
3
|
+
SPDX-License-Identifier: CC-BY-SA-4.0
|
|
4
|
+
-->
|
|
5
|
+
|
|
6
|
+
# Login Form Example
|
|
7
|
+
|
|
8
|
+
Demonstrates how to create a modal overlay for user input.
|
|
9
|
+
|
|
10
|
+
Many applications need to block interaction with the main UI while collecting specific information, like a login prompt or confirmation dialog. Managing the z-index and input focus for these overlays can be tricky.
|
|
11
|
+
|
|
12
|
+
This example solves this by using the `Overlay` widget to stack a centered popup on top of a base layer, conditionally rendering the popup based on state.
|
|
13
|
+
|
|
14
|
+
## Features Demonstrated
|
|
15
|
+
|
|
16
|
+
- **Overlays:** Stacking widgets on top of each other using `tui.overlay`.
|
|
17
|
+
- **Centering:** Positioning a widget in the center of the screen using `tui.center`.
|
|
18
|
+
- **State Management:** Switching between "Base" and "Popup" views.
|
|
19
|
+
- **Input Handling:** Capturing text input and handling specific keys (Enter, Esc) to trigger state changes.
|
|
20
|
+
- **Cursor Positioning:** Manually calculating cursor position within a `Paragraph`.
|
|
21
|
+
|
|
22
|
+
## Hotkeys
|
|
23
|
+
|
|
24
|
+
### Form Mode
|
|
25
|
+
- **Text Input**: Type to enter username (supports all characters including 'q').
|
|
26
|
+
- **Backspace**: Deletes the last character.
|
|
27
|
+
- **Enter**: Submits the form and opens the success popup.
|
|
28
|
+
- **Esc**: Quits the application.
|
|
29
|
+
- **Ctrl+C**: Quits the application.
|
|
30
|
+
|
|
31
|
+
### Popup Mode
|
|
32
|
+
- **q**: Closes the popup and quits the application.
|
|
33
|
+
- **Ctrl+C**: Quits the application.
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
ruby examples/app_login_form/app.rb
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Learning Outcomes
|
|
42
|
+
|
|
43
|
+
Use this example if you need to...
|
|
44
|
+
- Create a modal dialog or popup.
|
|
45
|
+
- Center a widget on the screen (vertically and horizontally).
|
|
46
|
+
- Implement a simple text input field with cursor management.
|
|
47
|
+
- layer widgets using the `Overlay` widget.
|
|
@@ -82,11 +82,10 @@ class AppLoginForm
|
|
|
82
82
|
|
|
83
83
|
private def handle_input
|
|
84
84
|
case @tui.poll_event
|
|
85
|
-
in { type: :key, code: "q" } | { type: :key, code: "c", modifiers: ["ctrl"] }
|
|
86
|
-
return :quit if @show_popup
|
|
87
|
-
nil
|
|
88
85
|
in { type: :key, code: "c", modifiers: ["ctrl"] }
|
|
89
86
|
:quit
|
|
87
|
+
in { type: :key, code: "q" } if @show_popup
|
|
88
|
+
:quit
|
|
90
89
|
in { type: :key, code: "enter" }
|
|
91
90
|
@show_popup ||= true
|
|
92
91
|
nil
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
|
|
3
|
+
SPDX-License-Identifier: CC-BY-SA-4.0
|
|
4
|
+
-->
|
|
5
|
+
|
|
6
|
+
# Stateful Interaction Example
|
|
7
|
+
|
|
8
|
+
This example demonstrates High-Fidelity Interaction using **Stateful Widget Rendering**.
|
|
9
|
+
|
|
10
|
+
It showcases a "Database Viewer" layout where:
|
|
11
|
+
1. **Selection Persistence:** `ListState` and `TableState` objects persist across frames, maintaining selection without manual index tracking variables.
|
|
12
|
+
2. **Offset Read-back:** The application reads `state.offset` *after* rendering to know exactly which items were visible on screen.
|
|
13
|
+
3. **Mouse Interaction:** Using the read-back offset, we can calculate exactly which row was clicked, even when the specific item wasn't drawn at that absolute Y position due to scrolling.
|
|
14
|
+
|
|
15
|
+
## Key Concept: The "Read-back" Loop
|
|
16
|
+
|
|
17
|
+
Standard immediate-mode interaction often requires you to re-calculate layout logic to determine what was clicked.
|
|
18
|
+
|
|
19
|
+
In `ratatui_ruby`'s Stateful Rendering:
|
|
20
|
+
1. **Update**: You modify `state` (e.g., `state.select(1)`).
|
|
21
|
+
2. **Render**: You pass `state` to `render_stateful_widget`. Ratatui's Rust backend calculates layout and **updates** `state.offset` in-place if scrolling happened.
|
|
22
|
+
3. **Interact**: On the next event loop, you use `state.offset` to correctly map mouse coordinates to data indices.
|
|
23
|
+
|
|
24
|
+
## Hotkeys
|
|
25
|
+
|
|
26
|
+
| Key | Action |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| `↑` / `↓` | Scroll the active pane |
|
|
29
|
+
| `Tab` / `←` / `→` | Switch active pane (List vs Table) |
|
|
30
|
+
| `Mouse Click` | Select the clicked row (Works with scrolling!) |
|
|
31
|
+
| `q` | Quit |
|