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.
- checksums.yaml +4 -4
- data/.travis.yml +2 -0
- data/CHANGELOG.md +29 -0
- data/Gemfile +1 -0
- data/README.md +213 -48
- data/bin/title_fonts +6 -0
- data/exe/whirled_peas +7 -0
- data/lib/whirled_peas.rb +12 -30
- data/lib/whirled_peas/command_line.rb +270 -0
- data/lib/whirled_peas/config.rb +21 -0
- data/lib/whirled_peas/errors.rb +5 -0
- data/lib/whirled_peas/frame.rb +0 -7
- data/lib/whirled_peas/frame/event_loop.rb +91 -0
- data/lib/whirled_peas/frame/print_consumer.rb +33 -0
- data/lib/whirled_peas/frame/producer.rb +35 -31
- data/lib/whirled_peas/template.rb +5 -0
- data/lib/whirled_peas/template/element.rb +230 -0
- data/lib/whirled_peas/{ui → template}/settings.rb +28 -10
- data/lib/whirled_peas/ui.rb +1 -3
- data/lib/whirled_peas/ui/canvas.rb +37 -4
- data/lib/whirled_peas/ui/painter.rb +22 -18
- data/lib/whirled_peas/ui/screen.rb +24 -23
- data/lib/whirled_peas/utils.rb +5 -0
- data/lib/whirled_peas/utils/ansi.rb +103 -0
- data/lib/whirled_peas/{ui/ansi.rb → utils/color.rb} +23 -76
- data/lib/whirled_peas/utils/title_font.rb +75 -0
- data/lib/whirled_peas/version.rb +1 -1
- data/whirled_peas.gemspec +4 -2
- metadata +22 -18
- data/lib/whirled_peas/frame/consumer.rb +0 -61
- data/lib/whirled_peas/frame/loop.rb +0 -56
- data/lib/whirled_peas/ui/element.rb +0 -199
- data/lib/whirled_peas/ui/stroke.rb +0 -29
- data/sandbox/auto.rb +0 -13
- data/sandbox/box.rb +0 -19
- data/sandbox/grid.rb +0 -13
- data/sandbox/sandbox.rb +0 -17
- data/sandbox/text.rb +0 -33
data/lib/whirled_peas/ui.rb
CHANGED
@@ -1,8 +1,39 @@
|
|
1
|
-
require_relative '
|
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
|
-
|
50
|
+
EMPTY_STROKE
|
18
51
|
elsif top < self.top || top >= self.top + self.height
|
19
|
-
|
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.
|
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 '
|
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
|
-
|
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
|
27
|
-
|
29
|
+
def visible(line)
|
30
|
+
if line.length <= text.preferred_width
|
31
|
+
line
|
28
32
|
elsif text.settings.align == TextAlign::LEFT
|
29
|
-
|
33
|
+
line[0..text.preferred_width - 1]
|
30
34
|
elsif text.settings.align == TextAlign::CENTER
|
31
|
-
left_chop = (
|
32
|
-
right_chop =
|
33
|
-
|
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
|
-
|
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 <<
|
42
|
-
format_settings <<
|
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 -
|
52
|
+
[0, (text.preferred_width - line.length) / 2].max
|
49
53
|
when TextAlign::RIGHT
|
50
|
-
[0, text.preferred_width -
|
54
|
+
[0, text.preferred_width - line.length].max
|
51
55
|
end
|
52
|
-
rjust = [0, text.preferred_width -
|
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
|
-
|
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
|
-
|
30
|
-
|
31
|
-
|
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
|
46
|
-
print
|
47
|
-
print
|
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,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
|
-
|
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
|