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,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ module Widgets
5
+ # Popup renders an overlay on top of existing content.
6
+ # It clears its area, optionally draws a drop shadow, and draws its block;
7
+ # popup content is rendered separately into {#inner}.
8
+ #
9
+ # @example
10
+ # popup = RubyTUI::Widgets::Popup.new(width: 40, height: 7, shadow: true)
11
+ # popup.render(frame.area, frame.buffer)
12
+ # inner = popup.inner(frame.area) # draw popup content here
13
+ class Popup
14
+ include Widget
15
+
16
+ # @param width [Integer, nil] fixed width, clamped to the parent width
17
+ # (default: nil, uses +width_percent+)
18
+ # @param height [Integer, nil] fixed height, clamped to the parent height
19
+ # (default: nil, uses +height_percent+)
20
+ # @param width_percent [Integer] width as a percentage of the parent width when
21
+ # +width+ is nil (default: 60)
22
+ # @param height_percent [Integer] height as a percentage of the parent height when
23
+ # +height+ is nil (default: 60)
24
+ # @param x [Integer, nil] absolute column of the left edge (default: nil, centered
25
+ # horizontally in the parent area)
26
+ # @param y [Integer, nil] absolute row of the top edge (default: nil, centered
27
+ # vertically in the parent area)
28
+ # @param block [Widgets::Block, nil] border/title block (default: nil, a block with
29
+ # all borders and {Symbols::ROUNDED} border characters)
30
+ # @param style [Style] style used to clear the popup area (default: {Style::DEFAULT})
31
+ # @param shadow [Boolean] draw a one-cell shadow to the right of and below the
32
+ # popup (default: false)
33
+ # @param shadow_style [Style, nil] shadow style (default: bright black foreground
34
+ # on a black background)
35
+ def initialize(
36
+ width: nil, # fixed width or nil for percentage
37
+ height: nil, # fixed height or nil for percentage
38
+ width_percent: 60, # percentage of parent width
39
+ height_percent: 60, # percentage of parent height
40
+ x: nil, # fixed x position or nil for centered
41
+ y: nil, # fixed y position or nil for centered
42
+ block: nil, # Block widget for border/title
43
+ style: Style::DEFAULT,
44
+ shadow: false,
45
+ shadow_style: nil
46
+ )
47
+ @fixed_width = width
48
+ @fixed_height = height
49
+ @width_percent = width_percent
50
+ @height_percent = height_percent
51
+ @fixed_x = x
52
+ @fixed_y = y
53
+ @block = block || Widgets::Block.new(borders: :all, border_set: Symbols::ROUNDED)
54
+ @style = style
55
+ @shadow = shadow
56
+ @shadow_style = shadow_style || Style.new.fg(Color::BRIGHT_BLACK).bg(Color::BLACK)
57
+ end
58
+
59
+ # Calculate the popup area within the given parent area
60
+ #
61
+ # The size is clamped to the parent size. A fixed +x+ or +y+ is used as is and
62
+ # is not clamped to the parent area.
63
+ #
64
+ # @param parent_area [Rect] area the popup is sized and centered against
65
+ # @return [Rect] the popup's outer area, including the block's borders
66
+ def popup_area(parent_area)
67
+ w = @fixed_width || (parent_area.width * @width_percent / 100)
68
+ h = @fixed_height || (parent_area.height * @height_percent / 100)
69
+ w = [w, parent_area.width].min
70
+ h = [h, parent_area.height].min
71
+
72
+ px = @fixed_x || (parent_area.x + (parent_area.width - w) / 2)
73
+ py = @fixed_y || (parent_area.y + (parent_area.height - h) / 2)
74
+
75
+ Rect.new(x: px, y: py, width: w, height: h)
76
+ end
77
+
78
+ # Get the inner area (inside the popup's block)
79
+ #
80
+ # @param parent_area [Rect] area the popup is sized and centered against
81
+ # @return [Rect] the area inside the block's borders and padding, for popup content
82
+ def inner(parent_area)
83
+ @block.inner(popup_area(parent_area))
84
+ end
85
+
86
+ # Draw the shadow (if enabled), clear the popup area with +style+, then draw
87
+ # the block.
88
+ #
89
+ # +area+ is the parent area; the popup is placed within it by {#popup_area}.
90
+ #
91
+ # @param area [Rect] the parent area to place the popup in
92
+ # @param buf [Buffer] the buffer to write cells into
93
+ # @return [void]
94
+ def render(area, buf)
95
+ return if area.empty?
96
+
97
+ pa = popup_area(area)
98
+ return if pa.empty?
99
+
100
+ # Draw shadow first (offset by 1,1)
101
+ if @shadow
102
+ render_shadow(pa, buf)
103
+ end
104
+
105
+ # Clear the popup area with background style
106
+ clear_area(pa, buf)
107
+
108
+ # Draw the block (border + title)
109
+ @block.render(pa, buf)
110
+ end
111
+
112
+ private
113
+
114
+ def clear_area(area, buf)
115
+ (area.y...area.bottom).each do |y|
116
+ (area.x...area.right).each do |x|
117
+ cell = buf[x, y]
118
+ cell&.set(" ", @style)
119
+ end
120
+ end
121
+ end
122
+
123
+ def render_shadow(popup_area, buf)
124
+ # Right shadow (1 column)
125
+ shadow_x = popup_area.right
126
+ (popup_area.y + 1...popup_area.bottom + 1).each do |y|
127
+ cell = buf[shadow_x, y]
128
+ cell&.set(" ", @shadow_style) if cell
129
+ end
130
+
131
+ # Bottom shadow (full width + 1)
132
+ shadow_y = popup_area.bottom
133
+ (popup_area.x + 1..popup_area.right).each do |x|
134
+ cell = buf[x, shadow_y]
135
+ cell&.set(" ", @shadow_style) if cell
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ module Widgets
5
+ # Scrollbar renders a vertical or horizontal scroll indicator.
6
+ #
7
+ # @example Vertical scrollbar in the rightmost column
8
+ # bar = RubyTUI::Widgets::Scrollbar.new(content_length: 100, position: offset, viewport_length: 20)
9
+ # frame.render_widget(bar, Rect.new(area.right - 1, area.y, 1, area.height))
10
+ class Scrollbar
11
+ include Widget
12
+
13
+ # Vertical orientation, drawn in the first column of the area.
14
+ VERTICAL = :vertical
15
+ # Horizontal orientation, drawn in the first row of the area.
16
+ HORIZONTAL = :horizontal
17
+
18
+ # Unicode characters for scrollbar parts
19
+
20
+ # Vertical track.
21
+ TRACK_V = "\u2502" # │
22
+ # Vertical thumb.
23
+ THUMB_V = "\u2588" # █
24
+ # Horizontal track.
25
+ TRACK_H = "\u2500" # ─
26
+ # Horizontal thumb.
27
+ THUMB_H = "\u2588" # █
28
+ # Up arrow.
29
+ ARROW_UP = "\u25B2" # ▲
30
+ # Down arrow.
31
+ ARROW_DOWN = "\u25BC" # ▼
32
+ # Left arrow.
33
+ ARROW_LEFT = "\u25C0" # ◀
34
+ # Right arrow.
35
+ ARROW_RIGHT = "\u25B6" # ▶
36
+
37
+ # @param content_length [Integer] total content length, clamped to >= 1
38
+ # @param position [Integer] scroll offset into the content, clamped to
39
+ # <tt>0..(content_length - viewport_length)</tt> when drawing (default: 0)
40
+ # @param viewport_length [Integer, nil] visible length of the content
41
+ # (default: nil, the track length in cells, arrows included)
42
+ # @param orientation [Symbol] {VERTICAL} (default) or {HORIZONTAL}; any other
43
+ # value is drawn horizontally
44
+ # @param style [Style] currently unused by rendering (default: {Style::DEFAULT})
45
+ # @param thumb_style [Style, nil] thumb style (default: white foreground)
46
+ # @param track_style [Style, nil] track and arrow style (default: bright black foreground)
47
+ # @param show_arrows [Boolean] draw an arrow at each end (default: true)
48
+ def initialize(
49
+ content_length:,
50
+ position: 0,
51
+ viewport_length: nil,
52
+ orientation: VERTICAL,
53
+ style: Style::DEFAULT,
54
+ thumb_style: nil,
55
+ track_style: nil,
56
+ show_arrows: true
57
+ )
58
+ @content_length = [content_length, 1].max
59
+ @position = position
60
+ @viewport_length = viewport_length
61
+ @orientation = orientation
62
+ @style = style
63
+ @thumb_style = thumb_style || Style.new.fg(Color::WHITE)
64
+ @track_style = track_style || Style.new.fg(Color::BRIGHT_BLACK)
65
+ @show_arrows = show_arrows
66
+ end
67
+
68
+ # Draw the track, thumb and optional arrows along the first column (vertical)
69
+ # or first row (horizontal) of the area.
70
+ #
71
+ # The thumb fills the whole track when the content fits in the viewport.
72
+ # Nothing is drawn if no track cells remain after the arrows.
73
+ #
74
+ # @param area [Rect] the rectangular area to render into
75
+ # @param buf [Buffer] the buffer to write cells into
76
+ # @return [void]
77
+ def render(area, buf)
78
+ return if area.empty?
79
+
80
+ if @orientation == VERTICAL
81
+ render_vertical(area, buf)
82
+ else
83
+ render_horizontal(area, buf)
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def render_vertical(area, buf)
90
+ track_length = area.height
91
+ viewport = @viewport_length || track_length
92
+ arrow_offset = @show_arrows ? 1 : 0
93
+ usable = track_length - (arrow_offset * 2)
94
+
95
+ return if usable <= 0
96
+
97
+ # Calculate thumb size and position
98
+ thumb_size = calculate_thumb_size(usable, viewport)
99
+ thumb_pos = calculate_thumb_position(usable, thumb_size, viewport)
100
+
101
+ x = area.x
102
+
103
+ # Top arrow
104
+ if @show_arrows
105
+ buf[x, area.y]&.set(ARROW_UP, @track_style)
106
+ end
107
+
108
+ # Track and thumb
109
+ usable.times do |i|
110
+ y = area.y + arrow_offset + i
111
+ if i >= thumb_pos && i < thumb_pos + thumb_size
112
+ buf[x, y]&.set(THUMB_V, @thumb_style)
113
+ else
114
+ buf[x, y]&.set(TRACK_V, @track_style)
115
+ end
116
+ end
117
+
118
+ # Bottom arrow
119
+ if @show_arrows
120
+ buf[x, area.bottom - 1]&.set(ARROW_DOWN, @track_style)
121
+ end
122
+ end
123
+
124
+ def render_horizontal(area, buf)
125
+ track_length = area.width
126
+ viewport = @viewport_length || track_length
127
+ arrow_offset = @show_arrows ? 1 : 0
128
+ usable = track_length - (arrow_offset * 2)
129
+
130
+ return if usable <= 0
131
+
132
+ thumb_size = calculate_thumb_size(usable, viewport)
133
+ thumb_pos = calculate_thumb_position(usable, thumb_size, viewport)
134
+
135
+ y = area.y
136
+
137
+ # Left arrow
138
+ if @show_arrows
139
+ buf[area.x, y]&.set(ARROW_LEFT, @track_style)
140
+ end
141
+
142
+ # Track and thumb
143
+ usable.times do |i|
144
+ x = area.x + arrow_offset + i
145
+ if i >= thumb_pos && i < thumb_pos + thumb_size
146
+ buf[x, y]&.set(THUMB_H, @thumb_style)
147
+ else
148
+ buf[x, y]&.set(TRACK_H, @track_style)
149
+ end
150
+ end
151
+
152
+ # Right arrow
153
+ if @show_arrows
154
+ buf[area.right - 1, y]&.set(ARROW_RIGHT, @track_style)
155
+ end
156
+ end
157
+
158
+ def calculate_thumb_size(track, viewport)
159
+ return track if @content_length <= viewport
160
+
161
+ size = (track.to_f * viewport / @content_length).round
162
+ size.clamp(1, track)
163
+ end
164
+
165
+ def calculate_thumb_position(track, thumb_size, viewport)
166
+ return 0 if @content_length <= viewport
167
+
168
+ max_scroll = @content_length - viewport
169
+ pos = @position.clamp(0, max_scroll)
170
+ available = track - thumb_size
171
+ (available.to_f * pos / max_scroll).round.clamp(0, available)
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ module Widgets
5
+ # Sparkline renders a mini bar chart from an array of numeric data.
6
+ # Each data point maps to a single column using Unicode block characters.
7
+ #
8
+ # @example
9
+ # spark = RubyTUI::Widgets::Sparkline.new(data: [1, 4, 2, 8, 5], max: 10)
10
+ # frame.render_widget(spark, area)
11
+ class Sparkline
12
+ include Widget
13
+
14
+ # Bar characters from empty to full (8 levels)
15
+ BARS = [" ", "\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"].freeze
16
+
17
+ # @param data [Array<Numeric>] values; the most recent values that fit the
18
+ # width are drawn, right-aligned (default: [])
19
+ # @param style [Style] style of the empty cells above the bars (default: {Style::DEFAULT})
20
+ # @param bar_style [Style, nil] bar style (default: green foreground)
21
+ # @param max [Numeric, nil] value drawn at full height; larger values are clamped
22
+ # to full height (default: nil, the largest visible value, or 1 if that is 0)
23
+ # @param block [Widgets::Block, nil] optional wrapping block
24
+ def initialize(
25
+ data: [],
26
+ style: Style::DEFAULT,
27
+ bar_style: nil,
28
+ max: nil,
29
+ block: nil
30
+ )
31
+ @data = data
32
+ @style = style
33
+ @bar_style = bar_style || Style.new.fg(Color::GREEN)
34
+ @max = max
35
+ @block = block
36
+ end
37
+
38
+ # Draw one column per value, bottom-up, with eighth-cell height precision.
39
+ #
40
+ # @param area [Rect] the rectangular area to render into
41
+ # @param buf [Buffer] the buffer to write cells into
42
+ # @return [void]
43
+ def render(area, buf)
44
+ return if area.empty?
45
+
46
+ render_area = area
47
+ if @block
48
+ @block.render(area, buf)
49
+ render_area = @block.inner(area)
50
+ end
51
+
52
+ return if render_area.empty? || @data.empty?
53
+
54
+ width = render_area.width
55
+ height = render_area.height
56
+
57
+ # Use the most recent data points that fit
58
+ visible_data = @data.last(width)
59
+ max_val = @max || visible_data.max || 1
60
+ max_val = 1 if max_val == 0
61
+
62
+ visible_data.each_with_index do |val, i|
63
+ x = render_area.x + (width - visible_data.length) + i
64
+ next if x < render_area.x
65
+
66
+ # Scale value to available height * 8 (for sub-character precision)
67
+ scaled = ((val.to_f / max_val) * height * 8).round.clamp(0, height * 8)
68
+
69
+ # Render from bottom to top
70
+ (0...height).each do |row|
71
+ y = render_area.bottom - 1 - row
72
+ row_value = scaled - (row * 8)
73
+
74
+ if row_value >= 8
75
+ buf[x, y]&.set(BARS[8], @bar_style)
76
+ elsif row_value > 0
77
+ buf[x, y]&.set(BARS[row_value], @bar_style)
78
+ else
79
+ buf[x, y]&.set(" ", @style)
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ module Widgets
5
+ # TableState holds external state for the Table widget.
6
+ #
7
+ # +selected+ is the index of the selected row and +offset+ the index of the
8
+ # first visible row. {Table#render} clamps both in place.
9
+ #
10
+ # @see Table
11
+ class TableState
12
+ attr_accessor :selected, :offset
13
+
14
+ # @param selected [Integer] initial selected row 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 row, wrapping from the last row to the first.
22
+ #
23
+ # Does nothing when +total+ is 0 or negative.
24
+ #
25
+ # @param total [Integer] number of rows in the table
26
+ # @return [void]
27
+ def select_next(total)
28
+ @selected = (@selected + 1) % total if total > 0
29
+ end
30
+
31
+ # Select the previous row, wrapping from the first row to the last.
32
+ #
33
+ # Does nothing when +total+ is 0 or negative.
34
+ #
35
+ # @param total [Integer] number of rows in the table
36
+ # @return [void]
37
+ def select_previous(total)
38
+ @selected = (@selected - 1) % total if total > 0
39
+ end
40
+ end
41
+
42
+ # Table renders tabular data with headers, rows, and configurable column widths.
43
+ #
44
+ # @example
45
+ # state = RubyTUI::Widgets::TableState.new
46
+ # table = RubyTUI::Widgets::Table.new(header: ["Name", "Size"],
47
+ # rows: [["a.txt", "1K"], ["b.txt", "2K"]],
48
+ # widths: [20, 8])
49
+ # frame.render_stateful_widget(table, area, state)
50
+ #
51
+ # @see TableState
52
+ class Table
53
+ include StatefulWidget
54
+
55
+ # @param header [Array<String, Span, #to_s>, nil] header cells, each converted to
56
+ # a {Span}; when set, a header row and a separator line are drawn (default: nil)
57
+ # @param rows [Array<Array<String, Span, #to_s>>] data rows (default: [])
58
+ # @param widths [Array<Integer>] column widths in cells; when empty, the available
59
+ # width is split equally across the columns of +header+, or of the first row
60
+ # (default: [])
61
+ # @param style [Style] base row style (default: {Style::DEFAULT})
62
+ # @param header_style [Style, nil] style of the header row and separator
63
+ # (default: bold cyan foreground)
64
+ # @param highlight_style [Style, nil] patched onto +style+ for the selected row
65
+ # (default: bold yellow foreground)
66
+ # @param highlight_symbol [String] prefix of the selected row; other rows and the
67
+ # header are prefixed with spaces of the same width (default: "> ")
68
+ # @param column_spacing [Integer] blank cells between columns (default: 1)
69
+ # @param block [Widgets::Block, nil] optional wrapping block
70
+ def initialize(
71
+ header: nil,
72
+ rows: [],
73
+ widths: [],
74
+ style: Style::DEFAULT,
75
+ header_style: nil,
76
+ highlight_style: nil,
77
+ highlight_symbol: "> ",
78
+ column_spacing: 1,
79
+ block: nil
80
+ )
81
+ @header = header&.map { |h| normalize_cell(h) }
82
+ @rows = rows.map { |row| row.map { |c| normalize_cell(c) } }
83
+ @widths = widths
84
+ @style = style
85
+ @header_style = header_style || Style.new.fg(Color::CYAN).bold
86
+ @highlight_style = highlight_style || Style.new.fg(Color::YELLOW).bold
87
+ @highlight_symbol = highlight_symbol
88
+ @highlight_symbol_width = Buffer.string_width(@highlight_symbol)
89
+ @normal_symbol = " " * @highlight_symbol_width
90
+ @column_spacing = column_spacing
91
+ @block = block
92
+ end
93
+
94
+ # Render the header, separator and visible rows.
95
+ #
96
+ # Clamps <tt>state.selected</tt> to the row range and adjusts
97
+ # <tt>state.offset</tt> in place so the selected row is visible. Cell text is
98
+ # truncated to its column width; explicit +widths+ are not reduced to fit the area.
99
+ #
100
+ # @param area [Rect] the rectangular area to render into
101
+ # @param buf [Buffer] the buffer to write cells into
102
+ # @param state [TableState] selection and scroll state, updated in place
103
+ # @return [void]
104
+ def render(area, buf, state)
105
+ return if area.empty?
106
+
107
+ render_area = area
108
+ if @block
109
+ @block.render(area, buf)
110
+ render_area = @block.inner(area)
111
+ end
112
+
113
+ return if render_area.empty?
114
+
115
+ # Resolve column widths
116
+ col_widths = resolve_widths(render_area.width - @highlight_symbol_width)
117
+ return if col_widths.empty?
118
+
119
+ y = render_area.y
120
+
121
+ # Render header
122
+ if @header
123
+ render_row(@header, col_widths, render_area.x, y, buf, @header_style, @normal_symbol)
124
+ y += 1
125
+
126
+ # Header separator
127
+ if y < render_area.bottom
128
+ x = render_area.x + @highlight_symbol_width
129
+ col_widths.each_with_index do |w, i|
130
+ buf.set_string(x, y, "\u2500" * w, @header_style)
131
+ x += w + (i < col_widths.length - 1 ? @column_spacing : 0)
132
+ end
133
+ y += 1
134
+ end
135
+ end
136
+
137
+ # Available height for rows
138
+ visible_height = render_area.bottom - y
139
+ return if visible_height <= 0
140
+
141
+ # Clamp state
142
+ total = @rows.length
143
+ state.selected = state.selected.clamp(0, [total - 1, 0].max)
144
+ if state.selected < state.offset
145
+ state.offset = state.selected
146
+ elsif state.selected >= state.offset + visible_height
147
+ state.offset = state.selected - visible_height + 1
148
+ end
149
+ state.offset = state.offset.clamp(0, [total - visible_height, 0].max)
150
+
151
+ # Render visible rows
152
+ visible_rows = @rows[state.offset, visible_height] || []
153
+ visible_rows.each_with_index do |row, i|
154
+ break if y >= render_area.bottom
155
+
156
+ actual_index = state.offset + i
157
+ is_selected = actual_index == state.selected
158
+ row_style = is_selected ? @style.patch(@highlight_style) : @style
159
+ symbol = is_selected ? @highlight_symbol : @normal_symbol
160
+
161
+ render_row(row, col_widths, render_area.x, y, buf, row_style, symbol)
162
+
163
+ # Fill remaining width for highlight
164
+ if is_selected
165
+ row_end = render_area.x + @highlight_symbol_width
166
+ col_widths.each_with_index do |w, ci|
167
+ row_end += w + (ci < col_widths.length - 1 ? @column_spacing : 0)
168
+ end
169
+ if row_end < render_area.right
170
+ buf.set_string(row_end, y, " " * (render_area.right - row_end), row_style)
171
+ end
172
+ end
173
+
174
+ y += 1
175
+ end
176
+ end
177
+
178
+ private
179
+
180
+ def normalize_cell(cell)
181
+ case cell
182
+ when Span then cell
183
+ when String then Span.new(cell)
184
+ else Span.new(cell.to_s)
185
+ end
186
+ end
187
+
188
+ def resolve_widths(available)
189
+ return @widths unless @widths.empty?
190
+
191
+ # Auto-calculate: equal widths based on column count
192
+ ncols = (@header&.length || @rows.first&.length || 0)
193
+ return [] if ncols == 0
194
+
195
+ total_spacing = @column_spacing * [ncols - 1, 0].max
196
+ usable = [available - total_spacing, 0].max
197
+ base = usable / ncols
198
+ remainder = usable % ncols
199
+
200
+ Array.new(ncols) { |i| i < remainder ? base + 1 : base }
201
+ end
202
+
203
+ def render_row(cells, col_widths, area_x, y, buf, row_style, symbol)
204
+ x = area_x
205
+ buf.set_string(x, y, symbol, row_style)
206
+ x += @highlight_symbol_width
207
+
208
+ cells.each_with_index do |cell, i|
209
+ break if i >= col_widths.length
210
+
211
+ w = col_widths[i]
212
+ text = Buffer.truncate_to_width(cell.content, w)
213
+ text_width = Buffer.string_width(text)
214
+ effective_style = row_style.patch(cell.style)
215
+ buf.set_string(x, y, text, effective_style)
216
+
217
+ # Pad remaining column width
218
+ pad = w - text_width
219
+ buf.set_string(x + text_width, y, " " * pad, row_style) if pad > 0
220
+
221
+ x += w
222
+ # Fill column spacing gap with row style
223
+ if i < col_widths.length - 1 && @column_spacing > 0
224
+ buf.set_string(x, y, " " * @column_spacing, row_style)
225
+ x += @column_spacing
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end