charming 0.1.0 → 0.1.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/README.md +38 -378
- data/lib/charming/application.rb +3 -3
- data/lib/charming/{application_model.rb → application_state.rb} +3 -3
- data/lib/charming/cli.rb +39 -3
- data/lib/charming/controller.rb +146 -24
- data/lib/charming/database_commands.rb +87 -0
- data/lib/charming/database_installer.rb +125 -0
- data/lib/charming/events/key_event.rb +15 -0
- data/lib/charming/events/mouse_event.rb +42 -0
- data/lib/charming/events/resize_event.rb +9 -0
- data/lib/charming/events/task_event.rb +19 -0
- data/lib/charming/events/timer_event.rb +9 -0
- data/lib/charming/generators/app_generator/app_spec_templates.rb +12 -8
- data/lib/charming/generators/app_generator/basic_templates.rb +14 -2
- data/lib/charming/generators/app_generator/component_templates.rb +1 -1
- data/lib/charming/generators/app_generator/controller_template.rb +3 -12
- data/lib/charming/generators/app_generator/database_templates.rb +45 -0
- data/lib/charming/generators/app_generator/layout_template.rb +51 -145
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +7 -8
- data/lib/charming/generators/app_generator/{model_templates.rb → state_templates.rb} +5 -5
- data/lib/charming/generators/app_generator/view_template.rb +12 -18
- data/lib/charming/generators/app_generator.rb +37 -11
- data/lib/charming/generators/component_generator.rb +1 -1
- data/lib/charming/generators/controller_generator.rb +1 -4
- data/lib/charming/generators/model_generator.rb +119 -0
- data/lib/charming/generators/name.rb +0 -4
- data/lib/charming/generators/screen_generator.rb +14 -28
- data/lib/charming/generators/view_generator.rb +11 -14
- data/lib/charming/internal/renderer/differential.rb +2 -3
- data/lib/charming/internal/terminal/tty_backend.rb +25 -8
- data/lib/charming/presentation/component.rb +10 -0
- data/lib/charming/presentation/components/activity_indicator.rb +160 -0
- data/lib/charming/presentation/components/command_palette.rb +120 -0
- data/lib/charming/presentation/components/empty_state.rb +43 -0
- data/lib/charming/presentation/components/form/builder.rb +48 -0
- data/lib/charming/presentation/components/form/confirm.rb +56 -0
- data/lib/charming/presentation/components/form/field.rb +96 -0
- data/lib/charming/presentation/components/form/input.rb +57 -0
- data/lib/charming/presentation/components/form/note.rb +32 -0
- data/lib/charming/presentation/components/form/select.rb +89 -0
- data/lib/charming/presentation/components/form/textarea.rb +70 -0
- data/lib/charming/presentation/components/form.rb +127 -0
- data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
- data/lib/charming/presentation/components/list.rb +104 -0
- data/lib/charming/presentation/components/markdown.rb +25 -0
- data/lib/charming/presentation/components/modal.rb +50 -0
- data/lib/charming/presentation/components/progressbar.rb +57 -0
- data/lib/charming/presentation/components/spinner.rb +39 -0
- data/lib/charming/presentation/components/table.rb +118 -0
- data/lib/charming/presentation/components/text_area.rb +219 -0
- data/lib/charming/presentation/components/text_input.rb +105 -0
- data/lib/charming/presentation/components/viewport.rb +220 -0
- data/lib/charming/presentation/layout.rb +43 -0
- data/lib/charming/presentation/markdown/renderer.rb +203 -0
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +63 -0
- data/lib/charming/presentation/markdown.rb +8 -0
- data/lib/charming/presentation/template_view.rb +27 -0
- data/lib/charming/presentation/templates/erb_handler.rb +15 -0
- data/lib/charming/presentation/templates.rb +51 -0
- data/lib/charming/presentation/ui/border.rb +35 -0
- data/lib/charming/presentation/ui/style.rb +246 -0
- data/lib/charming/presentation/ui/theme.rb +180 -0
- data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
- data/lib/charming/presentation/ui/width.rb +26 -0
- data/lib/charming/presentation/ui.rb +232 -0
- data/lib/charming/presentation/view.rb +118 -0
- data/lib/charming/runtime.rb +7 -7
- data/lib/charming/screen.rb +5 -1
- data/lib/charming/tasks/inline_executor.rb +28 -0
- data/lib/charming/tasks/task.rb +9 -0
- data/lib/charming/{task_executor.rb → tasks/threaded_executor.rb} +4 -27
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +4 -0
- metadata +114 -29
- data/lib/charming/component.rb +0 -8
- data/lib/charming/components/activity_indicator.rb +0 -158
- data/lib/charming/components/command_palette.rb +0 -118
- data/lib/charming/components/keyboard_handler.rb +0 -22
- data/lib/charming/components/list.rb +0 -105
- data/lib/charming/components/modal.rb +0 -48
- data/lib/charming/components/progressbar.rb +0 -55
- data/lib/charming/components/spinner.rb +0 -37
- data/lib/charming/components/table.rb +0 -115
- data/lib/charming/components/text_input.rb +0 -103
- data/lib/charming/components/viewport.rb +0 -191
- data/lib/charming/key_event.rb +0 -13
- data/lib/charming/mouse_event.rb +0 -40
- data/lib/charming/resize_event.rb +0 -7
- data/lib/charming/task.rb +0 -7
- data/lib/charming/task_event.rb +0 -17
- data/lib/charming/timer_event.rb +0 -7
- data/lib/charming/ui/border.rb +0 -33
- data/lib/charming/ui/style.rb +0 -244
- data/lib/charming/ui/theme.rb +0 -178
- data/lib/charming/ui/width.rb +0 -24
- data/lib/charming/ui.rb +0 -230
- data/lib/charming/view.rb +0 -116
- /data/lib/charming/{generators.rb → generators/error.rb} +0 -0
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "unicode/display_width"
|
|
4
|
-
|
|
5
|
-
module Charming
|
|
6
|
-
module Components
|
|
7
|
-
class Viewport < Component
|
|
8
|
-
include KeyboardHandler
|
|
9
|
-
|
|
10
|
-
ANSI_PATTERN = /\e\[[0-9;]*m/
|
|
11
|
-
KEY_ACTIONS = {
|
|
12
|
-
up: :scroll_up,
|
|
13
|
-
down: :scroll_down,
|
|
14
|
-
page_up: :page_up,
|
|
15
|
-
page_down: :page_down,
|
|
16
|
-
home: :scroll_home,
|
|
17
|
-
end: :scroll_end,
|
|
18
|
-
left: :scroll_left,
|
|
19
|
-
right: :scroll_right
|
|
20
|
-
}.freeze
|
|
21
|
-
|
|
22
|
-
attr_reader :offset, :column
|
|
23
|
-
|
|
24
|
-
def initialize(content:, width: nil, height: nil, offset: 0, column: 0)
|
|
25
|
-
super()
|
|
26
|
-
@content = content
|
|
27
|
-
@width = width
|
|
28
|
-
@height = height
|
|
29
|
-
@offset = offset
|
|
30
|
-
@column = column
|
|
31
|
-
clamp_position
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def render
|
|
35
|
-
visible_lines.map { |line| render_line(line) }.join("\n")
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def handle_mouse(event)
|
|
39
|
-
return nil unless height
|
|
40
|
-
|
|
41
|
-
if event.scroll?
|
|
42
|
-
scroll_delta = (event.button_name == :scroll_up) ? -1 : 1
|
|
43
|
-
@offset += scroll_delta
|
|
44
|
-
clamp_position
|
|
45
|
-
return :handled
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
return nil unless event.click?
|
|
49
|
-
|
|
50
|
-
clicked_row = event.y
|
|
51
|
-
return nil if clicked_row < offset || clicked_row >= offset + viewport_height
|
|
52
|
-
|
|
53
|
-
@offset = clicked_row
|
|
54
|
-
clamp_position
|
|
55
|
-
:handled
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
private
|
|
59
|
-
|
|
60
|
-
attr_reader :content, :width, :height
|
|
61
|
-
|
|
62
|
-
def scroll_up
|
|
63
|
-
@offset -= 1
|
|
64
|
-
clamp_position
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def scroll_down
|
|
68
|
-
@offset += 1
|
|
69
|
-
clamp_position
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def page_up
|
|
73
|
-
@offset -= page_size
|
|
74
|
-
clamp_position
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def page_down
|
|
78
|
-
@offset += page_size
|
|
79
|
-
clamp_position
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def scroll_home
|
|
83
|
-
@offset = 0
|
|
84
|
-
@column = 0
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def scroll_end
|
|
88
|
-
@offset = max_offset
|
|
89
|
-
@column = max_column
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def scroll_left
|
|
93
|
-
@column -= 1
|
|
94
|
-
clamp_position
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def scroll_right
|
|
98
|
-
@column += 1
|
|
99
|
-
clamp_position
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def clamp_position
|
|
103
|
-
@offset = offset.clamp(0, max_offset)
|
|
104
|
-
@column = column.clamp(0, max_column)
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def visible_lines
|
|
108
|
-
lines = content_lines.slice(offset, viewport_height) || []
|
|
109
|
-
return lines unless height
|
|
110
|
-
|
|
111
|
-
lines + Array.new([height - lines.length, 0].max, "")
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def render_line(line)
|
|
115
|
-
return line unless width
|
|
116
|
-
|
|
117
|
-
pad_line(clip_line(line), width)
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def clip_line(line)
|
|
121
|
-
clipped = clip_tokens(line.to_s)
|
|
122
|
-
needs_reset?(clipped) ? "#{clipped}\e[0m" : clipped
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
def clip_tokens(line)
|
|
126
|
-
state = {cursor: 0, output: +""}
|
|
127
|
-
line.scan(/#{ANSI_PATTERN}|./mo) do |token|
|
|
128
|
-
ansi?(token) ? append_ansi(state, token) : append_character(state, token)
|
|
129
|
-
end
|
|
130
|
-
state.fetch(:output)
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
def append_ansi(state, token)
|
|
134
|
-
state.fetch(:output) << token
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def append_character(state, char)
|
|
138
|
-
char_width = Unicode::DisplayWidth.of(char)
|
|
139
|
-
cursor = state.fetch(:cursor)
|
|
140
|
-
state.fetch(:output) << char if visible?(cursor, char_width)
|
|
141
|
-
state[:cursor] = cursor + char_width
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
def visible?(cursor, char_width)
|
|
145
|
-
cursor >= column && cursor + char_width <= column + width
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
def needs_reset?(value)
|
|
149
|
-
value.match?(ANSI_PATTERN) && !value.end_with?("\e[0m")
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def pad_line(line, target_width)
|
|
153
|
-
line + (" " * [target_width - UI::Width.measure(line), 0].max)
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
def content_lines
|
|
157
|
-
rendered_content.lines(chomp: true)
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
def rendered_content
|
|
161
|
-
content.respond_to?(:render) ? content.render.to_s : content.to_s
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
def viewport_height
|
|
165
|
-
height || content_lines.length
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
def page_size
|
|
169
|
-
[viewport_height, 1].max
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def max_offset
|
|
173
|
-
[content_lines.length - viewport_height, 0].max
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
def max_column
|
|
177
|
-
return 0 unless width
|
|
178
|
-
|
|
179
|
-
[content_width - width, 0].max
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
def content_width
|
|
183
|
-
content_lines.map { |line| UI::Width.measure(line) }.max || 0
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
def ansi?(token)
|
|
187
|
-
token.match?(ANSI_PATTERN)
|
|
188
|
-
end
|
|
189
|
-
end
|
|
190
|
-
end
|
|
191
|
-
end
|
data/lib/charming/key_event.rb
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Charming
|
|
4
|
-
# KeyEvent represents a terminal key press parsed by the backend. *key* is the normalized semantic
|
|
5
|
-
# action name (e.g., `:up`, `:down`, `:q`), while *char*, *ctrl*, *alt*, and *shift* capture raw
|
|
6
|
-
# input details for custom bindings.
|
|
7
|
-
KeyEvent = Data.define(:key, :char, :ctrl, :alt, :shift) do
|
|
8
|
-
# Constructs a key event with the required *key* symbol, plus optional *char* string and modifier booleans.
|
|
9
|
-
def initialize(key:, char: nil, ctrl: false, alt: false, shift: false)
|
|
10
|
-
super(key: key.to_sym, char: char, ctrl: ctrl, alt: alt, shift: shift)
|
|
11
|
-
end
|
|
12
|
-
end
|
|
13
|
-
end
|
data/lib/charming/mouse_event.rb
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Charming
|
|
4
|
-
# MOUSE_BUTTON_MAP encodes terminal mouse button codes to semantic symbols. The constant is frozen and private.
|
|
5
|
-
MOUSE_BUTTON_MAP = {
|
|
6
|
-
0 => :left, 1 => :middle, 2 => :right, 3 => :release,
|
|
7
|
-
64 => :scroll_up, 65 => :scroll_down,
|
|
8
|
-
66 => :scroll_up, 67 => :scroll_down
|
|
9
|
-
}.freeze
|
|
10
|
-
private_constant :MOUSE_BUTTON_MAP
|
|
11
|
-
|
|
12
|
-
# MouseEvent represents a mouse input event. *button* encodes which button or action was triggered (left,
|
|
13
|
-
# right, scroll), while *x* and *y* provide the cursor position. Modifier booleans (*ctrl*, *alt*, *shift*)
|
|
14
|
-
# capture key state at the time of the event.
|
|
15
|
-
MouseEvent = Data.define(:button, :x, :y, :ctrl, :alt, :shift) do
|
|
16
|
-
def initialize(button:, x:, y:, ctrl: false, alt: false, shift: false)
|
|
17
|
-
super
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
# Returns the semantic symbol for *button* — one of `left`, `right`, `scroll_up`, etc. or `:unknown`.
|
|
21
|
-
def button_name
|
|
22
|
-
MOUSE_BUTTON_MAP.fetch(button, :unknown)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# Returns `true` when the current event is a click (left, middle, or right button).
|
|
26
|
-
def click?
|
|
27
|
-
%i[left middle right].include?(button_name)
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# Returns `true` when the button name maps to either direction of scroll.
|
|
31
|
-
def scroll?
|
|
32
|
-
%i[scroll_up scroll_down].include?(button_name)
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# Returns `true` when the current event is a mouse release action.
|
|
36
|
-
def release?
|
|
37
|
-
button_name == :release
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
end
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Charming
|
|
4
|
-
# ResizeEvent represents a terminal window resize. *width* and *height* carry the new terminal dimensions
|
|
5
|
-
# in screen cells, replacing the previous Screen dimensions for all subsequent rendering.
|
|
6
|
-
ResizeEvent = Data.define(:width, :height)
|
|
7
|
-
end
|
data/lib/charming/task.rb
DELETED
data/lib/charming/task_event.rb
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Charming
|
|
4
|
-
# TaskEvent represents background task completion. *name* is the declared task identifier, *value* carries
|
|
5
|
-
# the return result and *error* captures any exception raised during execution. The `error?` predicate
|
|
6
|
-
# simplifies error handling in controller handlers.
|
|
7
|
-
TaskEvent = Data.define(:name, :value, :error) do
|
|
8
|
-
def initialize(name:, value: nil, error: nil)
|
|
9
|
-
super
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
# Returns `true` when the task finished with a non-nil exception.
|
|
13
|
-
def error?
|
|
14
|
-
!error.nil?
|
|
15
|
-
end
|
|
16
|
-
end
|
|
17
|
-
end
|
data/lib/charming/timer_event.rb
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Charming
|
|
4
|
-
# TimerEvent represents a timed dispatch from the runtime loop. *name* is the declared timer identifier;
|
|
5
|
-
# *now* is the monotonically rising clock value at emission for throttle comparisons.
|
|
6
|
-
TimerEvent = Data.define(:name, :now)
|
|
7
|
-
end
|
data/lib/charming/ui/border.rb
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Charming
|
|
4
|
-
module UI
|
|
5
|
-
class Border
|
|
6
|
-
attr_reader :top_left, :top_right, :bottom_left, :bottom_right, :horizontal, :vertical
|
|
7
|
-
|
|
8
|
-
def initialize(corners:, edges:)
|
|
9
|
-
@top_left, @top_right, @bottom_left, @bottom_right = corners
|
|
10
|
-
@horizontal, @vertical = edges
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def self.fetch(name)
|
|
14
|
-
STYLES.fetch(name.to_sym)
|
|
15
|
-
end
|
|
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
|
|
32
|
-
end
|
|
33
|
-
end
|
data/lib/charming/ui/style.rb
DELETED
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Charming
|
|
4
|
-
module UI
|
|
5
|
-
class Style
|
|
6
|
-
ATTRIBUTES = {
|
|
7
|
-
bold: 1,
|
|
8
|
-
faint: 2,
|
|
9
|
-
italic: 3,
|
|
10
|
-
underline: 4,
|
|
11
|
-
reverse: 7,
|
|
12
|
-
strikethrough: 9
|
|
13
|
-
}.freeze
|
|
14
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
def initialize(options = {})
|
|
35
|
-
@options = {
|
|
36
|
-
attributes: [],
|
|
37
|
-
padding: [0, 0, 0, 0],
|
|
38
|
-
align: :left
|
|
39
|
-
}.merge(options)
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def foreground(color)
|
|
43
|
-
with(foreground: color)
|
|
44
|
-
end
|
|
45
|
-
alias_method :fg, :foreground
|
|
46
|
-
|
|
47
|
-
def background(color)
|
|
48
|
-
with(background: color)
|
|
49
|
-
end
|
|
50
|
-
alias_method :bg, :background
|
|
51
|
-
|
|
52
|
-
ATTRIBUTES.each_key do |attribute|
|
|
53
|
-
define_method(attribute) do
|
|
54
|
-
with(attributes: (@options.fetch(:attributes) + [attribute]).uniq)
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def padding(*values)
|
|
59
|
-
with(padding: expand_box_values(values))
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def border(style = :normal, sides: nil, foreground: nil)
|
|
63
|
-
with(border: style, border_sides: sides, border_foreground: foreground)
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def width(value)
|
|
67
|
-
with(width: value)
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def height(value)
|
|
71
|
-
with(height: value)
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def align(value)
|
|
75
|
-
with(align: value)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def render(value)
|
|
79
|
-
lines = apply_dimensions(value.to_s.lines(chomp: true))
|
|
80
|
-
lines = apply_padding(lines)
|
|
81
|
-
lines = apply_border(lines)
|
|
82
|
-
apply_ansi(lines.join("\n"))
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
private
|
|
86
|
-
|
|
87
|
-
def with(changes)
|
|
88
|
-
self.class.new(@options.merge(changes))
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def apply_dimensions(lines)
|
|
92
|
-
content_width = target_content_width(lines)
|
|
93
|
-
dimensioned = lines.map { |line| align_line(fit_line(line, content_width), content_width) }
|
|
94
|
-
apply_height(dimensioned, content_width)
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def target_content_width(lines)
|
|
98
|
-
explicit_width = @options[:width]
|
|
99
|
-
natural_width = lines.map { |line| Width.measure(line) }.max || 0
|
|
100
|
-
explicit_width || natural_width
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def fit_line(line, width)
|
|
104
|
-
return line if Width.measure(line) <= width
|
|
105
|
-
|
|
106
|
-
UI.visible_slice(line, 0, width)
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def apply_height(lines, width)
|
|
110
|
-
height = @options[:height]
|
|
111
|
-
return lines unless height
|
|
112
|
-
|
|
113
|
-
visible = lines.first(height)
|
|
114
|
-
visible + Array.new([height - visible.length, 0].max) { " " * width }
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
def apply_padding(lines)
|
|
118
|
-
top, right, bottom, left = @options.fetch(:padding)
|
|
119
|
-
inner_width = lines.map { |line| Width.measure(line) }.max || 0
|
|
120
|
-
empty = " " * (left + inner_width + right)
|
|
121
|
-
padded = lines.map do |line|
|
|
122
|
-
pad_line(line, inner_width, left, right)
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
Array.new(top, empty) + padded + Array.new(bottom, empty)
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
def apply_border(lines)
|
|
129
|
-
border_name = @options[:border]
|
|
130
|
-
return lines unless border_name
|
|
131
|
-
|
|
132
|
-
border = Border.fetch(border_name)
|
|
133
|
-
sides = Array(@options[:border_sides] || %i[top right bottom left]).map(&:to_sym)
|
|
134
|
-
width = lines.map { |line| Width.measure(line) }.max || 0
|
|
135
|
-
horizontal = border.horizontal * width
|
|
136
|
-
body = lines.map { |line| border_line(line, width, border, sides) }
|
|
137
|
-
|
|
138
|
-
[top_border(border, horizontal, sides), *body, bottom_border(border, horizontal, sides)].compact
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def pad_line(line, inner_width, left, right)
|
|
142
|
-
(" " * left) + line + (" " * (inner_width - Width.measure(line) + right))
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
def border_line(line, width, border, sides)
|
|
146
|
-
left = sides.include?(:left) ? render_border(border.vertical) : ""
|
|
147
|
-
right = sides.include?(:right) ? render_border(border.vertical) : ""
|
|
148
|
-
|
|
149
|
-
"#{left}#{line}#{" " * (width - Width.measure(line))}#{right}"
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def top_border(border, horizontal, sides)
|
|
153
|
-
return unless sides.include?(:top)
|
|
154
|
-
return render_border(horizontal) unless full_horizontal_border?(sides)
|
|
155
|
-
|
|
156
|
-
render_border("#{border.top_left}#{horizontal}#{border.top_right}")
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def bottom_border(border, horizontal, sides)
|
|
160
|
-
return unless sides.include?(:bottom)
|
|
161
|
-
return render_border(horizontal) unless full_horizontal_border?(sides)
|
|
162
|
-
|
|
163
|
-
render_border("#{border.bottom_left}#{horizontal}#{border.bottom_right}")
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def full_horizontal_border?(sides)
|
|
167
|
-
sides.include?(:left) && sides.include?(:right)
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def render_border(value)
|
|
171
|
-
border_foreground = @options[:border_foreground]
|
|
172
|
-
return value unless border_foreground
|
|
173
|
-
|
|
174
|
-
Style.new(foreground: border_foreground, background: @options[:background]).render(value)
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def apply_ansi(value)
|
|
178
|
-
codes = ansi_codes
|
|
179
|
-
return value if codes.empty?
|
|
180
|
-
|
|
181
|
-
start = "\e[#{codes.join(";")}m"
|
|
182
|
-
value.split("\n", -1).map { |line| "#{start}#{line.gsub("\e[0m", "\e[0m#{start}")}\e[0m" }.join("\n")
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
def ansi_codes
|
|
186
|
-
@options.fetch(:attributes).map { |attribute| ATTRIBUTES.fetch(attribute) } +
|
|
187
|
-
color_codes(@options[:foreground], foreground: true) +
|
|
188
|
-
color_codes(@options[:background], foreground: false)
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
def color_codes(color, foreground:)
|
|
192
|
-
return [] unless color
|
|
193
|
-
return indexed_color_code(color, foreground: foreground) if color.is_a?(Integer)
|
|
194
|
-
return named_color_code(color, foreground: foreground) if COLORS.key?(color.to_sym)
|
|
195
|
-
return truecolor_codes(color, foreground: foreground) if color.to_s.start_with?("#")
|
|
196
|
-
|
|
197
|
-
raise ArgumentError, "unknown color: #{color.inspect}"
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
def named_color_code(color, foreground:)
|
|
201
|
-
code = COLORS.fetch(color.to_sym)
|
|
202
|
-
[foreground ? code : code + 10]
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
def indexed_color_code(color, foreground:)
|
|
206
|
-
raise ArgumentError, "indexed color must be between 0 and 255" unless color.between?(0, 255)
|
|
207
|
-
|
|
208
|
-
[foreground ? 38 : 48, 5, color]
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
def truecolor_codes(color, foreground:)
|
|
212
|
-
hex = color.to_s.delete_prefix("#")
|
|
213
|
-
raise ArgumentError, "truecolor must be #rrggbb" unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
|
|
214
|
-
|
|
215
|
-
[foreground ? 38 : 48, 2, hex[0..1].to_i(16), hex[2..3].to_i(16), hex[4..5].to_i(16)]
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
def align_line(line, width)
|
|
219
|
-
remaining = width - Width.measure(line)
|
|
220
|
-
return line if remaining <= 0
|
|
221
|
-
|
|
222
|
-
case @options.fetch(:align)
|
|
223
|
-
when :right
|
|
224
|
-
(" " * remaining) + line
|
|
225
|
-
when :center
|
|
226
|
-
left = remaining / 2
|
|
227
|
-
(" " * left) + line + (" " * (remaining - left))
|
|
228
|
-
else
|
|
229
|
-
line + (" " * remaining)
|
|
230
|
-
end
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
def expand_box_values(values)
|
|
234
|
-
case values.length
|
|
235
|
-
when 1 then [values[0], values[0], values[0], values[0]]
|
|
236
|
-
when 2 then [values[0], values[1], values[0], values[1]]
|
|
237
|
-
when 4 then values
|
|
238
|
-
else
|
|
239
|
-
raise ArgumentError, "padding expects 1, 2, or 4 values"
|
|
240
|
-
end
|
|
241
|
-
end
|
|
242
|
-
end
|
|
243
|
-
end
|
|
244
|
-
end
|