whirled_peas 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: caed061285ef515e70f20c18153a16eedb3876a98842f2029db069c4a14dcd7f
4
- data.tar.gz: c07b7daeaa8c048d0068c1150ad248099c335a2a9e8fd70bd47ad8f9bfc99174
3
+ metadata.gz: d3cb7aa7af0bf74f8818eb5796f310e3b65a73851b7cbab35834e14fe0262ac1
4
+ data.tar.gz: 6ae73d252d69d3730c1e1814d441800fca08177a4401abc3e4dcbe6edcd34548
5
5
  SHA512:
6
- metadata.gz: f4c3c1003793b47a99413aed446f0935da5a866819336c6b58e02f5b90ae1d69eeefae6b8ea92a8460fda3fcd89843faeeadf9824a4ba5502dd7ca5539a390d5
7
- data.tar.gz: dccb23acdc4a580e3b319e17f8d0deeac981be86482c9426b5306c40dbed0691b0308bb5300a764830bb3832700b34db0f99beb98796992521294f295623bcda
6
+ metadata.gz: 2b0965214a99209e4830ed68da162cc83d249188e197d8b9fe11b61356f797e2172a648aef2b23f83f9ac45db40d9f4e7daee9471f67069e61998e5f24a8d0ba
7
+ data.tar.gz: e0d546f8a60606a918aadf1743848b9839703328bf14a05dcb67f680886ee2eef2c4ae542d89055daf822f74200f64a719e77f1223520266aa7d6703b18ccb03
@@ -3,4 +3,6 @@ language: ruby
3
3
  cache: bundler
4
4
  rvm:
5
5
  - 2.6.6
6
+ - 2.7.2
7
+ - 3.0.0
6
8
  before_install: gem install bundler -v 2.1.4
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.2.0 - 2021-01-20
4
+
5
+ - [73eb326](https://github.com/tcollier/whirled_peas/tree/73eb326426f9814e91e3bc7a60dfd87be3d69f7e): Convert "primitive" data types to strings
6
+ - [f28b69d](https://github.com/tcollier/whirled_peas/tree/f28b69df8b6cfc973da2ebc0b8da29b278f23433): Give elements names
7
+ - [1ae1c929](https://github.com/tcollier/whirled_peas/tree/1ae1c929429c2f8520054d33a064c2b6d71955fe): Consistently format exceptions in logs
8
+ - [4c2114f](https://github.com/tcollier/whirled_peas/tree/4c2114fd360fd98c65e6e32f905a377f09b919ee): Fix bug with negative sleep times
9
+ - [627bf12](https://github.com/tcollier/whirled_peas/tree/627bf126dd7f9c845f65105e0826d14a35a0a953): Don't reraise pipe error
10
+
3
11
  ## v0.1.1 - 2021-01-20
4
12
 
5
13
  - [3852c8c](https://github.com/tcollier/whirled_peas/tree/3852c8c700c2e8fb92e65bbca1c99be74304c6d0): Improve error handling
data/Gemfile CHANGED
@@ -6,3 +6,4 @@ gemspec
6
6
  gem 'rake', '~> 12.0'
7
7
  gem 'rspec', '~> 3.0'
8
8
  gem 'pry-byebug'
9
+ gem 'tty-cursor', '~> 0.7'
data/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ [![Build Status](https://travis-ci.com/tcollier/whirled_peas.svg?branch=main)](https://travis-ci.com/tcollier/whirled_peas)
2
+
1
3
  # WhirledPeas
2
4
 
3
5
  Visualize your code's execution with Whirled Peas!
@@ -37,7 +39,7 @@ end
37
39
 
38
40
  class Driver
39
41
  def start(producer)
40
- producer.send('starting', args: { 'name' => 'World' })
42
+ producer.send_frame('starting', args: { 'name' => 'World' })
41
43
  # ...
42
44
  end
43
45
  end
@@ -70,9 +72,8 @@ The producer provides a single method
70
72
  #
71
73
  # @param name [String] application defined name for the frame. The template factory will be provided this name
72
74
  # @param duration [Number] time in seconds this frame should be displayed for (defaults to 1 frame)
73
- # @param args [Hash] key value pairs to send as arguments to the template factory, these values will be
74
- # serialized/deserialized
75
- def send(name, duration:, args:)
75
+ # @param args [Hash] key value pairs to send as arguments to the template factory
76
+ def send_frame(name, duration:, args:)
76
77
  # implementation
77
78
  end
78
79
  ```
@@ -85,25 +86,25 @@ Simple application that loads a set of numbers and looks for a pair that adds up
85
86
  class Driver
86
87
  def start(producer)
87
88
  numbers = File.readlines('/path/to/numbers.txt').map(&:to_i)
88
- producer.send('load-numbers', duration: 3, args: { numbers: numbers })
89
+ producer.send_frame('load-numbers', duration: 3, args: { numbers: numbers })
89
90
  numbers.sort!
90
- producer.send('sort-numbers', duration: 3, args: { numbers: numbers })
91
+ producer.send_frame('sort-numbers', duration: 3, args: { numbers: numbers })
91
92
  low = 0
92
93
  high = numbers.length - 1
93
94
  while low < high
94
95
  sum = numbers[low] + numbers[high]
95
96
  if sum == 1000
96
- producer.send('found-pair', duration: 5, args: { low: low, high: high, sum: sum })
97
+ producer.send_frame('found-pair', duration: 5, args: { low: low, high: high, sum: sum })
97
98
  return
98
99
  elsif sum < 1000
99
- producer.send('too-low', args: { low: low, high: high, sum: sum })
100
+ producer.send_frame('too-low', args: { low: low, high: high, sum: sum })
100
101
  low += 1
101
102
  else
102
- producer.send('too-high', args: { low: low, high: high, sum: sum })
103
+ producer.send_frame('too-high', args: { low: low, high: high, sum: sum })
103
104
  high -= 1
104
105
  end
105
106
  end
106
- producer.send('no-solution', duration: 5)
107
+ producer.send_frame('no-solution', duration: 5)
107
108
  end
108
109
  end
109
110
  ```
@@ -116,7 +117,7 @@ To render the frame events sent by the driver, the application requires a templa
116
117
 
117
118
  A template is created with `WhirledPeas.template`, which yields a `Template` object and `TemplateSettings`. This template object is a `ComposableElement`, which allows for attaching child elements and setting layout options. `GridElement` and `BoxElement` are two other composable elements and `TextElement` is a simple element that can hold a text/number value and has layout options, but cannot have any child elements.
118
119
 
119
- A `ComposableElement` provides the following methods to add child elements
120
+ A `ComposableElement` provides the following methods to add child elements, each of these takes an optional string argument that is set as the name of the element (which can be useful when debugging).
120
121
 
121
122
  - `add_box` - yields a `ComposableElement` and a `BoxSettings`, which will be added to the parent's children
122
123
  - `add_grid` - yields a `ComposableElement` and a `GridSettings`, which will be added to the parent's children
@@ -202,15 +203,16 @@ The available settigs are
202
203
 
203
204
  Margin and padding settings allow for setting the spacing on each of the 4 sides of the element independently. The set these values, use
204
205
 
206
+ - `clear_margin` - sets all margin values to 0
205
207
  - `set_margin(left:, top:, right:, bottom:)`
208
+ - `clear_padding` - sets all margin values to 0
206
209
  - `set_padding(left:, top:, right:, bottom:)`
207
210
 
208
- Any argument value not provided will result in that value being 0.
209
-
210
211
  ##### Border
211
212
 
212
213
  The border settings consist of 6 boolean values (border are either width 1 or not shown), the 4 obvious values (`left`, `top`, `right`, and `bottom`) along with 2 other values for inner borders (`inner_horiz` and `inner_vert`) in a grid. A border also has a foreground color (defaults to `:white`) and a style. The background color is determined by the `bg_color` of the element. Border values can be set with
213
214
 
215
+ - `clear_border` - sets all border positions to `false`
214
216
  - `set_border(left:, top:, right:, bottom:, inner_horiz:, inner_vert:, color:, style:)`
215
217
 
216
218
  Available border styles are
@@ -309,7 +311,7 @@ class TemplateFactory
309
311
  def build(frame, args)
310
312
  set_state(frame, args)
311
313
  WhirledPeas.template do |t|
312
- t.add_box(&method(:body))
314
+ t.add_box('Body', &method(:body))
313
315
  end
314
316
  end
315
317
 
@@ -317,10 +319,10 @@ class TemplateFactory
317
319
 
318
320
  def set_state(frame, args)
319
321
  @frame = frame
320
- @numbers = args.key?('numbers') ? args['numbers'] || []
321
- @sum = args['sum'] if args.key?('sum')
322
- @low = args['low'] if args.key?('low')
323
- @high = args['high'] if args.key?('high')
322
+ @numbers = args.key?(:numbers) ? args[:numbers] || []
323
+ @sum = args[:sum] if args.key?(:sum)
324
+ @low = args[:low] if args.key?(:low)
325
+ @high = args[:high] if args.key?(:high)
324
326
  end
325
327
 
326
328
  def title(_elem, settings)
@@ -347,9 +349,9 @@ class TemplateFactory
347
349
  settings.flow = :l2r
348
350
  settings.auto_margin = true
349
351
 
350
- elem.add_box(&method(:title))
351
- elem.add_box(&method(:sum))
352
- elem.add_grid(&method(:number_grid))
352
+ elem.add_box('Title', &method(:title))
353
+ elem.add_box('Sum', &method(:sum))
354
+ elem.add_grid('NumberGrid', &method(:number_grid))
353
355
  end
354
356
  end
355
357
  ```
@@ -7,13 +7,11 @@ require 'whirled_peas/version'
7
7
  module WhirledPeas
8
8
  class Error < StandardError; end
9
9
 
10
- DEFAULT_HOST = 'localhost'
11
- DEFAULT_PORT = 8765
12
10
  DEFAULT_REFRESH_RATE = 30
13
11
 
14
12
  LOGGER_ID = 'MAIN'
15
13
 
16
- def self.start(driver, template_factory, log_level: Logger::INFO, refresh_rate: DEFAULT_REFRESH_RATE, host: DEFAULT_HOST, port: DEFAULT_PORT)
14
+ def self.start(driver, template_factory, log_level: Logger::INFO, refresh_rate: DEFAULT_REFRESH_RATE)
17
15
  logger = Logger.new(File.open('whirled_peas.log', 'a'))
18
16
  logger.level = log_level
19
17
  logger.formatter = proc do |severity, datetime, progname, msg|
@@ -23,27 +21,23 @@ module WhirledPeas
23
21
  "[#{severity}] #{datetime.strftime('%Y-%m-%dT%H:%M:%S.%L')} (#{progname}) - #{msg}\n"
24
22
  end
25
23
 
26
- consumer = Frame::Consumer.new(template_factory, refresh_rate, logger)
27
- consumer_thread = Thread.new do
24
+ event_loop = Frame::EventLoop.new(template_factory, refresh_rate, logger)
25
+ event_loop_thread = Thread.new do
28
26
  Thread.current.report_on_exception = false
29
- consumer.start(host: host, port: port)
27
+ event_loop.start
30
28
  end
31
29
 
32
- Frame::Producer.start(logger: logger, host: host, port: port) do |producer|
30
+ Frame::Producer.produce(event_loop: event_loop, logger: logger) do |producer|
33
31
  begin
34
32
  driver.start(producer)
35
- producer.stop
36
- rescue Errno::EPIPE
37
- logger.error(LOGGER_ID) { 'Producer cannot connect to consumer, exiting...' }
38
33
  rescue => e
39
34
  logger.warn(LOGGER_ID) { 'Driver exited with error, terminating producer...' }
40
35
  logger.error(LOGGER_ID) { e }
41
- producer.terminate
42
36
  raise
43
37
  end
44
38
  end
45
39
 
46
- consumer_thread.join
40
+ event_loop_thread.join
47
41
  end
48
42
 
49
43
  def self.template(&block)
@@ -1,13 +1,10 @@
1
- require_relative 'frame/consumer'
1
+ require_relative 'frame/event_loop'
2
2
  require_relative 'frame/producer'
3
3
 
4
4
  module WhirledPeas
5
5
  module Frame
6
6
  TERMINATE = '__term__'
7
7
  EOF = '__EOF__'
8
-
9
- DEFAULT_ADDRESS = 'localhost'
10
- DEFAULT_PORT = 8765
11
8
  end
12
9
 
13
10
  private_constant :Frame
@@ -8,7 +8,7 @@ module WhirledPeas
8
8
  @logger = logger
9
9
  end
10
10
 
11
- def enqueue(name, duration, args)
11
+ def enqueue(name:, duration:, args:)
12
12
  queue.push([name, duration, args])
13
13
  end
14
14
 
@@ -55,6 +55,5 @@ module WhirledPeas
55
55
 
56
56
  attr_reader :template_factory, :queue, :refresh_rate, :logger
57
57
  end
58
- private_constant :EventLoop
59
58
  end
60
59
  end
@@ -6,58 +6,45 @@ module WhirledPeas
6
6
  class Producer
7
7
  LOGGER_ID = 'PRODUCER'
8
8
 
9
- def self.start(logger: NullLogger.new, host:, port:, &block)
10
- server = TCPServer.new(host, port)
11
- client = server.accept
12
- logger.info(LOGGER_ID) { "Connected to #{host}:#{port}" }
13
- producer = new(client, logger)
9
+ def self.produce(event_loop:, logger: NullLogger.new)
10
+ producer = new(event_loop, logger)
11
+ logger.info(LOGGER_ID) { 'Starting' }
14
12
  yield producer
13
+ logger.info(LOGGER_ID) { 'Done with yield' }
14
+ producer.send_frame(Frame::EOF)
15
15
  logger.info(LOGGER_ID) { 'Exited normally' }
16
16
  rescue => e
17
- producer.terminate
17
+ producer.send_frame(Frame::TERMINATE)
18
18
  logger.warn(LOGGER_ID) { 'Exited with error' }
19
19
  logger.error(LOGGER_ID) { e }
20
20
  raise
21
- ensure
22
- if client
23
- logger.info(LOGGER_ID) { 'Closing connection'}
24
- client.close
25
- end
26
21
  end
27
22
 
28
- def initialize(client, logger=NullLogger.new)
29
- @client = client
23
+ def initialize(event_loop, logger=NullLogger.new)
24
+ @event_loop = event_loop
30
25
  @logger = logger
31
26
  @queue = Queue.new
32
27
  end
33
28
 
34
- def send(name, duration: nil, args: {})
35
- client.puts(JSON.generate('name' => name, 'duration' => duration, **args))
29
+ def send_frame(name, duration: nil, args: {})
30
+ event_loop.enqueue(name: name, duration: duration, args: args)
36
31
  logger.debug(LOGGER_ID) { "Sending frame: #{name}" }
37
32
  end
38
33
 
39
- def enqueue(name, duration: nil, args: {})
34
+ def enqueue_frame(name, duration: nil, args: {})
40
35
  queue.push([name, duration, args])
41
36
  end
42
37
 
43
38
  def flush
44
39
  while !queue.empty?
45
40
  name, duration, args = queue.pop
46
- send(name, duration: duration, args: args)
41
+ send_frame(name: name, duration: duration, args: args)
47
42
  end
48
43
  end
49
44
 
50
- def stop
51
- send(Frame::EOF)
52
- end
53
-
54
- def terminate
55
- send(Frame::TERMINATE)
56
- end
57
-
58
45
  private
59
46
 
60
- attr_reader :client, :logger, :queue
47
+ attr_reader :event_loop, :logger
61
48
  end
62
49
  end
63
50
  end
@@ -1,11 +1,17 @@
1
1
  module WhirledPeas
2
2
  module UI
3
- DEBUG_COLOR = ARGV.include?('--debug-color')
4
-
3
+ # Helper module for working with ANSI escape codes. The most useful ANSI escape codes
4
+ # relate to text formatting.
5
+ #
6
+ # @see https://en.wikipedia.org/wiki/ANSI_escape_code
5
7
  module Ansi
8
+ ESC = "\033"
9
+
10
+ # Text formatting constants
6
11
  BOLD = 1
7
12
  UNDERLINE = 4
8
13
 
14
+ # Text and background color constants
9
15
  BLACK = 30
10
16
  RED = 31
11
17
  GREEN = 32
@@ -16,8 +22,26 @@ module WhirledPeas
16
22
  WHITE = 37
17
23
 
18
24
  END_FORMATTING = 0
25
+ private_constant :END_FORMATTING
19
26
 
20
27
  class << self
28
+ def cursor_pos(top: 0, left: 0)
29
+ "#{ESC}[#{top + 1};#{left + 1}H"
30
+ end
31
+
32
+ def cursor_visible(visible)
33
+ visible ? "#{ESC}[?25h" : "#{ESC}[?25l"
34
+ end
35
+
36
+ def clear_down
37
+ "#{ESC}[J"
38
+ end
39
+
40
+ # Format the string with the ANSI escapes codes for the given integer codes
41
+ #
42
+ # @param str [String] the string to format
43
+ # @param codes [Array<Integer>] the integer part of the ANSI escape code (see
44
+ # constants in this module for codes and meanings)
21
45
  def format(str, codes)
22
46
  if str.empty? || codes.length == 0
23
47
  str
@@ -31,124 +55,49 @@ module WhirledPeas
31
55
  esc_seq(END_FORMATTING)
32
56
  end
33
57
 
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/)
58
+ # If the string has unclosed formatting, add the end formatting characters to
59
+ # the end of the string
60
+ def close_formatting(str)
61
+ codes = str.scan(/#{ESC}\[(\d+)m/)
43
62
  if codes.length > 0 && codes.last[0] != END_FORMATTING.to_s
44
- "#{line}#{esc_seq(END_FORMATTING)}"
63
+ "#{str}#{esc_seq(END_FORMATTING)}"
45
64
  else
46
- line
65
+ str
47
66
  end
48
67
  end
49
68
 
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
69
+ # Return a substring of the input string that preservse the formatting
70
+ #
71
+ # @param str [String] the (possibly formatted) string
72
+ # @param first_visible_character [Integer] the index of the first character to
73
+ # include in the substring (ignoring all hidden formatting characters)
74
+ # @param num_visible_chars [Integer] the maximum number of visible characters to
75
+ # include in the substring (ignoring all hidden formatting characters)
76
+ def substring(str, first_visible_character, num_visible_chars)
77
+ substr = ''
78
+ is_visible = true
79
+ visible_index = 0
80
+ substr_visible_len = 0
55
81
  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
82
+ in_substring = (visible_index >= first_visible_character)
83
+ is_visible = false if is_visible && char == ESC
84
+ visible_index += 1 if is_visible
85
+ if !is_visible || in_substring
86
+ substr += char
87
+ substr_visible_len += 1 if is_visible
88
+ end
89
+ is_visible = true if !is_visible && char == 'm'
90
+ break if substr_visible_len == num_visible_chars
61
91
  end
62
- close_formatting(result)
92
+ close_formatting(substr)
63
93
  end
64
94
 
65
95
  private
66
96
 
67
97
  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
98
+ "#{ESC}[#{code}m"
96
99
  end
97
100
  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
101
  end
153
102
  end
154
103
  end
@@ -1,8 +1,39 @@
1
- require_relative 'stroke'
1
+ require_relative 'ansi'
2
2
 
3
3
  module WhirledPeas
4
4
  module UI
5
+ # Canvas represent the area of the screen a painter can paint on.
5
6
  class Canvas
7
+ # A Stroke is a single line, formatted string of characters that is painted at
8
+ # a given position on a Canvas. This class is not meant to be instantiated
9
+ # directly. Instead, use Canvas#stroke to create a new Stroke.
10
+ class Stroke
11
+ attr_reader :left, :top, :chars
12
+
13
+ def initialize(left, top, chars)
14
+ @left = left
15
+ @top = top
16
+ @chars = chars
17
+ end
18
+
19
+ def hash
20
+ [left, top, chars].hash
21
+ end
22
+
23
+ def ==(other)
24
+ other.is_a?(self.class) && self.hash == other.hash
25
+ end
26
+
27
+ def inspect
28
+ "Stroke(left=#{left}, top=#{top}, chars=#{chars})"
29
+ end
30
+
31
+ alias_method :eq?, :==
32
+ end
33
+ private_constant :Stroke
34
+
35
+ EMPTY_STROKE = Stroke.new(nil, nil, nil)
36
+
6
37
  attr_reader :left, :top, :width, :height
7
38
 
8
39
  def initialize(left, top, width, height)
@@ -12,18 +43,20 @@ module WhirledPeas
12
43
  @height = height
13
44
  end
14
45
 
46
+ # Return a new Stroke instance, verifying only characters within the canvas
47
+ # are included in the stroke.
15
48
  def stroke(left, top, chars)
16
49
  if left >= self.left + self.width || left + chars.length <= self.left
17
- Stroke::EMPTY
50
+ EMPTY_STROKE
18
51
  elsif top < self.top || top >= self.top + self.height
19
- Stroke::EMPTY
52
+ EMPTY_STROKE
20
53
  else
21
54
  if left < self.left
22
55
  chars = chars[self.left - left..-1]
23
56
  left = self.left
24
57
  end
25
58
  num_chars = [self.left + self.width, left + chars.length].min - left
26
- Stroke.new(left, top, Ansi.first(chars, num_chars))
59
+ Stroke.new(left, top, Ansi.substring(chars, 0, num_chars))
27
60
  end
28
61
  end
29
62
 
@@ -0,0 +1,101 @@
1
+ require_relative 'ansi'
2
+
3
+ module WhirledPeas
4
+ module UI
5
+ # An abstract class that encapsulates colors for a specific use case
6
+ class Color
7
+ # The ANSI codes for bright colors are offset by this much from their
8
+ # standard versions
9
+ BRIGHT_OFFSET = 60
10
+ private_constant :BRIGHT_OFFSET
11
+
12
+ # Validate the `color` argument is either (1) nil, (2) a valid Color constant in
13
+ # this class or (3) a symbol that maps to valid Color constant. E.g. if there
14
+ # is a RED constant in an implementing class, then :red or :bright_red are
15
+ # valid values for `color`
16
+ #
17
+ # @param color [Color|Symbol]
18
+ # @return [Color|Symbol] the value passed in if valid, otherwise an ArgumentError
19
+ # is raised.
20
+ def self.validate!(color)
21
+ return unless color
22
+ if color.is_a?(Symbol)
23
+ error_message = "Unsupported #{self.name.split('::').last}: #{color.inspect}"
24
+ match = color.to_s.match(/^(bright_)?(\w+)$/)
25
+ begin
26
+ color = self.const_get(match[2].upcase)
27
+ raise ArgumentError, error_message unless color.is_a?(Color)
28
+ if match[1]
29
+ raise ArgumentError, error_message if color.bright?
30
+ color.bright
31
+ else
32
+ color
33
+ end
34
+ rescue NameError
35
+ raise ArgumentError, error_message
36
+ end
37
+ else
38
+ color
39
+ end
40
+ end
41
+
42
+ def initialize(code, bright=false)
43
+ @code = code
44
+ @bright = bright
45
+ end
46
+
47
+ def bright?
48
+ @bright
49
+ end
50
+
51
+ def bright
52
+ bright? ? self : self.class.new(@code + BRIGHT_OFFSET, true)
53
+ end
54
+
55
+ def hash
56
+ [@code, @bright].hash
57
+ end
58
+
59
+ def ==(other)
60
+ other.is_a?(self.class) && self.hash == other.hash
61
+ end
62
+ alias_method :eq?, :==
63
+
64
+ def to_s
65
+ @code.to_s
66
+ end
67
+
68
+ def inspect
69
+ "#{self.class.name.split('::').last}(code=#{@code}, bright=#{@bright})"
70
+ end
71
+ end
72
+ private_constant :Color
73
+
74
+ class BgColor < Color
75
+ BG_OFFSET = 10
76
+ private_constant :BG_OFFSET
77
+
78
+ BLACK = new(Ansi::BLACK + BG_OFFSET)
79
+ RED = new(Ansi::RED + BG_OFFSET)
80
+ GREEN = new(Ansi::GREEN + BG_OFFSET)
81
+ YELLOW = new(Ansi::YELLOW + BG_OFFSET)
82
+ BLUE = new(Ansi::BLUE + BG_OFFSET)
83
+ MAGENTA = new(Ansi::MAGENTA + BG_OFFSET)
84
+ CYAN = new(Ansi::CYAN + BG_OFFSET)
85
+ WHITE = new(Ansi::WHITE + BG_OFFSET)
86
+ GRAY = BLACK.bright
87
+ end
88
+
89
+ class TextColor < Color
90
+ BLACK = new(Ansi::BLACK)
91
+ RED = new(Ansi::RED)
92
+ GREEN = new(Ansi::GREEN)
93
+ YELLOW = new(Ansi::YELLOW)
94
+ BLUE = new(Ansi::BLUE)
95
+ MAGENTA = new(Ansi::MAGENTA)
96
+ CYAN = new(Ansi::CYAN)
97
+ WHITE = new(Ansi::WHITE)
98
+ GRAY = BLACK.bright
99
+ end
100
+ end
101
+ end
@@ -205,7 +205,6 @@ module WhirledPeas
205
205
  @row_height
206
206
  end
207
207
 
208
-
209
208
  def preferred_width
210
209
  margin_width +
211
210
  outer_border_width +
@@ -38,8 +38,8 @@ module WhirledPeas
38
38
 
39
39
  def justified
40
40
  format_settings = [*text.settings.color, *text.settings.bg_color]
41
- format_settings << TextFormat::BOLD if text.settings.bold?
42
- format_settings << TextFormat::UNDERLINE if text.settings.underline?
41
+ format_settings << Ansi::BOLD if text.settings.bold?
42
+ format_settings << Ansi::UNDERLINE if text.settings.underline?
43
43
 
44
44
  ljust = case text.settings.align
45
45
  when TextAlign::LEFT
@@ -1,5 +1,4 @@
1
1
  require 'highline'
2
- require 'tty-cursor'
3
2
 
4
3
  require_relative 'ansi'
5
4
  require_relative 'painter'
@@ -10,7 +9,6 @@ module WhirledPeas
10
9
  def initialize(print_output=true)
11
10
  @print_output = print_output
12
11
  @terminal = HighLine.new.terminal
13
- @cursor = TTY::Cursor
14
12
  @strokes = []
15
13
  refresh_size!
16
14
  Signal.trap('SIGWINCH', proc { self.refresh_size! })
@@ -26,10 +24,10 @@ module WhirledPeas
26
24
  end
27
25
 
28
26
  def refresh
29
- strokes = [cursor.hide, cursor.move_to(0, 0), cursor.clear_screen_down]
27
+ strokes = [Ansi.cursor_visible(false), Ansi.cursor_pos, Ansi.clear_down]
30
28
  Painter.paint(@template, Canvas.new(0, 0, width, height)) do |stroke|
31
29
  unless stroke.chars.nil?
32
- strokes << cursor.move_to(stroke.left, stroke.top)
30
+ strokes << Ansi.cursor_pos(left: stroke.left, top: stroke.top)
33
31
  strokes << stroke.chars
34
32
  end
35
33
  end
@@ -42,9 +40,9 @@ module WhirledPeas
42
40
 
43
41
  def finalize
44
42
  return unless @print_output
45
- print UI::Ansi.clear
46
- print cursor.move_to(0, height - 1)
47
- print cursor.show
43
+ print Ansi.clear
44
+ print Ansi.cursor_pos(top: height - 1)
45
+ print Ansi.cursor_visible(true)
48
46
  STDOUT.flush
49
47
  end
50
48
 
@@ -1,5 +1,7 @@
1
1
  require 'json'
2
2
 
3
+ require_relative 'color'
4
+
3
5
  module WhirledPeas
4
6
  module UI
5
7
  module TextAlign
@@ -305,11 +307,11 @@ module WhirledPeas
305
307
  end
306
308
  private_constant :ElementSettings
307
309
 
308
- module WidthSetting
310
+ module WidthSettings
309
311
  attr_accessor :width
310
312
  end
311
313
 
312
- module AlignSetting
314
+ module AlignSettings
313
315
  def align
314
316
  @_align || TextAlign::LEFT
315
317
  end
@@ -322,7 +324,7 @@ module WhirledPeas
322
324
  merged = super
323
325
  merged._align = if @_align
324
326
  @_align
325
- elsif parent.is_a?(AlignSetting)
327
+ elsif parent.is_a?(AlignSettings)
326
328
  parent._align
327
329
  end
328
330
  merged
@@ -453,8 +455,8 @@ module WhirledPeas
453
455
  end
454
456
 
455
457
  class TextSettings < ElementSettings
456
- include WidthSetting
457
- include AlignSetting
458
+ include WidthSettings
459
+ include AlignSettings
458
460
  end
459
461
 
460
462
  class ContainerSettings < ElementSettings
@@ -464,8 +466,8 @@ module WhirledPeas
464
466
  end
465
467
 
466
468
  class BoxSettings < ContainerSettings
467
- include WidthSetting
468
- include AlignSetting
469
+ include WidthSettings
470
+ include AlignSettings
469
471
 
470
472
  def flow=(flow)
471
473
  @_flow = DisplayFlow.validate!(flow)
@@ -507,8 +509,8 @@ module WhirledPeas
507
509
  end
508
510
 
509
511
  class GridSettings < ContainerSettings
510
- include WidthSetting
511
- include AlignSetting
512
+ include WidthSettings
513
+ include AlignSettings
512
514
 
513
515
  attr_accessor :num_cols
514
516
  attr_writer :transpose
@@ -1,3 +1,3 @@
1
1
  module WhirledPeas
2
- VERSION = '0.2.0'
2
+ VERSION = '0.3.0'
3
3
  end
@@ -24,5 +24,4 @@ Gem::Specification.new do |spec|
24
24
 
25
25
  spec.add_runtime_dependency 'highline', '~> 2.0'
26
26
  spec.add_runtime_dependency 'json', '~> 2.5'
27
- spec.add_runtime_dependency 'tty-cursor', '~> 0.7'
28
27
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: whirled_peas
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Collier
@@ -38,20 +38,6 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '2.5'
41
- - !ruby/object:Gem::Dependency
42
- name: tty-cursor
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - "~>"
46
- - !ruby/object:Gem::Version
47
- version: '0.7'
48
- type: :runtime
49
- prerelease: false
50
- version_requirements: !ruby/object:Gem::Requirement
51
- requirements:
52
- - - "~>"
53
- - !ruby/object:Gem::Version
54
- version: '0.7'
55
41
  description:
56
42
  email:
57
43
  - tcollier@gmail.com
@@ -72,18 +58,17 @@ files:
72
58
  - bin/setup
73
59
  - lib/whirled_peas.rb
74
60
  - lib/whirled_peas/frame.rb
75
- - lib/whirled_peas/frame/consumer.rb
76
61
  - lib/whirled_peas/frame/event_loop.rb
77
62
  - lib/whirled_peas/frame/producer.rb
78
63
  - lib/whirled_peas/null_logger.rb
79
64
  - lib/whirled_peas/ui.rb
80
65
  - lib/whirled_peas/ui/ansi.rb
81
66
  - lib/whirled_peas/ui/canvas.rb
67
+ - lib/whirled_peas/ui/color.rb
82
68
  - lib/whirled_peas/ui/element.rb
83
69
  - lib/whirled_peas/ui/painter.rb
84
70
  - lib/whirled_peas/ui/screen.rb
85
71
  - lib/whirled_peas/ui/settings.rb
86
- - lib/whirled_peas/ui/stroke.rb
87
72
  - lib/whirled_peas/version.rb
88
73
  - sandbox/auto.rb
89
74
  - sandbox/box.rb
@@ -1,66 +0,0 @@
1
- require 'socket'
2
- require 'json'
3
-
4
- require_relative 'event_loop'
5
-
6
- module WhirledPeas
7
- module Frame
8
- class Consumer
9
- LOGGER_ID = 'CONSUMER'
10
-
11
- def initialize(template_factory, refresh_rate, logger=NullLogger.new)
12
- @event_loop = EventLoop.new(template_factory, refresh_rate, logger)
13
- @logger = logger
14
- @running = false
15
- @mutex = Mutex.new
16
- end
17
-
18
- def start(host:, port:)
19
- mutex.synchronize { @running = true }
20
- loop_thread = Thread.new do
21
- Thread.current.report_on_exception = false
22
- event_loop.start
23
- end
24
- socket = TCPSocket.new(host, port)
25
- logger.info(LOGGER_ID) { "Connected to #{host}:#{port}" }
26
- while @running && event_loop.running?
27
- line = socket.gets
28
- if line.nil?
29
- sleep(0.001)
30
- next
31
- end
32
- args = JSON.parse(line)
33
- name = args.delete('name')
34
- if [Frame::EOF, Frame::TERMINATE].include?(name)
35
- logger.info(LOGGER_ID) { "Received #{name} event, stopping..." }
36
- event_loop.stop if name == Frame::TERMINATE
37
- @running = false
38
- else
39
- duration = args.delete('duration')
40
- event_loop.enqueue(name, duration, args)
41
- end
42
- end
43
- logger.info(LOGGER_ID) { 'Exited normally' }
44
- logger.info(LOGGER_ID) { 'Waiting for loop thread to exit' }
45
- loop_thread.join
46
- rescue => e
47
- event_loop.stop if event_loop.running?
48
- logger.warn(LOGGER_ID) { 'Exited with error' }
49
- logger.error(LOGGER_ID) { e }
50
- raise
51
- ensure
52
- logger.info(LOGGER_ID) { 'Closing socket' }
53
- socket.close if socket
54
- end
55
-
56
- def stop
57
- logger.info(LOGGER_ID) { 'Stopping...' }
58
- mutex.synchronize { @running = false }
59
- end
60
-
61
- private
62
-
63
- attr_reader :event_loop, :logger, :mutex
64
- end
65
- end
66
- end
@@ -1,29 +0,0 @@
1
- module WhirledPeas
2
- module UI
3
- class Stroke
4
- EMPTY = Stroke.new
5
-
6
- attr_reader :left, :top, :chars
7
-
8
- def initialize(left, top, chars)
9
- @left = left
10
- @top = top
11
- @chars = chars
12
- end
13
-
14
- def hash
15
- [left, top, chars].hash
16
- end
17
-
18
- def ==(other)
19
- other.is_a?(self.class) && self.hash == other.hash
20
- end
21
-
22
- def inspect
23
- "Stroke(left=#{left}, top=#{top}, chars=#{chars})"
24
- end
25
-
26
- alias_method :eql?, :==
27
- end
28
- end
29
- end