whirled_peas 0.1.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/.gitignore +14 -0
- data/.rspec +3 -0
- data/.travis.yml +6 -0
- data/CHANGELOG.md +0 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +331 -0
- data/Rakefile +6 -0
- data/bin/console +7 -0
- data/bin/setup +8 -0
- data/lib/whirled_peas.rb +46 -0
- data/lib/whirled_peas/frame.rb +14 -0
- data/lib/whirled_peas/frame/consumer.rb +61 -0
- data/lib/whirled_peas/frame/loop.rb +56 -0
- data/lib/whirled_peas/frame/producer.rb +57 -0
- data/lib/whirled_peas/null_logger.rb +20 -0
- data/lib/whirled_peas/ui.rb +7 -0
- data/lib/whirled_peas/ui/ansi.rb +154 -0
- data/lib/whirled_peas/ui/canvas.rb +35 -0
- data/lib/whirled_peas/ui/element.rb +199 -0
- data/lib/whirled_peas/ui/painter.rb +283 -0
- data/lib/whirled_peas/ui/screen.rb +62 -0
- data/lib/whirled_peas/ui/settings.rb +512 -0
- data/lib/whirled_peas/ui/stroke.rb +29 -0
- data/lib/whirled_peas/version.rb +3 -0
- data/sandbox/auto.rb +13 -0
- data/sandbox/box.rb +19 -0
- data/sandbox/grid.rb +13 -0
- data/sandbox/sandbox.rb +17 -0
- data/sandbox/text.rb +33 -0
- data/whirled_peas.gemspec +28 -0
- metadata +120 -0
data/Rakefile
ADDED
data/bin/console
ADDED
data/bin/setup
ADDED
data/lib/whirled_peas.rb
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
require 'logger'
|
2
|
+
|
3
|
+
require 'whirled_peas/frame'
|
4
|
+
require 'whirled_peas/ui'
|
5
|
+
require 'whirled_peas/version'
|
6
|
+
|
7
|
+
module WhirledPeas
|
8
|
+
class Error < StandardError; end
|
9
|
+
|
10
|
+
DEFAULT_HOST = 'localhost'
|
11
|
+
DEFAULT_PORT = 8765
|
12
|
+
DEFAULT_REFRESH_RATE = 30
|
13
|
+
|
14
|
+
|
15
|
+
def self.start(driver, template_factory, log_level: Logger::INFO, refresh_rate: DEFAULT_REFRESH_RATE, host: DEFAULT_HOST, port: DEFAULT_PORT)
|
16
|
+
logger = Logger.new(File.open('whirled_peas.log', 'a'))
|
17
|
+
logger.level = log_level
|
18
|
+
logger.formatter = proc do |severity, datetime, progname, msg|
|
19
|
+
"[#{severity}] #{datetime.strftime('%Y-%m-%dT%H:%M:%S.%L')} (#{progname}) - #{msg}\n"
|
20
|
+
end
|
21
|
+
|
22
|
+
consumer = Frame::Consumer.new(template_factory, refresh_rate, logger)
|
23
|
+
consumer_thread = Thread.new { consumer.start(host: host, port: port) }
|
24
|
+
|
25
|
+
Frame::Producer.start(logger: logger, host: host, port: port) do |producer|
|
26
|
+
begin
|
27
|
+
driver.start(producer)
|
28
|
+
producer.stop
|
29
|
+
rescue => e
|
30
|
+
logger.warn('MAIN') { "Driver exited with error, terminating producer..." }
|
31
|
+
logger.error('MAIN') { e }
|
32
|
+
logger.error('MAIN') { e.backtrace.join("\n") }
|
33
|
+
producer.terminate
|
34
|
+
raise
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
consumer_thread.join
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.template(&block)
|
42
|
+
template = UI::Template.new
|
43
|
+
yield template, template.settings
|
44
|
+
template
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
require 'socket'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
require_relative 'loop'
|
5
|
+
|
6
|
+
module WhirledPeas
|
7
|
+
module Frame
|
8
|
+
class Consumer
|
9
|
+
def initialize(template_factory, refresh_rate, logger=NullLogger.new)
|
10
|
+
@loop = Loop.new(template_factory, refresh_rate, logger)
|
11
|
+
@logger = logger
|
12
|
+
@running = false
|
13
|
+
@mutex = Mutex.new
|
14
|
+
end
|
15
|
+
|
16
|
+
def start(host:, port:)
|
17
|
+
mutex.synchronize { @running = true }
|
18
|
+
loop_thread = Thread.new { loop.start }
|
19
|
+
socket = TCPSocket.new(host, port)
|
20
|
+
logger.info('CONSUMER') { "Connected to #{host}:#{port}" }
|
21
|
+
while @running
|
22
|
+
line = socket.gets
|
23
|
+
if line.nil?
|
24
|
+
sleep(0.001)
|
25
|
+
next
|
26
|
+
end
|
27
|
+
args = JSON.parse(line)
|
28
|
+
name = args.delete('name')
|
29
|
+
if [Frame::EOF, Frame::TERMINATE].include?(name)
|
30
|
+
logger.info('CONSUMER') { "Received #{name} event, stopping..." }
|
31
|
+
loop.stop if name == Frame::TERMINATE
|
32
|
+
@running = false
|
33
|
+
else
|
34
|
+
duration = args.delete('duration')
|
35
|
+
loop.enqueue(name, duration, args)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
logger.info('CONSUMER') { "Exited normally" }
|
39
|
+
rescue => e
|
40
|
+
logger.warn('CONSUMER') { "Exited with error" }
|
41
|
+
logger.error('CONSUMER') { e.message }
|
42
|
+
logger.error('CONSUMER') { e.backtrace.join("\n") }
|
43
|
+
loop.stop
|
44
|
+
ensure
|
45
|
+
logger.info('CONSUMER') { "Waiting for loop thread to exit" }
|
46
|
+
loop_thread.join
|
47
|
+
logger.info('CONSUMER') { "Closing socket" }
|
48
|
+
socket.close if socket
|
49
|
+
end
|
50
|
+
|
51
|
+
def stop
|
52
|
+
logger.info('CONSUMER') { "Stopping..." }
|
53
|
+
mutex.synchronize { @running = false }
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
attr_reader :loop, :logger, :mutex
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module WhirledPeas
|
2
|
+
module Frame
|
3
|
+
class Loop
|
4
|
+
def initialize(template_factory, refresh_rate, logger=NullLogger.new)
|
5
|
+
@template_factory = template_factory
|
6
|
+
@queue = Queue.new
|
7
|
+
@refresh_rate = refresh_rate
|
8
|
+
@logger = logger
|
9
|
+
end
|
10
|
+
|
11
|
+
def enqueue(name, duration, args)
|
12
|
+
queue.push([name, duration, args])
|
13
|
+
end
|
14
|
+
|
15
|
+
def start
|
16
|
+
logger.info('EVENT LOOP') { "Starting" }
|
17
|
+
@running = true
|
18
|
+
screen = UI::Screen.new
|
19
|
+
sleep(0.01) while queue.empty? # Wait for the first event
|
20
|
+
remaining_frames = 1
|
21
|
+
template = nil
|
22
|
+
while @running && remaining_frames > 0
|
23
|
+
frame_at = Time.now
|
24
|
+
next_frame_at = frame_at + 1.0 / refresh_rate
|
25
|
+
remaining_frames -= 1
|
26
|
+
if remaining_frames > 0
|
27
|
+
screen.refresh if screen.needs_refresh?
|
28
|
+
elsif !queue.empty?
|
29
|
+
name, duration, args = queue.pop
|
30
|
+
remaining_frames = duration ? duration * refresh_rate : 1
|
31
|
+
template = template_factory.build(name, args)
|
32
|
+
screen.paint(template)
|
33
|
+
end
|
34
|
+
sleep(next_frame_at - Time.now)
|
35
|
+
end
|
36
|
+
logger.info('EVENT LOOP') { "Exiting normally" }
|
37
|
+
rescue => e
|
38
|
+
logger.warn('EVENT LOOP') { "Exiting with error" }
|
39
|
+
logger.error('EVENT LOOP') { e.message }
|
40
|
+
logger.error('EVENT LOOP') { e.backtrace.join("\n") }
|
41
|
+
ensure
|
42
|
+
screen.finalize if screen
|
43
|
+
end
|
44
|
+
|
45
|
+
def stop
|
46
|
+
logger.info('EVENT LOOP') { "Stopping..." }
|
47
|
+
@running = false
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
attr_reader :template_factory, :queue, :refresh_rate, :logger
|
53
|
+
end
|
54
|
+
private_constant :Loop
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
require 'socket'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module WhirledPeas
|
5
|
+
module Frame
|
6
|
+
class Producer
|
7
|
+
def self.start(logger: NullLogger.new, host:, port:, &block)
|
8
|
+
server = TCPServer.new(host, port)
|
9
|
+
client = server.accept
|
10
|
+
logger.info('PRODUCER') { "Connected to #{host}:#{port}" }
|
11
|
+
producer = new(client, logger)
|
12
|
+
yield producer
|
13
|
+
logger.info('PRODUCER') { "Exited normally" }
|
14
|
+
rescue => e
|
15
|
+
logger.warn('PRODUCER') { "Exited with error" }
|
16
|
+
logger.error('PRODUCER') { e.message }
|
17
|
+
logger.error('PRODUCER') { e.backtrace.join("\n") }
|
18
|
+
ensure
|
19
|
+
client.close if client
|
20
|
+
end
|
21
|
+
|
22
|
+
def initialize(client, logger=NullLogger.new)
|
23
|
+
@client = client
|
24
|
+
@logger = logger
|
25
|
+
@queue = Queue.new
|
26
|
+
end
|
27
|
+
|
28
|
+
def send(name, duration: nil, args: {})
|
29
|
+
client.puts(JSON.generate('name' => name, 'duration' => duration, **args))
|
30
|
+
logger.debug('PRODUCER') { "Sending frame: #{name}" }
|
31
|
+
end
|
32
|
+
|
33
|
+
def enqueue(name, duration: nil, args: {})
|
34
|
+
queue.push([name, duration, args])
|
35
|
+
end
|
36
|
+
|
37
|
+
def flush
|
38
|
+
while !queue.empty?
|
39
|
+
name, duration, args = queue.pop
|
40
|
+
send(name, duration: duration, args: args)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def stop
|
45
|
+
send(Frame::EOF)
|
46
|
+
end
|
47
|
+
|
48
|
+
def terminate
|
49
|
+
send(Frame::TERMINATE)
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
attr_reader :client, :logger, :queue
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,154 @@
|
|
1
|
+
module WhirledPeas
|
2
|
+
module UI
|
3
|
+
DEBUG_COLOR = ARGV.include?('--debug-color')
|
4
|
+
|
5
|
+
module Ansi
|
6
|
+
BOLD = 1
|
7
|
+
UNDERLINE = 4
|
8
|
+
|
9
|
+
BLACK = 30
|
10
|
+
RED = 31
|
11
|
+
GREEN = 32
|
12
|
+
YELLOW = 33
|
13
|
+
BLUE = 34
|
14
|
+
MAGENTA = 35
|
15
|
+
CYAN = 36
|
16
|
+
WHITE = 37
|
17
|
+
|
18
|
+
END_FORMATTING = 0
|
19
|
+
|
20
|
+
class << self
|
21
|
+
def format(str, codes)
|
22
|
+
if str.empty? || codes.length == 0
|
23
|
+
str
|
24
|
+
else
|
25
|
+
start_formatting = codes.map(&method(:esc_seq)).join
|
26
|
+
"#{start_formatting}#{str}#{esc_seq(END_FORMATTING)}"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def clear
|
31
|
+
esc_seq(END_FORMATTING)
|
32
|
+
end
|
33
|
+
|
34
|
+
def hidden_width(line)
|
35
|
+
return 0 if DEBUG_COLOR
|
36
|
+
width = 0
|
37
|
+
line.scan(/\033\[\d+m/).each { |f| width += f.length }
|
38
|
+
width
|
39
|
+
end
|
40
|
+
|
41
|
+
def close_formatting(line)
|
42
|
+
codes = line.scan(DEBUG_COLOR ? /<(\d+)>/ : /\033\[(\d+)m/)
|
43
|
+
if codes.length > 0 && codes.last[0] != END_FORMATTING.to_s
|
44
|
+
"#{line}#{esc_seq(END_FORMATTING)}"
|
45
|
+
else
|
46
|
+
line
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def first(str, num_visible_chars)
|
51
|
+
return str if str.length <= num_visible_chars + hidden_width(str)
|
52
|
+
result = ''
|
53
|
+
in_format = false
|
54
|
+
visible_len = 0
|
55
|
+
str.chars.each do |char|
|
56
|
+
in_format = true if !in_format && char == "\033"
|
57
|
+
result += char
|
58
|
+
visible_len += 1 if !in_format
|
59
|
+
in_format = false if in_format && char == 'm'
|
60
|
+
break if visible_len == num_visible_chars
|
61
|
+
end
|
62
|
+
close_formatting(result)
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
def esc_seq(code)
|
68
|
+
DEBUG_COLOR ? "<#{code}>" : "\033[#{code}m"
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
class Color
|
74
|
+
BRIGHT_OFFSET = 60
|
75
|
+
private_constant :BRIGHT_OFFSET
|
76
|
+
|
77
|
+
def self.validate!(color)
|
78
|
+
return unless color
|
79
|
+
if color.is_a?(Symbol)
|
80
|
+
error_message = "Unsupported #{self.name.split('::').last}: #{color}"
|
81
|
+
match = color.to_s.match(/^(bright_)?(\w+)$/)
|
82
|
+
begin
|
83
|
+
color = self.const_get(match[2].upcase)
|
84
|
+
raise ArgumentError, error_message unless color.is_a?(Color)
|
85
|
+
if match[1]
|
86
|
+
raise ArgumentError, error_message if color.bright?
|
87
|
+
color.bright
|
88
|
+
else
|
89
|
+
color
|
90
|
+
end
|
91
|
+
rescue NameError
|
92
|
+
raise ArgumentError, error_message
|
93
|
+
end
|
94
|
+
else
|
95
|
+
color
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def initialize(code, bright=false)
|
100
|
+
@code = code
|
101
|
+
@bright = bright
|
102
|
+
end
|
103
|
+
|
104
|
+
def bright?
|
105
|
+
@bright
|
106
|
+
end
|
107
|
+
|
108
|
+
def bright
|
109
|
+
bright? ? self : self.class.new(@code + BRIGHT_OFFSET, true)
|
110
|
+
end
|
111
|
+
|
112
|
+
def to_s
|
113
|
+
@code.to_s
|
114
|
+
end
|
115
|
+
|
116
|
+
def inspect
|
117
|
+
"#{self.class.name.split('::').last}(code=#{@code}, bright=#{@bright})"
|
118
|
+
end
|
119
|
+
end
|
120
|
+
private_constant :Color
|
121
|
+
|
122
|
+
class BgColor < Color
|
123
|
+
BG_OFFSET = 10
|
124
|
+
private_constant :BG_OFFSET
|
125
|
+
|
126
|
+
BLACK = new(Ansi::BLACK + BG_OFFSET)
|
127
|
+
RED = new(Ansi::RED + BG_OFFSET)
|
128
|
+
GREEN = new(Ansi::GREEN + BG_OFFSET)
|
129
|
+
YELLOW = new(Ansi::YELLOW + BG_OFFSET)
|
130
|
+
BLUE = new(Ansi::BLUE + BG_OFFSET)
|
131
|
+
MAGENTA = new(Ansi::MAGENTA + BG_OFFSET)
|
132
|
+
CYAN = new(Ansi::CYAN + BG_OFFSET)
|
133
|
+
WHITE = new(Ansi::WHITE + BG_OFFSET)
|
134
|
+
GRAY = BLACK.bright
|
135
|
+
end
|
136
|
+
|
137
|
+
class TextColor < Color
|
138
|
+
BLACK = new(Ansi::BLACK)
|
139
|
+
RED = new(Ansi::RED)
|
140
|
+
GREEN = new(Ansi::GREEN)
|
141
|
+
YELLOW = new(Ansi::YELLOW)
|
142
|
+
BLUE = new(Ansi::BLUE)
|
143
|
+
MAGENTA = new(Ansi::MAGENTA)
|
144
|
+
CYAN = new(Ansi::CYAN)
|
145
|
+
WHITE = new(Ansi::WHITE)
|
146
|
+
GRAY = BLACK.bright
|
147
|
+
end
|
148
|
+
|
149
|
+
module TextFormat
|
150
|
+
BOLD = Ansi::BOLD
|
151
|
+
UNDERLINE = Ansi::UNDERLINE
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|