rubytui 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +574 -0
  4. data/lib/rubytui/app.rb +174 -0
  5. data/lib/rubytui/backend.rb +158 -0
  6. data/lib/rubytui/backends/ansi_backend.rb +391 -0
  7. data/lib/rubytui/backends/test_backend.rb +223 -0
  8. data/lib/rubytui/buffer.rb +400 -0
  9. data/lib/rubytui/cell.rb +55 -0
  10. data/lib/rubytui/color.rb +153 -0
  11. data/lib/rubytui/color_mode.rb +203 -0
  12. data/lib/rubytui/errors.rb +13 -0
  13. data/lib/rubytui/event.rb +161 -0
  14. data/lib/rubytui/frame.rb +70 -0
  15. data/lib/rubytui/input/key.rb +93 -0
  16. data/lib/rubytui/input/parser.rb +231 -0
  17. data/lib/rubytui/input/reader.rb +119 -0
  18. data/lib/rubytui/layout/constraint.rb +83 -0
  19. data/lib/rubytui/layout/flex.rb +16 -0
  20. data/lib/rubytui/layout/layout.rb +205 -0
  21. data/lib/rubytui/modifier.rb +67 -0
  22. data/lib/rubytui/rect.rb +126 -0
  23. data/lib/rubytui/stateful_widget.rb +20 -0
  24. data/lib/rubytui/style.rb +143 -0
  25. data/lib/rubytui/symbols.rb +88 -0
  26. data/lib/rubytui/terminal.rb +218 -0
  27. data/lib/rubytui/text/line.rb +67 -0
  28. data/lib/rubytui/text/span.rb +34 -0
  29. data/lib/rubytui/text/text.rb +82 -0
  30. data/lib/rubytui/unicode.rb +162 -0
  31. data/lib/rubytui/version.rb +6 -0
  32. data/lib/rubytui/widget.rb +20 -0
  33. data/lib/rubytui/widgets/async_image.rb +248 -0
  34. data/lib/rubytui/widgets/block.rb +260 -0
  35. data/lib/rubytui/widgets/canvas.rb +248 -0
  36. data/lib/rubytui/widgets/chart.rb +224 -0
  37. data/lib/rubytui/widgets/gauge.rb +139 -0
  38. data/lib/rubytui/widgets/image.rb +330 -0
  39. data/lib/rubytui/widgets/input_field.rb +245 -0
  40. data/lib/rubytui/widgets/list.rb +186 -0
  41. data/lib/rubytui/widgets/paragraph.rb +181 -0
  42. data/lib/rubytui/widgets/popup.rb +140 -0
  43. data/lib/rubytui/widgets/scrollbar.rb +175 -0
  44. data/lib/rubytui/widgets/sparkline.rb +86 -0
  45. data/lib/rubytui/widgets/table.rb +231 -0
  46. data/lib/rubytui/widgets/tabs.rb +90 -0
  47. data/lib/rubytui.rb +389 -0
  48. metadata +89 -0
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ module Widgets
5
+ # InputState holds the mutable state for the InputField widget.
6
+ #
7
+ # +cursor+ is a character index into +text+. +scroll_offset+ is the index of
8
+ # the first visible character and is updated by {InputField#render}.
9
+ #
10
+ # @see InputField
11
+ class InputState
12
+ attr_accessor :text, :cursor, :scroll_offset
13
+
14
+ # @param text [String] initial text; a mutable copy is stored (default: "")
15
+ # @param cursor [Integer] initial cursor character index, not clamped to the
16
+ # text length (default: 0)
17
+ def initialize(text: "", cursor: 0)
18
+ @text = String.new(text) # ensure mutable copy
19
+ @cursor = cursor
20
+ @scroll_offset = 0
21
+ end
22
+
23
+ # Insert a character at the cursor position
24
+ #
25
+ # @param char [String] character (or string) to insert; the cursor advances by its length
26
+ # @return [void]
27
+ def insert(char)
28
+ @text.insert(@cursor, char)
29
+ @cursor += char.length
30
+ end
31
+
32
+ # Delete character before cursor (backspace)
33
+ #
34
+ # Does nothing when the cursor is at the start.
35
+ #
36
+ # @return [void]
37
+ def backspace
38
+ return if @cursor == 0
39
+
40
+ @text.slice!(@cursor - 1)
41
+ @cursor -= 1
42
+ end
43
+
44
+ # Delete character at cursor (delete key)
45
+ #
46
+ # Does nothing when the cursor is at the end.
47
+ #
48
+ # @return [void]
49
+ def delete
50
+ return if @cursor >= @text.length
51
+
52
+ @text.slice!(@cursor)
53
+ end
54
+
55
+ # Move cursor left
56
+ #
57
+ # @return [void]
58
+ def move_left
59
+ @cursor = [@cursor - 1, 0].max
60
+ end
61
+
62
+ # Move cursor right
63
+ #
64
+ # @return [void]
65
+ def move_right
66
+ @cursor = [@cursor + 1, @text.length].min
67
+ end
68
+
69
+ # Move cursor to start
70
+ #
71
+ # @return [void]
72
+ def home
73
+ @cursor = 0
74
+ end
75
+
76
+ # Move cursor to end
77
+ #
78
+ # @return [void]
79
+ def end_pos
80
+ @cursor = @text.length
81
+ end
82
+
83
+ # Delete from cursor to end of line
84
+ #
85
+ # The cursor does not move.
86
+ #
87
+ # @return [void]
88
+ def kill_to_end
89
+ @text.slice!(@cursor..)
90
+ end
91
+
92
+ # Delete from start to cursor
93
+ #
94
+ # The cursor moves to the start.
95
+ #
96
+ # @return [void]
97
+ def kill_to_start
98
+ @text.slice!(0...@cursor)
99
+ @cursor = 0
100
+ end
101
+
102
+ # Delete the word before the cursor
103
+ #
104
+ # Deletes any spaces directly before the cursor and the run of non-space
105
+ # characters before them. Only the space character counts as a separator.
106
+ # Does nothing when the cursor is at the start.
107
+ #
108
+ # @return [void]
109
+ def delete_word_back
110
+ return if @cursor == 0
111
+
112
+ # Find start of previous word
113
+ pos = @cursor - 1
114
+ pos -= 1 while pos > 0 && @text[pos] == " "
115
+ pos -= 1 while pos > 0 && @text[pos] != " "
116
+ pos += 1 if pos > 0
117
+
118
+ @text.slice!(pos...@cursor)
119
+ @cursor = pos
120
+ end
121
+
122
+ # Handle a key event, returns true if the event was consumed
123
+ #
124
+ # Character keys without Ctrl insert <tt>event.char</tt>. Backspace, Delete,
125
+ # Left, Right, Home and End edit or move. Ctrl-A / Ctrl-E move to start / end,
126
+ # Ctrl-K / Ctrl-U delete to end / start, Ctrl-W deletes the previous word.
127
+ # Other Ctrl combinations, other keys and non-{KeyEvent} events are not consumed.
128
+ #
129
+ # @param event [Event] the input event
130
+ # @return [Boolean] true if the event was consumed
131
+ def handle_key(event)
132
+ return false unless event.is_a?(KeyEvent)
133
+
134
+ case event.code
135
+ when Input::Key::CHAR
136
+ if event.ctrl?
137
+ case event.char
138
+ when "a" then home; true
139
+ when "e" then end_pos; true
140
+ when "k" then kill_to_end; true
141
+ when "u" then kill_to_start; true
142
+ when "w" then delete_word_back; true
143
+ else false
144
+ end
145
+ else
146
+ insert(event.char) if event.char
147
+ true
148
+ end
149
+ when Input::Key::BACKSPACE then backspace; true
150
+ when Input::Key::DELETE then delete; true
151
+ when Input::Key::LEFT then move_left; true
152
+ when Input::Key::RIGHT then move_right; true
153
+ when Input::Key::HOME then home; true
154
+ when Input::Key::END_KEY then end_pos; true
155
+ else
156
+ false
157
+ end
158
+ end
159
+ end
160
+
161
+ # InputField renders a single-line text input with cursor.
162
+ #
163
+ # @example
164
+ # state = RubyTUI::Widgets::InputState.new
165
+ # state.handle_key(event) # in the event loop
166
+ # field = RubyTUI::Widgets::InputField.new(placeholder: "Type here...")
167
+ # frame.render_stateful_widget(field, area, state)
168
+ #
169
+ # @see InputState
170
+ class InputField
171
+ include StatefulWidget
172
+
173
+ # @param style [Style] text style (default: {Style::DEFAULT})
174
+ # @param cursor_style [Style, nil] style of the cell under the cursor
175
+ # (default: reversed video)
176
+ # @param placeholder [String] text shown while the input is empty (default: "")
177
+ # @param placeholder_style [Style, nil] placeholder style (default: bright black foreground)
178
+ # @param block [Widgets::Block, nil] optional wrapping block
179
+ def initialize(
180
+ style: Style::DEFAULT,
181
+ cursor_style: nil,
182
+ placeholder: "",
183
+ placeholder_style: nil,
184
+ block: nil
185
+ )
186
+ @style = style
187
+ @cursor_style = cursor_style || Style.new.reversed
188
+ @placeholder = placeholder
189
+ @placeholder_style = placeholder_style || Style.new.fg(Color::BRIGHT_BLACK)
190
+ @block = block
191
+ end
192
+
193
+ # Render the visible part of the text, or the placeholder, on the first row of
194
+ # the area and apply +cursor_style+ to the cell at the cursor.
195
+ #
196
+ # Updates <tt>state.scroll_offset</tt> in place so the cursor stays visible.
197
+ # Scrolling and cursor placement count characters, not display columns.
198
+ #
199
+ # @param area [Rect] the rectangular area to render into
200
+ # @param buf [Buffer] the buffer to write cells into
201
+ # @param state [InputState] text, cursor and scroll state
202
+ # @return [void]
203
+ def render(area, buf, state)
204
+ return if area.empty?
205
+
206
+ render_area = area
207
+ if @block
208
+ @block.render(area, buf)
209
+ render_area = @block.inner(area)
210
+ end
211
+
212
+ return if render_area.empty?
213
+
214
+ width = render_area.width
215
+
216
+ # Adjust scroll offset to keep cursor visible
217
+ if state.cursor < state.scroll_offset
218
+ state.scroll_offset = state.cursor
219
+ elsif state.cursor >= state.scroll_offset + width
220
+ state.scroll_offset = state.cursor - width + 1
221
+ end
222
+
223
+ if state.text.empty?
224
+ # Show placeholder
225
+ visible = @placeholder[0, width]
226
+ buf.set_string(render_area.x, render_area.y, visible, @placeholder_style)
227
+ else
228
+ # Show text with scrolling
229
+ visible = state.text[state.scroll_offset, width] || ""
230
+ buf.set_string(render_area.x, render_area.y, visible, @style)
231
+ end
232
+
233
+ # Draw cursor
234
+ cursor_screen_x = render_area.x + state.cursor - state.scroll_offset
235
+ if cursor_screen_x >= render_area.x && cursor_screen_x < render_area.right
236
+ cell = buf[cursor_screen_x, render_area.y]
237
+ if cell
238
+ char = cell.symbol == " " && state.cursor >= state.text.length ? " " : cell.symbol
239
+ cell.set(char, @cursor_style)
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ module Widgets
5
+ # ListState holds external state for the List widget.
6
+ #
7
+ # +selected+ is the index of the selected item and +offset+ the index of the
8
+ # first visible item. {List#render} clamps both in place.
9
+ #
10
+ # @see List
11
+ class ListState
12
+ attr_accessor :selected, :offset
13
+
14
+ # @param selected [Integer] initial selected index (default: 0)
15
+ # @param offset [Integer] initial scroll offset (default: 0)
16
+ def initialize(selected: 0, offset: 0)
17
+ @selected = selected
18
+ @offset = offset
19
+ end
20
+
21
+ # Select the next item, wrapping from the last item to the first.
22
+ #
23
+ # Does nothing when +total+ is 0 or negative.
24
+ #
25
+ # @param total [Integer] number of items in the list
26
+ # @return [void]
27
+ def select_next(total)
28
+ @selected = (@selected + 1) % total if total > 0
29
+ end
30
+
31
+ # Select the previous item, wrapping from the first item to the last.
32
+ #
33
+ # Does nothing when +total+ is 0 or negative.
34
+ #
35
+ # @param total [Integer] number of items in the list
36
+ # @return [void]
37
+ def select_previous(total)
38
+ @selected = (@selected - 1) % total if total > 0
39
+ end
40
+
41
+ # Select the first item and reset the scroll offset to 0.
42
+ #
43
+ # @return [void]
44
+ def select_first
45
+ @selected = 0
46
+ @offset = 0
47
+ end
48
+
49
+ # Select the last item.
50
+ #
51
+ # The offset is not changed; {List#render} scrolls to keep the selection visible.
52
+ #
53
+ # @param total [Integer] number of items in the list
54
+ # @return [void]
55
+ def select_last(total)
56
+ @selected = [total - 1, 0].max
57
+ end
58
+ end
59
+
60
+ # List renders a scrollable, selectable list of items.
61
+ #
62
+ # @example
63
+ # state = RubyTUI::Widgets::ListState.new
64
+ # list = RubyTUI::Widgets::List.new(items: ["One", "Two", "Three"])
65
+ # frame.render_stateful_widget(list, area, state)
66
+ # state.select_next(list.items.length) # e.g. on a Down key press
67
+ #
68
+ # @see ListState
69
+ class List
70
+ include StatefulWidget
71
+
72
+ attr_reader :items
73
+
74
+ # @param items [Array<String, Span, Line, #to_s>] list items, each converted to a
75
+ # {Line} (default: [])
76
+ # @param style [Style] base style of every item (default: {Style::DEFAULT})
77
+ # @param highlight_style [Style, nil] patched onto +style+ for the selected item
78
+ # (default: bold yellow foreground)
79
+ # @param highlight_symbol [String] prefix of the selected item; other items are
80
+ # prefixed with spaces of the same width (default: "> ")
81
+ # @param block [Widgets::Block, nil] optional wrapping block
82
+ def initialize(
83
+ items: [],
84
+ style: Style::DEFAULT,
85
+ highlight_style: nil,
86
+ highlight_symbol: "> ",
87
+ block: nil
88
+ )
89
+ @items = items.map { |item| normalize_item(item) }
90
+ @style = style
91
+ @highlight_style = highlight_style || Style.new.fg(Color::YELLOW).bold
92
+ @highlight_symbol = highlight_symbol
93
+ @highlight_symbol_width = Buffer.string_width(@highlight_symbol)
94
+ @normal_symbol = " " * @highlight_symbol_width
95
+ @block = block
96
+ end
97
+
98
+ # Render the visible items, one per row, marking the selected item with
99
+ # +highlight_symbol+ and +highlight_style+.
100
+ #
101
+ # Clamps <tt>state.selected</tt> to the item range and adjusts
102
+ # <tt>state.offset</tt> in place so the selected item is visible. Item text is
103
+ # truncated to the area width; span styles are patched onto the item style.
104
+ #
105
+ # @param area [Rect] the rectangular area to render into
106
+ # @param buf [Buffer] the buffer to write cells into
107
+ # @param state [ListState] selection and scroll state, updated in place
108
+ # @return [void]
109
+ def render(area, buf, state)
110
+ return if area.empty?
111
+
112
+ render_area = area
113
+ if @block
114
+ @block.render(area, buf)
115
+ render_area = @block.inner(area)
116
+ end
117
+
118
+ return if render_area.empty? || @items.empty?
119
+
120
+ # Adjust scroll offset to keep selected item visible
121
+ visible_height = render_area.height
122
+ clamp_state(state, visible_height)
123
+
124
+ # Render visible items
125
+ visible_items = @items[state.offset, visible_height] || []
126
+ visible_items.each_with_index do |item, i|
127
+ y = render_area.y + i
128
+ actual_index = state.offset + i
129
+ is_selected = actual_index == state.selected
130
+
131
+ symbol = is_selected ? @highlight_symbol : @normal_symbol
132
+ item_style = is_selected ? @style.patch(@highlight_style) : @style
133
+
134
+ # Write selection symbol
135
+ x = render_area.x
136
+ buf.set_string(x, y, symbol, item_style)
137
+ x += @highlight_symbol_width
138
+
139
+ # Write item text
140
+ available = render_area.width - @highlight_symbol_width
141
+ item.spans.each do |span|
142
+ break if available <= 0
143
+
144
+ text = Buffer.truncate_to_width(span.content, available)
145
+ text_width = Buffer.string_width(text)
146
+ effective_style = item_style.patch(span.style)
147
+ buf.set_string(x, y, text, effective_style)
148
+ x += text_width
149
+ available -= text_width
150
+ end
151
+
152
+ # Fill remaining width with style (for highlight background)
153
+ if is_selected && x < render_area.right
154
+ remaining = render_area.right - x
155
+ buf.set_string(x, y, " " * remaining, item_style)
156
+ end
157
+ end
158
+ end
159
+
160
+ private
161
+
162
+ def normalize_item(item)
163
+ case item
164
+ when Line then item
165
+ when Span then Line.new([item])
166
+ when String then Line.from(item)
167
+ else Line.from(item.to_s)
168
+ end
169
+ end
170
+
171
+ def clamp_state(state, visible_height)
172
+ total = @items.length
173
+ state.selected = state.selected.clamp(0, [total - 1, 0].max)
174
+
175
+ # Scroll to keep selected visible
176
+ if state.selected < state.offset
177
+ state.offset = state.selected
178
+ elsif state.selected >= state.offset + visible_height
179
+ state.offset = state.selected - visible_height + 1
180
+ end
181
+
182
+ state.offset = state.offset.clamp(0, [total - visible_height, 0].max)
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ module Widgets
5
+ # Paragraph renders styled text with wrapping and alignment.
6
+ #
7
+ # @example
8
+ # para = RubyTUI::Widgets::Paragraph.new(text: "Hello\nWorld", alignment: :center)
9
+ # frame.render_widget(para, area)
10
+ class Paragraph
11
+ include Widget
12
+
13
+ # Wrap at whitespace; a single word wider than the area is not broken.
14
+ WRAP_WORD = :word
15
+ # Wrap at any character.
16
+ WRAP_CHAR = :char
17
+ # Do not wrap.
18
+ WRAP_NONE = :none
19
+
20
+ attr_reader :text, :style, :alignment, :wrap
21
+
22
+ # @param text [String, Line, Text, nil, #to_s] content; a String is split into
23
+ # lines on newlines, +nil+ means no text, other objects are converted with
24
+ # +to_s+ (default: nil)
25
+ # @param style [Style] base style under each span's style; when it is not
26
+ # {Style::DEFAULT} it is also patched onto every cell of the area
27
+ # (default: {Style::DEFAULT})
28
+ # @param alignment [Symbol] +:left+ (default), +:center+ or +:right+
29
+ # @param wrap [Symbol] {WRAP_WORD} (default), {WRAP_CHAR} or {WRAP_NONE}. Wrapped
30
+ # lines take the style of the original line's first span
31
+ # @param block [Widgets::Block, nil] optional wrapping block
32
+ def initialize(
33
+ text: nil,
34
+ style: Style::DEFAULT,
35
+ alignment: :left,
36
+ wrap: WRAP_WORD,
37
+ block: nil # optional Block to render inside
38
+ )
39
+ @text = normalize_text(text)
40
+ @style = style
41
+ @alignment = alignment
42
+ @wrap = wrap
43
+ @block = block
44
+ end
45
+
46
+ # Render the wrapped lines from the top of the area; lines beyond its height
47
+ # are not drawn.
48
+ #
49
+ # Lines are not clipped at the right edge of the area, only at the buffer
50
+ # edge, so unwrapped lines wider than the area extend past it.
51
+ #
52
+ # @param area [Rect] the rectangular area to render into
53
+ # @param buf [Buffer] the buffer to write cells into
54
+ # @return [void]
55
+ def render(area, buf)
56
+ return if area.empty?
57
+
58
+ render_area = area
59
+ if @block
60
+ @block.render(area, buf)
61
+ render_area = @block.inner(area)
62
+ end
63
+
64
+ return if render_area.empty?
65
+
66
+ # Apply background style to entire area
67
+ buf.set_style(render_area, @style) if @style != Style::DEFAULT
68
+
69
+ # Wrap and render lines
70
+ wrapped = wrap_lines(render_area.width)
71
+
72
+ wrapped.each_with_index do |line, i|
73
+ break if i >= render_area.height
74
+
75
+ render_line(line, render_area, i, buf)
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def normalize_text(text)
82
+ case text
83
+ when Text then text
84
+ when String then Text.from(text)
85
+ when Line then Text.new([text])
86
+ when nil then Text.new([])
87
+ else Text.from(text.to_s)
88
+ end
89
+ end
90
+
91
+ # Wrap text lines to fit within the given width.
92
+ # Returns an Array of Lines.
93
+ def wrap_lines(max_width)
94
+ return @text.lines if @wrap == WRAP_NONE || max_width <= 0
95
+
96
+ result = []
97
+ @text.lines.each do |line|
98
+ if line.width <= max_width
99
+ result << line
100
+ elsif @wrap == WRAP_WORD
101
+ result.concat(word_wrap(line, max_width))
102
+ else
103
+ result.concat(char_wrap(line, max_width))
104
+ end
105
+ end
106
+ result
107
+ end
108
+
109
+ def word_wrap(line, max_width)
110
+ plain = line.to_s
111
+ # Use the first span's style for wrapped text (simplification for v0.1)
112
+ line_style = line.spans.first&.style || Style::DEFAULT
113
+
114
+ words = plain.split(/(\s+)/)
115
+ wrapped = []
116
+ current = String.new
117
+ current_width = 0
118
+
119
+ words.each do |word|
120
+ word_width = Buffer.string_width(word)
121
+ if current.empty?
122
+ current << word
123
+ current_width = word_width
124
+ elsif current_width + word_width <= max_width
125
+ current << word
126
+ current_width += word_width
127
+ else
128
+ wrapped << Line.from(current.rstrip, line_style) unless current.strip.empty?
129
+ current = word.lstrip
130
+ current_width = Buffer.string_width(current)
131
+ end
132
+ end
133
+
134
+ wrapped << Line.from(current.rstrip, line_style) unless current.strip.empty?
135
+ wrapped << Line.new if wrapped.empty?
136
+ wrapped
137
+ end
138
+
139
+ def char_wrap(line, max_width)
140
+ plain = line.to_s
141
+ line_style = line.spans.first&.style || Style::DEFAULT
142
+
143
+ wrapped = []
144
+ current = String.new
145
+ current_width = 0
146
+
147
+ plain.each_char do |ch|
148
+ ch_width = Buffer.char_width(ch)
149
+ if current_width + ch_width > max_width
150
+ wrapped << Line.from(current, line_style) unless current.empty?
151
+ current = String.new
152
+ current_width = 0
153
+ end
154
+ current << ch
155
+ current_width += ch_width
156
+ end
157
+ wrapped << Line.from(current, line_style) unless current.empty?
158
+ wrapped << Line.new if wrapped.empty?
159
+ wrapped
160
+ end
161
+
162
+ def render_line(line, area, row, buf)
163
+ line_width = line.width
164
+ x_offset = case @alignment
165
+ when :center then [(area.width - line_width) / 2, 0].max
166
+ when :right then [area.width - line_width, 0].max
167
+ else 0
168
+ end
169
+
170
+ x = area.x + x_offset
171
+ y = area.y + row
172
+
173
+ line.spans.each do |span|
174
+ effective_style = @style.patch(span.style)
175
+ count = buf.set_string(x, y, span.content, effective_style)
176
+ x += count
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end