charming 0.1.2 → 0.1.3
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/lib/charming/application.rb +3 -3
- data/lib/charming/controller/class_methods.rb +2 -2
- data/lib/charming/controller/command_palette.rb +2 -2
- data/lib/charming/controller/rendering.rb +2 -2
- data/lib/charming/controller/session_state.rb +1 -1
- data/lib/charming/generators/component_generator.rb +1 -1
- data/lib/charming/generators/templates/app/application.template +1 -1
- data/lib/charming/generators/templates/app/layout.template +3 -6
- data/lib/charming/generators/templates/app/view.template +1 -1
- data/lib/charming/generators/templates/component/component.rb.template +1 -1
- data/lib/charming/generators/templates/screen/view.rb.template +1 -1
- data/lib/charming/generators/templates/view/view.rb.template +1 -1
- data/lib/charming/internal/renderer/differential.rb +13 -5
- data/lib/charming/internal/terminal/tty_backend.rb +22 -2
- data/lib/charming/presentation/component.rb +3 -5
- data/lib/charming/presentation/components/activity_indicator.rb +173 -134
- data/lib/charming/presentation/components/command_palette.rb +94 -96
- data/lib/charming/presentation/components/command_palette_modal.rb +33 -0
- data/lib/charming/presentation/components/empty_state.rb +47 -49
- data/lib/charming/presentation/components/form/builder.rb +52 -54
- data/lib/charming/presentation/components/form/confirm.rb +49 -51
- data/lib/charming/presentation/components/form/field.rb +94 -96
- data/lib/charming/presentation/components/form/input.rb +53 -55
- data/lib/charming/presentation/components/form/note.rb +27 -29
- data/lib/charming/presentation/components/form/select.rb +84 -86
- data/lib/charming/presentation/components/form/textarea.rb +67 -69
- data/lib/charming/presentation/components/form.rb +120 -122
- data/lib/charming/presentation/components/keyboard_handler.rb +41 -43
- data/lib/charming/presentation/components/list.rb +123 -125
- data/lib/charming/presentation/components/markdown.rb +21 -23
- data/lib/charming/presentation/components/modal.rb +46 -48
- data/lib/charming/presentation/components/progressbar.rb +51 -53
- data/lib/charming/presentation/components/spinner.rb +40 -42
- data/lib/charming/presentation/components/table.rb +109 -111
- data/lib/charming/presentation/components/text_area.rb +219 -221
- data/lib/charming/presentation/components/text_input.rb +120 -122
- data/lib/charming/presentation/components/viewport.rb +218 -220
- data/lib/charming/presentation/layout/builder.rb +64 -66
- data/lib/charming/presentation/layout/overlay.rb +48 -50
- data/lib/charming/presentation/layout/pane.rb +122 -118
- data/lib/charming/presentation/layout/rect.rb +14 -16
- data/lib/charming/presentation/layout/screen_layout.rb +40 -42
- data/lib/charming/presentation/layout/split.rb +101 -103
- data/lib/charming/presentation/layout.rb +28 -30
- data/lib/charming/presentation/markdown/block_renderers.rb +94 -96
- data/lib/charming/presentation/markdown/inline_renderers.rb +52 -54
- data/lib/charming/presentation/markdown/render_context.rb +12 -14
- data/lib/charming/presentation/markdown/renderer.rb +84 -86
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +57 -59
- data/lib/charming/presentation/markdown.rb +4 -6
- data/lib/charming/presentation/template_view.rb +22 -24
- data/lib/charming/presentation/templates/erb_handler.rb +4 -6
- data/lib/charming/presentation/templates.rb +47 -49
- data/lib/charming/presentation/ui/ansi_codes.rb +66 -68
- data/lib/charming/presentation/ui/ansi_slicer.rb +67 -69
- data/lib/charming/presentation/ui/border.rb +24 -26
- data/lib/charming/presentation/ui/border_painter.rb +37 -39
- data/lib/charming/presentation/ui/canvas.rb +59 -61
- data/lib/charming/presentation/ui/style.rb +173 -175
- data/lib/charming/presentation/ui/theme.rb +133 -135
- data/lib/charming/presentation/ui/width.rb +12 -14
- data/lib/charming/presentation/ui.rb +69 -71
- data/lib/charming/presentation/view.rb +103 -105
- data/lib/charming/runtime.rb +23 -10
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +3 -2
- metadata +2 -1
|
@@ -1,67 +1,65 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
handler.render(path, view)
|
|
15
|
-
end
|
|
4
|
+
# Templates resolves and renders view templates by name. Template handlers are registered
|
|
5
|
+
# for file extensions (e.g., `.tui.erb`) and the resolver searches `app/views/<name><ext>`
|
|
6
|
+
# under the application root, falling back through registered extensions when the first
|
|
7
|
+
# match is not found.
|
|
8
|
+
module Templates
|
|
9
|
+
# A resolved template: an on-disk *path* paired with the *handler* responsible for rendering it.
|
|
10
|
+
ResolvedTemplate = Data.define(:path, :handler) do
|
|
11
|
+
# Renders the template against *view* by delegating to the registered handler.
|
|
12
|
+
def render(view)
|
|
13
|
+
handler.render(path, view)
|
|
16
14
|
end
|
|
15
|
+
end
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class << self
|
|
22
|
-
# Registers a template *handler* for a file *extension* (e.g., ".tui.erb" => ErbHandler).
|
|
23
|
-
# The handler responds to `.render(path, view)`.
|
|
24
|
-
def register(extension, handler)
|
|
25
|
-
handlers[extension] = handler
|
|
26
|
-
end
|
|
17
|
+
# Raised when no template file matches the given name under the application root.
|
|
18
|
+
MissingTemplateError = Class.new(Error)
|
|
27
19
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
20
|
+
class << self
|
|
21
|
+
# Registers a template *handler* for a file *extension* (e.g., ".tui.erb" => ErbHandler).
|
|
22
|
+
# The handler responds to `.render(path, view)`.
|
|
23
|
+
def register(extension, handler)
|
|
24
|
+
handlers[extension] = handler
|
|
25
|
+
end
|
|
33
26
|
|
|
34
|
-
|
|
35
|
-
|
|
27
|
+
# Resolves a template by *name* under `app/views` of *root* (defaults to the current
|
|
28
|
+
# working directory). Raises MissingTemplateError when no matching file exists.
|
|
29
|
+
def resolve(name, root: nil)
|
|
30
|
+
views_root = File.join(root || Dir.pwd, "app", "views")
|
|
31
|
+
searched_paths = candidate_paths(views_root, name.to_s)
|
|
36
32
|
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
searched_paths.each do |path|
|
|
34
|
+
next unless File.file?(path)
|
|
39
35
|
|
|
40
|
-
|
|
36
|
+
return ResolvedTemplate.new(path: path, handler: handler_for(path))
|
|
41
37
|
end
|
|
42
38
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
@handlers ||= {}
|
|
46
|
-
end
|
|
39
|
+
raise MissingTemplateError, "Missing template #{name.inspect}. Searched: #{searched_paths.join(", ")}"
|
|
40
|
+
end
|
|
47
41
|
|
|
48
|
-
|
|
42
|
+
# Hash of registered handlers keyed by extension. Populated by `register`.
|
|
43
|
+
def handlers
|
|
44
|
+
@handlers ||= {}
|
|
45
|
+
end
|
|
49
46
|
|
|
50
|
-
|
|
51
|
-
# extension, returns it directly; otherwise returns the path with each registered extension
|
|
52
|
-
# appended (in registration order).
|
|
53
|
-
def candidate_paths(views_root, name)
|
|
54
|
-
path = File.expand_path(name, views_root)
|
|
55
|
-
return [path] if handler_for(path)
|
|
47
|
+
private
|
|
56
48
|
|
|
57
|
-
|
|
58
|
-
|
|
49
|
+
# Returns candidate paths under *views_root* for *name*. When the bare path has a known
|
|
50
|
+
# extension, returns it directly; otherwise returns the path with each registered extension
|
|
51
|
+
# appended (in registration order).
|
|
52
|
+
def candidate_paths(views_root, name)
|
|
53
|
+
path = File.expand_path(name, views_root)
|
|
54
|
+
return [path] if handler_for(path)
|
|
59
55
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
56
|
+
handlers.keys.map { |extension| "#{path}#{extension}" }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Looks up the handler whose registered extension matches the end of *path*. Returns nil
|
|
60
|
+
# when no handler matches.
|
|
61
|
+
def handler_for(path)
|
|
62
|
+
handlers.find { |extension, _handler| path.end_with?(extension) }&.last
|
|
65
63
|
end
|
|
66
64
|
end
|
|
67
65
|
end
|
|
@@ -1,88 +1,86 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
module
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}.freeze
|
|
4
|
+
module UI
|
|
5
|
+
class ANSICodes
|
|
6
|
+
ATTRIBUTES = {
|
|
7
|
+
bold: 1,
|
|
8
|
+
faint: 2,
|
|
9
|
+
italic: 3,
|
|
10
|
+
underline: 4,
|
|
11
|
+
reverse: 7,
|
|
12
|
+
strikethrough: 9
|
|
13
|
+
}.freeze
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
15
|
+
COLORS = {
|
|
16
|
+
black: 30,
|
|
17
|
+
red: 31,
|
|
18
|
+
green: 32,
|
|
19
|
+
yellow: 33,
|
|
20
|
+
blue: 34,
|
|
21
|
+
magenta: 35,
|
|
22
|
+
cyan: 36,
|
|
23
|
+
white: 37,
|
|
24
|
+
bright_black: 90,
|
|
25
|
+
bright_red: 91,
|
|
26
|
+
bright_green: 92,
|
|
27
|
+
bright_yellow: 93,
|
|
28
|
+
bright_blue: 94,
|
|
29
|
+
bright_magenta: 95,
|
|
30
|
+
bright_cyan: 96,
|
|
31
|
+
bright_white: 97
|
|
32
|
+
}.freeze
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
def initialize(attributes:, foreground:, background:)
|
|
35
|
+
@attributes = attributes
|
|
36
|
+
@foreground = foreground
|
|
37
|
+
@background = background
|
|
38
|
+
end
|
|
40
39
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
def codes
|
|
41
|
+
@codes ||= attribute_codes +
|
|
42
|
+
color_codes(@foreground, foreground: true) +
|
|
43
|
+
color_codes(@background, foreground: false)
|
|
44
|
+
end
|
|
46
45
|
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
def apply(value)
|
|
47
|
+
return value if codes.empty?
|
|
49
48
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
start = "\e[#{codes.join(";")}m"
|
|
50
|
+
value.split("\n", -1).map { |line| "#{start}#{line.gsub("\e[0m", "\e[0m#{start}")}\e[0m" }.join("\n")
|
|
51
|
+
end
|
|
53
52
|
|
|
54
|
-
|
|
53
|
+
private
|
|
55
54
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
def attribute_codes
|
|
56
|
+
@attributes.map { |attribute| ATTRIBUTES.fetch(attribute) }
|
|
57
|
+
end
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
59
|
+
def color_codes(color, foreground:)
|
|
60
|
+
return [] unless color
|
|
61
|
+
return indexed_color_code(color, foreground: foreground) if color.is_a?(Integer)
|
|
62
|
+
return named_color_code(color, foreground: foreground) if COLORS.key?(color.to_sym)
|
|
63
|
+
return truecolor_codes(color, foreground: foreground) if color.to_s.start_with?("#")
|
|
65
64
|
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
raise ArgumentError, "unknown color: #{color.inspect}"
|
|
66
|
+
end
|
|
68
67
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
def named_color_code(color, foreground:)
|
|
69
|
+
code = COLORS.fetch(color.to_sym)
|
|
70
|
+
[foreground ? code : code + 10]
|
|
71
|
+
end
|
|
73
72
|
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
def indexed_color_code(color, foreground:)
|
|
74
|
+
raise ArgumentError, "indexed color must be between 0 and 255" unless color.between?(0, 255)
|
|
76
75
|
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
[foreground ? 38 : 48, 5, color]
|
|
77
|
+
end
|
|
79
78
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
def truecolor_codes(color, foreground:)
|
|
80
|
+
hex = color.to_s.delete_prefix("#")
|
|
81
|
+
raise ArgumentError, "truecolor must be #rrggbb" unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
|
|
83
82
|
|
|
84
|
-
|
|
85
|
-
end
|
|
83
|
+
[foreground ? 38 : 48, 2, hex[0..1].to_i(16), hex[2..3].to_i(16), hex[4..5].to_i(16)]
|
|
86
84
|
end
|
|
87
85
|
end
|
|
88
86
|
end
|
|
@@ -1,94 +1,92 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
module
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
end
|
|
4
|
+
module UI
|
|
5
|
+
# ANSISlicer extracts a visible substring from a string that may contain ANSI
|
|
6
|
+
# escape sequences, preserving the styling that is active at the start of
|
|
7
|
+
# the slice and emitting a trailing reset if any styled content was copied.
|
|
8
|
+
class ANSISlicer
|
|
9
|
+
def self.slice(line, start_column, width)
|
|
10
|
+
return "" unless width.positive?
|
|
11
|
+
|
|
12
|
+
slice_range(line.to_s, start_column, start_column + width)
|
|
13
|
+
end
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
def self.slice_range(line, start_column, end_column)
|
|
16
|
+
state = {column: 0, output: +"", active: [], started: false, styled: false}
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
end
|
|
18
|
+
each_ansi_or_char(line) do |token, ansi|
|
|
19
|
+
if ansi
|
|
20
|
+
slice_ansi_token(token, state, start_column, end_column)
|
|
21
|
+
else
|
|
22
|
+
slice_char(token, state, start_column, end_column)
|
|
25
23
|
end
|
|
26
|
-
|
|
27
|
-
terminate_slice(state)
|
|
28
24
|
end
|
|
29
25
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
26
|
+
terminate_slice(state)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.each_ansi_or_char(line)
|
|
30
|
+
index = 0
|
|
31
|
+
while index < line.length
|
|
32
|
+
match = line.match(Width::ANSI_PATTERN, index)
|
|
33
|
+
if match&.begin(0) == index
|
|
34
|
+
yield match[0], true
|
|
35
|
+
index = match.end(0)
|
|
36
|
+
else
|
|
37
|
+
yield line[index], false
|
|
38
|
+
index += 1
|
|
41
39
|
end
|
|
42
40
|
end
|
|
41
|
+
end
|
|
43
42
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
def self.slice_ansi_token(token, state, start_column, end_column)
|
|
44
|
+
started = state[:started]
|
|
45
|
+
update_active_styles(state[:active], token)
|
|
46
|
+
return unless state[:column].between?(start_column, end_column - 1)
|
|
48
47
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
end
|
|
48
|
+
start_slice(state)
|
|
49
|
+
if started
|
|
50
|
+
state[:output] << token
|
|
51
|
+
state[:styled] = !token.include?("[0m")
|
|
54
52
|
end
|
|
53
|
+
end
|
|
55
54
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
55
|
+
def self.slice_char(char, state, start_column, end_column)
|
|
56
|
+
char_width = Width.measure(char)
|
|
57
|
+
char_start = state[:column]
|
|
58
|
+
char_end = char_start + char_width
|
|
59
|
+
state[:column] = char_end
|
|
60
|
+
return unless char_end > start_column && char_start < end_column
|
|
62
61
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
start_slice(state)
|
|
63
|
+
state[:output] << char
|
|
64
|
+
end
|
|
66
65
|
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
def self.start_slice(state)
|
|
67
|
+
return if state[:started]
|
|
69
68
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
state[:output] << state[:active].join
|
|
70
|
+
state[:styled] = true unless state[:active].empty?
|
|
71
|
+
state[:started] = true
|
|
72
|
+
end
|
|
74
73
|
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
def self.terminate_slice(state)
|
|
75
|
+
return state[:output] if !state[:styled] || state[:output].empty?
|
|
77
76
|
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
"#{state[:output]}\e[0m"
|
|
78
|
+
end
|
|
80
79
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
end
|
|
80
|
+
def self.update_active_styles(active, token)
|
|
81
|
+
if token.include?("[0m")
|
|
82
|
+
active.clear
|
|
83
|
+
else
|
|
84
|
+
active << token
|
|
87
85
|
end
|
|
88
|
-
|
|
89
|
-
private_class_method :each_ansi_or_char, :slice_ansi_token, :slice_char,
|
|
90
|
-
:start_slice, :terminate_slice, :update_active_styles
|
|
91
86
|
end
|
|
87
|
+
|
|
88
|
+
private_class_method :each_ansi_or_char, :slice_ansi_token, :slice_char,
|
|
89
|
+
:start_slice, :terminate_slice, :update_active_styles
|
|
92
90
|
end
|
|
93
91
|
end
|
|
94
92
|
end
|
|
@@ -1,35 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
module
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
attr_reader :top_left, :top_right, :bottom_left, :bottom_right, :horizontal, :vertical
|
|
4
|
+
module UI
|
|
5
|
+
class Border
|
|
6
|
+
attr_reader :top_left, :top_right, :bottom_left, :bottom_right, :horizontal, :vertical
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def self.fetch(name)
|
|
15
|
-
STYLES.fetch(name.to_sym)
|
|
16
|
-
end
|
|
8
|
+
def initialize(corners:, edges:)
|
|
9
|
+
@top_left, @top_right, @bottom_left, @bottom_right = corners
|
|
10
|
+
@horizontal, @vertical = edges
|
|
17
11
|
end
|
|
18
12
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
),
|
|
23
|
-
rounded: Border.new(
|
|
24
|
-
corners: ["╭", "╮", "╰", "╯"], edges: ["─", "│"]
|
|
25
|
-
),
|
|
26
|
-
thick: Border.new(
|
|
27
|
-
corners: ["┏", "┓", "┗", "┛"], edges: ["━", "┃"]
|
|
28
|
-
),
|
|
29
|
-
double: Border.new(
|
|
30
|
-
corners: ["╔", "╗", "╚", "╝"], edges: ["═", "║"]
|
|
31
|
-
)
|
|
32
|
-
}.freeze
|
|
13
|
+
def self.fetch(name)
|
|
14
|
+
STYLES.fetch(name.to_sym)
|
|
15
|
+
end
|
|
33
16
|
end
|
|
17
|
+
|
|
18
|
+
Border::STYLES = {
|
|
19
|
+
normal: Border.new(
|
|
20
|
+
corners: ["+", "+", "+", "+"], edges: ["-", "|"]
|
|
21
|
+
),
|
|
22
|
+
rounded: Border.new(
|
|
23
|
+
corners: ["╭", "╮", "╰", "╯"], edges: ["─", "│"]
|
|
24
|
+
),
|
|
25
|
+
thick: Border.new(
|
|
26
|
+
corners: ["┏", "┓", "┗", "┛"], edges: ["━", "┃"]
|
|
27
|
+
),
|
|
28
|
+
double: Border.new(
|
|
29
|
+
corners: ["╔", "╗", "╚", "╝"], edges: ["═", "║"]
|
|
30
|
+
)
|
|
31
|
+
}.freeze
|
|
34
32
|
end
|
|
35
33
|
end
|
|
@@ -1,57 +1,55 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
module
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
end
|
|
4
|
+
module UI
|
|
5
|
+
class BorderPainter
|
|
6
|
+
DEFAULT_SIDES = %i[top right bottom left].freeze
|
|
7
|
+
|
|
8
|
+
def initialize(border:, sides: nil, foreground: nil, background: nil)
|
|
9
|
+
@border = border
|
|
10
|
+
@sides = Array(sides || DEFAULT_SIDES).map(&:to_sym)
|
|
11
|
+
@foreground = foreground
|
|
12
|
+
@background = background
|
|
13
|
+
end
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
def paint(lines, inner_width)
|
|
16
|
+
horizontal = @border.horizontal * inner_width
|
|
17
|
+
body = lines.map { |line| border_line(line, inner_width) }
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
[top_border(horizontal), *body, bottom_border(horizontal)].compact
|
|
20
|
+
end
|
|
22
21
|
|
|
23
|
-
|
|
22
|
+
private
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
def border_line(line, width)
|
|
25
|
+
left = @sides.include?(:left) ? render_border(@border.vertical) : ""
|
|
26
|
+
right = @sides.include?(:right) ? render_border(@border.vertical) : ""
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
"#{left}#{line}#{" " * (width - Width.measure(line))}#{right}"
|
|
29
|
+
end
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
def top_border(horizontal)
|
|
32
|
+
return unless @sides.include?(:top)
|
|
33
|
+
return render_border(horizontal) unless full_horizontal?
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
render_border("#{@border.top_left}#{horizontal}#{@border.top_right}")
|
|
36
|
+
end
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
def bottom_border(horizontal)
|
|
39
|
+
return unless @sides.include?(:bottom)
|
|
40
|
+
return render_border(horizontal) unless full_horizontal?
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
render_border("#{@border.bottom_left}#{horizontal}#{@border.bottom_right}")
|
|
43
|
+
end
|
|
45
44
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
def full_horizontal?
|
|
46
|
+
@sides.include?(:left) && @sides.include?(:right)
|
|
47
|
+
end
|
|
49
48
|
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
def render_border(value)
|
|
50
|
+
return value unless @foreground
|
|
52
51
|
|
|
53
|
-
|
|
54
|
-
end
|
|
52
|
+
Style.new(foreground: @foreground, background: @background).render(value)
|
|
55
53
|
end
|
|
56
54
|
end
|
|
57
55
|
end
|