rubytui 1.2.3 → 1.2.4

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.
@@ -10,24 +10,30 @@ module RubyTUI
10
10
  class Paragraph
11
11
  include Widget
12
12
 
13
- # Wrap at whitespace; a single word wider than the area is not broken.
13
+ # Wrap at whitespace; a single word wider than the area is not broken
14
+ # and is clipped at the area's right edge.
14
15
  WRAP_WORD = :word
15
- # Wrap at any character.
16
+ # Wrap between any two grapheme clusters (see {Unicode.cluster_width}), so a
17
+ # Devanagari conjunct or an emoji ZWJ sequence is never split and a
18
+ # two-column cluster moves to the next line rather than straddle the edge.
16
19
  WRAP_CHAR = :char
17
20
  # Do not wrap.
18
21
  WRAP_NONE = :none
19
22
 
20
23
  attr_reader :text, :style, :alignment, :wrap
21
24
 
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})
25
+ # @param text [String, Line, Text, Span, nil, #to_s] content; a String is split
26
+ # into lines on newlines, a Span is split the same way into one-span Lines
27
+ # that keep its {Span#style}, +nil+ means no text, other objects are
28
+ # converted with +to_s+ (default: nil)
29
+ # @param style [Style] base style of the text. Each span is drawn with this
30
+ # style patched ({Style#patch}) with {Text#style}, then {Line#style}, then
31
+ # {Span#style}, so later layers win. When it is not {Style::DEFAULT} it is
32
+ # also patched onto every cell of the area (default: {Style::DEFAULT})
28
33
  # @param alignment [Symbol] +:left+ (default), +:center+ or +:right+
29
34
  # @param wrap [Symbol] {WRAP_WORD} (default), {WRAP_CHAR} or {WRAP_NONE}. Wrapped
30
- # lines take the style of the original line's first span
35
+ # lines take the style of the original line's first span and keep the
36
+ # original line's {Line#style}
31
37
  # @param block [Widgets::Block, nil] optional wrapping block
32
38
  def initialize(
33
39
  text: nil,
@@ -46,8 +52,9 @@ module RubyTUI
46
52
  # Render the wrapped lines from the top of the area; lines beyond its height
47
53
  # are not drawn.
48
54
  #
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.
55
+ # Lines wider than the area (with {WRAP_NONE}, or a long word with
56
+ # {WRAP_WORD}) are clipped at its right edge: nothing is drawn at or beyond
57
+ # it, and a two-column character that would straddle it is not drawn.
51
58
  #
52
59
  # @param area [Rect] the rectangular area to render into
53
60
  # @param buf [Buffer] the buffer to write cells into
@@ -76,6 +83,22 @@ module RubyTUI
76
83
  end
77
84
  end
78
85
 
86
+ # Number of lines {#render} draws, before clipping to the area's height, when
87
+ # the text is wrapped with {#wrap} to +width+ columns.
88
+ #
89
+ # @param width [Integer] width of the area the text is drawn in: the area
90
+ # inside +block+ when the paragraph has one. A width of 0 or less does not
91
+ # wrap
92
+ # @return [Integer] wrapped line count (0 when there is no text); empty lines
93
+ # count as one line each
94
+ #
95
+ # @example Height needed to show all of the text
96
+ # para = RubyTUI::Widgets::Paragraph.new(text: "alpha beta gamma delta")
97
+ # para.line_count(10) # => 3
98
+ def line_count(width)
99
+ wrap_lines(width).length
100
+ end
101
+
79
102
  private
80
103
 
81
104
  def normalize_text(text)
@@ -83,6 +106,7 @@ module RubyTUI
83
106
  when Text then text
84
107
  when String then Text.from(text)
85
108
  when Line then Text.new([text])
109
+ when Span then Text.new(text.content.split("\n").map { |l| Line.from(l, text.style) })
86
110
  when nil then Text.new([])
87
111
  else Text.from(text.to_s)
88
112
  end
@@ -117,7 +141,7 @@ module RubyTUI
117
141
  current_width = 0
118
142
 
119
143
  words.each do |word|
120
- word_width = Buffer.string_width(word)
144
+ word_width = Unicode.string_width(word)
121
145
  if current.empty?
122
146
  current << word
123
147
  current_width = word_width
@@ -125,13 +149,13 @@ module RubyTUI
125
149
  current << word
126
150
  current_width += word_width
127
151
  else
128
- wrapped << Line.from(current.rstrip, line_style) unless current.strip.empty?
152
+ wrapped << Line.new([Span.new(current.rstrip, line_style)], line.style) unless current.strip.empty?
129
153
  current = word.lstrip
130
- current_width = Buffer.string_width(current)
154
+ current_width = Unicode.string_width(current)
131
155
  end
132
156
  end
133
157
 
134
- wrapped << Line.from(current.rstrip, line_style) unless current.strip.empty?
158
+ wrapped << Line.new([Span.new(current.rstrip, line_style)], line.style) unless current.strip.empty?
135
159
  wrapped << Line.new if wrapped.empty?
136
160
  wrapped
137
161
  end
@@ -144,17 +168,18 @@ module RubyTUI
144
168
  current = String.new
145
169
  current_width = 0
146
170
 
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?
171
+ # Break only between grapheme clusters, so conjuncts and ZWJ sequences stay whole
172
+ plain.grapheme_clusters.each do |gc|
173
+ gc_width = Unicode.cluster_width(gc)
174
+ if current_width + gc_width > max_width
175
+ wrapped << Line.new([Span.new(current, line_style)], line.style) unless current.empty?
151
176
  current = String.new
152
177
  current_width = 0
153
178
  end
154
- current << ch
155
- current_width += ch_width
179
+ current << gc
180
+ current_width += gc_width
156
181
  end
157
- wrapped << Line.from(current, line_style) unless current.empty?
182
+ wrapped << Line.new([Span.new(current, line_style)], line.style) unless current.empty?
158
183
  wrapped << Line.new if wrapped.empty?
159
184
  wrapped
160
185
  end
@@ -169,11 +194,15 @@ module RubyTUI
169
194
 
170
195
  x = area.x + x_offset
171
196
  y = area.y + row
197
+ line_style = @style.patch(@text.style).patch(line.style)
172
198
 
173
199
  line.spans.each do |span|
174
- effective_style = @style.patch(span.style)
175
- count = buf.set_string(x, y, span.content, effective_style)
200
+ # Clip at the area's right edge; a wide cluster that would straddle it is dropped
201
+ content = Unicode.truncate_to_width(span.content, area.right - x)
202
+ effective_style = line_style.patch(span.style)
203
+ count = buf.set_string(x, y, content, effective_style)
176
204
  x += count
205
+ break if content.length < span.content.length
177
206
  end
178
207
  end
179
208
  end
@@ -41,9 +41,12 @@ module RubyTUI
41
41
  # (default: nil, the track length in cells, arrows included)
42
42
  # @param orientation [Symbol] {VERTICAL} (default) or {HORIZONTAL}; any other
43
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)
44
+ # @param style [Style] base style; +thumb_style+ and +track_style+ are patched
45
+ # onto it with {Style#patch} (default: {Style::DEFAULT})
46
+ # @param thumb_style [Style, nil] thumb style, patched onto +style+ (default: nil,
47
+ # white foreground unless +style+ sets one)
48
+ # @param track_style [Style, nil] track and arrow style, patched onto +style+
49
+ # (default: nil, bright black foreground unless +style+ sets one)
47
50
  # @param show_arrows [Boolean] draw an arrow at each end (default: true)
48
51
  def initialize(
49
52
  content_length:,
@@ -60,8 +63,8 @@ module RubyTUI
60
63
  @viewport_length = viewport_length
61
64
  @orientation = orientation
62
65
  @style = style
63
- @thumb_style = thumb_style || Style.new.fg(Color::WHITE)
64
- @track_style = track_style || Style.new.fg(Color::BRIGHT_BLACK)
66
+ @thumb_style = thumb_style ? style.patch(thumb_style) : Style.new.fg(Color::WHITE).patch(style)
67
+ @track_style = track_style ? style.patch(track_style) : Style.new.fg(Color::BRIGHT_BLACK).patch(style)
65
68
  @show_arrows = show_arrows
66
69
  end
67
70
 
@@ -4,38 +4,42 @@ module RubyTUI
4
4
  module Widgets
5
5
  # TableState holds external state for the Table widget.
6
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.
7
+ # +selected+ is the index of the selected row, or +nil+ for no selection, and
8
+ # +offset+ the index of the first visible row. {Table#render} clamps both in
9
+ # place; a +nil+ +selected+ is left as +nil+.
9
10
  #
10
11
  # @see Table
11
12
  class TableState
12
13
  attr_accessor :selected, :offset
13
14
 
14
- # @param selected [Integer] initial selected row index (default: 0)
15
+ # @param selected [Integer, nil] initial selected row index; +nil+ selects no
16
+ # row (default: 0)
15
17
  # @param offset [Integer] initial scroll offset (default: 0)
16
18
  def initialize(selected: 0, offset: 0)
17
19
  @selected = selected
18
20
  @offset = offset
19
21
  end
20
22
 
21
- # Select the next row, wrapping from the last row to the first.
23
+ # Select the next row, wrapping from the last row to the first. With no
24
+ # selection (+nil+), selects the first row.
22
25
  #
23
26
  # Does nothing when +total+ is 0 or negative.
24
27
  #
25
28
  # @param total [Integer] number of rows in the table
26
29
  # @return [void]
27
30
  def select_next(total)
28
- @selected = (@selected + 1) % total if total > 0
31
+ @selected = ((@selected || -1) + 1) % total if total > 0
29
32
  end
30
33
 
31
- # Select the previous row, wrapping from the first row to the last.
34
+ # Select the previous row, wrapping from the first row to the last. With no
35
+ # selection (+nil+), selects the last row.
32
36
  #
33
37
  # Does nothing when +total+ is 0 or negative.
34
38
  #
35
39
  # @param total [Integer] number of rows in the table
36
40
  # @return [void]
37
41
  def select_previous(total)
38
- @selected = (@selected - 1) % total if total > 0
42
+ @selected = ((@selected || 0) - 1) % total if total > 0
39
43
  end
40
44
  end
41
45
 
@@ -85,7 +89,7 @@ module RubyTUI
85
89
  @header_style = header_style || Style.new.fg(Color::CYAN).bold
86
90
  @highlight_style = highlight_style || Style.new.fg(Color::YELLOW).bold
87
91
  @highlight_symbol = highlight_symbol
88
- @highlight_symbol_width = Buffer.string_width(@highlight_symbol)
92
+ @highlight_symbol_width = Unicode.string_width(@highlight_symbol)
89
93
  @normal_symbol = " " * @highlight_symbol_width
90
94
  @column_spacing = column_spacing
91
95
  @block = block
@@ -94,8 +98,11 @@ module RubyTUI
94
98
  # Render the header, separator and visible rows.
95
99
  #
96
100
  # 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.
101
+ # <tt>state.offset</tt> in place so the selected row is visible. When
102
+ # <tt>state.selected</tt> is +nil+, no row is highlighted or prefixed with
103
+ # +highlight_symbol+, +selected+ stays +nil+ and +offset+ is only clamped to the
104
+ # row range. Cell text is truncated to its column width; explicit +widths+ are
105
+ # not reduced to fit the area.
99
106
  #
100
107
  # @param area [Rect] the rectangular area to render into
101
108
  # @param buf [Buffer] the buffer to write cells into
@@ -140,11 +147,13 @@ module RubyTUI
140
147
 
141
148
  # Clamp state
142
149
  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
150
+ if state.selected
151
+ state.selected = state.selected.clamp(0, [total - 1, 0].max)
152
+ if state.selected < state.offset
153
+ state.offset = state.selected
154
+ elsif state.selected >= state.offset + visible_height
155
+ state.offset = state.selected - visible_height + 1
156
+ end
148
157
  end
149
158
  state.offset = state.offset.clamp(0, [total - visible_height, 0].max)
150
159
 
@@ -209,8 +218,8 @@ module RubyTUI
209
218
  break if i >= col_widths.length
210
219
 
211
220
  w = col_widths[i]
212
- text = Buffer.truncate_to_width(cell.content, w)
213
- text_width = Buffer.string_width(text)
221
+ text = Unicode.truncate_to_width(cell.content, w)
222
+ text_width = Unicode.string_width(text)
214
223
  effective_style = row_style.patch(cell.style)
215
224
  buf.set_string(x, y, text, effective_style)
216
225
 
@@ -18,7 +18,8 @@ module RubyTUI
18
18
  # @param inactive_style [Style, nil] style of inactive tabs and dividers
19
19
  # (default: bright black foreground)
20
20
  # @param divider [String] separator drawn between tabs (default: <tt>" │ "</tt>)
21
- # @param highlight_style [Style, nil] currently unused by rendering (default: nil)
21
+ # @param highlight_style [Style, nil] patched onto the active tab's style after
22
+ # +active_style+ (default: nil, no extra styling)
22
23
  # @param block [Widgets::Block, nil] optional wrapping block
23
24
  def initialize(
24
25
  titles: [],
@@ -65,12 +66,13 @@ module RubyTUI
65
66
 
66
67
  is_active = i == @selected
67
68
  tab_style = is_active ? @style.patch(@active_style) : @style.patch(@inactive_style)
69
+ tab_style = tab_style.patch(@highlight_style) if is_active && @highlight_style
68
70
 
69
71
  # Render tab title
70
72
  display = title.to_s
71
73
  available = render_area.right - x
72
- display = Buffer.truncate_to_width(display, available)
73
- display_width = Buffer.string_width(display)
74
+ display = Unicode.truncate_to_width(display, available)
75
+ display_width = Unicode.string_width(display)
74
76
 
75
77
  buf.set_string(x, y, display, tab_style)
76
78
  x += display_width
@@ -78,8 +80,8 @@ module RubyTUI
78
80
  # Render divider (except after last tab)
79
81
  if i < @titles.length - 1
80
82
  available = render_area.right - x
81
- divider_text = Buffer.truncate_to_width(@divider, available)
82
- divider_width = Buffer.string_width(divider_text)
83
+ divider_text = Unicode.truncate_to_width(@divider, available)
84
+ divider_width = Unicode.string_width(divider_text)
83
85
  buf.set_string(x, y, divider_text, @style.patch(@inactive_style))
84
86
  x += divider_width
85
87
  end
data/lib/rubytui.rb CHANGED
@@ -61,7 +61,7 @@ require_relative "rubytui/input/reader"
61
61
  # App framework
62
62
  require_relative "rubytui/app"
63
63
 
64
- # A pure Ruby terminal user interface library.
64
+ # A zero-dependency terminal user interface library.
65
65
  #
66
66
  # RubyTUI provides immediate-mode rendering with double buffering,
67
67
  # constraint-based layouts, and a duck-typed widget protocol.
@@ -206,6 +206,7 @@ module RubyTUI
206
206
  #
207
207
  # Renders a {Widgets::Table} with optional header, borders, and title,
208
208
  # then prints the ANSI output directly. Auto-calculates the required height.
209
+ # No row is selected, so no row is drawn in the highlight style.
209
210
  #
210
211
  # @param header [Array<String>, nil] column header labels
211
212
  # @param rows [Array<Array<String>>] table data rows
@@ -216,7 +217,8 @@ module RubyTUI
216
217
  # @param border_style [Style] style for the border characters (default: {Style::DEFAULT})
217
218
  # @param header_style [Style, nil] style for header row (default: cyan bold)
218
219
  # @param style [Style] base style for data rows (default: {Style::DEFAULT})
219
- # @param highlight_symbol [String] prefix for the selected row (default: " ")
220
+ # @param highlight_symbol [String] only its display width is used: every row is
221
+ # prefixed with that many spaces, as no row is selected (default: " ")
220
222
  # @param column_spacing [Integer] gap between columns (default: 1)
221
223
  # @param width [Integer, nil] total width in columns (default: auto-detect)
222
224
  # @param output [IO] output stream (default: $stdout)
@@ -245,7 +247,7 @@ module RubyTUI
245
247
  header_style: header_style, highlight_symbol: highlight_symbol,
246
248
  column_spacing: column_spacing, block: block_widget
247
249
  )
248
- state = Widgets::TableState.new(selected: -1) # No selection for one-shot
250
+ state = Widgets::TableState.new(selected: nil) # No selection for one-shot
249
251
 
250
252
  # Calculate height: header(1) + separator(1) + rows + border(2)
251
253
  h = rows.length + (header ? 2 : 0) + (title ? 2 : 0)
@@ -264,7 +266,13 @@ module RubyTUI
264
266
  # then prints the ANSI output directly.
265
267
  #
266
268
  # @param title [String, nil] box title rendered on the top border
267
- # @param content [String, Array<String>] text content (newlines or array of lines)
269
+ # @param content [String, Array<String>, Text, Line, Span, nil] text content: a
270
+ # String split on newlines, an Array of lines (an element may itself contain
271
+ # newlines), or a {Text}, {Line} or {Span}. Lines are word-wrapped to the box's
272
+ # inner width (+width+ - 2) and the box is tall enough for every wrapped line
273
+ # ({Widgets::Paragraph#line_count}). Trailing empty lines of a String or Array
274
+ # are dropped (see {Text#initialize}); empty content (+nil+, <tt>""</tt>,
275
+ # <tt>[]</tt>) gives one empty row
268
276
  # @param border [Symbol, Symbols::BorderSet] border style — +:rounded+, +:double+,
269
277
  # +:thick+, +:light+, +:plain+, or a {Symbols::BorderSet} (default: +:rounded+)
270
278
  # @param border_style [Style] style for the border characters (default: {Style::DEFAULT})
@@ -303,11 +311,8 @@ module RubyTUI
303
311
  )
304
312
 
305
313
  w = width || terminal_width(output)
306
- line_count = case content
307
- when String then content.count("\n") + 1
308
- when Array then content.length
309
- else 1
310
- end
314
+ # Lines after wrapping to the inner width; at least one (empty) row
315
+ line_count = [para.line_count(w - 2), 1].max
311
316
  h = line_count + 2 # +2 for borders
312
317
 
313
318
  area = Rect.new(0, 0, w, h)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubytui
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.3
4
+ version: 1.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - CoCL
@@ -10,7 +10,7 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: |
13
- A pure Ruby, zero-dependency terminal user interface library featuring
13
+ A zero-dependency terminal user interface library featuring
14
14
  immediate-mode rendering with double buffering, constraint-based layouts,
15
15
  a duck-typed widget system, cross-platform input handling, and Kitty
16
16
  graphics protocol support for inline images.
@@ -85,5 +85,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
85
85
  requirements: []
86
86
  rubygems_version: 4.0.19
87
87
  specification_version: 4
88
- summary: Pure Ruby, zero-dependency terminal user interface library
88
+ summary: Zero-dependency terminal user interface library
89
89
  test_files: []