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,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ # Detects and manages terminal color capabilities.
5
+ #
6
+ # @example
7
+ # ColorMode.force(ColorMode::COLOR256)
8
+ # ColorMode.downgrade(Color.rgb(255, 128, 0)) # => Color.indexed(214)
9
+ module ColorMode
10
+ # Basic 16-color ANSI palette.
11
+ BASIC16 = :basic16
12
+ # 256-color indexed palette.
13
+ COLOR256 = :color256
14
+ # 24-bit RGB truecolor.
15
+ TRUECOLOR = :truecolor
16
+
17
+ class << self
18
+ # Detect terminal color capability from environment variables.
19
+ #
20
+ # Returns {TRUECOLOR} when +COLORTERM+ is +truecolor+ or +24bit+, +TERM+
21
+ # contains +24bit+ or +truecolor+, or +TERM_PROGRAM+ is one of
22
+ # +iterm.app+, +hyper+, +wezterm+, +alacritty+, +kitty+;
23
+ # {COLOR256} when +TERM+ contains +256color+ or +COLORTERM+ is +256+;
24
+ # otherwise {BASIC16}. All comparisons are case-insensitive. The result
25
+ # is cached until {.reset!}, and {.force} overrides it.
26
+ #
27
+ # @return [Symbol] the detected (or forced) color mode
28
+ def detect
29
+ return @detected if defined?(@detected)
30
+
31
+ @detected = detect_from_env
32
+ end
33
+
34
+ # Reset cached detection (for testing)
35
+ #
36
+ # @return [void]
37
+ def reset!
38
+ remove_instance_variable(:@detected) if defined?(@detected)
39
+ end
40
+
41
+ # Force a specific color mode
42
+ #
43
+ # Replaces the cached value returned by {.detect} until {.reset!}.
44
+ #
45
+ # @param mode [Symbol] color mode to report, normally {BASIC16},
46
+ # {COLOR256} or {TRUECOLOR}
47
+ # @return [Symbol] +mode+
48
+ def force(mode)
49
+ @detected = mode
50
+ end
51
+
52
+ # Downgrade a Color to fit the current terminal capability.
53
+ #
54
+ # {TRUECOLOR} (or an unrecognized mode) returns +color+ unchanged.
55
+ # {COLOR256} maps RGB colors to the nearest indexed color. {BASIC16}
56
+ # maps RGB and indexed colors to the nearest named color. Colors that
57
+ # already fit the mode are returned unchanged.
58
+ #
59
+ # @param color [Color, nil] color to convert
60
+ # @param mode [Symbol] target color mode (default: {.detect})
61
+ # @return [Color, nil] the converted color, or nil if +color+ is nil
62
+ def downgrade(color, mode = detect)
63
+ return color if color.nil?
64
+
65
+ case mode
66
+ when TRUECOLOR
67
+ color # no downgrade needed
68
+ when COLOR256
69
+ downgrade_to_256(color)
70
+ when BASIC16
71
+ downgrade_to_16(color)
72
+ else
73
+ color
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def detect_from_env
80
+ colorterm = ENV["COLORTERM"]&.downcase
81
+ term = ENV["TERM"]&.downcase || ""
82
+ term_program = ENV["TERM_PROGRAM"]&.downcase || ""
83
+
84
+ # Truecolor detection
85
+ if colorterm == "truecolor" || colorterm == "24bit"
86
+ return TRUECOLOR
87
+ end
88
+
89
+ if term.include?("24bit") || term.include?("truecolor")
90
+ return TRUECOLOR
91
+ end
92
+
93
+ # Known truecolor terminals
94
+ if %w[iterm.app hyper wezterm alacritty kitty].include?(term_program)
95
+ return TRUECOLOR
96
+ end
97
+
98
+ # 256 color detection
99
+ if term.include?("256color") || colorterm == "256"
100
+ return COLOR256
101
+ end
102
+
103
+ # Default to basic 16
104
+ BASIC16
105
+ end
106
+
107
+ # Convert RGB/truecolor to nearest 256-color index
108
+ def downgrade_to_256(color)
109
+ case color.kind
110
+ when :rgb
111
+ r, g, b = color.value
112
+ idx = rgb_to_256(r, g, b)
113
+ Color.indexed(idx)
114
+ else
115
+ color # :named and :indexed already fit
116
+ end
117
+ end
118
+
119
+ # Convert any color to nearest basic 16 color
120
+ def downgrade_to_16(color)
121
+ case color.kind
122
+ when :rgb
123
+ r, g, b = color.value
124
+ rgb_to_named16(r, g, b)
125
+ when :indexed
126
+ indexed_to_named16(color.value)
127
+ else
128
+ color # :named already fits
129
+ end
130
+ end
131
+
132
+ # Map RGB to nearest 256-color palette index
133
+ def rgb_to_256(r, g, b)
134
+ # Check grayscale ramp first (indices 232-255)
135
+ if r == g && g == b
136
+ return 16 if r < 8
137
+ return 231 if r > 248
138
+ return (((r - 8).to_f / 247) * 24).round + 232
139
+ end
140
+
141
+ # Map to 6x6x6 color cube (indices 16-231)
142
+ ri = (r.to_f / 255 * 5).round
143
+ gi = (g.to_f / 255 * 5).round
144
+ bi = (b.to_f / 255 * 5).round
145
+ 16 + (36 * ri) + (6 * gi) + bi
146
+ end
147
+
148
+ # Map RGB to nearest named 16 color
149
+ def rgb_to_named16(r, g, b)
150
+ # Simple luminance-based mapping to basic 16
151
+ brightness = (r * 299 + g * 587 + b * 114) / 1000
152
+ bright = brightness > 128
153
+
154
+ # Determine dominant channel
155
+ max = [r, g, b].max
156
+ if max == 0
157
+ return bright ? Color::BRIGHT_BLACK : Color::BLACK
158
+ end
159
+
160
+ rn = r.to_f / max
161
+ gn = g.to_f / max
162
+ bn = b.to_f / max
163
+
164
+ if rn > 0.6 && gn > 0.6 && bn > 0.6
165
+ bright ? Color::WHITE : Color::BRIGHT_BLACK
166
+ elsif rn > 0.6 && gn > 0.6
167
+ bright ? Color::BRIGHT_YELLOW : Color::YELLOW
168
+ elsif rn > 0.6 && bn > 0.6
169
+ bright ? Color::BRIGHT_MAGENTA : Color::MAGENTA
170
+ elsif gn > 0.6 && bn > 0.6
171
+ bright ? Color::BRIGHT_CYAN : Color::CYAN
172
+ elsif rn > 0.5
173
+ bright ? Color::BRIGHT_RED : Color::RED
174
+ elsif gn > 0.5
175
+ bright ? Color::BRIGHT_GREEN : Color::GREEN
176
+ elsif bn > 0.5
177
+ bright ? Color::BRIGHT_BLUE : Color::BLUE
178
+ else
179
+ bright ? Color::BRIGHT_BLACK : Color::BLACK
180
+ end
181
+ end
182
+
183
+ # Map 256-color index to nearest named 16 color
184
+ def indexed_to_named16(idx)
185
+ if idx < 16
186
+ # Already a basic color
187
+ Color.new(:named, idx).freeze
188
+ elsif idx >= 232
189
+ # Grayscale ramp
190
+ level = (idx - 232) * 10 + 8
191
+ level > 128 ? Color::WHITE : Color::BRIGHT_BLACK
192
+ else
193
+ # 6x6x6 cube -> RGB -> named16
194
+ idx -= 16
195
+ b = (idx % 6) * 51
196
+ g = ((idx / 6) % 6) * 51
197
+ r = ((idx / 36) % 6) * 51
198
+ rgb_to_named16(r, g, b)
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ # Base class for all RubyTUI-specific errors.
5
+ class Error < StandardError; end
6
+ # Error category for terminal-related failures.
7
+ class TerminalError < Error; end
8
+ # Error category for layout-related failures.
9
+ class LayoutError < Error; end
10
+ # Raised by {Widgets::Image} when given image data in a format the Kitty
11
+ # graphics protocol path does not accept (JPEG, WebP, GIF or BMP).
12
+ class UnsupportedImageFormat < Error; end
13
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ # Base class for input events: {KeyEvent}, {MouseEvent} and {ResizeEvent}.
5
+ class Event
6
+ # Base class for all events
7
+ end
8
+
9
+ # A keyboard event: key code, optional character and modifier flags.
10
+ #
11
+ # @example
12
+ # event = KeyEvent.new(code: Input::Key::CHAR, char: "q", modifiers: Input::Key::CTRL)
13
+ # event.ctrl? # => true
14
+ # event.to_s # => "KeyEvent(Ctrl+q)"
15
+ class KeyEvent < Event
16
+ attr_reader :code, :char, :modifiers
17
+
18
+ # @param code [Symbol] key code, one of the {Input::Key} key-code constants
19
+ # (e.g. {Input::Key::ENTER}, {Input::Key::CHAR})
20
+ # @param char [String, nil] the character, for {Input::Key::CHAR} events (default: nil)
21
+ # @param modifiers [Integer] bitmask of {Input::Key::SHIFT}, {Input::Key::ALT} and
22
+ # {Input::Key::CTRL} (default: {Input::Key::NONE})
23
+ def initialize(code:, char: nil, modifiers: Input::Key::NONE)
24
+ @code = code
25
+ @char = char
26
+ @modifiers = modifiers
27
+ end
28
+
29
+ # @return [Boolean] true if the {Input::Key::CTRL} flag is set
30
+ def ctrl? = (@modifiers & Input::Key::CTRL) != 0
31
+ # @return [Boolean] true if the {Input::Key::ALT} flag is set
32
+ def alt? = (@modifiers & Input::Key::ALT) != 0
33
+ # @return [Boolean] true if the {Input::Key::SHIFT} flag is set
34
+ def shift? = (@modifiers & Input::Key::SHIFT) != 0
35
+
36
+ # Value equality: same code, character and modifiers.
37
+ #
38
+ # @param other [Object] object to compare
39
+ # @return [Boolean] true if +other+ is a KeyEvent with equal {#code}, {#char}
40
+ # and {#modifiers}
41
+ def ==(other)
42
+ other.is_a?(KeyEvent) && @code == other.code &&
43
+ @char == other.char && @modifiers == other.modifiers
44
+ end
45
+
46
+ # Human-readable form: modifier names, then the character (or the key
47
+ # code when there is no character), joined by <tt>+</tt>.
48
+ #
49
+ # @return [String] e.g. <tt>KeyEvent(Ctrl+q)</tt> or <tt>KeyEvent(enter)</tt>
50
+ def to_s
51
+ parts = []
52
+ parts << "Ctrl" if ctrl?
53
+ parts << "Alt" if alt?
54
+ parts << "Shift" if shift?
55
+ parts << (@char || @code.to_s)
56
+ "KeyEvent(#{parts.join("+")})"
57
+ end
58
+ alias_method :inspect, :to_s
59
+ end
60
+
61
+ # A mouse event: button, action, cell position and modifier flags.
62
+ #
63
+ # {Input::Parser} produces these from SGR (mode 1006) and legacy
64
+ # (<tt>ESC [ M</tt>) mouse reports, with 0-based cell coordinates.
65
+ class MouseEvent < Event
66
+ # @!group Mouse Buttons
67
+
68
+ # Left button.
69
+ LEFT = :left
70
+ # Middle button.
71
+ MIDDLE = :middle
72
+ # Right button.
73
+ RIGHT = :right
74
+ # Scroll wheel up (reported with action {PRESS}).
75
+ SCROLL_UP = :scroll_up
76
+ # Scroll wheel down (reported with action {PRESS}).
77
+ SCROLL_DOWN = :scroll_down
78
+ # No specific button (legacy-protocol release, or motion with no button held).
79
+ NONE = :none
80
+
81
+ # @!endgroup
82
+
83
+ # @!group Mouse Actions
84
+
85
+ # Button pressed; also used for scroll-wheel events.
86
+ PRESS = :press
87
+ # Button released.
88
+ RELEASE = :release
89
+ # Pointer motion reported by the terminal (motion flag set).
90
+ DRAG = :drag
91
+ # Pointer motion with no button held. Currently never emitted by {Input::Parser}.
92
+ MOVE = :move
93
+
94
+ # @!endgroup
95
+
96
+ attr_reader :button, :action, :x, :y, :modifiers
97
+
98
+ # @param button [Symbol] button constant ({LEFT}, {MIDDLE}, {RIGHT},
99
+ # {SCROLL_UP}, {SCROLL_DOWN} or {NONE})
100
+ # @param action [Symbol] action constant ({PRESS}, {RELEASE}, {DRAG} or {MOVE})
101
+ # @param x [Integer] column (0-based cell coordinate)
102
+ # @param y [Integer] row (0-based cell coordinate)
103
+ # @param modifiers [Integer] bitmask of {Input::Key::SHIFT}, {Input::Key::ALT} and
104
+ # {Input::Key::CTRL} (default: {Input::Key::NONE})
105
+ def initialize(button:, action:, x:, y:, modifiers: Input::Key::NONE)
106
+ @button = button
107
+ @action = action
108
+ @x = x
109
+ @y = y
110
+ @modifiers = modifiers
111
+ end
112
+
113
+ # @return [Boolean] true if the {Input::Key::CTRL} flag is set
114
+ def ctrl? = (@modifiers & Input::Key::CTRL) != 0
115
+ # @return [Boolean] true if the {Input::Key::ALT} flag is set
116
+ def alt? = (@modifiers & Input::Key::ALT) != 0
117
+ # @return [Boolean] true if the {Input::Key::SHIFT} flag is set
118
+ def shift? = (@modifiers & Input::Key::SHIFT) != 0
119
+
120
+ # Value equality: same button, action, position and modifiers.
121
+ #
122
+ # @param other [Object] object to compare
123
+ # @return [Boolean] true if +other+ is a MouseEvent with equal {#button},
124
+ # {#action}, {#x}, {#y} and {#modifiers}
125
+ def ==(other)
126
+ other.is_a?(MouseEvent) && @button == other.button &&
127
+ @action == other.action && @x == other.x && @y == other.y &&
128
+ @modifiers == other.modifiers
129
+ end
130
+
131
+ # Human-readable form: modifier names, then <tt>action(button)</tt>,
132
+ # joined by <tt>+</tt>, followed by the position.
133
+ #
134
+ # @return [String] e.g. <tt>MouseEvent(press(left) @10,5)</tt>
135
+ def to_s
136
+ parts = []
137
+ parts << "Ctrl" if ctrl?
138
+ parts << "Alt" if alt?
139
+ parts << "Shift" if shift?
140
+ parts << "#{@action}(#{@button})"
141
+ "MouseEvent(#{parts.join("+")} @#{@x},#{@y})"
142
+ end
143
+ alias_method :inspect, :to_s
144
+ end
145
+
146
+ # A terminal resize event carrying the new size in cells.
147
+ class ResizeEvent < Event
148
+ attr_reader :width, :height
149
+
150
+ # @param width [Integer] new width in columns
151
+ # @param height [Integer] new height in rows
152
+ def initialize(width:, height:)
153
+ @width = width
154
+ @height = height
155
+ end
156
+
157
+ # @return [String] human-readable form, e.g. <tt>ResizeEvent(80x24)</tt>
158
+ def to_s = "ResizeEvent(#{@width}x#{@height})"
159
+ alias_method :inspect, :to_s
160
+ end
161
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ # Rendering context yielded by {Terminal#draw} and {RubyTUI.render_once}.
5
+ #
6
+ # Frame wraps a {Buffer} and provides methods for rendering widgets.
7
+ # It is the primary interface for placing widgets during a draw call.
8
+ #
9
+ # @example Rendering widgets in a frame
10
+ # terminal.draw do |frame|
11
+ # frame.render_widget(block, frame.area)
12
+ # frame.render_stateful_widget(list, chunks[1], list_state)
13
+ # end
14
+ class Frame
15
+ # @return [Buffer] the buffer to render into
16
+ attr_reader :buffer
17
+ # @return [Rect] the total renderable area
18
+ attr_reader :area
19
+
20
+ # @param buffer [Buffer]
21
+ # @param area [Rect]
22
+ def initialize(buffer, area)
23
+ @buffer = buffer
24
+ @area = area
25
+ @cursor_position = nil
26
+ end
27
+
28
+ # Render a widget into a specific area of this frame.
29
+ #
30
+ # The widget must implement <tt>render(area, buf)</tt> (the {Widget} protocol).
31
+ #
32
+ # @param widget [#render] any object responding to <tt>render(area, buf)</tt>
33
+ # @param area [Rect] the area to render into
34
+ # @return [void]
35
+ def render_widget(widget, area)
36
+ widget.render(area, @buffer)
37
+ end
38
+
39
+ # Render a stateful widget with its state object.
40
+ #
41
+ # The widget must implement <tt>render(area, buf, state)</tt> (the {StatefulWidget} protocol).
42
+ #
43
+ # @param widget [#render] any object responding to <tt>render(area, buf, state)</tt>
44
+ # @param area [Rect] the area to render into
45
+ # @param state [Object] the widget's external state
46
+ # @return [void]
47
+ def render_stateful_widget(widget, area, state)
48
+ widget.render(area, @buffer, state)
49
+ end
50
+
51
+ # Request cursor placement at a specific position.
52
+ #
53
+ # Used by widgets like {Widgets::InputField} to position the terminal cursor.
54
+ #
55
+ # @param x [Integer] column position
56
+ # @param y [Integer] row position
57
+ # @return [void]
58
+ def set_cursor_position(x, y)
59
+ @cursor_position = [x, y]
60
+ end
61
+
62
+ # Get the requested cursor position.
63
+ #
64
+ # @return [Array(Integer, Integer), nil] [x, y] pair, or nil if not set
65
+ def cursor_position = @cursor_position
66
+
67
+ # @return [Rect] the total renderable area (alias for {#area})
68
+ def size = @area
69
+ end
70
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ # Terminal input handling: key constants, byte-sequence {Parser} and
5
+ # non-blocking {Reader}.
6
+ module Input
7
+ # Key codes and modifier flags used by {KeyEvent} and {MouseEvent}.
8
+ #
9
+ # Key codes are Symbols stored in <tt>KeyEvent#code</tt>; modifier flags are
10
+ # Integer bits OR'd together in the +modifiers+ of {KeyEvent} and {MouseEvent}.
11
+ #
12
+ # @example
13
+ # case event.code
14
+ # when Input::Key::ENTER then submit
15
+ # when Input::Key::CHAR then insert(event.char)
16
+ # end
17
+ module Key
18
+ # @!group Key Codes
19
+
20
+ # Character key; the character itself is in <tt>KeyEvent#char</tt>.
21
+ CHAR = :char
22
+ # Enter / Return.
23
+ ENTER = :enter
24
+ # Escape.
25
+ ESCAPE = :escape
26
+ # Backspace.
27
+ BACKSPACE = :backspace
28
+ # Delete (forward delete).
29
+ DELETE = :delete
30
+ # Tab.
31
+ TAB = :tab
32
+ # Shift+Tab (back-tab).
33
+ BACKTAB = :backtab # Shift+Tab
34
+ # Up arrow.
35
+ UP = :up
36
+ # Down arrow.
37
+ DOWN = :down
38
+ # Left arrow.
39
+ LEFT = :left
40
+ # Right arrow.
41
+ RIGHT = :right
42
+ # Home.
43
+ HOME = :home
44
+ # End.
45
+ END_KEY = :end
46
+ # Page Up.
47
+ PAGE_UP = :page_up
48
+ # Page Down.
49
+ PAGE_DOWN = :page_down
50
+ # Insert.
51
+ INSERT = :insert
52
+ # Function key F1.
53
+ F1 = :f1
54
+ # Function key F2.
55
+ F2 = :f2
56
+ # Function key F3.
57
+ F3 = :f3
58
+ # Function key F4.
59
+ F4 = :f4
60
+ # Function key F5.
61
+ F5 = :f5
62
+ # Function key F6.
63
+ F6 = :f6
64
+ # Function key F7.
65
+ F7 = :f7
66
+ # Function key F8.
67
+ F8 = :f8
68
+ # Function key F9.
69
+ F9 = :f9
70
+ # Function key F10.
71
+ F10 = :f10
72
+ # Function key F11.
73
+ F11 = :f11
74
+ # Function key F12.
75
+ F12 = :f12
76
+
77
+ # @!endgroup
78
+
79
+ # @!group Modifier Flags
80
+
81
+ # No modifier keys held.
82
+ NONE = 0
83
+ # Shift modifier bit.
84
+ SHIFT = 0b001
85
+ # Alt (Meta / Option) modifier bit.
86
+ ALT = 0b010
87
+ # Ctrl modifier bit.
88
+ CTRL = 0b100
89
+
90
+ # @!endgroup
91
+ end
92
+ end
93
+ end