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.
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'whirled_peas'
5
+
6
+ require 'pry'
7
+ Pry.start
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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,14 @@
1
+ require_relative 'frame/consumer'
2
+ require_relative 'frame/producer'
3
+
4
+ module WhirledPeas
5
+ module Frame
6
+ TERMINATE = '__term__'
7
+ EOF = '__EOF__'
8
+
9
+ DEFAULT_ADDRESS = 'localhost'
10
+ DEFAULT_PORT = 8765
11
+ end
12
+
13
+ private_constant :Frame
14
+ 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,20 @@
1
+ module WhirledPeas
2
+ class NullLogger
3
+ def debug(*)
4
+ end
5
+
6
+ def error(*)
7
+ end
8
+
9
+ def fatal(*)
10
+ end
11
+
12
+ def info(*)
13
+ end
14
+
15
+ def warn(*)
16
+ end
17
+ end
18
+
19
+ private_constant :NullLogger
20
+ end
@@ -0,0 +1,7 @@
1
+ require_relative 'ui/element'
2
+ require_relative 'ui/screen'
3
+
4
+ module WhirledPeas
5
+ module UI
6
+ end
7
+ 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