tty 0.0.5 → 0.0.6
Sign up to get free protection for your applications and to get access to all the features.
- data/README.md +60 -5
- data/lib/tty/shell/question/modifier.rb +96 -0
- data/lib/tty/shell/question/validation.rb +91 -0
- data/lib/tty/shell/question.rb +304 -0
- data/lib/tty/shell/statement.rb +55 -0
- data/lib/tty/shell.rb +159 -0
- data/lib/tty/table/operation/wrapped.rb +6 -0
- data/lib/tty/terminal/color.rb +143 -0
- data/lib/tty/terminal.rb +46 -21
- data/lib/tty/version.rb +1 -1
- data/lib/tty.rb +19 -1
- data/spec/tty/shell/ask_spec.rb +65 -0
- data/spec/tty/shell/error_spec.rb +28 -0
- data/spec/tty/shell/print_table_spec.rb +25 -0
- data/spec/tty/shell/question/initialize_spec.rb +227 -0
- data/spec/tty/shell/question/modifier/apply_to_spec.rb +30 -0
- data/spec/tty/shell/question/modifier/letter_case_spec.rb +27 -0
- data/spec/tty/shell/question/modifier/whitespace_spec.rb +33 -0
- data/spec/tty/shell/question/validation/coerce_spec.rb +25 -0
- data/spec/tty/shell/question/validation/valid_value_spec.rb +28 -0
- data/spec/tty/shell/say_spec.rb +64 -0
- data/spec/tty/shell/statement/initialize_spec.rb +15 -0
- data/spec/tty/shell/warn_spec.rb +28 -0
- data/spec/tty/table/renderer_spec.rb +0 -1
- data/spec/tty/terminal/color/code_spec.rb +19 -0
- data/spec/tty/terminal/color/remove_spec.rb +12 -0
- data/spec/tty/terminal/color/set_spec.rb +30 -0
- data/spec/tty/terminal/color_spec.rb +15 -0
- data/spec/tty/terminal/home_spec.rb +37 -0
- data/tasks/metrics/reek.rake +1 -3
- metadata +48 -11
- data/lib/tty/color.rb +0 -14
- data/spec/tty/color_spec.rb +0 -5
data/lib/tty/shell.rb
ADDED
@@ -0,0 +1,159 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
|
3
|
+
module TTY
|
4
|
+
|
5
|
+
# A class responsible for shell prompt interactions.
|
6
|
+
class Shell
|
7
|
+
|
8
|
+
# @api private
|
9
|
+
attr_reader :input
|
10
|
+
|
11
|
+
# @api private
|
12
|
+
attr_reader :output
|
13
|
+
|
14
|
+
# Initialize a Shell
|
15
|
+
#
|
16
|
+
# @api public
|
17
|
+
def initialize(input=stdin, output=stdout)
|
18
|
+
@input = input
|
19
|
+
@output = output
|
20
|
+
end
|
21
|
+
|
22
|
+
# Ask a question.
|
23
|
+
#
|
24
|
+
# @example
|
25
|
+
# shell = TTY::Shell.new
|
26
|
+
# shell.ask("What is your name?")
|
27
|
+
#
|
28
|
+
# @param [String] statement
|
29
|
+
# string question to be asked
|
30
|
+
#
|
31
|
+
# @yieldparam [TTY::Question] question
|
32
|
+
# further configure the question
|
33
|
+
#
|
34
|
+
# @yield [question]
|
35
|
+
#
|
36
|
+
# @return [TTY::Question]
|
37
|
+
#
|
38
|
+
# @api public
|
39
|
+
def ask(statement, *args, &block)
|
40
|
+
options = Utils.extract_options!(args)
|
41
|
+
|
42
|
+
question = Question.new self, statement, options
|
43
|
+
question.instance_eval(&block) if block_given?
|
44
|
+
question.prompt(statement)
|
45
|
+
end
|
46
|
+
|
47
|
+
# A shortcut method to ask the user positive question and return
|
48
|
+
# true for 'yes' reply.
|
49
|
+
#
|
50
|
+
# @return [Boolean]
|
51
|
+
#
|
52
|
+
# @api public
|
53
|
+
def yes?(statement, *args, &block)
|
54
|
+
ask(statement, *args, &block).read_bool
|
55
|
+
end
|
56
|
+
|
57
|
+
# A shortcut method to ask the user negative question and return
|
58
|
+
# true for 'no' reply.
|
59
|
+
#
|
60
|
+
# @return [Boolean]
|
61
|
+
#
|
62
|
+
# @api public
|
63
|
+
def no?(statement, *args, &block)
|
64
|
+
!yes?(statement, *args, &block)
|
65
|
+
end
|
66
|
+
|
67
|
+
# Print statement out. If the supplied message ends with a space or
|
68
|
+
# tab character, a new line will not be appended.
|
69
|
+
#
|
70
|
+
# @example
|
71
|
+
# say("Simple things.")
|
72
|
+
#
|
73
|
+
# @param [String] message
|
74
|
+
#
|
75
|
+
# @return [String]
|
76
|
+
#
|
77
|
+
# @api public
|
78
|
+
def say(message="", options={})
|
79
|
+
message = message.to_str
|
80
|
+
return unless message.length > 0
|
81
|
+
|
82
|
+
statement = Statement.new(self, options)
|
83
|
+
statement.declare message
|
84
|
+
end
|
85
|
+
|
86
|
+
# Print statement(s) out in red green.
|
87
|
+
#
|
88
|
+
# @example
|
89
|
+
# shell.confirm "Are you sure?"
|
90
|
+
# shell.confirm "All is fine!", "This is fine too."
|
91
|
+
#
|
92
|
+
# @param [Array] messages
|
93
|
+
#
|
94
|
+
# @return [Array] messages
|
95
|
+
#
|
96
|
+
# @api public
|
97
|
+
def confirm(*args)
|
98
|
+
options = Utils.extract_options!(args)
|
99
|
+
args.each { |message| say message, options.merge(:color => :green) }
|
100
|
+
end
|
101
|
+
|
102
|
+
# Print statement(s) out in yellow color.
|
103
|
+
#
|
104
|
+
# @example
|
105
|
+
# shell.warn "This action can have dire consequences"
|
106
|
+
# shell.warn "Carefull young apprentice", "This is potentially dangerous."
|
107
|
+
#
|
108
|
+
# @param [Array] messages
|
109
|
+
#
|
110
|
+
# @return [Array] messages
|
111
|
+
#
|
112
|
+
# @api public
|
113
|
+
def warn(*args)
|
114
|
+
options = Utils.extract_options!(args)
|
115
|
+
args.each { |message| say message, options.merge(:color => :yellow) }
|
116
|
+
end
|
117
|
+
|
118
|
+
# Print statement(s) out in red color.
|
119
|
+
#
|
120
|
+
# @example
|
121
|
+
# shell.error "Shutting down all systems!"
|
122
|
+
# shell.error "Nothing is fine!", "All is broken!"
|
123
|
+
#
|
124
|
+
# @param [Array] messages
|
125
|
+
#
|
126
|
+
# @return [Array] messages
|
127
|
+
#
|
128
|
+
# @api public
|
129
|
+
def error(*args)
|
130
|
+
options = Utils.extract_options!(args)
|
131
|
+
args.each { |message| say message, options.merge(:color => :red) }
|
132
|
+
end
|
133
|
+
|
134
|
+
# Print a table to shell.
|
135
|
+
#
|
136
|
+
# @return [undefined]
|
137
|
+
#
|
138
|
+
# @api public
|
139
|
+
def print_table(*args, &block)
|
140
|
+
table = TTY::Table.new *args, &block
|
141
|
+
say table.to_s
|
142
|
+
end
|
143
|
+
|
144
|
+
protected
|
145
|
+
|
146
|
+
def stdin
|
147
|
+
$stdin
|
148
|
+
end
|
149
|
+
|
150
|
+
def stdout
|
151
|
+
$stdout
|
152
|
+
end
|
153
|
+
|
154
|
+
def stderr
|
155
|
+
$stderr
|
156
|
+
end
|
157
|
+
|
158
|
+
end # Shell
|
159
|
+
end # TTY
|
@@ -0,0 +1,143 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
|
3
|
+
module TTY
|
4
|
+
class Terminal
|
5
|
+
|
6
|
+
# A class responsible for coloring strings.
|
7
|
+
class Color
|
8
|
+
|
9
|
+
# Embed in a String to clear all previous ANSI sequences.
|
10
|
+
CLEAR = "\e[0m"
|
11
|
+
# The start of an ANSI bold sequence.
|
12
|
+
BOLD = "\e[1m"
|
13
|
+
# The start of an ANSI underlined sequence.
|
14
|
+
UNDERLINE = "\e[4m"
|
15
|
+
|
16
|
+
STYLES = %w[ BOLD CLEAR UNDERLINE ].freeze
|
17
|
+
|
18
|
+
# Escape codes for text color.
|
19
|
+
BLACK = "\e[30m"
|
20
|
+
RED = "\e[31m"
|
21
|
+
GREEN = "\e[32m"
|
22
|
+
YELLOW = "\e[33m"
|
23
|
+
BLUE = "\e[34m"
|
24
|
+
MAGENTA = "\e[35m"
|
25
|
+
CYAN = "\e[36m"
|
26
|
+
WHITE = "\e[37m"
|
27
|
+
|
28
|
+
TEXT_COLORS = %w[ BLACK RED GREEN YELLOW BLUE MAGENTA CYAN WHITE ].freeze
|
29
|
+
|
30
|
+
# Escape codes for background color.
|
31
|
+
ON_BLACK = "\e[40m"
|
32
|
+
ON_RED = "\e[41m"
|
33
|
+
ON_GREEN = "\e[42m"
|
34
|
+
ON_YELLOW = "\e[43m"
|
35
|
+
ON_BLUE = "\e[44m"
|
36
|
+
ON_MAGENTA = "\e[45m"
|
37
|
+
ON_CYAN = "\e[46m"
|
38
|
+
ON_WHITE = "\e[47m"
|
39
|
+
|
40
|
+
BACKGROUND_COLORS = %w[ ON_BLACK ON_RED ON_GREEN ON_YELLOW ON_BLUE ON_MAGENTA ON_CYAN ON_WHITE ].freeze
|
41
|
+
|
42
|
+
attr_reader :enabled
|
43
|
+
|
44
|
+
# Initialize a Terminal Color
|
45
|
+
#
|
46
|
+
# @api public
|
47
|
+
def initialize(enabled=false)
|
48
|
+
@enabled = enabled
|
49
|
+
end
|
50
|
+
|
51
|
+
# Disable coloring of this terminal session
|
52
|
+
#
|
53
|
+
# @api public
|
54
|
+
def disable!
|
55
|
+
@enabled = false
|
56
|
+
end
|
57
|
+
|
58
|
+
# Check if coloring is on
|
59
|
+
#
|
60
|
+
# @api public
|
61
|
+
def enabled?
|
62
|
+
@enabled
|
63
|
+
end
|
64
|
+
|
65
|
+
# Apply ANSI color to the given string.
|
66
|
+
#
|
67
|
+
# @param [String] string
|
68
|
+
# text to add ANSI strings
|
69
|
+
#
|
70
|
+
# @param [Array[Symbol]] colors
|
71
|
+
#
|
72
|
+
# @example
|
73
|
+
# apply "text", :yellow, :on_green, :underline
|
74
|
+
#
|
75
|
+
# @return [String]
|
76
|
+
#
|
77
|
+
# @api public
|
78
|
+
def set(string, *colors)
|
79
|
+
validate *colors
|
80
|
+
ansi_colors = colors.map { |color| lookup(color) }
|
81
|
+
"#{ansi_colors.join}#{string}#{CLEAR}"
|
82
|
+
end
|
83
|
+
|
84
|
+
# Same as instance method.
|
85
|
+
#
|
86
|
+
# @return [undefined]
|
87
|
+
#
|
88
|
+
# @api public
|
89
|
+
def self.set(string, *colors)
|
90
|
+
new.set(string, *colors)
|
91
|
+
end
|
92
|
+
|
93
|
+
# Remove color codes from a string.
|
94
|
+
#
|
95
|
+
# @param [String] string
|
96
|
+
#
|
97
|
+
# @return [String]
|
98
|
+
#
|
99
|
+
# @api public
|
100
|
+
def remove(string)
|
101
|
+
string.gsub(/\e\[\d+(;\d+)*m/, '')
|
102
|
+
end
|
103
|
+
|
104
|
+
# Return raw color code without embeding it into a string.
|
105
|
+
#
|
106
|
+
# @return [Array[String]]
|
107
|
+
# ANSI escape codes
|
108
|
+
#
|
109
|
+
# @api public
|
110
|
+
def code(*colors)
|
111
|
+
validate *colors
|
112
|
+
colors.map { |color| lookup(color) }
|
113
|
+
end
|
114
|
+
|
115
|
+
# All ANSI color names as strings.
|
116
|
+
#
|
117
|
+
# @return [Array[String]]
|
118
|
+
#
|
119
|
+
# @api public
|
120
|
+
def names
|
121
|
+
(STYLES + BACKGROUND_COLORS + TEXT_COLORS).map { |color| color.to_s.downcase }
|
122
|
+
end
|
123
|
+
|
124
|
+
protected
|
125
|
+
|
126
|
+
# Find color representation.
|
127
|
+
#
|
128
|
+
# @api private
|
129
|
+
def lookup(color)
|
130
|
+
self.class.const_get(color.to_s.upcase)
|
131
|
+
end
|
132
|
+
|
133
|
+
# @api private
|
134
|
+
def validate(*colors)
|
135
|
+
unless colors.all? { |color| names.include?(color.to_s) }
|
136
|
+
raise ArgumentError, "Bad color or unintialized constant, valid colors are: #{names.join(', ')}."
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
end # Color
|
141
|
+
|
142
|
+
end
|
143
|
+
end # TTY
|
data/lib/tty/terminal.rb
CHANGED
@@ -3,10 +3,6 @@
|
|
3
3
|
module TTY
|
4
4
|
class Terminal
|
5
5
|
|
6
|
-
@@default_width = 80
|
7
|
-
|
8
|
-
@@default_height = 24
|
9
|
-
|
10
6
|
# Return default width of terminal
|
11
7
|
#
|
12
8
|
# @example
|
@@ -15,31 +11,36 @@ module TTY
|
|
15
11
|
# @return [Integer]
|
16
12
|
#
|
17
13
|
# @api public
|
18
|
-
|
19
|
-
@@default_width
|
20
|
-
end
|
14
|
+
attr_reader :default_width
|
21
15
|
|
22
|
-
#
|
16
|
+
# Return default height of terminal
|
23
17
|
#
|
24
|
-
# @
|
18
|
+
# @example
|
19
|
+
# default_height = TTY::Terminal.default_height
|
25
20
|
#
|
26
21
|
# @return [Integer]
|
27
22
|
#
|
28
23
|
# @api public
|
29
|
-
|
30
|
-
|
24
|
+
attr_reader :default_height
|
25
|
+
|
26
|
+
# @api public
|
27
|
+
attr_reader :color
|
28
|
+
|
29
|
+
def initialize
|
30
|
+
@color = TTY::Terminal::Color.new(self.color?)
|
31
|
+
@default_width = 80
|
32
|
+
@default_height = 24
|
31
33
|
end
|
32
34
|
|
33
|
-
#
|
35
|
+
# Set default width of terminal
|
34
36
|
#
|
35
|
-
# @
|
36
|
-
# default_height = TTY::Terminal.default_height
|
37
|
+
# @param [Integer] width
|
37
38
|
#
|
38
39
|
# @return [Integer]
|
39
40
|
#
|
40
41
|
# @api public
|
41
|
-
def
|
42
|
-
|
42
|
+
def default_width=(width)
|
43
|
+
@default_width = width
|
43
44
|
end
|
44
45
|
|
45
46
|
# Set default height of terminal
|
@@ -51,7 +52,7 @@ module TTY
|
|
51
52
|
#
|
52
53
|
# @api public
|
53
54
|
def default_height=(height)
|
54
|
-
|
55
|
+
@default_height = height
|
55
56
|
end
|
56
57
|
|
57
58
|
# Determine current width
|
@@ -60,8 +61,9 @@ module TTY
|
|
60
61
|
#
|
61
62
|
# @api width
|
62
63
|
def width
|
63
|
-
|
64
|
-
|
64
|
+
env_tty_columns = ENV['TTY_COLUMNS']
|
65
|
+
if env_tty_columns =~ /^\d+$/
|
66
|
+
result = env_tty_columns.to_i
|
65
67
|
else
|
66
68
|
result = TTY::System.unix? ? dynamic_width : default_width
|
67
69
|
end
|
@@ -73,8 +75,9 @@ module TTY
|
|
73
75
|
#
|
74
76
|
# @api public
|
75
77
|
def height
|
76
|
-
|
77
|
-
|
78
|
+
env_tty_lines = ENV['TTY_LINES']
|
79
|
+
if env_tty_lines =~ /^\d+$/
|
80
|
+
result = env_tty_lines.to_i
|
78
81
|
else
|
79
82
|
result = TTY::System.unix? ? dynamic_height : self.default_height
|
80
83
|
end
|
@@ -145,5 +148,27 @@ module TTY
|
|
145
148
|
%x{tput colors 2>/dev/null}.to_i > 2
|
146
149
|
end
|
147
150
|
|
151
|
+
# Find user home directory
|
152
|
+
#
|
153
|
+
# @return [String]
|
154
|
+
#
|
155
|
+
# @api public
|
156
|
+
def home
|
157
|
+
@home ||= if (env_home = ENV['HOME'])
|
158
|
+
env_home
|
159
|
+
else
|
160
|
+
begin
|
161
|
+
require 'etc'
|
162
|
+
File.expand_path("~#{Etc.getlogin}")
|
163
|
+
rescue
|
164
|
+
if TTY::System.windows?
|
165
|
+
"C:/"
|
166
|
+
else
|
167
|
+
"/"
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
148
173
|
end # Terminal
|
149
174
|
end # TTY
|
data/lib/tty/version.rb
CHANGED
data/lib/tty.rb
CHANGED
@@ -9,11 +9,17 @@ require 'tty/support/coercion'
|
|
9
9
|
require 'tty/support/equatable'
|
10
10
|
require 'tty/support/unicode'
|
11
11
|
|
12
|
-
require 'tty/color'
|
13
12
|
require 'tty/terminal'
|
13
|
+
require 'tty/terminal/color'
|
14
14
|
require 'tty/system'
|
15
15
|
require 'tty/table'
|
16
16
|
require 'tty/vector'
|
17
|
+
require 'tty/shell'
|
18
|
+
|
19
|
+
require 'tty/shell/question'
|
20
|
+
require 'tty/shell/question/validation'
|
21
|
+
require 'tty/shell/question/modifier'
|
22
|
+
require 'tty/shell/statement'
|
17
23
|
|
18
24
|
require 'tty/table/border'
|
19
25
|
require 'tty/table/border/unicode'
|
@@ -32,6 +38,18 @@ module TTY
|
|
32
38
|
# Raised when the argument type is different from expected
|
33
39
|
class TypeError < ArgumentError; end
|
34
40
|
|
41
|
+
# Raised when the required argument is not supplied
|
42
|
+
class ArgumentRequired < ArgumentError; end
|
43
|
+
|
44
|
+
# Raised when the argument validation fails
|
45
|
+
class ArgumentValidation < ArgumentError; end
|
46
|
+
|
47
|
+
# Raised when the argument is not expected
|
48
|
+
class InvalidArgument < ArgumentError; end
|
49
|
+
|
50
|
+
# Raised when the passed in validation argument is of wrong type
|
51
|
+
class ValidationCoercion < TypeError; end
|
52
|
+
|
35
53
|
class << self
|
36
54
|
|
37
55
|
# Return terminal instance
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
describe TTY::Shell, '#ask' do
|
6
|
+
let(:input) { StringIO.new }
|
7
|
+
let(:output) { StringIO.new }
|
8
|
+
|
9
|
+
subject(:shell) { TTY::Shell.new(input, output) }
|
10
|
+
|
11
|
+
it 'prints message' do
|
12
|
+
shell.ask "What is your name?"
|
13
|
+
expect(output.string).to eql "What is your name?\n"
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'prints an empty message ' do
|
17
|
+
shell.ask ""
|
18
|
+
expect(output.string).to eql ""
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'prints an empty message and returns nil if EOF is sent to stdin' do
|
22
|
+
input << nil
|
23
|
+
input.rewind
|
24
|
+
q = shell.ask ""
|
25
|
+
expect(q.read).to eql nil
|
26
|
+
end
|
27
|
+
|
28
|
+
it 'asks a question with block' do
|
29
|
+
input << ''
|
30
|
+
input.rewind
|
31
|
+
q = shell.ask "What is your name?" do
|
32
|
+
default 'Piotr'
|
33
|
+
end
|
34
|
+
expect(q.read).to eql "Piotr"
|
35
|
+
end
|
36
|
+
|
37
|
+
context 'yes?' do
|
38
|
+
it 'agrees' do
|
39
|
+
input << 'yes'
|
40
|
+
input.rewind
|
41
|
+
expect(shell.yes?("Are you a human?")).to be_true
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'disagrees' do
|
45
|
+
input << 'no'
|
46
|
+
input.rewind
|
47
|
+
expect(shell.yes?("Are you a human?")).to be_false
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
context 'no?' do
|
52
|
+
it 'agrees' do
|
53
|
+
input << 'no'
|
54
|
+
input.rewind
|
55
|
+
expect(shell.no?("Are you a human?")).to be_true
|
56
|
+
end
|
57
|
+
|
58
|
+
it 'disagrees' do
|
59
|
+
input << 'yes'
|
60
|
+
input.rewind
|
61
|
+
expect(shell.no?("Are you a human?")).to be_false
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
describe TTY::Shell, '#error' do
|
6
|
+
let(:input) { StringIO.new }
|
7
|
+
let(:output) { StringIO.new }
|
8
|
+
|
9
|
+
subject(:shell) { TTY::Shell.new(input, output) }
|
10
|
+
|
11
|
+
after { output.rewind }
|
12
|
+
|
13
|
+
it 'displays one message' do
|
14
|
+
shell.error "Nothing is fine!"
|
15
|
+
expect(output.string).to eql "\e[31mNothing is fine!\e[0m\n"
|
16
|
+
end
|
17
|
+
|
18
|
+
it 'displays many messages' do
|
19
|
+
shell.error "Nothing is fine!", "All is broken!"
|
20
|
+
expect(output.string).to eql "\e[31mNothing is fine!\e[0m\n\e[31mAll is broken!\e[0m\n"
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'displays message with option' do
|
24
|
+
shell.error "Nothing is fine!", :newline => false
|
25
|
+
expect(output.string).to eql "\e[31mNothing is fine!\e[0m"
|
26
|
+
end
|
27
|
+
|
28
|
+
end # error
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
describe TTY::Shell, '#print_table' do
|
6
|
+
let(:input) { StringIO.new }
|
7
|
+
let(:output) { StringIO.new }
|
8
|
+
let(:header) { ['h1', 'h2'] }
|
9
|
+
let(:rows) { [['a1', 'a2'], ['b1', 'b2']] }
|
10
|
+
|
11
|
+
subject(:shell) { TTY::Shell.new(input, output) }
|
12
|
+
|
13
|
+
it 'prints a table' do
|
14
|
+
shell.print_table header, rows, :renderer => :ascii
|
15
|
+
expect(output.string).to eql <<-EOS.normalize
|
16
|
+
+--+--+
|
17
|
+
|h1|h2|
|
18
|
+
+--+--+
|
19
|
+
|a1|a2|
|
20
|
+
|b1|b2|
|
21
|
+
+--+--+\n
|
22
|
+
EOS
|
23
|
+
end
|
24
|
+
|
25
|
+
end # print_table
|