whirled_peas 0.3.0 → 0.4.0

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