whirled_peas 0.1.0 → 0.4.1

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