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,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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ # Gem version string.
5
+ VERSION = "1.2.3"
6
+ 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