whirled_peas 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|