whirled_peas 0.3.0 → 0.4.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,21 @@
1
+ module WhirledPeas
2
+ class Config
3
+ attr_writer :driver, :template_factory
4
+ attr_accessor :loading_template_factory
5
+
6
+ def driver
7
+ unless @driver
8
+ raise ConfigurationError, 'driver must be configured'
9
+ end
10
+ @driver
11
+ end
12
+
13
+ def template_factory
14
+ unless @template_factory
15
+ raise ConfigurationError, 'template_factory must be configured'
16
+ end
17
+ @template_factory
18
+ end
19
+ end
20
+ private_constant :Config
21
+ end
@@ -0,0 +1,5 @@
1
+ module WhirledPeas
2
+ class Error < StandardError; end
3
+
4
+ class ConfigurationError < Error; end
5
+ end
@@ -1,9 +1,5 @@
1
- require_relative 'frame/event_loop'
2
- require_relative 'frame/producer'
3
-
4
1
  module WhirledPeas
5
2
  module Frame
6
- TERMINATE = '__term__'
7
3
  EOF = '__EOF__'
8
4
  end
9
5
 
@@ -1,15 +1,22 @@
1
+ require_relative '../null_logger'
2
+ require_relative '../ui/screen'
3
+
1
4
  module WhirledPeas
2
5
  module Frame
3
6
  class EventLoop
4
- def initialize(template_factory, refresh_rate, logger=NullLogger.new)
7
+ LOGGER_ID = 'EVENT LOOP'
8
+
9
+ def initialize(template_factory, loading_template_factory, refresh_rate, logger=NullLogger.new)
5
10
  @template_factory = template_factory
11
+ @loading_template_factory = loading_template_factory
6
12
  @queue = Queue.new
7
- @refresh_rate = refresh_rate
13
+ @frame_duration = 1.0 / refresh_rate
8
14
  @logger = logger
9
15
  end
10
16
 
11
- def enqueue(name:, duration:, args:)
12
- queue.push([name, duration, args])
17
+ def enqueue(name, duration, args)
18
+ # If duration is nil, set it to the duration of a single frame
19
+ queue.push([name, duration || frame_duration, args])
13
20
  end
14
21
 
15
22
  def running?
@@ -17,43 +24,68 @@ module WhirledPeas
17
24
  end
18
25
 
19
26
  def start
20
- logger.info('EVENT LOOP') { 'Starting' }
21
- @running = true
22
27
  screen = UI::Screen.new
23
- sleep(0.01) while queue.empty? # Wait for the first event
24
- remaining_frames = 1
25
- template = nil
26
- while @running && remaining_frames > 0
27
- frame_at = Time.now
28
- next_frame_at = frame_at + 1.0 / refresh_rate
29
- remaining_frames -= 1
30
- if remaining_frames > 0
31
- screen.refresh if screen.needs_refresh?
32
- elsif !queue.empty?
33
- name, duration, args = queue.pop
34
- remaining_frames = duration ? duration * refresh_rate : 1
35
- template = template_factory.build(name, args)
36
- screen.paint(template)
37
- end
38
- sleep([0, next_frame_at - Time.now].max)
39
- end
40
- logger.info('EVENT LOOP') { 'Exiting normally' }
28
+ wait_for_content(screen)
29
+ play_content(screen)
41
30
  rescue
42
- logger.warn('EVENT LOOP') { 'Exiting with error' }
43
- @running = false
31
+ logger.warn(LOGGER_ID) { 'Exiting with error' }
44
32
  raise
45
33
  ensure
34
+ # We may have exited due to an EOF or a raised exception, set state so that
35
+ # instance reflects actual state.
36
+ @running = false
46
37
  screen.finalize if screen
47
38
  end
48
39
 
49
40
  def stop
50
- logger.info('EVENT LOOP') { 'Stopping...' }
41
+ logger.info(LOGGER_ID) { 'Stopping...' }
51
42
  @running = false
52
43
  end
53
44
 
54
45
  private
55
46
 
56
- attr_reader :template_factory, :queue, :refresh_rate, :logger
47
+ attr_reader :template_factory, :loading_template_factory, :queue, :frame_duration, :logger
48
+
49
+ def wait_for_content(screen)
50
+ if loading_template_factory
51
+ play_loading_screen(screen)
52
+ else
53
+ sleep(frame_duration) while queue.empty?
54
+ end
55
+ end
56
+
57
+ def play_loading_screen(screen)
58
+ while queue.empty?
59
+ screen.paint(loading_template_factory.build)
60
+ sleep(frame_duration)
61
+ end
62
+ end
63
+
64
+ def play_content(screen)
65
+ @running = true
66
+ template = nil
67
+ frame_until = Time.new(0) # Tell the loop to immediately pick up a new frame
68
+ while running?
69
+ frame_start = Time.now
70
+ next_frame_at = frame_start + frame_duration
71
+ if frame_until > frame_start
72
+ # While we're still displaying the previous frame, refresh the screen
73
+ screen.refresh
74
+ elsif !queue.empty?
75
+ name, duration, args = queue.pop
76
+ if name == Frame::EOF
77
+ @running = false
78
+ else
79
+ frame_until = frame_start + duration
80
+ template = template_factory.build(name, args)
81
+ screen.paint(template)
82
+ end
83
+ else
84
+ wait_for_content(screen)
85
+ end
86
+ sleep([0, next_frame_at - Time.now].max)
87
+ end
88
+ end
57
89
  end
58
90
  end
59
91
  end
@@ -0,0 +1,33 @@
1
+ require_relative '../null_logger'
2
+
3
+ module WhirledPeas
4
+ module Frame
5
+ class PrintConsumer
6
+ LOGGER_ID = 'EVENT LOOP'
7
+
8
+ def initialize(logger=NullLogger.new)
9
+ @logger = logger
10
+ end
11
+
12
+ def enqueue(name, duration, args)
13
+ if name == Frame::EOF
14
+ puts "EOF frame detected"
15
+ else
16
+ displayed_for = duration ? "#{duration} second(s)" : '1 frame'
17
+ args_str = args.empty? ? '' : " (#{args.inspect})"
18
+ puts "Frame '#{name}' displayed for #{displayed_for}#{args_str}"
19
+ end
20
+ end
21
+
22
+ def start
23
+ end
24
+
25
+ def stop
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :logger
31
+ end
32
+ end
33
+ end
@@ -1,50 +1,61 @@
1
1
  require 'socket'
2
2
  require 'json'
3
3
 
4
+ require_relative '../null_logger'
5
+
4
6
  module WhirledPeas
5
7
  module Frame
6
8
  class Producer
7
9
  LOGGER_ID = 'PRODUCER'
8
10
 
9
- def self.produce(event_loop:, logger: NullLogger.new)
10
- producer = new(event_loop, logger)
11
- logger.info(LOGGER_ID) { 'Starting' }
11
+ # Manages the EventLoop lifecycle and yields a Producer to send frames to the
12
+ # EventLoop
13
+ def self.produce(consumer, logger=NullLogger.new)
14
+ producer = new(consumer, logger)
15
+ consumer_thread = Thread.new do
16
+ Thread.current.report_on_exception = false
17
+ consumer.start
18
+ end
12
19
  yield producer
13
- logger.info(LOGGER_ID) { 'Done with yield' }
14
20
  producer.send_frame(Frame::EOF)
15
- logger.info(LOGGER_ID) { 'Exited normally' }
21
+ producer.flush
16
22
  rescue => e
17
- producer.send_frame(Frame::TERMINATE)
23
+ consumer.stop if consumer
18
24
  logger.warn(LOGGER_ID) { 'Exited with error' }
19
25
  logger.error(LOGGER_ID) { e }
20
26
  raise
27
+ ensure
28
+ consumer_thread.join if consumer_thread
21
29
  end
22
30
 
23
- def initialize(event_loop, logger=NullLogger.new)
24
- @event_loop = event_loop
31
+ def initialize(consumer, logger=NullLogger.new)
32
+ @consumer = consumer
25
33
  @logger = logger
26
34
  @queue = Queue.new
27
35
  end
28
36
 
37
+ # Buffer a frame to be played for the given duration. `#flush` must be called
38
+ # for frames to get pushed to the EventLoop.
39
+ #
40
+ # @param name [String] name of frame, which is passed to #build of the
41
+ # TemplateFactory
42
+ # @param duration [Float|Integer] duration in seconds the frame should be,
43
+ # displayed (default is nil, which results in a duration of a single refresh
44
+ # cycle)
45
+ # @param args [Hash] key/value pair of arguments, which is passed to #build of
46
+ # the TemplateFactory
29
47
  def send_frame(name, duration: nil, args: {})
30
- event_loop.enqueue(name: name, duration: duration, args: args)
31
- logger.debug(LOGGER_ID) { "Sending frame: #{name}" }
32
- end
33
-
34
- def enqueue_frame(name, duration: nil, args: {})
35
48
  queue.push([name, duration, args])
36
49
  end
37
50
 
51
+ # Send any buffered frames to the EventLoop
38
52
  def flush
39
- while !queue.empty?
40
- name, duration, args = queue.pop
41
- send_frame(name: name, duration: duration, args: args)
42
- end
53
+ consumer.enqueue(*queue.pop) while !queue.empty?
43
54
  end
44
55
 
45
56
  private
46
57
 
47
- attr_reader :event_loop, :logger
58
+ attr_reader :consumer, :logger, :queue
48
59
  end
49
60
  end
50
61
  end
@@ -0,0 +1,5 @@
1
+ module WhirledPeas
2
+ module Template
3
+ end
4
+ private_constant :Template
5
+ end
@@ -1,3 +1,5 @@
1
+ require_relative '../utils/title_font'
2
+
1
3
  require_relative 'settings'
2
4
 
3
5
  module WhirledPeas
@@ -16,19 +18,23 @@ module WhirledPeas
16
18
  private_constant :Element
17
19
 
18
20
  class TextElement < Element
19
- attr_reader :value
21
+ attr_reader :lines
20
22
 
21
23
  def initialize(name, settings)
22
24
  super(name, TextSettings.merge(settings))
23
25
  end
24
26
 
25
- def value=(val)
27
+ def lines=(val)
26
28
  unless STRINGALBE_CLASSES.include?(val.class)
27
29
  raise ArgmentError, "Unsupported type for TextElement: #{val.class}"
28
30
  end
29
- @value = val.to_s
30
- @preferred_width = settings.width || value.length
31
- @preferred_height = 1
31
+ if settings.title_font
32
+ @lines = Utils::TitleFont.to_s(val.to_s, settings.title_font).split("\n")
33
+ else
34
+ @lines = [val.to_s]
35
+ end
36
+ @preferred_width = settings.width || @lines.first.length
37
+ @preferred_height = @lines.length
32
38
  end
33
39
 
34
40
  def inspect(indent='')
@@ -63,7 +69,7 @@ module WhirledPeas
63
69
 
64
70
  def add_text(name=self.class.next_name, &block)
65
71
  element = TextElement.new(name, settings)
66
- element.value = yield nil, element.settings
72
+ element.lines = yield nil, element.settings
67
73
  children << element
68
74
  end
69
75
 
@@ -1,6 +1,7 @@
1
1
  require 'json'
2
2
 
3
- require_relative 'color'
3
+ require_relative '../utils/color'
4
+ require_relative '../utils/title_font'
4
5
 
5
6
  module WhirledPeas
6
7
  module UI
@@ -457,6 +458,12 @@ module WhirledPeas
457
458
  class TextSettings < ElementSettings
458
459
  include WidthSettings
459
460
  include AlignSettings
461
+
462
+ attr_reader :title_font
463
+
464
+ def title_font=(font)
465
+ @title_font = Utils::TitleFont.validate!(font)
466
+ end
460
467
  end
461
468
 
462
469
  class ContainerSettings < ElementSettings
@@ -1,7 +1,5 @@
1
- require_relative 'ui/element'
2
- require_relative 'ui/screen'
3
-
4
1
  module WhirledPeas
5
2
  module UI
6
3
  end
4
+ private_constant :UI
7
5
  end
@@ -1,4 +1,4 @@
1
- require_relative 'ansi'
1
+ require_relative '../utils/ansi'
2
2
 
3
3
  module WhirledPeas
4
4
  module UI
@@ -1,6 +1,8 @@
1
- require_relative 'ansi'
1
+ require_relative '../template/element'
2
+ require_relative '../template/settings'
3
+ require_relative '../utils/ansi'
4
+
2
5
  require_relative 'canvas'
3
- require_relative 'settings'
4
6
 
5
7
  module WhirledPeas
6
8
  module UI
@@ -15,28 +17,30 @@ module WhirledPeas
15
17
  end
16
18
 
17
19
  def paint(&block)
18
- yield canvas.stroke(canvas.left, canvas.top, justified)
20
+ text.lines.each.with_index do |line, index|
21
+ yield canvas.stroke(canvas.left, canvas.top + index, justified(line))
22
+ end
19
23
  end
20
24
 
21
25
  private
22
26
 
23
27
  attr_reader :text, :canvas
24
28
 
25
- def visible
26
- if text.value.length <= text.preferred_width
27
- text.value
29
+ def visible(line)
30
+ if line.length <= text.preferred_width
31
+ line
28
32
  elsif text.settings.align == TextAlign::LEFT
29
- text.value[0..text.preferred_width - 1]
33
+ line[0..text.preferred_width - 1]
30
34
  elsif text.settings.align == TextAlign::CENTER
31
- left_chop = (text.value.length - text.preferred_width) / 2
32
- right_chop = text.value.length - text.preferred_width - left_chop
33
- text.value[left_chop..-right_chop - 1]
35
+ left_chop = (line.length - text.preferred_width) / 2
36
+ right_chop = line.length - text.preferred_width - left_chop
37
+ line[left_chop..-right_chop - 1]
34
38
  else
35
- text.value[-text.preferred_width..-1]
39
+ line[-text.preferred_width..-1]
36
40
  end
37
41
  end
38
42
 
39
- def justified
43
+ def justified(line)
40
44
  format_settings = [*text.settings.color, *text.settings.bg_color]
41
45
  format_settings << Ansi::BOLD if text.settings.bold?
42
46
  format_settings << Ansi::UNDERLINE if text.settings.underline?
@@ -45,13 +49,13 @@ module WhirledPeas
45
49
  when TextAlign::LEFT
46
50
  0
47
51
  when TextAlign::CENTER
48
- [0, (text.preferred_width - text.value.length) / 2].max
52
+ [0, (text.preferred_width - line.length) / 2].max
49
53
  when TextAlign::RIGHT
50
- [0, text.preferred_width - text.value.length].max
54
+ [0, text.preferred_width - line.length].max
51
55
  end
52
- rjust = [0, text.preferred_width - text.value.length - ljust].max
56
+ rjust = [0, text.preferred_width - line.length - ljust].max
53
57
  Ansi.format(JUSTIFICATION * ljust, [*text.settings.bg_color]) +
54
- Ansi.format(visible, format_settings) +
58
+ Ansi.format(visible(line), format_settings) +
55
59
  Ansi.format(JUSTIFICATION * rjust, [*text.settings.bg_color])
56
60
  end
57
61
  end
@@ -1,6 +1,7 @@
1
1
  require 'highline'
2
2
 
3
- require_relative 'ansi'
3
+ require_relative '../utils/ansi'
4
+
4
5
  require_relative 'painter'
5
6
 
6
7
  module WhirledPeas
@@ -16,26 +17,13 @@ module WhirledPeas
16
17
 
17
18
  def paint(template)
18
19
  @template = template
19
- refresh
20
- end
21
-
22
- def needs_refresh?
23
- @refreshed_width != width || @refreshed_height != height
20
+ draw
24
21
  end
25
22
 
26
23
  def refresh
27
- strokes = [Ansi.cursor_visible(false), Ansi.cursor_pos, Ansi.clear_down]
28
- Painter.paint(@template, Canvas.new(0, 0, width, height)) do |stroke|
29
- unless stroke.chars.nil?
30
- strokes << Ansi.cursor_pos(left: stroke.left, top: stroke.top)
31
- strokes << stroke.chars
32
- end
33
- end
34
- return unless @print_output
35
- strokes.each(&method(:print))
36
- STDOUT.flush
37
- @refreshed_width = width
38
- @refreshed_height = height
24
+ # No need to refresh if the screen dimensions have not changed
25
+ return if @refreshed_width == width || @refreshed_height == height
26
+ draw
39
27
  end
40
28
 
41
29
  def finalize
@@ -55,6 +43,21 @@ module WhirledPeas
55
43
  private
56
44
 
57
45
  attr_reader :cursor, :terminal, :width, :height
46
+
47
+ def draw
48
+ strokes = [Ansi.cursor_visible(false), Ansi.cursor_pos, Ansi.clear_down]
49
+ Painter.paint(@template, Canvas.new(0, 0, width, height)) do |stroke|
50
+ unless stroke.chars.nil?
51
+ strokes << Ansi.cursor_pos(left: stroke.left, top: stroke.top)
52
+ strokes << stroke.chars
53
+ end
54
+ end
55
+ return unless @print_output
56
+ strokes.each(&method(:print))
57
+ STDOUT.flush
58
+ @refreshed_width = width
59
+ @refreshed_height = height
60
+ end
58
61
  end
59
62
  end
60
63
  end