ratatat 1.0.0
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/README.md +201 -0
- data/examples/log_tailer.rb +460 -0
- data/lib/ratatat/ansi_backend.rb +175 -0
- data/lib/ratatat/app.rb +342 -0
- data/lib/ratatat/binding.rb +74 -0
- data/lib/ratatat/buffer.rb +238 -0
- data/lib/ratatat/cell.rb +166 -0
- data/lib/ratatat/color.rb +191 -0
- data/lib/ratatat/css_parser.rb +192 -0
- data/lib/ratatat/dom_query.rb +124 -0
- data/lib/ratatat/driver.rb +200 -0
- data/lib/ratatat/input.rb +208 -0
- data/lib/ratatat/message.rb +147 -0
- data/lib/ratatat/reactive.rb +79 -0
- data/lib/ratatat/styles.rb +293 -0
- data/lib/ratatat/terminal.rb +168 -0
- data/lib/ratatat/version.rb +3 -0
- data/lib/ratatat/widget.rb +337 -0
- data/lib/ratatat/widgets/button.rb +43 -0
- data/lib/ratatat/widgets/checkbox.rb +68 -0
- data/lib/ratatat/widgets/container.rb +50 -0
- data/lib/ratatat/widgets/data_table.rb +123 -0
- data/lib/ratatat/widgets/grid.rb +40 -0
- data/lib/ratatat/widgets/horizontal.rb +52 -0
- data/lib/ratatat/widgets/log.rb +97 -0
- data/lib/ratatat/widgets/modal.rb +161 -0
- data/lib/ratatat/widgets/progress_bar.rb +80 -0
- data/lib/ratatat/widgets/radio_set.rb +91 -0
- data/lib/ratatat/widgets/scrollable_container.rb +88 -0
- data/lib/ratatat/widgets/select.rb +100 -0
- data/lib/ratatat/widgets/sparkline.rb +61 -0
- data/lib/ratatat/widgets/spinner.rb +93 -0
- data/lib/ratatat/widgets/static.rb +23 -0
- data/lib/ratatat/widgets/tabbed_content.rb +114 -0
- data/lib/ratatat/widgets/text_area.rb +183 -0
- data/lib/ratatat/widgets/text_input.rb +143 -0
- data/lib/ratatat/widgets/toast.rb +55 -0
- data/lib/ratatat/widgets/tooltip.rb +66 -0
- data/lib/ratatat/widgets/tree.rb +172 -0
- data/lib/ratatat/widgets/vertical.rb +52 -0
- data/lib/ratatat.rb +51 -0
- metadata +142 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module Ratatat
|
|
7
|
+
# Base class for all messages in the event system.
|
|
8
|
+
# Messages flow up the widget tree (bubbling) unless stopped.
|
|
9
|
+
class Message
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig { returns(T.untyped) }
|
|
13
|
+
attr_reader :sender
|
|
14
|
+
|
|
15
|
+
sig { returns(Time) }
|
|
16
|
+
attr_reader :time
|
|
17
|
+
|
|
18
|
+
sig { returns(T::Boolean) }
|
|
19
|
+
attr_accessor :bubble
|
|
20
|
+
|
|
21
|
+
sig { params(sender: T.untyped, bubble: T::Boolean).void }
|
|
22
|
+
def initialize(sender:, bubble: true)
|
|
23
|
+
@sender = sender
|
|
24
|
+
@time = T.let(Time.now, Time)
|
|
25
|
+
@bubble = bubble
|
|
26
|
+
@stopped = T.let(false, T::Boolean)
|
|
27
|
+
@prevented = T.let(false, T::Boolean)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
sig { void }
|
|
31
|
+
def stop
|
|
32
|
+
@stopped = true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
sig { returns(T::Boolean) }
|
|
36
|
+
def stopped?
|
|
37
|
+
@stopped
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
sig { void }
|
|
41
|
+
def prevent_default
|
|
42
|
+
@prevented = true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
sig { returns(T::Boolean) }
|
|
46
|
+
def prevented?
|
|
47
|
+
@prevented
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Keyboard input message
|
|
52
|
+
class Key < Message
|
|
53
|
+
extend T::Sig
|
|
54
|
+
|
|
55
|
+
sig { returns(T.any(Symbol, String)) }
|
|
56
|
+
attr_reader :key
|
|
57
|
+
|
|
58
|
+
sig { returns(T::Set[Symbol]) }
|
|
59
|
+
attr_reader :modifiers
|
|
60
|
+
|
|
61
|
+
sig { params(sender: T.untyped, key: T.any(Symbol, String), modifiers: T::Set[Symbol], bubble: T::Boolean).void }
|
|
62
|
+
def initialize(sender:, key:, modifiers: Set.new, bubble: true)
|
|
63
|
+
super(sender: sender, bubble: bubble)
|
|
64
|
+
@key = key
|
|
65
|
+
@modifiers = modifiers
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
sig { returns(T::Boolean) }
|
|
69
|
+
def ctrl? = @modifiers.include?(:ctrl)
|
|
70
|
+
|
|
71
|
+
sig { returns(T::Boolean) }
|
|
72
|
+
def alt? = @modifiers.include?(:alt)
|
|
73
|
+
|
|
74
|
+
sig { returns(T::Boolean) }
|
|
75
|
+
def shift? = @modifiers.include?(:shift)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Terminal resize message
|
|
79
|
+
class Resize < Message
|
|
80
|
+
extend T::Sig
|
|
81
|
+
|
|
82
|
+
sig { returns(Integer) }
|
|
83
|
+
attr_reader :width, :height
|
|
84
|
+
|
|
85
|
+
sig { params(sender: T.untyped, width: Integer, height: Integer, bubble: T::Boolean).void }
|
|
86
|
+
def initialize(sender:, width:, height:, bubble: true)
|
|
87
|
+
super(sender: sender, bubble: bubble)
|
|
88
|
+
@width = width
|
|
89
|
+
@height = height
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Application quit message
|
|
94
|
+
class Quit < Message
|
|
95
|
+
extend T::Sig
|
|
96
|
+
|
|
97
|
+
sig { params(sender: T.untyped).void }
|
|
98
|
+
def initialize(sender:)
|
|
99
|
+
super(sender: sender, bubble: false)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Widget gained focus
|
|
104
|
+
class Focus < Message
|
|
105
|
+
extend T::Sig
|
|
106
|
+
|
|
107
|
+
sig { params(sender: T.untyped).void }
|
|
108
|
+
def initialize(sender:)
|
|
109
|
+
super(sender: sender, bubble: false)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Widget lost focus
|
|
114
|
+
class Blur < Message
|
|
115
|
+
extend T::Sig
|
|
116
|
+
|
|
117
|
+
sig { params(sender: T.untyped).void }
|
|
118
|
+
def initialize(sender:)
|
|
119
|
+
super(sender: sender, bubble: false)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Worker namespace for background task messages
|
|
124
|
+
module Worker
|
|
125
|
+
# Worker completed message
|
|
126
|
+
class Done < Message
|
|
127
|
+
extend T::Sig
|
|
128
|
+
|
|
129
|
+
sig { returns(Symbol) }
|
|
130
|
+
attr_reader :name
|
|
131
|
+
|
|
132
|
+
sig { returns(T.untyped) }
|
|
133
|
+
attr_reader :result
|
|
134
|
+
|
|
135
|
+
sig { returns(T.nilable(Exception)) }
|
|
136
|
+
attr_reader :error
|
|
137
|
+
|
|
138
|
+
sig { params(sender: T.untyped, name: Symbol, result: T.untyped, error: T.nilable(Exception)).void }
|
|
139
|
+
def initialize(sender:, name:, result: nil, error: nil)
|
|
140
|
+
super(sender: sender, bubble: true)
|
|
141
|
+
@name = name
|
|
142
|
+
@result = result
|
|
143
|
+
@error = error
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module Ratatat
|
|
7
|
+
# Provides the reactive property DSL for widgets
|
|
8
|
+
module Reactive
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
# Class methods added when Reactive is extended
|
|
12
|
+
module ClassMethods
|
|
13
|
+
extend T::Sig
|
|
14
|
+
|
|
15
|
+
# Define a reactive property
|
|
16
|
+
sig { params(name: Symbol, default: T.untyped, repaint: T::Boolean).void }
|
|
17
|
+
def reactive(name, default: nil, repaint: true)
|
|
18
|
+
# Store reactive metadata
|
|
19
|
+
@_reactives ||= {}
|
|
20
|
+
@_reactives[name] = { default: default, repaint: repaint }
|
|
21
|
+
|
|
22
|
+
# Define getter
|
|
23
|
+
define_method(name) do
|
|
24
|
+
ivar = :"@#{name}"
|
|
25
|
+
if instance_variable_defined?(ivar)
|
|
26
|
+
instance_variable_get(ivar)
|
|
27
|
+
else
|
|
28
|
+
self.class.reactive_default(name)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Define setter
|
|
33
|
+
define_method(:"#{name}=") do |value|
|
|
34
|
+
ivar = :"@#{name}"
|
|
35
|
+
old_value = send(name)
|
|
36
|
+
|
|
37
|
+
# Run validator if defined
|
|
38
|
+
validator = :"validate_#{name}"
|
|
39
|
+
value = send(validator, value) if respond_to?(validator, true)
|
|
40
|
+
|
|
41
|
+
# Skip if unchanged
|
|
42
|
+
return if old_value == value
|
|
43
|
+
|
|
44
|
+
# Store new value
|
|
45
|
+
instance_variable_set(ivar, value)
|
|
46
|
+
|
|
47
|
+
# Call watcher if defined
|
|
48
|
+
watcher = :"watch_#{name}"
|
|
49
|
+
send(watcher, old_value, value) if respond_to?(watcher, true)
|
|
50
|
+
|
|
51
|
+
# Trigger repaint if configured
|
|
52
|
+
if self.class.reactive_repaint?(name) && respond_to?(:refresh, true)
|
|
53
|
+
send(:refresh)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
sig { params(name: Symbol).returns(T.untyped) }
|
|
59
|
+
def reactive_default(name)
|
|
60
|
+
@_reactives&.dig(name, :default)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
sig { params(name: Symbol).returns(T::Boolean) }
|
|
64
|
+
def reactive_repaint?(name)
|
|
65
|
+
@_reactives&.dig(name, :repaint) != false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
sig { returns(T::Hash[Symbol, T::Hash[Symbol, T.untyped]]) }
|
|
69
|
+
def reactives
|
|
70
|
+
@_reactives || {}
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
sig { params(base: T::Class[T.anything]).void }
|
|
75
|
+
def self.extended(base)
|
|
76
|
+
base.extend(ClassMethods)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module Ratatat
|
|
7
|
+
# Style properties for widgets
|
|
8
|
+
class Styles
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
PROPERTIES = T.let(%i[
|
|
12
|
+
foreground background
|
|
13
|
+
width height min_width max_width min_height max_height
|
|
14
|
+
padding margin
|
|
15
|
+
bold italic underline
|
|
16
|
+
border border_title
|
|
17
|
+
text_align
|
|
18
|
+
].freeze, T::Array[Symbol])
|
|
19
|
+
|
|
20
|
+
sig { returns(T.nilable(T.any(Symbol, Color::Rgb, Color::Indexed))) }
|
|
21
|
+
attr_accessor :foreground, :background
|
|
22
|
+
|
|
23
|
+
sig { returns(T.nilable(Integer)) }
|
|
24
|
+
attr_accessor :width, :height, :min_width, :max_width, :min_height, :max_height
|
|
25
|
+
|
|
26
|
+
sig { returns(T.nilable(T::Array[Integer])) }
|
|
27
|
+
attr_accessor :padding, :margin
|
|
28
|
+
|
|
29
|
+
sig { returns(T.nilable(T::Boolean)) }
|
|
30
|
+
attr_accessor :bold, :italic, :underline
|
|
31
|
+
|
|
32
|
+
sig { returns(T.nilable(Symbol)) }
|
|
33
|
+
attr_accessor :border, :text_align
|
|
34
|
+
|
|
35
|
+
sig { returns(T.nilable(String)) }
|
|
36
|
+
attr_accessor :border_title
|
|
37
|
+
|
|
38
|
+
sig { params(props: T.untyped).void }
|
|
39
|
+
def initialize(**props)
|
|
40
|
+
props.each do |key, value|
|
|
41
|
+
send(:"#{key}=", value) if respond_to?(:"#{key}=")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
sig { params(other: Styles).returns(Styles) }
|
|
46
|
+
def merge(other)
|
|
47
|
+
result = Styles.new
|
|
48
|
+
PROPERTIES.each do |prop|
|
|
49
|
+
my_value = send(prop)
|
|
50
|
+
other_value = other.send(prop)
|
|
51
|
+
result.send(:"#{prop}=", other_value.nil? ? my_value : other_value)
|
|
52
|
+
end
|
|
53
|
+
result
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
57
|
+
def to_h
|
|
58
|
+
PROPERTIES.each_with_object({}) do |prop, hash|
|
|
59
|
+
value = send(prop)
|
|
60
|
+
hash[prop] = value unless value.nil?
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Manages style rules and computes styles for widgets
|
|
66
|
+
class StyleSheet
|
|
67
|
+
extend T::Sig
|
|
68
|
+
|
|
69
|
+
Rule = T.type_alias { { selector: String, styles: Styles, specificity: Integer } }
|
|
70
|
+
|
|
71
|
+
sig { void }
|
|
72
|
+
def initialize
|
|
73
|
+
@rules = T.let([], T::Array[Rule])
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
sig { params(selector: String, props: T.untyped).void }
|
|
77
|
+
def add_rule(selector, **props)
|
|
78
|
+
@rules << {
|
|
79
|
+
selector: selector,
|
|
80
|
+
styles: Styles.new(**props),
|
|
81
|
+
specificity: compute_specificity(selector)
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
sig { params(widget: Widget).returns(Styles) }
|
|
86
|
+
def compute(widget)
|
|
87
|
+
matching = @rules.select { |rule| matches?(widget, rule[:selector]) }
|
|
88
|
+
sorted = matching.sort_by { |rule| rule[:specificity] }
|
|
89
|
+
|
|
90
|
+
result = Styles.new
|
|
91
|
+
sorted.each do |rule|
|
|
92
|
+
result = result.merge(rule[:styles])
|
|
93
|
+
end
|
|
94
|
+
result
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
sig { params(selector: String).returns(Integer) }
|
|
100
|
+
def compute_specificity(selector)
|
|
101
|
+
# Handle combinator selectors
|
|
102
|
+
parts = parse_combinator_parts(selector)
|
|
103
|
+
return parts.sum { |part| compute_simple_specificity(part[:selector]) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
sig { params(selector: String).returns(Integer) }
|
|
107
|
+
def compute_simple_specificity(selector)
|
|
108
|
+
# Strip :not() for specificity calculation
|
|
109
|
+
selector = selector.gsub(/:not\([^)]+\)/, "")
|
|
110
|
+
|
|
111
|
+
# Parse compound selector parts
|
|
112
|
+
parts = parse_compound_selector(selector)
|
|
113
|
+
specificity = 0
|
|
114
|
+
|
|
115
|
+
parts.each do |part|
|
|
116
|
+
if part.start_with?("#")
|
|
117
|
+
specificity += 100
|
|
118
|
+
elsif part.start_with?(".")
|
|
119
|
+
specificity += 10
|
|
120
|
+
else
|
|
121
|
+
specificity += 1
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Add pseudo-class specificity
|
|
126
|
+
specificity += 10 if selector.include?(":")
|
|
127
|
+
specificity
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
sig { params(selector: String).returns(T::Array[{ selector: String, combinator: T.nilable(Symbol) }]) }
|
|
131
|
+
def parse_combinator_parts(selector)
|
|
132
|
+
parts = T.let([], T::Array[{ selector: String, combinator: T.nilable(Symbol) }])
|
|
133
|
+
|
|
134
|
+
# Split by child combinator first
|
|
135
|
+
if selector.include?(" > ")
|
|
136
|
+
segments = selector.split(" > ")
|
|
137
|
+
segments.each_with_index do |seg, i|
|
|
138
|
+
# Each segment might have descendant selectors
|
|
139
|
+
if seg.include?(" ")
|
|
140
|
+
sub_parts = seg.strip.split(/\s+/)
|
|
141
|
+
sub_parts.each_with_index do |sub, j|
|
|
142
|
+
combinator = j < sub_parts.length - 1 ? :descendant : (i < segments.length - 1 ? :child : nil)
|
|
143
|
+
parts << { selector: sub, combinator: combinator }
|
|
144
|
+
end
|
|
145
|
+
else
|
|
146
|
+
parts << { selector: seg.strip, combinator: i < segments.length - 1 ? :child : nil }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
elsif selector.include?(" ")
|
|
150
|
+
# Only descendant combinators
|
|
151
|
+
segments = selector.split(/\s+/)
|
|
152
|
+
segments.each_with_index do |seg, i|
|
|
153
|
+
parts << { selector: seg, combinator: i < segments.length - 1 ? :descendant : nil }
|
|
154
|
+
end
|
|
155
|
+
else
|
|
156
|
+
parts << { selector: selector, combinator: nil }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
parts
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
sig { params(selector: String).returns(T::Array[String]) }
|
|
163
|
+
def parse_compound_selector(selector)
|
|
164
|
+
# Remove pseudo-classes for parsing
|
|
165
|
+
selector = selector.gsub(/:[a-z-]+(\([^)]*\))?/, "")
|
|
166
|
+
|
|
167
|
+
parts = T.let([], T::Array[String])
|
|
168
|
+
|
|
169
|
+
# Extract ID
|
|
170
|
+
if selector.include?("#")
|
|
171
|
+
id_match = selector.match(/#([a-zA-Z0-9_-]+)/)
|
|
172
|
+
parts << "##{id_match[1]}" if id_match
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Extract classes
|
|
176
|
+
selector.scan(/\.([a-zA-Z0-9_-]+)/).each do |match|
|
|
177
|
+
parts << ".#{match[0]}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Extract type (must be at start, before # or .)
|
|
181
|
+
type_match = selector.match(/^([a-zA-Z][a-zA-Z0-9_]*)/)
|
|
182
|
+
if type_match && !type_match[1].nil?
|
|
183
|
+
type_name = T.must(type_match[1])
|
|
184
|
+
parts.unshift(type_name) unless type_name.empty?
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
parts
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
sig { params(widget: Widget, selector: String).returns(T::Boolean) }
|
|
191
|
+
def matches?(widget, selector)
|
|
192
|
+
parts = parse_combinator_parts(selector)
|
|
193
|
+
|
|
194
|
+
# Single selector (no combinators)
|
|
195
|
+
return matches_simple?(widget, parts[0][:selector]) if parts.length == 1
|
|
196
|
+
|
|
197
|
+
# Combinator selector - check from right to left
|
|
198
|
+
current = widget
|
|
199
|
+
(parts.length - 1).downto(0) do |i|
|
|
200
|
+
part = parts[i]
|
|
201
|
+
return false unless matches_simple?(current, part[:selector])
|
|
202
|
+
|
|
203
|
+
# Move up the tree based on combinator
|
|
204
|
+
if i > 0
|
|
205
|
+
prev_combinator = parts[i - 1][:combinator]
|
|
206
|
+
case prev_combinator
|
|
207
|
+
when :child
|
|
208
|
+
current = current.parent
|
|
209
|
+
return false if current.nil?
|
|
210
|
+
when :descendant
|
|
211
|
+
# Find any ancestor matching the next selector
|
|
212
|
+
found = false
|
|
213
|
+
ancestor = current.parent
|
|
214
|
+
while ancestor
|
|
215
|
+
if matches_simple?(ancestor, parts[i - 1][:selector])
|
|
216
|
+
current = ancestor
|
|
217
|
+
found = true
|
|
218
|
+
break
|
|
219
|
+
end
|
|
220
|
+
ancestor = ancestor.parent
|
|
221
|
+
end
|
|
222
|
+
return false unless found
|
|
223
|
+
next # Skip the normal iteration since we handled it
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
true
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
sig { params(widget: Widget, selector: String).returns(T::Boolean) }
|
|
232
|
+
def matches_simple?(widget, selector)
|
|
233
|
+
# Handle :not() pseudo-class
|
|
234
|
+
not_match = selector.match(/:not\(([^)]+)\)/)
|
|
235
|
+
if not_match
|
|
236
|
+
not_selector = not_match[1]
|
|
237
|
+
return false if matches_simple?(widget, not_selector)
|
|
238
|
+
selector = selector.gsub(/:not\([^)]+\)/, "")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Parse base and pseudo-class
|
|
242
|
+
base, pseudo = parse_simple_selector(selector)
|
|
243
|
+
|
|
244
|
+
# Check compound selector
|
|
245
|
+
base_matches = matches_compound?(widget, base)
|
|
246
|
+
return false unless base_matches
|
|
247
|
+
|
|
248
|
+
# Check pseudo-class if present
|
|
249
|
+
return true unless pseudo
|
|
250
|
+
|
|
251
|
+
case pseudo
|
|
252
|
+
when "focus"
|
|
253
|
+
widget.has_focus?
|
|
254
|
+
when "disabled"
|
|
255
|
+
widget.disabled == true
|
|
256
|
+
when "hover"
|
|
257
|
+
widget.hover == true
|
|
258
|
+
else
|
|
259
|
+
false
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
sig { params(selector: String).returns([String, T.nilable(String)]) }
|
|
264
|
+
def parse_simple_selector(selector)
|
|
265
|
+
# Match pseudo-class but not :not()
|
|
266
|
+
if selector.match?(/:[a-z]+$/)
|
|
267
|
+
parts = selector.split(":")
|
|
268
|
+
[parts[0..-2].join(":"), parts.last]
|
|
269
|
+
else
|
|
270
|
+
[selector, nil]
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
sig { params(widget: Widget, selector: String).returns(T::Boolean) }
|
|
275
|
+
def matches_compound?(widget, selector)
|
|
276
|
+
return true if selector.empty?
|
|
277
|
+
|
|
278
|
+
parts = parse_compound_selector(selector)
|
|
279
|
+
return true if parts.empty?
|
|
280
|
+
|
|
281
|
+
parts.all? do |part|
|
|
282
|
+
if part.start_with?("#")
|
|
283
|
+
widget.id == part[1..]
|
|
284
|
+
elsif part.start_with?(".")
|
|
285
|
+
widget.classes.include?(part[1..])
|
|
286
|
+
else
|
|
287
|
+
# Type selector
|
|
288
|
+
widget.class.name&.split("::")&.last == part
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "io/console"
|
|
5
|
+
require "sorbet-runtime"
|
|
6
|
+
|
|
7
|
+
module Ratatat
|
|
8
|
+
# Terminal abstraction with double buffering for flicker-free rendering.
|
|
9
|
+
# Manages two buffers (current and previous) and computes diffs.
|
|
10
|
+
class Terminal
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
sig { returns(AnsiBackend) }
|
|
14
|
+
attr_reader :backend
|
|
15
|
+
|
|
16
|
+
sig { returns(Integer) }
|
|
17
|
+
attr_reader :width, :height
|
|
18
|
+
|
|
19
|
+
sig { params(backend: T.nilable(AnsiBackend), io: IO).void }
|
|
20
|
+
def initialize(backend: nil, io: $stdout)
|
|
21
|
+
@backend = T.let(backend || AnsiBackend.new(io: io), AnsiBackend)
|
|
22
|
+
size = detect_size
|
|
23
|
+
@width = T.let(size[0], Integer)
|
|
24
|
+
@height = T.let(size[1], Integer)
|
|
25
|
+
@buffers = T.let([Buffer.new(@width, @height), Buffer.new(@width, @height)], [Buffer, Buffer])
|
|
26
|
+
@current = T.let(0, Integer)
|
|
27
|
+
@cursor_hidden = T.let(false, T::Boolean)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Get current buffer (the one being drawn to)
|
|
31
|
+
sig { returns(Buffer) }
|
|
32
|
+
def current_buffer
|
|
33
|
+
@buffers[@current]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Get previous buffer (last rendered frame)
|
|
37
|
+
sig { returns(Buffer) }
|
|
38
|
+
def previous_buffer
|
|
39
|
+
@buffers[1 - @current]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Draw a frame. Yields the current buffer for drawing.
|
|
43
|
+
# After the block, computes diff and renders to terminal.
|
|
44
|
+
sig { params(blk: T.proc.params(buffer: Buffer).void).void }
|
|
45
|
+
def draw(&blk)
|
|
46
|
+
# Check for resize
|
|
47
|
+
check_resize
|
|
48
|
+
|
|
49
|
+
# Clear current buffer
|
|
50
|
+
current_buffer.clear
|
|
51
|
+
|
|
52
|
+
# Let caller draw to buffer
|
|
53
|
+
blk.call(current_buffer)
|
|
54
|
+
|
|
55
|
+
# Compute diff and render
|
|
56
|
+
flush
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Compute diff between buffers and render to terminal
|
|
60
|
+
sig { void }
|
|
61
|
+
def flush
|
|
62
|
+
updates = previous_buffer.diff(current_buffer)
|
|
63
|
+
@backend.draw(updates)
|
|
64
|
+
@backend.flush
|
|
65
|
+
swap_buffers
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Force a full redraw (no diffing)
|
|
69
|
+
sig { void }
|
|
70
|
+
def force_redraw
|
|
71
|
+
@backend.clear
|
|
72
|
+
previous_buffer.clear
|
|
73
|
+
flush
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Swap current and previous buffers
|
|
77
|
+
sig { void }
|
|
78
|
+
def swap_buffers
|
|
79
|
+
# Copy current to previous for next frame's diff
|
|
80
|
+
previous_buffer.cells.each_with_index do |_, i|
|
|
81
|
+
previous_buffer.cells[i] = T.must(current_buffer.cells[i])
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Get current terminal size
|
|
86
|
+
sig { returns([Integer, Integer]) }
|
|
87
|
+
def size
|
|
88
|
+
[@width, @height]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Check if terminal was resized and update buffers
|
|
92
|
+
sig { void }
|
|
93
|
+
def check_resize
|
|
94
|
+
new_width, new_height = detect_size
|
|
95
|
+
return if new_width == @width && new_height == @height
|
|
96
|
+
|
|
97
|
+
@width = new_width
|
|
98
|
+
@height = new_height
|
|
99
|
+
@buffers.each { |buf| buf.resize(@width, @height) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Enter raw mode and alternate screen
|
|
103
|
+
sig { void }
|
|
104
|
+
def enter
|
|
105
|
+
@backend.enter_alternate_screen
|
|
106
|
+
@backend.hide_cursor
|
|
107
|
+
@backend.clear
|
|
108
|
+
@cursor_hidden = true
|
|
109
|
+
enable_raw_mode
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Exit raw mode and restore terminal
|
|
113
|
+
sig { void }
|
|
114
|
+
def exit
|
|
115
|
+
@backend.show_cursor if @cursor_hidden
|
|
116
|
+
@backend.reset_style
|
|
117
|
+
@backend.leave_alternate_screen
|
|
118
|
+
@backend.flush
|
|
119
|
+
disable_raw_mode
|
|
120
|
+
@cursor_hidden = false
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Show cursor at position
|
|
124
|
+
sig { params(x: T.nilable(Integer), y: T.nilable(Integer)).void }
|
|
125
|
+
def show_cursor(x = nil, y = nil)
|
|
126
|
+
if x && y
|
|
127
|
+
@backend.io.write(@backend.move_to(x, y))
|
|
128
|
+
end
|
|
129
|
+
@backend.show_cursor
|
|
130
|
+
@cursor_hidden = false
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Hide cursor
|
|
134
|
+
sig { void }
|
|
135
|
+
def hide_cursor
|
|
136
|
+
@backend.hide_cursor
|
|
137
|
+
@cursor_hidden = true
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
sig { returns([Integer, Integer]) }
|
|
143
|
+
def detect_size
|
|
144
|
+
if $stdout.respond_to?(:winsize)
|
|
145
|
+
rows, cols = $stdout.winsize
|
|
146
|
+
[cols || 80, rows || 24]
|
|
147
|
+
else
|
|
148
|
+
[80, 24]
|
|
149
|
+
end
|
|
150
|
+
rescue StandardError
|
|
151
|
+
[80, 24]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
sig { void }
|
|
155
|
+
def enable_raw_mode
|
|
156
|
+
$stdin.raw! if $stdin.respond_to?(:raw!)
|
|
157
|
+
rescue StandardError
|
|
158
|
+
# Ignore if we can't enter raw mode
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
sig { void }
|
|
162
|
+
def disable_raw_mode
|
|
163
|
+
$stdin.cooked! if $stdin.respond_to?(:cooked!)
|
|
164
|
+
rescue StandardError
|
|
165
|
+
# Ignore if we can't restore mode
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|