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.
- 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
|