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
+ # Text modifier flags stored as a bitmask on {Style#modifiers}.
5
+ #
6
+ # @example Check if bold is set
7
+ # Modifier.contains?(style.modifiers, Modifier::BOLD)
8
+ #
9
+ # @example Or use Style query methods
10
+ # style.bold?
11
+ module Modifier
12
+ # Bold text (SGR 1, cleared by SGR 22).
13
+ BOLD = 0b000001
14
+ # Dim / faint text (SGR 2, cleared by SGR 22).
15
+ DIM = 0b000010
16
+ # Italic text (SGR 3, cleared by SGR 23).
17
+ ITALIC = 0b000100
18
+ # Underlined text (SGR 4, cleared by SGR 24).
19
+ UNDERLINED = 0b001000
20
+ # Reverse video: swap foreground and background (SGR 7, cleared by SGR 27).
21
+ REVERSED = 0b010000
22
+ # Strikethrough text (SGR 9, cleared by SGR 29).
23
+ STRIKETHROUGH = 0b100000
24
+
25
+ # No modifiers set.
26
+ NONE = 0
27
+
28
+ # Map of flag => human-readable name.
29
+ ALL = {
30
+ BOLD => "bold",
31
+ DIM => "dim",
32
+ ITALIC => "italic",
33
+ UNDERLINED => "underlined",
34
+ REVERSED => "reversed",
35
+ STRIKETHROUGH => "strikethrough"
36
+ }.freeze
37
+
38
+ # ANSI SGR codes for enabling modifiers.
39
+ SGR_ON = {
40
+ BOLD => "1",
41
+ DIM => "2",
42
+ ITALIC => "3",
43
+ UNDERLINED => "4",
44
+ REVERSED => "7",
45
+ STRIKETHROUGH => "9"
46
+ }.freeze
47
+
48
+ # ANSI SGR codes for disabling modifiers.
49
+ SGR_OFF = {
50
+ BOLD => "22",
51
+ DIM => "22",
52
+ ITALIC => "23",
53
+ UNDERLINED => "24",
54
+ REVERSED => "27",
55
+ STRIKETHROUGH => "29"
56
+ }.freeze
57
+
58
+ class << self
59
+ # Check if a modifier flag is set in a bitmask.
60
+ #
61
+ # @param modifiers [Integer] the bitmask to check
62
+ # @param flag [Integer] the modifier constant (e.g., {BOLD})
63
+ # @return [Boolean]
64
+ def contains?(modifiers, flag) = modifiers.anybits?(flag)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ # A rectangle defined by position and size, used throughout the layout
5
+ # and rendering system to define widget areas.
6
+ #
7
+ # @example Create with keyword arguments
8
+ # Rect.new(x: 0, y: 0, width: 80, height: 24)
9
+ #
10
+ # @example Create with positional arguments
11
+ # Rect.new(0, 0, 80, 24)
12
+ class Rect
13
+ attr_reader :x, :y, :width, :height
14
+
15
+ # Create a new Rect.
16
+ #
17
+ # Supports both positional and keyword arguments.
18
+ #
19
+ # @overload initialize(x, y, width, height)
20
+ # @param x [Integer] left edge position
21
+ # @param y [Integer] top edge position
22
+ # @param width [Integer] width (clamped to >= 0)
23
+ # @param height [Integer] height (clamped to >= 0)
24
+ #
25
+ # @overload initialize(x:, y:, width:, height:)
26
+ # @param x [Integer] left edge position
27
+ # @param y [Integer] top edge position
28
+ # @param width [Integer] width (clamped to >= 0)
29
+ # @param height [Integer] height (clamped to >= 0)
30
+ #
31
+ # @raise [ArgumentError] if argument format is invalid
32
+ def initialize(*args, x: nil, y: nil, width: nil, height: nil)
33
+ if args.length == 4
34
+ @x, @y, w, h = args
35
+ elsif args.empty? && x && y && width && height
36
+ @x = x
37
+ @y = y
38
+ w = width
39
+ h = height
40
+ else
41
+ raise ArgumentError, "Rect.new(x, y, width, height) or Rect.new(x:, y:, width:, height:)"
42
+ end
43
+ @width = [w, 0].max
44
+ @height = [h, 0].max
45
+ end
46
+
47
+ # @return [Integer] right edge (x + width)
48
+ def right = @x + @width
49
+ # @return [Integer] bottom edge (y + height)
50
+ def bottom = @y + @height
51
+ # @return [Integer] left edge (alias for {#x})
52
+ def left = @x
53
+ # @return [Integer] top edge (alias for {#y})
54
+ def top = @y
55
+ # @return [Integer] total cell count (width * height)
56
+ def area = @width * @height
57
+ # @return [Boolean] true if width or height is zero
58
+ def empty? = @width == 0 || @height == 0
59
+
60
+ # Return a Rect inset by the given margin on all sides.
61
+ #
62
+ # @param margin [Integer] inset distance for all four sides
63
+ # @return [Rect] the inner rectangle
64
+ def inner(margin)
65
+ margin_h = [margin * 2, @width].min
66
+ margin_v = [margin * 2, @height].min
67
+ Rect.new(
68
+ x: @x + margin_h / 2,
69
+ y: @y + margin_v / 2,
70
+ width: @width - margin_h,
71
+ height: @height - margin_v
72
+ )
73
+ end
74
+
75
+ # Return a Rect inset by individual margins per side.
76
+ #
77
+ # @param top [Integer] inset from top
78
+ # @param right [Integer] inset from right
79
+ # @param bottom [Integer] inset from bottom
80
+ # @param left [Integer] inset from left
81
+ # @return [Rect] the inner rectangle
82
+ def inner_rect(top: 0, right: 0, bottom: 0, left: 0)
83
+ Rect.new(
84
+ x: @x + left,
85
+ y: @y + top,
86
+ width: @width - left - right,
87
+ height: @height - top - bottom
88
+ )
89
+ end
90
+
91
+ # Value equality: same position and size.
92
+ #
93
+ # @param other [Object] object to compare
94
+ # @return [Boolean] true if +other+ is a Rect with equal x, y, width and height
95
+ def ==(other)
96
+ other.is_a?(Rect) && @x == other.x && @y == other.y &&
97
+ @width == other.width && @height == other.height
98
+ end
99
+ alias_method :eql?, :==
100
+
101
+ # @return [Integer] hash derived from x, y, width and height
102
+ def hash = [@x, @y, @width, @height].hash
103
+
104
+ # @return [String] human-readable form, e.g. <tt>Rect(0, 0, 80x24)</tt>
105
+ def to_s = "Rect(#{@x}, #{@y}, #{@width}x#{@height})"
106
+ # @return [String] same as {#to_s}
107
+ def inspect = to_s
108
+
109
+ # Shorthand positional constructor.
110
+ #
111
+ # @example
112
+ # Rect[0, 0, 80, 24]
113
+ #
114
+ # @param x [Integer] left edge
115
+ # @param y [Integer] top edge
116
+ # @param width [Integer] width
117
+ # @param height [Integer] height
118
+ # @return [Rect]
119
+ def self.[](x, y, width, height)
120
+ new(x, y, width, height)
121
+ end
122
+
123
+ # Empty rectangle at the origin (0, 0, 0x0).
124
+ ZERO = new(x: 0, y: 0, width: 0, height: 0).freeze
125
+ end
126
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ # StatefulWidget protocol for widgets with external state.
5
+ # State persists across frames and is owned by the application, not the widget.
6
+ module StatefulWidget
7
+ # Render this widget with external state.
8
+ #
9
+ # Including classes override this method.
10
+ #
11
+ # @param area [Rect] the rectangular area to render into
12
+ # @param buf [Buffer] the buffer to write cells into
13
+ # @param state [Object] external state object
14
+ # @return [void]
15
+ # @raise [NotImplementedError] always, unless overridden by the including class
16
+ def render(area, buf, state)
17
+ raise NotImplementedError, "#{self.class}#render(area, buf, state) not implemented"
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ # Composable text style with foreground/background colors and modifiers.
5
+ #
6
+ # Style is immutable — builder methods return new Style instances.
7
+ # Use {#patch} to merge styles for overlay composition.
8
+ #
9
+ # @example Builder pattern
10
+ # style = Style.new.fg(Color::RED).bold.italic
11
+ #
12
+ # @example Merge styles
13
+ # base = Style.new.fg(Color::WHITE)
14
+ # highlight = Style.new.bold.underlined
15
+ # combined = base.patch(highlight) # white, bold, underlined
16
+ class Style
17
+ # @return [Integer] modifier bitmask (see {Modifier} constants)
18
+ attr_reader :modifiers
19
+
20
+ # Sentinel for unset color arguments. Private — not part of the public API.
21
+ UNSET = Object.new.freeze
22
+ private_constant :UNSET
23
+
24
+ # @param fg [Color, nil] foreground color
25
+ # @param bg [Color, nil] background color
26
+ # @param modifiers [Integer] modifier bitmask (default: {Modifier::NONE})
27
+ def initialize(fg: nil, bg: nil, modifiers: Modifier::NONE)
28
+ @fg = fg
29
+ @bg = bg
30
+ @modifiers = modifiers
31
+ end
32
+
33
+ # Get or set the foreground color.
34
+ #
35
+ # @overload fg
36
+ # @return [Color, nil] current foreground color
37
+ #
38
+ # @overload fg(color)
39
+ # @param color [Color] new foreground color
40
+ # @return [Style] new Style with the given foreground color
41
+ def fg(color = UNSET)
42
+ return @fg if color.equal?(UNSET)
43
+
44
+ Style.new(fg: color, bg: @bg, modifiers: @modifiers)
45
+ end
46
+
47
+ # Get or set the background color.
48
+ #
49
+ # @overload bg
50
+ # @return [Color, nil] current background color
51
+ #
52
+ # @overload bg(color)
53
+ # @param color [Color] new background color
54
+ # @return [Style] new Style with the given background color
55
+ def bg(color = UNSET)
56
+ return @bg if color.equal?(UNSET)
57
+
58
+ Style.new(fg: @fg, bg: color, modifiers: @modifiers)
59
+ end
60
+
61
+ # @!group Modifier Builders
62
+ # Each returns a new Style with the modifier flag added.
63
+
64
+ # @return [Style] new Style with bold enabled
65
+ def bold = add_modifier(Modifier::BOLD)
66
+ # @return [Style] new Style with dim enabled
67
+ def dim = add_modifier(Modifier::DIM)
68
+ # @return [Style] new Style with italic enabled
69
+ def italic = add_modifier(Modifier::ITALIC)
70
+ # @return [Style] new Style with underline enabled
71
+ def underlined = add_modifier(Modifier::UNDERLINED)
72
+ # @return [Style] new Style with reverse video enabled
73
+ def reversed = add_modifier(Modifier::REVERSED)
74
+ # @return [Style] new Style with strikethrough enabled
75
+ def strikethrough = add_modifier(Modifier::STRIKETHROUGH)
76
+
77
+ # @!endgroup
78
+
79
+ # @!group Modifier Queries
80
+
81
+ # @return [Boolean] true if bold is enabled
82
+ def bold? = Modifier.contains?(@modifiers, Modifier::BOLD)
83
+ # @return [Boolean] true if dim is enabled
84
+ def dim? = Modifier.contains?(@modifiers, Modifier::DIM)
85
+ # @return [Boolean] true if italic is enabled
86
+ def italic? = Modifier.contains?(@modifiers, Modifier::ITALIC)
87
+ # @return [Boolean] true if underline is enabled
88
+ def underlined? = Modifier.contains?(@modifiers, Modifier::UNDERLINED)
89
+ # @return [Boolean] true if reverse video is enabled
90
+ def reversed? = Modifier.contains?(@modifiers, Modifier::REVERSED)
91
+ # @return [Boolean] true if strikethrough is enabled
92
+ def strikethrough? = Modifier.contains?(@modifiers, Modifier::STRIKETHROUGH)
93
+
94
+ # @!endgroup
95
+
96
+ # Add a modifier flag.
97
+ #
98
+ # @param mod [Integer] modifier constant from {Modifier}
99
+ # @return [Style] new Style with the flag added
100
+ def add_modifier(mod)
101
+ Style.new(fg: @fg, bg: @bg, modifiers: @modifiers | mod)
102
+ end
103
+
104
+ # Remove a modifier flag.
105
+ #
106
+ # @param mod [Integer] modifier constant from {Modifier}
107
+ # @return [Style] new Style with the flag removed
108
+ def remove_modifier(mod)
109
+ Style.new(fg: @fg, bg: @bg, modifiers: @modifiers & ~mod)
110
+ end
111
+
112
+ # Merge another style on top of this one.
113
+ #
114
+ # The other style's non-nil colors override this style's colors.
115
+ # Modifiers are OR'd together (additive).
116
+ #
117
+ # @param other [Style] style to merge on top
118
+ # @return [Style] merged style
119
+ def patch(other)
120
+ Style.new(
121
+ fg: other.fg || @fg,
122
+ bg: other.bg || @bg,
123
+ modifiers: @modifiers | other.modifiers
124
+ )
125
+ end
126
+
127
+ # Value equality: same colors and modifiers.
128
+ #
129
+ # @param other [Object] object to compare
130
+ # @return [Boolean] true if +other+ is a Style with equal foreground,
131
+ # background and {#modifiers}
132
+ def ==(other)
133
+ other.is_a?(Style) && @fg == other.fg && @bg == other.bg && @modifiers == other.modifiers
134
+ end
135
+ alias_method :eql?, :==
136
+
137
+ # @return [Integer] hash derived from foreground, background and modifiers
138
+ def hash = [@fg, @bg, @modifiers].hash
139
+
140
+ # Default style: no colors, no modifiers.
141
+ DEFAULT = new.freeze
142
+ end
143
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ # Predefined box-drawing glyph sets for widget borders.
5
+ #
6
+ # @example
7
+ # Widgets::Block.new(borders: :all, border_set: Symbols::DOUBLE)
8
+ module Symbols
9
+ # Immutable set of the glyphs used to draw a border.
10
+ #
11
+ # @!attribute [r] top_left
12
+ # @return [String] top-left corner glyph
13
+ # @!attribute [r] top_right
14
+ # @return [String] top-right corner glyph
15
+ # @!attribute [r] bottom_left
16
+ # @return [String] bottom-left corner glyph
17
+ # @!attribute [r] bottom_right
18
+ # @return [String] bottom-right corner glyph
19
+ # @!attribute [r] horizontal
20
+ # @return [String] horizontal line glyph
21
+ # @!attribute [r] vertical
22
+ # @return [String] vertical line glyph
23
+ # @!attribute [r] cross
24
+ # @return [String] four-way junction glyph
25
+ # @!attribute [r] tee_left
26
+ # @return [String] T-junction glyph with its branch pointing left
27
+ # @!attribute [r] tee_right
28
+ # @return [String] T-junction glyph with its branch pointing right
29
+ # @!attribute [r] tee_top
30
+ # @return [String] T-junction glyph with its branch pointing up
31
+ # @!attribute [r] tee_bottom
32
+ # @return [String] T-junction glyph with its branch pointing down
33
+ BorderSet = Data.define(
34
+ :top_left, :top_right, :bottom_left, :bottom_right,
35
+ :horizontal, :vertical,
36
+ :cross,
37
+ :tee_left, :tee_right, :tee_top, :tee_bottom
38
+ )
39
+
40
+ # ASCII-only borders using <tt>+</tt>, <tt>-</tt> and <tt>|</tt>.
41
+ PLAIN = BorderSet.new(
42
+ top_left: "+", top_right: "+", bottom_left: "+", bottom_right: "+",
43
+ horizontal: "-", vertical: "|",
44
+ cross: "+",
45
+ tee_left: "+", tee_right: "+", tee_top: "+", tee_bottom: "+"
46
+ ).freeze
47
+
48
+ # Light lines with rounded corners (╭ ╮ ╰ ╯).
49
+ ROUNDED = BorderSet.new(
50
+ top_left: "\u256D", top_right: "\u256E", # ╭ ╮
51
+ bottom_left: "\u2570", bottom_right: "\u256F", # ╰ ╯
52
+ horizontal: "\u2500", vertical: "\u2502", # ─ │
53
+ cross: "\u253C", # ┼
54
+ tee_left: "\u2524", tee_right: "\u251C", # ┤ ├
55
+ tee_top: "\u2534", tee_bottom: "\u252C" # ┴ ┬
56
+ ).freeze
57
+
58
+ # Double lines (═ ║ ╔ ╗ ╚ ╝).
59
+ DOUBLE = BorderSet.new(
60
+ top_left: "\u2554", top_right: "\u2557", # ╔ ╗
61
+ bottom_left: "\u255A", bottom_right: "\u255D", # ╚ ╝
62
+ horizontal: "\u2550", vertical: "\u2551", # ═ ║
63
+ cross: "\u256C", # ╬
64
+ tee_left: "\u2563", tee_right: "\u2560", # ╣ ╠
65
+ tee_top: "\u2569", tee_bottom: "\u2566" # ╩ ╦
66
+ ).freeze
67
+
68
+ # Heavy lines (━ ┃ ┏ ┓ ┗ ┛).
69
+ THICK = BorderSet.new(
70
+ top_left: "\u250F", top_right: "\u2513", # ┏ ┓
71
+ bottom_left: "\u2517", bottom_right: "\u251B", # ┗ ┛
72
+ horizontal: "\u2501", vertical: "\u2503", # ━ ┃
73
+ cross: "\u254B", # ╋
74
+ tee_left: "\u252B", tee_right: "\u2523", # ┫ ┣
75
+ tee_top: "\u253B", tee_bottom: "\u2533" # ┻ ┳
76
+ ).freeze
77
+
78
+ # Light lines with square corners (─ │ ┌ ┐ └ ┘).
79
+ LIGHT = BorderSet.new(
80
+ top_left: "\u250C", top_right: "\u2510", # ┌ ┐
81
+ bottom_left: "\u2514", bottom_right: "\u2518", # └ ┘
82
+ horizontal: "\u2500", vertical: "\u2502", # ─ │
83
+ cross: "\u253C", # ┼
84
+ tee_left: "\u2524", tee_right: "\u251C", # ┤ ├
85
+ tee_top: "\u2534", tee_bottom: "\u252C" # ┴ ┬
86
+ ).freeze
87
+ end
88
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyTUI
4
+ # Orchestrates the rendering pipeline with double buffering.
5
+ #
6
+ # Terminal manages two buffers and diffs them each frame to send only
7
+ # changed cells to the backend. This is the core of the immediate-mode
8
+ # rendering system.
9
+ #
10
+ # @example Drawing a frame
11
+ # terminal.draw do |frame|
12
+ # frame.buffer.set_string(0, 0, "Hello!")
13
+ # frame.render_widget(my_widget, frame.area)
14
+ # end
15
+ class Terminal
16
+ # @return [Backend] the rendering backend (AnsiBackend or TestBackend)
17
+ attr_reader :backend
18
+
19
+ # Create a terminal that renders through +backend+.
20
+ #
21
+ # Allocates both buffers at the backend's current {Backend#size} and,
22
+ # where supported, installs a +SIGWINCH+ trap (replacing any existing
23
+ # handler) so the buffers are reallocated and the screen cleared on the
24
+ # next {#draw} after a resize.
25
+ #
26
+ # @param backend [Backend] the rendering backend
27
+ def initialize(backend:)
28
+ @backend = backend
29
+ @needs_resize = false
30
+ @current = 0
31
+ allocate_buffers
32
+ setup_resize_handler
33
+ end
34
+
35
+ # Main draw method — the heart of the rendering pipeline.
36
+ #
37
+ # Resets the current buffer, yields a {Frame} for the caller to render
38
+ # widgets into, diffs against the previous buffer, sends changes to
39
+ # the backend, and swaps buffers.
40
+ #
41
+ # The cursor is hidden while drawing and shown again only if the frame
42
+ # requested a position via {Frame#set_cursor_position}. Image
43
+ # placement changes are sent to the backend, and the backend is
44
+ # flushed before returning.
45
+ #
46
+ # @yield [frame] a Frame to render widgets into
47
+ # @yieldparam frame [Frame]
48
+ # @return [Frame] the rendered frame
49
+ def draw
50
+ check_resize
51
+
52
+ # A pending full repaint (image placements changed last frame, or
53
+ # force_redraw!): clear the screen and forget the previous buffer so
54
+ # every cell and image is re-emitted. Image pixels live on a layer
55
+ # separate from text cells, so placement changes can reveal stale
56
+ # terminal content that the cell diff alone cannot detect.
57
+ if @full_repaint
58
+ @full_repaint = false
59
+ @force_images = true
60
+ # Cells only — image placements must survive so deletions and
61
+ # changes still diff correctly against the previous frame.
62
+ previous_buffer.reset_cells!
63
+ @backend.clear
64
+ end
65
+
66
+ # Reset the current (next) buffer
67
+ current_buffer.reset!
68
+
69
+ # Create frame and yield to caller
70
+ frame = Frame.new(current_buffer, current_buffer.area)
71
+ yield frame
72
+
73
+ # Diff against the previous buffer
74
+ changes = current_buffer.diff(previous_buffer)
75
+
76
+ # Send changes to backend
77
+ @backend.hide_cursor
78
+ @backend.draw(changes)
79
+
80
+ # Handle image placements (Kitty graphics protocol)
81
+ draw_images(current_buffer, previous_buffer)
82
+
83
+ # Handle cursor position
84
+ if (pos = frame.cursor_position)
85
+ @backend.show_cursor
86
+ @backend.move_cursor(pos[0], pos[1])
87
+ end
88
+
89
+ @backend.flush
90
+
91
+ # Swap buffers
92
+ @current = 1 - @current
93
+
94
+ frame
95
+ end
96
+
97
+ # Get current terminal size as a Rect.
98
+ #
99
+ # @return [Rect] a Rect at origin with terminal dimensions
100
+ def size
101
+ w, h = @backend.size
102
+ Rect.new(x: 0, y: 0, width: w, height: h)
103
+ end
104
+
105
+ # Get cell pixel dimensions for aspect-ratio calculations.
106
+ #
107
+ # Queries the terminal via CSI 16 t if not already cached.
108
+ # Use this with {Widgets::Image.fit_area} for proper image sizing.
109
+ #
110
+ # @return [Array(Integer, Integer)] cell dimensions [width_px, height_px]
111
+ def cell_pixel_size
112
+ @backend.query_cell_pixel_size
113
+ end
114
+
115
+ # Clear all Kitty graphics protocol images.
116
+ #
117
+ # Useful during transitions or when precise tracking isn't possible.
118
+ #
119
+ # @return [void]
120
+ def clear_all_images!
121
+ @backend.clear_all_images!
122
+ end
123
+
124
+ # Delegate alternate screen management to backend.
125
+ # @return [void]
126
+ def enter_alternate_screen
127
+ @backend.enter_alternate_screen
128
+ end
129
+
130
+ # Leave the alternate screen buffer via the backend.
131
+ #
132
+ # @return [void]
133
+ def leave_alternate_screen
134
+ @backend.leave_alternate_screen
135
+ end
136
+
137
+ # Force a full redraw on the next {#draw} call.
138
+ #
139
+ # The next frame clears the screen, re-sends every cell, and
140
+ # re-transmits current image placements.
141
+ #
142
+ # @return [void]
143
+ def force_redraw!
144
+ @full_repaint = true
145
+ end
146
+
147
+ private
148
+
149
+ def current_buffer = @buffers[@current]
150
+ def previous_buffer = @buffers[1 - @current]
151
+
152
+ def allocate_buffers
153
+ w, h = @backend.size
154
+ area = Rect.new(x: 0, y: 0, width: w, height: h)
155
+ @buffers = [Buffer.new(area), Buffer.new(area)]
156
+ end
157
+
158
+ def setup_resize_handler
159
+ Signal.trap("WINCH") do
160
+ @needs_resize = true
161
+ end
162
+ rescue ArgumentError, SignalException
163
+ # Signal not available on this platform (e.g., Windows)
164
+ end
165
+
166
+ def draw_images(current, previous)
167
+ force = @force_images
168
+ @force_images = false
169
+
170
+ prev_placements = previous.image_placements
171
+ curr_placements = current.image_placements
172
+ changed = false
173
+
174
+ # Delete images that are no longer present or have changed.
175
+ # Keys include content_hash, so same-position different-content
176
+ # will have different keys and trigger proper delete + redraw.
177
+ prev_placements.each do |key, prev_placement|
178
+ unless curr_placements.key?(key)
179
+ @backend.delete_kitty_image(prev_placement)
180
+ changed = true
181
+ end
182
+ end
183
+
184
+ # Transmit new or changed images
185
+ curr_placements.each do |key, placement|
186
+ prev = prev_placements[key]
187
+
188
+ # Identical placement (same key means same content hash): only
189
+ # re-transmit when a full repaint cleared the screen.
190
+ identical = prev &&
191
+ prev[:cols] == placement[:cols] &&
192
+ prev[:rows] == placement[:rows] &&
193
+ prev[:src_x] == placement[:src_x] &&
194
+ prev[:src_y] == placement[:src_y] &&
195
+ prev[:src_w] == placement[:src_w] &&
196
+ prev[:src_h] == placement[:src_h]
197
+ changed = true unless identical
198
+ next if identical && !force
199
+
200
+ @backend.draw_kitty_image(placement)
201
+ end
202
+
203
+ # Placement changes can reveal stale terminal content beneath the
204
+ # old image area — schedule a one-off full repaint for the next frame.
205
+ @full_repaint = true if changed
206
+ end
207
+
208
+ def check_resize
209
+ return unless @needs_resize
210
+
211
+ @needs_resize = false
212
+ @current = 0
213
+ allocate_buffers
214
+ @backend.clear
215
+ @backend.flush
216
+ end
217
+ end
218
+ end