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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +574 -0
- data/lib/rubytui/app.rb +174 -0
- data/lib/rubytui/backend.rb +158 -0
- data/lib/rubytui/backends/ansi_backend.rb +391 -0
- data/lib/rubytui/backends/test_backend.rb +223 -0
- data/lib/rubytui/buffer.rb +400 -0
- data/lib/rubytui/cell.rb +55 -0
- data/lib/rubytui/color.rb +153 -0
- data/lib/rubytui/color_mode.rb +203 -0
- data/lib/rubytui/errors.rb +13 -0
- data/lib/rubytui/event.rb +161 -0
- data/lib/rubytui/frame.rb +70 -0
- data/lib/rubytui/input/key.rb +93 -0
- data/lib/rubytui/input/parser.rb +231 -0
- data/lib/rubytui/input/reader.rb +119 -0
- data/lib/rubytui/layout/constraint.rb +83 -0
- data/lib/rubytui/layout/flex.rb +16 -0
- data/lib/rubytui/layout/layout.rb +205 -0
- data/lib/rubytui/modifier.rb +67 -0
- data/lib/rubytui/rect.rb +126 -0
- data/lib/rubytui/stateful_widget.rb +20 -0
- data/lib/rubytui/style.rb +143 -0
- data/lib/rubytui/symbols.rb +88 -0
- data/lib/rubytui/terminal.rb +218 -0
- data/lib/rubytui/text/line.rb +67 -0
- data/lib/rubytui/text/span.rb +34 -0
- data/lib/rubytui/text/text.rb +82 -0
- data/lib/rubytui/unicode.rb +162 -0
- data/lib/rubytui/version.rb +6 -0
- data/lib/rubytui/widget.rb +20 -0
- data/lib/rubytui/widgets/async_image.rb +248 -0
- data/lib/rubytui/widgets/block.rb +260 -0
- data/lib/rubytui/widgets/canvas.rb +248 -0
- data/lib/rubytui/widgets/chart.rb +224 -0
- data/lib/rubytui/widgets/gauge.rb +139 -0
- data/lib/rubytui/widgets/image.rb +330 -0
- data/lib/rubytui/widgets/input_field.rb +245 -0
- data/lib/rubytui/widgets/list.rb +186 -0
- data/lib/rubytui/widgets/paragraph.rb +181 -0
- data/lib/rubytui/widgets/popup.rb +140 -0
- data/lib/rubytui/widgets/scrollbar.rb +175 -0
- data/lib/rubytui/widgets/sparkline.rb +86 -0
- data/lib/rubytui/widgets/table.rb +231 -0
- data/lib/rubytui/widgets/tabs.rb +90 -0
- data/lib/rubytui.rb +389 -0
- metadata +89 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
# A Line is a sequence of Spans forming a single line of styled text.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# Line.new([Span.new("Name: ", Style.new.bold), Span.new("Ada")])
|
|
8
|
+
class Line
|
|
9
|
+
attr_reader :spans, :style
|
|
10
|
+
|
|
11
|
+
# @param spans [String, Span, Array<Span>] content; a String becomes one
|
|
12
|
+
# unstyled Span, a single Span is wrapped in an Array, an Array is used
|
|
13
|
+
# as-is (default: empty)
|
|
14
|
+
# @param style [Style] line-level style, stored as {#style}
|
|
15
|
+
# (default: {Style::DEFAULT})
|
|
16
|
+
# @raise [ArgumentError] if +spans+ is not a String, Span or Array
|
|
17
|
+
def initialize(spans = [], style = Style::DEFAULT)
|
|
18
|
+
@spans = case spans
|
|
19
|
+
when String then [Span.new(spans)]
|
|
20
|
+
when Span then [spans]
|
|
21
|
+
when Array then spans
|
|
22
|
+
else raise ArgumentError, "Line expects String, Span, or Array, got #{spans.class}"
|
|
23
|
+
end
|
|
24
|
+
@style = style
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Factory: from a plain string
|
|
28
|
+
#
|
|
29
|
+
# @param content [String] text of the line
|
|
30
|
+
# @param style [Style] style of the single Span (default: {Style::DEFAULT})
|
|
31
|
+
# @return [Line] a Line containing one Span
|
|
32
|
+
def self.from(content, style = Style::DEFAULT)
|
|
33
|
+
new([Span.new(content, style)])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Factory: from a styled string
|
|
37
|
+
#
|
|
38
|
+
# @param content [String] text of the line
|
|
39
|
+
# @param style [Style] style of the single Span
|
|
40
|
+
# @return [Line] a Line containing one Span
|
|
41
|
+
def self.styled(content, style)
|
|
42
|
+
new([Span.new(content, style)])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Total display width of all spans
|
|
46
|
+
#
|
|
47
|
+
# @return [Integer] sum of {Span#width} over {#spans}, in terminal columns
|
|
48
|
+
def width
|
|
49
|
+
@spans.sum(&:width)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Plain text content (no styling)
|
|
53
|
+
#
|
|
54
|
+
# @return [String] concatenated span contents
|
|
55
|
+
def to_s
|
|
56
|
+
@spans.map(&:to_s).join
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Value equality: same spans and style.
|
|
60
|
+
#
|
|
61
|
+
# @param other [Object] object to compare
|
|
62
|
+
# @return [Boolean] true if +other+ is a Line with equal {#spans} and {#style}
|
|
63
|
+
def ==(other)
|
|
64
|
+
other.is_a?(Line) && @spans == other.spans && @style == other.style
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
# A Span is a styled fragment of text.
|
|
5
|
+
# It is the atomic unit for rich text rendering.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# Span.new("error", Style.new.fg(Color::RED).bold)
|
|
9
|
+
class Span
|
|
10
|
+
attr_reader :content, :style
|
|
11
|
+
|
|
12
|
+
# @param content [String] text of the span
|
|
13
|
+
# @param style [Style] style applied to the text (default: {Style::DEFAULT})
|
|
14
|
+
def initialize(content, style = Style::DEFAULT)
|
|
15
|
+
@content = content
|
|
16
|
+
@style = style
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Integer] display width of {#content} in terminal columns
|
|
20
|
+
# (see {Unicode.string_width})
|
|
21
|
+
def width = Unicode.string_width(@content)
|
|
22
|
+
|
|
23
|
+
# Value equality: same content and style.
|
|
24
|
+
#
|
|
25
|
+
# @param other [Object] object to compare
|
|
26
|
+
# @return [Boolean] true if +other+ is a Span with equal {#content} and {#style}
|
|
27
|
+
def ==(other)
|
|
28
|
+
other.is_a?(Span) && @content == other.content && @style == other.style
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [String] the unstyled text ({#content})
|
|
32
|
+
def to_s = @content
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
# Text is a collection of Lines, representing multi-line styled text.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# Text.new("first\nsecond").height # => 2
|
|
8
|
+
class Text
|
|
9
|
+
attr_reader :lines, :style
|
|
10
|
+
|
|
11
|
+
# @param lines [String, Line, Array<Line, #to_s>] content; a String is split
|
|
12
|
+
# on newlines into unstyled Lines (trailing empty lines are dropped), a
|
|
13
|
+
# single Line is wrapped in an Array, and Array elements that are not
|
|
14
|
+
# Lines become unstyled Lines of their +to_s+ (default: empty)
|
|
15
|
+
# @param style [Style] text-level style, stored as {#style}
|
|
16
|
+
# (default: {Style::DEFAULT})
|
|
17
|
+
# @raise [ArgumentError] if +lines+ is not a String, Line or Array
|
|
18
|
+
def initialize(lines = [], style = Style::DEFAULT)
|
|
19
|
+
@lines = case lines
|
|
20
|
+
when String then lines.split("\n").map { |l| Line.new(l) }
|
|
21
|
+
when Line then [lines]
|
|
22
|
+
when Array then lines.map { |l| l.is_a?(Line) ? l : Line.new(l.to_s) }
|
|
23
|
+
else raise ArgumentError, "Text expects String, Line, or Array, got #{lines.class}"
|
|
24
|
+
end
|
|
25
|
+
@style = style
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Factory: from a plain string (splits on newlines)
|
|
29
|
+
#
|
|
30
|
+
# @param content [String] text; see {#initialize} for the other accepted types
|
|
31
|
+
# @param style [Style] text-level style stored as {#style}; not applied to
|
|
32
|
+
# the spans (default: {Style::DEFAULT})
|
|
33
|
+
# @return [Text]
|
|
34
|
+
# @raise [ArgumentError] if +content+ is not a String, Line or Array
|
|
35
|
+
def self.from(content, style = Style::DEFAULT)
|
|
36
|
+
new(content, style)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Factory: from an array of Lines
|
|
40
|
+
#
|
|
41
|
+
# @param lines [Array<Line>] the lines
|
|
42
|
+
# @return [Text]
|
|
43
|
+
def self.from_lines(lines)
|
|
44
|
+
new(lines)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Factory: styled single-line text
|
|
48
|
+
#
|
|
49
|
+
# @param content [String] text of the line (not split on newlines)
|
|
50
|
+
# @param style [Style] style of the single Span
|
|
51
|
+
# @return [Text] a Text with one Line containing one Span
|
|
52
|
+
def self.styled(content, style)
|
|
53
|
+
new([Line.styled(content, style)])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [Integer] number of lines
|
|
57
|
+
def height = @lines.length
|
|
58
|
+
|
|
59
|
+
# Maximum width across all lines
|
|
60
|
+
#
|
|
61
|
+
# @return [Integer] largest {Line#width} in terminal columns (0 if there
|
|
62
|
+
# are no lines)
|
|
63
|
+
def width
|
|
64
|
+
@lines.map(&:width).max || 0
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Plain text content (no styling).
|
|
68
|
+
#
|
|
69
|
+
# @return [String] line texts joined with newlines
|
|
70
|
+
def to_s
|
|
71
|
+
@lines.map(&:to_s).join("\n")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Value equality: same lines and style.
|
|
75
|
+
#
|
|
76
|
+
# @param other [Object] object to compare
|
|
77
|
+
# @return [Boolean] true if +other+ is a Text with equal {#lines} and {#style}
|
|
78
|
+
def ==(other)
|
|
79
|
+
other.is_a?(Text) && @lines == other.lines && @style == other.style
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module RubyTUI
|
|
6
|
+
# Unicode text width utilities for terminal rendering.
|
|
7
|
+
#
|
|
8
|
+
# Handles display width calculation for wide characters (CJK, emoji),
|
|
9
|
+
# combining marks (Devanagari, diacritics), variation selectors (FE0F),
|
|
10
|
+
# and grapheme cluster segmentation.
|
|
11
|
+
#
|
|
12
|
+
# @example Measure display width
|
|
13
|
+
# Unicode.string_width("Hello") # => 5
|
|
14
|
+
# Unicode.string_width("Hi🔥") # => 4
|
|
15
|
+
#
|
|
16
|
+
# @example Truncate to fit
|
|
17
|
+
# Unicode.truncate_to_width("Hello World", 5) # => "Hello"
|
|
18
|
+
module Unicode
|
|
19
|
+
# Unicode Emoji_Presentation codepoints below U+3000.
|
|
20
|
+
# These render as 2-wide by default (without FE0F) in modern terminals.
|
|
21
|
+
# Source: Unicode 16.0 emoji-data.txt
|
|
22
|
+
EMOJI_PRESENTATION = Set[
|
|
23
|
+
0x231A, 0x231B, # watch, hourglass
|
|
24
|
+
0x23E9, 0x23EA, 0x23EB, 0x23EC, 0x23F0, 0x23F3, # fast-forward etc.
|
|
25
|
+
0x25FD, 0x25FE, # medium small squares
|
|
26
|
+
0x2614, 0x2615, # umbrella, hot beverage
|
|
27
|
+
0x2648, 0x2649, 0x264A, 0x264B, 0x264C, 0x264D, # zodiac signs
|
|
28
|
+
0x264E, 0x264F, 0x2650, 0x2651, 0x2652, 0x2653,
|
|
29
|
+
0x267F, # wheelchair
|
|
30
|
+
0x2693, # anchor
|
|
31
|
+
0x26A1, # high voltage
|
|
32
|
+
0x26AA, 0x26AB, # circles
|
|
33
|
+
0x26BD, 0x26BE, # soccer, baseball
|
|
34
|
+
0x26C4, 0x26C5, # snowman, sun behind cloud
|
|
35
|
+
0x26CE, # ophiuchus
|
|
36
|
+
0x26D4, # no entry
|
|
37
|
+
0x26EA, # church
|
|
38
|
+
0x26F2, 0x26F3, # fountain, golf
|
|
39
|
+
0x26F5, # sailboat
|
|
40
|
+
0x26FA, # tent
|
|
41
|
+
0x26FD, # fuel pump
|
|
42
|
+
0x2705, # check mark
|
|
43
|
+
0x270A, 0x270B, # fists
|
|
44
|
+
0x2728, # sparkles
|
|
45
|
+
0x274C, 0x274E, # cross marks
|
|
46
|
+
0x2753, 0x2754, 0x2755, # question/exclamation
|
|
47
|
+
0x2757, # exclamation
|
|
48
|
+
0x2795, 0x2796, 0x2797, # plus/minus/division
|
|
49
|
+
0x27B0, 0x27BF, # curly loops
|
|
50
|
+
0x2B1B, 0x2B1C, # large squares
|
|
51
|
+
0x2B50, # star
|
|
52
|
+
0x2B55, # circle
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
55
|
+
module_function
|
|
56
|
+
|
|
57
|
+
# Calculate display width of a string in terminal columns.
|
|
58
|
+
#
|
|
59
|
+
# Operates on grapheme clusters so that combining sequences (Devanagari
|
|
60
|
+
# matras/virama, Latin diacritics, emoji ZWJ sequences) are measured as
|
|
61
|
+
# a single unit. Wide characters (CJK, emoji) count as 2 columns.
|
|
62
|
+
#
|
|
63
|
+
# @param string [String] the string to measure
|
|
64
|
+
# @return [Integer] display width in terminal columns
|
|
65
|
+
def string_width(string)
|
|
66
|
+
string.grapheme_clusters.sum { |gc| cluster_width(gc) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Calculate display width of a single grapheme cluster.
|
|
70
|
+
#
|
|
71
|
+
# @param cluster [String] a grapheme cluster (one or more codepoints)
|
|
72
|
+
# @return [Integer] 0, 1, or 2
|
|
73
|
+
def cluster_width(cluster)
|
|
74
|
+
return char_width(cluster) if cluster.bytesize == 1
|
|
75
|
+
|
|
76
|
+
codepoints = cluster.codepoints
|
|
77
|
+
has_fe0f = codepoints.include?(0xFE0F)
|
|
78
|
+
|
|
79
|
+
codepoints.each do |cp|
|
|
80
|
+
next if combining_mark?(cp)
|
|
81
|
+
return 2 if wide_char?(cp)
|
|
82
|
+
return 2 if has_fe0f
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
1
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Truncate a string to fit within a maximum display width.
|
|
89
|
+
#
|
|
90
|
+
# Unlike simple character slicing, this respects grapheme clusters and
|
|
91
|
+
# wide characters that take 2 columns.
|
|
92
|
+
#
|
|
93
|
+
# @param string [String] the string to truncate
|
|
94
|
+
# @param max_width [Integer] maximum display width in columns
|
|
95
|
+
# @return [String] truncated string
|
|
96
|
+
def truncate_to_width(string, max_width)
|
|
97
|
+
return "" if max_width <= 0
|
|
98
|
+
|
|
99
|
+
result = String.new
|
|
100
|
+
width = 0
|
|
101
|
+
|
|
102
|
+
string.grapheme_clusters.each do |gc|
|
|
103
|
+
w = cluster_width(gc)
|
|
104
|
+
break if width + w > max_width
|
|
105
|
+
|
|
106
|
+
result << gc
|
|
107
|
+
width += w
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
result
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Estimate the display width of a single character.
|
|
114
|
+
#
|
|
115
|
+
# @param ch [String] a single character
|
|
116
|
+
# @return [Integer] 0 for control characters and combining marks, 2 for wide
|
|
117
|
+
# characters, 1 otherwise
|
|
118
|
+
def char_width(ch)
|
|
119
|
+
cp = ch.ord
|
|
120
|
+
return 1 if cp >= 0x20 && cp < 0x7F
|
|
121
|
+
return 0 if cp < 0x20 || cp == 0x7F
|
|
122
|
+
return 0 if combining_mark?(cp)
|
|
123
|
+
return 2 if wide_char?(cp)
|
|
124
|
+
1
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @param cp [Integer] Unicode codepoint
|
|
128
|
+
# @return [Boolean] true if codepoint is a combining mark (zero-width)
|
|
129
|
+
def combining_mark?(cp)
|
|
130
|
+
(cp >= 0x0300 && cp <= 0x036F) || # Combining Diacritical Marks
|
|
131
|
+
(cp >= 0x0900 && cp <= 0x097F && # Devanagari dependent vowels, virama, anusvara
|
|
132
|
+
((cp >= 0x0901 && cp <= 0x0903) || (cp >= 0x093A && cp <= 0x094F) ||
|
|
133
|
+
(cp >= 0x0951 && cp <= 0x0957) || cp == 0x0962 || cp == 0x0963)) ||
|
|
134
|
+
(cp >= 0x1AB0 && cp <= 0x1AFF) || # Combining Diacritical Marks Extended
|
|
135
|
+
(cp >= 0x1DC0 && cp <= 0x1DFF) || # Combining Diacritical Marks Supplement
|
|
136
|
+
(cp >= 0x20D0 && cp <= 0x20FF) || # Combining Marks for Symbols
|
|
137
|
+
(cp >= 0xFE00 && cp <= 0xFE0F) || # Variation Selectors
|
|
138
|
+
(cp >= 0xFE20 && cp <= 0xFE2F) || # Combining Half Marks
|
|
139
|
+
(cp >= 0xE0100 && cp <= 0xE01EF) # Variation Selectors Supplement
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @param cp [Integer] Unicode codepoint
|
|
143
|
+
# @return [Boolean] true if codepoint is a wide character (2-column)
|
|
144
|
+
def wide_char?(cp)
|
|
145
|
+
(cp >= 0x1100 && cp <= 0x115F) || # Hangul Jamo
|
|
146
|
+
EMOJI_PRESENTATION.include?(cp) || # Emoji with default 2-wide presentation
|
|
147
|
+
(cp >= 0x2E80 && cp <= 0x303E) || # CJK Radicals, Kangxi, CJK Symbols
|
|
148
|
+
(cp >= 0x3040 && cp <= 0x33BF) || # Hiragana, Katakana, CJK Compat
|
|
149
|
+
(cp >= 0x3400 && cp <= 0x4DBF) || # CJK Unified Extension A
|
|
150
|
+
(cp >= 0x4E00 && cp <= 0x9FFF) || # CJK Unified Ideographs
|
|
151
|
+
(cp >= 0xA960 && cp <= 0xA97F) || # Hangul Jamo Extended-A
|
|
152
|
+
(cp >= 0xAC00 && cp <= 0xD7AF) || # Hangul Syllables
|
|
153
|
+
(cp >= 0xF900 && cp <= 0xFAFF) || # CJK Compatibility Ideographs
|
|
154
|
+
(cp >= 0xFE10 && cp <= 0xFE19) || # Vertical Forms
|
|
155
|
+
(cp >= 0xFE30 && cp <= 0xFE6F) || # CJK Compatibility Forms
|
|
156
|
+
(cp >= 0xFF01 && cp <= 0xFF60) || # Fullwidth Forms
|
|
157
|
+
(cp >= 0xFFE0 && cp <= 0xFFE6) || # Fullwidth Sign
|
|
158
|
+
(cp >= 0x1F000 && cp <= 0x1FAFF) || # Emoji & Symbols
|
|
159
|
+
(cp >= 0x20000 && cp <= 0x2FA1F) # CJK Unified Extensions B-F
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyTUI
|
|
4
|
+
# Widget protocol for immediate-mode rendering.
|
|
5
|
+
# Any object with #render(area, buf) is a widget.
|
|
6
|
+
# Include this module for documentation and a default NotImplementedError.
|
|
7
|
+
module Widget
|
|
8
|
+
# Render this widget into the buffer at the given area.
|
|
9
|
+
#
|
|
10
|
+
# Including classes override this method.
|
|
11
|
+
#
|
|
12
|
+
# @param area [Rect] the rectangular area to render into
|
|
13
|
+
# @param buf [Buffer] the buffer to write cells into
|
|
14
|
+
# @return [void]
|
|
15
|
+
# @raise [NotImplementedError] always, unless overridden by the including class
|
|
16
|
+
def render(area, buf)
|
|
17
|
+
raise NotImplementedError, "#{self.class}#render(area, buf) not implemented"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thread"
|
|
4
|
+
|
|
5
|
+
module RubyTUI
|
|
6
|
+
module Widgets
|
|
7
|
+
# Asynchronously loads and renders images with placeholder support.
|
|
8
|
+
#
|
|
9
|
+
# Handles background image loading with thread-safe cache handoff,
|
|
10
|
+
# displays a placeholder while loading, and signals when ready for redraw.
|
|
11
|
+
#
|
|
12
|
+
# @example Basic async image
|
|
13
|
+
# async_img = RubyTUI::Widgets::AsyncImage.new(
|
|
14
|
+
# loader: -> { File.binread("large_image.png") }
|
|
15
|
+
# )
|
|
16
|
+
# frame.render_widget(async_img, area)
|
|
17
|
+
#
|
|
18
|
+
# @example With placeholder and callback
|
|
19
|
+
# async_img = RubyTUI::Widgets::AsyncImage.new(
|
|
20
|
+
# loader: -> { download_and_convert(url) },
|
|
21
|
+
# placeholder: "Loading...",
|
|
22
|
+
# on_ready: -> { needs_redraw = true }
|
|
23
|
+
# )
|
|
24
|
+
#
|
|
25
|
+
# @example From URL with caching
|
|
26
|
+
# cache = {}
|
|
27
|
+
# async_img = RubyTUI::Widgets::AsyncImage.new(
|
|
28
|
+
# key: url,
|
|
29
|
+
# cache: cache,
|
|
30
|
+
# loader: -> { fetch_and_convert_to_png(url) }
|
|
31
|
+
# )
|
|
32
|
+
class AsyncImage
|
|
33
|
+
include Widget
|
|
34
|
+
|
|
35
|
+
# Loading states
|
|
36
|
+
|
|
37
|
+
# Loading not started (<tt>auto_start: false</tt> and no cache hit).
|
|
38
|
+
STATE_PENDING = :pending
|
|
39
|
+
# Loader running on the background thread.
|
|
40
|
+
STATE_LOADING = :loading
|
|
41
|
+
# Image data loaded and ready to render.
|
|
42
|
+
STATE_READY = :ready
|
|
43
|
+
# Loading or image validation failed; see {#error}.
|
|
44
|
+
STATE_ERROR = :error
|
|
45
|
+
|
|
46
|
+
# @return [Symbol] current loading state
|
|
47
|
+
attr_reader :state
|
|
48
|
+
|
|
49
|
+
# @return [String, nil] error message if state is :error
|
|
50
|
+
attr_reader :error
|
|
51
|
+
|
|
52
|
+
# @param loader [Proc] proc that returns PNG data (called in background thread)
|
|
53
|
+
# @param key [Object, nil] cache key (uses loader.object_id if nil)
|
|
54
|
+
# @param cache [Hash, nil] shared cache hash for storing loaded images; if it
|
|
55
|
+
# already holds +key+, the widget is ready immediately and +loader+ is not
|
|
56
|
+
# called (default: nil, uses a private Hash)
|
|
57
|
+
# @param placeholder [String, Widget, nil] widget or text to show while the
|
|
58
|
+
# image is not ready. A {Widget} is rendered in the pending, loading and
|
|
59
|
+
# error states. A String is drawn centered only while pending; while loading
|
|
60
|
+
# the text <tt>"Loading..."</tt> is shown instead (default: nil)
|
|
61
|
+
# @param error_placeholder [String, nil] text to show on error unless
|
|
62
|
+
# +placeholder+ is a {Widget} (default: nil, shows <tt>"[Error]"</tt>)
|
|
63
|
+
# @param on_ready [Proc, nil] callback invoked when image is ready (for triggering redraw).
|
|
64
|
+
# Called with no arguments on the loading thread after +loader+ returns; not
|
|
65
|
+
# called on a cache hit
|
|
66
|
+
# @param on_error [Proc, nil] callback invoked on load error. Called on the loading
|
|
67
|
+
# thread with the exception raised by +loader+
|
|
68
|
+
# @param style [Style] background style, also used for placeholder text
|
|
69
|
+
# (default: {Style::DEFAULT})
|
|
70
|
+
# @param block [Widgets::Block, nil] optional wrapping block
|
|
71
|
+
# @param fit [Symbol, nil] aspect-ratio mode for the loaded image (see {Image#initialize})
|
|
72
|
+
# @param cell_size [Array(Integer, Integer), nil] cell dimensions for aspect calculations
|
|
73
|
+
# @param auto_start [Boolean] start loading immediately (default: true)
|
|
74
|
+
def initialize(loader:, key: nil, cache: nil, placeholder: nil,
|
|
75
|
+
error_placeholder: nil, on_ready: nil, on_error: nil,
|
|
76
|
+
style: Style::DEFAULT, block: nil, fit: nil,
|
|
77
|
+
cell_size: nil, auto_start: true)
|
|
78
|
+
@loader = loader
|
|
79
|
+
@key = key || loader.object_id
|
|
80
|
+
@cache = cache || {}
|
|
81
|
+
@placeholder = placeholder
|
|
82
|
+
@error_placeholder = error_placeholder || "[Error]"
|
|
83
|
+
@on_ready = on_ready
|
|
84
|
+
@on_error = on_error
|
|
85
|
+
@style = style
|
|
86
|
+
@block = block
|
|
87
|
+
@fit = fit
|
|
88
|
+
@cell_size = cell_size
|
|
89
|
+
|
|
90
|
+
@mutex = Mutex.new
|
|
91
|
+
@state = STATE_PENDING
|
|
92
|
+
@image_data = nil
|
|
93
|
+
@image_widget = nil
|
|
94
|
+
@error = nil
|
|
95
|
+
@thread = nil
|
|
96
|
+
|
|
97
|
+
# Check cache first
|
|
98
|
+
if @cache.key?(@key)
|
|
99
|
+
@image_data = @cache[@key]
|
|
100
|
+
@state = STATE_READY
|
|
101
|
+
build_image_widget
|
|
102
|
+
elsif auto_start
|
|
103
|
+
start_loading
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Start background loading if not already started.
|
|
108
|
+
#
|
|
109
|
+
# Does nothing unless the state is +:pending+. Otherwise sets the state to
|
|
110
|
+
# +:loading+ and calls +loader+ on a new thread. On success the data is
|
|
111
|
+
# stored in the cache and the state becomes +:ready+; if +loader+ raises a
|
|
112
|
+
# StandardError the state becomes +:error+.
|
|
113
|
+
#
|
|
114
|
+
# @return [self]
|
|
115
|
+
def start_loading
|
|
116
|
+
@mutex.synchronize do
|
|
117
|
+
return self unless @state == STATE_PENDING
|
|
118
|
+
|
|
119
|
+
@state = STATE_LOADING
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
@thread = Thread.new do
|
|
123
|
+
begin
|
|
124
|
+
data = @loader.call
|
|
125
|
+
@mutex.synchronize do
|
|
126
|
+
@image_data = data
|
|
127
|
+
@cache[@key] = data
|
|
128
|
+
@state = STATE_READY
|
|
129
|
+
end
|
|
130
|
+
build_image_widget
|
|
131
|
+
@on_ready&.call
|
|
132
|
+
rescue => e
|
|
133
|
+
@mutex.synchronize do
|
|
134
|
+
@error = e.message
|
|
135
|
+
@state = STATE_ERROR
|
|
136
|
+
end
|
|
137
|
+
@on_error&.call(e)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
self
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Check if the image is ready for rendering.
|
|
145
|
+
#
|
|
146
|
+
# @return [Boolean]
|
|
147
|
+
def ready?
|
|
148
|
+
@state == STATE_READY
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Check if loading is in progress.
|
|
152
|
+
#
|
|
153
|
+
# @return [Boolean]
|
|
154
|
+
def loading?
|
|
155
|
+
@state == STATE_LOADING
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Check if loading failed.
|
|
159
|
+
#
|
|
160
|
+
# @return [Boolean]
|
|
161
|
+
def error?
|
|
162
|
+
@state == STATE_ERROR
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Cancel the background loading thread.
|
|
166
|
+
#
|
|
167
|
+
# Kills the thread if one is running. The state is not changed, so a load
|
|
168
|
+
# cancelled mid-flight stays +:loading+ and {#start_loading} will not restart it.
|
|
169
|
+
#
|
|
170
|
+
# @return [void]
|
|
171
|
+
def cancel
|
|
172
|
+
@thread&.kill
|
|
173
|
+
@thread = nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Render the async image or placeholder.
|
|
177
|
+
#
|
|
178
|
+
# When ready, the loaded {Image} is rendered; otherwise the placeholder
|
|
179
|
+
# described in {#initialize} is drawn.
|
|
180
|
+
#
|
|
181
|
+
# @param area [Rect] the rectangular area to render into
|
|
182
|
+
# @param buf [Buffer] the buffer to write cells into
|
|
183
|
+
# @return [void]
|
|
184
|
+
def render(area, buf)
|
|
185
|
+
return if area.empty?
|
|
186
|
+
|
|
187
|
+
render_area = area
|
|
188
|
+
if @block
|
|
189
|
+
@block.render(area, buf)
|
|
190
|
+
render_area = @block.inner(area)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
return if render_area.empty?
|
|
194
|
+
|
|
195
|
+
case @state
|
|
196
|
+
when STATE_READY
|
|
197
|
+
@image_widget&.render(render_area, buf)
|
|
198
|
+
when STATE_LOADING
|
|
199
|
+
render_placeholder(render_area, buf, "Loading...")
|
|
200
|
+
when STATE_ERROR
|
|
201
|
+
render_placeholder(render_area, buf, @error_placeholder)
|
|
202
|
+
when STATE_PENDING
|
|
203
|
+
render_placeholder(render_area, buf, @placeholder || "")
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
def build_image_widget
|
|
210
|
+
return unless @image_data
|
|
211
|
+
|
|
212
|
+
@mutex.synchronize do
|
|
213
|
+
@image_widget = Image.new(
|
|
214
|
+
data: @image_data,
|
|
215
|
+
style: @style,
|
|
216
|
+
fit: @fit,
|
|
217
|
+
cell_size: @cell_size,
|
|
218
|
+
clip: true
|
|
219
|
+
)
|
|
220
|
+
end
|
|
221
|
+
rescue ArgumentError, UnsupportedImageFormat => e
|
|
222
|
+
@mutex.synchronize do
|
|
223
|
+
@error = e.message
|
|
224
|
+
@state = STATE_ERROR
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def render_placeholder(area, buf, text)
|
|
229
|
+
case @placeholder
|
|
230
|
+
when Widget
|
|
231
|
+
@placeholder.render(area, buf)
|
|
232
|
+
when String
|
|
233
|
+
display_text = text || @placeholder
|
|
234
|
+
# Center the placeholder text
|
|
235
|
+
x = area.x + [(area.width - Unicode.string_width(display_text)) / 2, 0].max
|
|
236
|
+
y = area.y + area.height / 2
|
|
237
|
+
buf.set_string(x, y, display_text, @style)
|
|
238
|
+
else
|
|
239
|
+
# Default: show text centered
|
|
240
|
+
display_text = text.to_s
|
|
241
|
+
x = area.x + [(area.width - Unicode.string_width(display_text)) / 2, 0].max
|
|
242
|
+
y = area.y + area.height / 2
|
|
243
|
+
buf.set_string(x, y, display_text, @style)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|