whirled_peas 0.1.0 → 0.4.1

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.
@@ -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,8 +1,39 @@
1
- require_relative 'stroke'
1
+ require_relative '../utils/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
 
@@ -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,43 +17,45 @@ 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
- format_settings << TextFormat::BOLD if text.settings.bold?
42
- format_settings << TextFormat::UNDERLINE if text.settings.underline?
45
+ format_settings << Ansi::BOLD if text.settings.bold?
46
+ format_settings << Ansi::UNDERLINE if text.settings.underline?
43
47
 
44
48
  ljust = case text.settings.align
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,7 +1,7 @@
1
1
  require 'highline'
2
- require 'tty-cursor'
3
2
 
4
- require_relative 'ansi'
3
+ require_relative '../utils/ansi'
4
+
5
5
  require_relative 'painter'
6
6
 
7
7
  module WhirledPeas
@@ -10,7 +10,6 @@ module WhirledPeas
10
10
  def initialize(print_output=true)
11
11
  @print_output = print_output
12
12
  @terminal = HighLine.new.terminal
13
- @cursor = TTY::Cursor
14
13
  @strokes = []
15
14
  refresh_size!
16
15
  Signal.trap('SIGWINCH', proc { self.refresh_size! })
@@ -18,33 +17,20 @@ module WhirledPeas
18
17
 
19
18
  def paint(template)
20
19
  @template = template
21
- refresh
22
- end
23
-
24
- def needs_refresh?
25
- @refreshed_width != width || @refreshed_height != height
20
+ draw
26
21
  end
27
22
 
28
23
  def refresh
29
- strokes = [cursor.hide, cursor.move_to(0, 0), cursor.clear_screen_down]
30
- Painter.paint(@template, Canvas.new(0, 0, width, height)) do |stroke|
31
- unless stroke.chars.nil?
32
- strokes << cursor.move_to(stroke.left, stroke.top)
33
- strokes << stroke.chars
34
- end
35
- end
36
- return unless @print_output
37
- strokes.each(&method(:print))
38
- STDOUT.flush
39
- @refreshed_width = width
40
- @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
41
27
  end
42
28
 
43
29
  def finalize
44
30
  return unless @print_output
45
- print UI::Ansi.clear
46
- print cursor.move_to(0, height - 1)
47
- print cursor.show
31
+ print Ansi.clear
32
+ print Ansi.cursor_pos(top: height - 1)
33
+ print Ansi.cursor_visible(true)
48
34
  STDOUT.flush
49
35
  end
50
36
 
@@ -57,6 +43,21 @@ module WhirledPeas
57
43
  private
58
44
 
59
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
60
61
  end
61
62
  end
62
63
  end
@@ -0,0 +1,5 @@
1
+ module WhirledPeas
2
+ module Utils
3
+ end
4
+ private_constant :Utils
5
+ end
@@ -0,0 +1,103 @@
1
+ module WhirledPeas
2
+ module UI
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
7
+ module Ansi
8
+ ESC = "\033"
9
+
10
+ # Text formatting constants
11
+ BOLD = 1
12
+ UNDERLINE = 4
13
+
14
+ # Text and background color constants
15
+ BLACK = 30
16
+ RED = 31
17
+ GREEN = 32
18
+ YELLOW = 33
19
+ BLUE = 34
20
+ MAGENTA = 35
21
+ CYAN = 36
22
+ WHITE = 37
23
+
24
+ END_FORMATTING = 0
25
+ private_constant :END_FORMATTING
26
+
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)
45
+ def format(str, codes)
46
+ if str.empty? || codes.length == 0
47
+ str
48
+ else
49
+ start_formatting = codes.map(&method(:esc_seq)).join
50
+ "#{start_formatting}#{str}#{esc_seq(END_FORMATTING)}"
51
+ end
52
+ end
53
+
54
+ def clear
55
+ esc_seq(END_FORMATTING)
56
+ end
57
+
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/)
62
+ if codes.length > 0 && codes.last[0] != END_FORMATTING.to_s
63
+ "#{str}#{esc_seq(END_FORMATTING)}"
64
+ else
65
+ str
66
+ end
67
+ end
68
+
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
81
+ str.chars.each do |char|
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
91
+ end
92
+ close_formatting(substr)
93
+ end
94
+
95
+ private
96
+
97
+ def esc_seq(code)
98
+ "#{ESC}[#{code}m"
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -1,83 +1,26 @@
1
+ require_relative 'ansi'
2
+
1
3
  module WhirledPeas
2
4
  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
-
5
+ # An abstract class that encapsulates colors for a specific use case
73
6
  class Color
7
+ # The ANSI codes for bright colors are offset by this much from their
8
+ # standard versions
74
9
  BRIGHT_OFFSET = 60
75
10
  private_constant :BRIGHT_OFFSET
76
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.
77
20
  def self.validate!(color)
78
21
  return unless color
79
22
  if color.is_a?(Symbol)
80
- error_message = "Unsupported #{self.name.split('::').last}: #{color}"
23
+ error_message = "Unsupported #{self.name.split('::').last}: #{color.inspect}"
81
24
  match = color.to_s.match(/^(bright_)?(\w+)$/)
82
25
  begin
83
26
  color = self.const_get(match[2].upcase)
@@ -109,6 +52,15 @@ module WhirledPeas
109
52
  bright? ? self : self.class.new(@code + BRIGHT_OFFSET, true)
110
53
  end
111
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
+
112
64
  def to_s
113
65
  @code.to_s
114
66
  end
@@ -145,10 +97,5 @@ module WhirledPeas
145
97
  WHITE = new(Ansi::WHITE)
146
98
  GRAY = BLACK.bright
147
99
  end
148
-
149
- module TextFormat
150
- BOLD = Ansi::BOLD
151
- UNDERLINE = Ansi::UNDERLINE
152
- end
153
100
  end
154
101
  end