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
|
+
# 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
|
data/lib/rubytui/rect.rb
ADDED
|
@@ -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
|