charming 0.1.1 → 0.1.2
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 +2 -2
- data/lib/charming/application.rb +11 -0
- data/lib/charming/cli.rb +23 -0
- data/lib/charming/controller/class_methods.rb +115 -0
- data/lib/charming/controller/command_palette.rb +135 -0
- data/lib/charming/controller/component_dispatching.rb +81 -0
- data/lib/charming/controller/dispatching.rb +60 -0
- data/lib/charming/controller/focus_management.rb +30 -0
- data/lib/charming/controller/rendering.rb +127 -0
- data/lib/charming/controller/session_state.rb +41 -0
- data/lib/charming/controller/sidebar_navigation.rb +111 -0
- data/lib/charming/controller.rb +35 -559
- data/lib/charming/database_commands.rb +16 -0
- data/lib/charming/database_installer.rb +27 -0
- data/lib/charming/focus.rb +58 -2
- data/lib/charming/generators/app_file_generator.rb +13 -0
- data/lib/charming/generators/app_generator.rb +123 -47
- data/lib/charming/generators/base.rb +26 -0
- data/lib/charming/generators/component_generator.rb +10 -10
- data/lib/charming/generators/controller_generator.rb +22 -11
- data/lib/charming/generators/model_generator.rb +38 -29
- data/lib/charming/generators/name.rb +10 -0
- data/lib/charming/generators/screen_generator.rb +78 -32
- data/lib/charming/generators/templates/app/Gemfile.template +5 -0
- data/lib/charming/generators/templates/app/README.md.template +9 -0
- data/lib/charming/generators/templates/app/Rakefile.template +3 -0
- data/lib/charming/generators/templates/app/application.template +13 -0
- data/lib/charming/generators/templates/app/application_controller.template +19 -0
- data/lib/charming/generators/templates/app/application_record.template +7 -0
- data/lib/charming/generators/templates/app/application_state.template +6 -0
- data/lib/charming/generators/templates/app/database_config.template +12 -0
- data/lib/charming/generators/templates/app/executable.template +7 -0
- data/lib/charming/generators/templates/app/gemspec.template +6 -0
- data/lib/charming/generators/templates/app/home_controller.template +6 -0
- data/lib/charming/generators/templates/app/home_state.template +7 -0
- data/lib/charming/generators/templates/app/keep.template +0 -0
- data/lib/charming/generators/templates/app/layout.template +113 -0
- data/lib/charming/generators/templates/app/root_file.template +20 -0
- data/lib/charming/generators/templates/app/routes.template +5 -0
- data/lib/charming/generators/templates/app/seeds.template +1 -0
- data/lib/charming/generators/templates/app/spec_controller.template +17 -0
- data/lib/charming/generators/templates/app/spec_helper.template +3 -0
- data/lib/charming/generators/templates/app/spec_state.template +17 -0
- data/lib/charming/generators/templates/app/spec_view.template +16 -0
- data/lib/charming/generators/templates/app/version.template +5 -0
- data/lib/charming/generators/templates/app/view.template +21 -0
- data/lib/charming/generators/templates/component/component.rb.template +9 -0
- data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
- data/lib/charming/generators/templates/model/migration.rb.template +9 -0
- data/lib/charming/generators/templates/model/model.rb.template +6 -0
- data/lib/charming/generators/templates/model/spec.rb.template +9 -0
- data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
- data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
- data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
- data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
- data/lib/charming/generators/templates/screen/state.rb.template +7 -0
- data/lib/charming/generators/templates/screen/view.rb.template +11 -0
- data/lib/charming/generators/templates/view/view.rb.template +11 -0
- data/lib/charming/generators/view_generator.rb +19 -3
- data/lib/charming/internal/renderer/differential.rb +15 -0
- data/lib/charming/internal/renderer/full_repaint.rb +6 -0
- data/lib/charming/internal/terminal/adapter.rb +29 -3
- data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
- data/lib/charming/internal/terminal/memory_backend.rb +28 -1
- data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
- data/lib/charming/internal/terminal/tty_backend.rb +43 -113
- data/lib/charming/presentation/components/empty_state.rb +13 -0
- data/lib/charming/presentation/components/form/builder.rb +14 -0
- data/lib/charming/presentation/components/form/confirm.rb +13 -0
- data/lib/charming/presentation/components/form/field.rb +25 -0
- data/lib/charming/presentation/components/form/input.rb +14 -0
- data/lib/charming/presentation/components/form/note.rb +9 -0
- data/lib/charming/presentation/components/form/select.rb +23 -0
- data/lib/charming/presentation/components/form/textarea.rb +16 -0
- data/lib/charming/presentation/components/form.rb +29 -0
- data/lib/charming/presentation/components/list.rb +28 -0
- data/lib/charming/presentation/components/markdown.rb +6 -0
- data/lib/charming/presentation/components/modal.rb +14 -0
- data/lib/charming/presentation/components/progressbar.rb +13 -0
- data/lib/charming/presentation/components/spinner.rb +10 -0
- data/lib/charming/presentation/components/table.rb +25 -0
- data/lib/charming/presentation/components/text_area.rb +48 -0
- data/lib/charming/presentation/components/text_input.rb +24 -0
- data/lib/charming/presentation/components/viewport.rb +52 -0
- data/lib/charming/presentation/layout/builder.rb +86 -0
- data/lib/charming/presentation/layout/overlay.rb +57 -0
- data/lib/charming/presentation/layout/pane.rb +145 -0
- data/lib/charming/presentation/layout/rect.rb +23 -0
- data/lib/charming/presentation/layout/screen_layout.rb +60 -0
- data/lib/charming/presentation/layout/split.rb +134 -0
- data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
- data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
- data/lib/charming/presentation/markdown/render_context.rb +22 -0
- data/lib/charming/presentation/markdown/renderer.rb +45 -135
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +16 -0
- data/lib/charming/presentation/markdown.rb +3 -0
- data/lib/charming/presentation/template_view.rb +7 -0
- data/lib/charming/presentation/templates.rb +17 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
- data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
- data/lib/charming/presentation/ui/border_painter.rb +58 -0
- data/lib/charming/presentation/ui/canvas.rb +82 -0
- data/lib/charming/presentation/ui/style.rb +62 -95
- data/lib/charming/presentation/ui.rb +15 -156
- data/lib/charming/presentation/view.rb +17 -0
- data/lib/charming/runtime.rb +2 -0
- data/lib/charming/tasks/inline_executor.rb +9 -0
- data/lib/charming/tasks/task.rb +3 -0
- data/lib/charming/tasks/threaded_executor.rb +12 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +13 -0
- metadata +59 -10
- data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
- data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
- data/lib/charming/generators/app_generator/component_templates.rb +0 -36
- data/lib/charming/generators/app_generator/controller_template.rb +0 -60
- data/lib/charming/generators/app_generator/database_templates.rb +0 -45
- data/lib/charming/generators/app_generator/layout_template.rb +0 -66
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
- data/lib/charming/generators/app_generator/state_templates.rb +0 -30
- data/lib/charming/generators/app_generator/view_template.rb +0 -84
|
@@ -2,20 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
4
|
module Presentation
|
|
5
|
+
# Templates resolves and renders view templates by name. Template handlers are registered
|
|
6
|
+
# for file extensions (e.g., `.tui.erb`) and the resolver searches `app/views/<name><ext>`
|
|
7
|
+
# under the application root, falling back through registered extensions when the first
|
|
8
|
+
# match is not found.
|
|
5
9
|
module Templates
|
|
10
|
+
# A resolved template: an on-disk *path* paired with the *handler* responsible for rendering it.
|
|
6
11
|
ResolvedTemplate = Data.define(:path, :handler) do
|
|
12
|
+
# Renders the template against *view* by delegating to the registered handler.
|
|
7
13
|
def render(view)
|
|
8
14
|
handler.render(path, view)
|
|
9
15
|
end
|
|
10
16
|
end
|
|
11
17
|
|
|
18
|
+
# Raised when no template file matches the given name under the application root.
|
|
12
19
|
MissingTemplateError = Class.new(Error)
|
|
13
20
|
|
|
14
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)`.
|
|
15
24
|
def register(extension, handler)
|
|
16
25
|
handlers[extension] = handler
|
|
17
26
|
end
|
|
18
27
|
|
|
28
|
+
# Resolves a template by *name* under `app/views` of *root* (defaults to the current
|
|
29
|
+
# working directory). Raises MissingTemplateError when no matching file exists.
|
|
19
30
|
def resolve(name, root: nil)
|
|
20
31
|
views_root = File.join(root || Dir.pwd, "app", "views")
|
|
21
32
|
searched_paths = candidate_paths(views_root, name.to_s)
|
|
@@ -29,12 +40,16 @@ module Charming
|
|
|
29
40
|
raise MissingTemplateError, "Missing template #{name.inspect}. Searched: #{searched_paths.join(", ")}"
|
|
30
41
|
end
|
|
31
42
|
|
|
43
|
+
# Hash of registered handlers keyed by extension. Populated by `register`.
|
|
32
44
|
def handlers
|
|
33
45
|
@handlers ||= {}
|
|
34
46
|
end
|
|
35
47
|
|
|
36
48
|
private
|
|
37
49
|
|
|
50
|
+
# Returns candidate paths under *views_root* for *name*. When the bare path has a known
|
|
51
|
+
# extension, returns it directly; otherwise returns the path with each registered extension
|
|
52
|
+
# appended (in registration order).
|
|
38
53
|
def candidate_paths(views_root, name)
|
|
39
54
|
path = File.expand_path(name, views_root)
|
|
40
55
|
return [path] if handler_for(path)
|
|
@@ -42,6 +57,8 @@ module Charming
|
|
|
42
57
|
handlers.keys.map { |extension| "#{path}#{extension}" }
|
|
43
58
|
end
|
|
44
59
|
|
|
60
|
+
# Looks up the handler whose registered extension matches the end of *path*. Returns nil
|
|
61
|
+
# when no handler matches.
|
|
45
62
|
def handler_for(path)
|
|
46
63
|
handlers.find { |extension, _handler| path.end_with?(extension) }&.last
|
|
47
64
|
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module UI
|
|
6
|
+
class ANSICodes
|
|
7
|
+
ATTRIBUTES = {
|
|
8
|
+
bold: 1,
|
|
9
|
+
faint: 2,
|
|
10
|
+
italic: 3,
|
|
11
|
+
underline: 4,
|
|
12
|
+
reverse: 7,
|
|
13
|
+
strikethrough: 9
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
COLORS = {
|
|
17
|
+
black: 30,
|
|
18
|
+
red: 31,
|
|
19
|
+
green: 32,
|
|
20
|
+
yellow: 33,
|
|
21
|
+
blue: 34,
|
|
22
|
+
magenta: 35,
|
|
23
|
+
cyan: 36,
|
|
24
|
+
white: 37,
|
|
25
|
+
bright_black: 90,
|
|
26
|
+
bright_red: 91,
|
|
27
|
+
bright_green: 92,
|
|
28
|
+
bright_yellow: 93,
|
|
29
|
+
bright_blue: 94,
|
|
30
|
+
bright_magenta: 95,
|
|
31
|
+
bright_cyan: 96,
|
|
32
|
+
bright_white: 97
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
def initialize(attributes:, foreground:, background:)
|
|
36
|
+
@attributes = attributes
|
|
37
|
+
@foreground = foreground
|
|
38
|
+
@background = background
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def codes
|
|
42
|
+
@codes ||= attribute_codes +
|
|
43
|
+
color_codes(@foreground, foreground: true) +
|
|
44
|
+
color_codes(@background, foreground: false)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def apply(value)
|
|
48
|
+
return value if codes.empty?
|
|
49
|
+
|
|
50
|
+
start = "\e[#{codes.join(";")}m"
|
|
51
|
+
value.split("\n", -1).map { |line| "#{start}#{line.gsub("\e[0m", "\e[0m#{start}")}\e[0m" }.join("\n")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def attribute_codes
|
|
57
|
+
@attributes.map { |attribute| ATTRIBUTES.fetch(attribute) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def color_codes(color, foreground:)
|
|
61
|
+
return [] unless color
|
|
62
|
+
return indexed_color_code(color, foreground: foreground) if color.is_a?(Integer)
|
|
63
|
+
return named_color_code(color, foreground: foreground) if COLORS.key?(color.to_sym)
|
|
64
|
+
return truecolor_codes(color, foreground: foreground) if color.to_s.start_with?("#")
|
|
65
|
+
|
|
66
|
+
raise ArgumentError, "unknown color: #{color.inspect}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def named_color_code(color, foreground:)
|
|
70
|
+
code = COLORS.fetch(color.to_sym)
|
|
71
|
+
[foreground ? code : code + 10]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def indexed_color_code(color, foreground:)
|
|
75
|
+
raise ArgumentError, "indexed color must be between 0 and 255" unless color.between?(0, 255)
|
|
76
|
+
|
|
77
|
+
[foreground ? 38 : 48, 5, color]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def truecolor_codes(color, foreground:)
|
|
81
|
+
hex = color.to_s.delete_prefix("#")
|
|
82
|
+
raise ArgumentError, "truecolor must be #rrggbb" unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
|
|
83
|
+
|
|
84
|
+
[foreground ? 38 : 48, 2, hex[0..1].to_i(16), hex[2..3].to_i(16), hex[4..5].to_i(16)]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module UI
|
|
6
|
+
# ANSISlicer extracts a visible substring from a string that may contain ANSI
|
|
7
|
+
# escape sequences, preserving the styling that is active at the start of
|
|
8
|
+
# the slice and emitting a trailing reset if any styled content was copied.
|
|
9
|
+
class ANSISlicer
|
|
10
|
+
def self.slice(line, start_column, width)
|
|
11
|
+
return "" unless width.positive?
|
|
12
|
+
|
|
13
|
+
slice_range(line.to_s, start_column, start_column + width)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.slice_range(line, start_column, end_column)
|
|
17
|
+
state = {column: 0, output: +"", active: [], started: false, styled: false}
|
|
18
|
+
|
|
19
|
+
each_ansi_or_char(line) do |token, ansi|
|
|
20
|
+
if ansi
|
|
21
|
+
slice_ansi_token(token, state, start_column, end_column)
|
|
22
|
+
else
|
|
23
|
+
slice_char(token, state, start_column, end_column)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
terminate_slice(state)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.each_ansi_or_char(line)
|
|
31
|
+
index = 0
|
|
32
|
+
while index < line.length
|
|
33
|
+
match = line.match(Width::ANSI_PATTERN, index)
|
|
34
|
+
if match&.begin(0) == index
|
|
35
|
+
yield match[0], true
|
|
36
|
+
index = match.end(0)
|
|
37
|
+
else
|
|
38
|
+
yield line[index], false
|
|
39
|
+
index += 1
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.slice_ansi_token(token, state, start_column, end_column)
|
|
45
|
+
started = state[:started]
|
|
46
|
+
update_active_styles(state[:active], token)
|
|
47
|
+
return unless state[:column].between?(start_column, end_column - 1)
|
|
48
|
+
|
|
49
|
+
start_slice(state)
|
|
50
|
+
if started
|
|
51
|
+
state[:output] << token
|
|
52
|
+
state[:styled] = !token.include?("[0m")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.slice_char(char, state, start_column, end_column)
|
|
57
|
+
char_width = Width.measure(char)
|
|
58
|
+
char_start = state[:column]
|
|
59
|
+
char_end = char_start + char_width
|
|
60
|
+
state[:column] = char_end
|
|
61
|
+
return unless char_end > start_column && char_start < end_column
|
|
62
|
+
|
|
63
|
+
start_slice(state)
|
|
64
|
+
state[:output] << char
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.start_slice(state)
|
|
68
|
+
return if state[:started]
|
|
69
|
+
|
|
70
|
+
state[:output] << state[:active].join
|
|
71
|
+
state[:styled] = true unless state[:active].empty?
|
|
72
|
+
state[:started] = true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.terminate_slice(state)
|
|
76
|
+
return state[:output] if !state[:styled] || state[:output].empty?
|
|
77
|
+
|
|
78
|
+
"#{state[:output]}\e[0m"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.update_active_styles(active, token)
|
|
82
|
+
if token.include?("[0m")
|
|
83
|
+
active.clear
|
|
84
|
+
else
|
|
85
|
+
active << token
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private_class_method :each_ansi_or_char, :slice_ansi_token, :slice_char,
|
|
90
|
+
:start_slice, :terminate_slice, :update_active_styles
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module UI
|
|
6
|
+
class BorderPainter
|
|
7
|
+
DEFAULT_SIDES = %i[top right bottom left].freeze
|
|
8
|
+
|
|
9
|
+
def initialize(border:, sides: nil, foreground: nil, background: nil)
|
|
10
|
+
@border = border
|
|
11
|
+
@sides = Array(sides || DEFAULT_SIDES).map(&:to_sym)
|
|
12
|
+
@foreground = foreground
|
|
13
|
+
@background = background
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def paint(lines, inner_width)
|
|
17
|
+
horizontal = @border.horizontal * inner_width
|
|
18
|
+
body = lines.map { |line| border_line(line, inner_width) }
|
|
19
|
+
|
|
20
|
+
[top_border(horizontal), *body, bottom_border(horizontal)].compact
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def border_line(line, width)
|
|
26
|
+
left = @sides.include?(:left) ? render_border(@border.vertical) : ""
|
|
27
|
+
right = @sides.include?(:right) ? render_border(@border.vertical) : ""
|
|
28
|
+
|
|
29
|
+
"#{left}#{line}#{" " * (width - Width.measure(line))}#{right}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def top_border(horizontal)
|
|
33
|
+
return unless @sides.include?(:top)
|
|
34
|
+
return render_border(horizontal) unless full_horizontal?
|
|
35
|
+
|
|
36
|
+
render_border("#{@border.top_left}#{horizontal}#{@border.top_right}")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def bottom_border(horizontal)
|
|
40
|
+
return unless @sides.include?(:bottom)
|
|
41
|
+
return render_border(horizontal) unless full_horizontal?
|
|
42
|
+
|
|
43
|
+
render_border("#{@border.bottom_left}#{horizontal}#{@border.bottom_right}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def full_horizontal?
|
|
47
|
+
@sides.include?(:left) && @sides.include?(:right)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def render_border(value)
|
|
51
|
+
return value unless @foreground
|
|
52
|
+
|
|
53
|
+
Style.new(foreground: @foreground, background: @background).render(value)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module UI
|
|
6
|
+
# Canvas is a 2D character grid of fixed width and height that supports
|
|
7
|
+
# placing content at (row, column) coordinates and overlaying one block
|
|
8
|
+
# on top of another. Construct via .new(width, height) for a blank grid
|
|
9
|
+
# or .parse(string) to reconstruct from rendered output.
|
|
10
|
+
class Canvas
|
|
11
|
+
def initialize(width, height)
|
|
12
|
+
@width = width
|
|
13
|
+
@height = height
|
|
14
|
+
@grid = Array.new(height) { " " * width }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.parse(string)
|
|
18
|
+
lines = string.to_s.lines(chomp: true)
|
|
19
|
+
width = UI.block_width(lines)
|
|
20
|
+
canvas = new(width, lines.length)
|
|
21
|
+
lines.each_with_index { |line, i| canvas.instance_variable_get(:@grid)[i] = line }
|
|
22
|
+
canvas
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_s
|
|
26
|
+
@grid.join("\n")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def place(block, top: 0, left: 0, background: nil)
|
|
30
|
+
lines = block.to_s.lines(chomp: true)
|
|
31
|
+
row = Canvas.offset(top, @height, lines.length)
|
|
32
|
+
column = Canvas.offset(left, @width, UI.block_width(lines))
|
|
33
|
+
draw_lines(lines, row: row, column: column, onto: @grid)
|
|
34
|
+
rendered = to_s
|
|
35
|
+
background ? UI::Style.new.background(background).render(rendered) : rendered
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def overlay(other, top: :center, left: :center)
|
|
39
|
+
overlay_lines = other.to_s.lines(chomp: true)
|
|
40
|
+
row = Canvas.offset(top, @grid.length, overlay_lines.length)
|
|
41
|
+
column = Canvas.offset(left, @width, UI.block_width(overlay_lines))
|
|
42
|
+
draw_lines(overlay_lines, row: row, column: column, onto: @grid)
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.offset(value, available, size)
|
|
47
|
+
return [(available - size) / 2, 0].max if value == :center
|
|
48
|
+
|
|
49
|
+
value
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def draw_lines(lines, row:, column:, onto:)
|
|
55
|
+
lines.each_with_index do |line, index|
|
|
56
|
+
line_index = row + index
|
|
57
|
+
next if line_index.negative? || line_index >= onto.length
|
|
58
|
+
|
|
59
|
+
onto[line_index] = compose_line(onto[line_index], line, column)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def compose_line(base_line, overlay_line, column)
|
|
64
|
+
return ANSISlicer.slice(base_line, 0, @width) if column >= @width
|
|
65
|
+
return ANSISlicer.slice(base_line, 0, @width) if column + Width.measure(overlay_line) <= 0
|
|
66
|
+
|
|
67
|
+
target_column = [column, 0].max
|
|
68
|
+
overlay_start = [0 - column, 0].max
|
|
69
|
+
overlay = ANSISlicer.slice(overlay_line, overlay_start, @width - target_column)
|
|
70
|
+
overlay_width = Width.measure(overlay)
|
|
71
|
+
return ANSISlicer.slice(base_line, 0, @width) if overlay_width.zero?
|
|
72
|
+
|
|
73
|
+
right_column = target_column + overlay_width
|
|
74
|
+
|
|
75
|
+
ANSISlicer.slice(base_line, 0, target_column) +
|
|
76
|
+
overlay +
|
|
77
|
+
ANSISlicer.slice(base_line, right_column, [@width - right_column, 0].max)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -3,35 +3,18 @@
|
|
|
3
3
|
module Charming
|
|
4
4
|
module Presentation
|
|
5
5
|
module UI
|
|
6
|
+
# Style is an immutable builder for terminal text styling. Every method returns a new
|
|
7
|
+
# Style instance with the requested attribute added, so styles can be safely chained and
|
|
8
|
+
# shared across views. `render(value)` applies the accumulated style to a string.
|
|
6
9
|
class Style
|
|
7
|
-
ATTRIBUTES =
|
|
8
|
-
bold: 1,
|
|
9
|
-
faint: 2,
|
|
10
|
-
italic: 3,
|
|
11
|
-
underline: 4,
|
|
12
|
-
reverse: 7,
|
|
13
|
-
strikethrough: 9
|
|
14
|
-
}.freeze
|
|
15
|
-
|
|
16
|
-
COLORS = {
|
|
17
|
-
black: 30,
|
|
18
|
-
red: 31,
|
|
19
|
-
green: 32,
|
|
20
|
-
yellow: 33,
|
|
21
|
-
blue: 34,
|
|
22
|
-
magenta: 35,
|
|
23
|
-
cyan: 36,
|
|
24
|
-
white: 37,
|
|
25
|
-
bright_black: 90,
|
|
26
|
-
bright_red: 91,
|
|
27
|
-
bright_green: 92,
|
|
28
|
-
bright_yellow: 93,
|
|
29
|
-
bright_blue: 94,
|
|
30
|
-
bright_magenta: 95,
|
|
31
|
-
bright_cyan: 96,
|
|
32
|
-
bright_white: 97
|
|
33
|
-
}.freeze
|
|
10
|
+
ATTRIBUTES = ANSICodes::ATTRIBUTES
|
|
34
11
|
|
|
12
|
+
COLORS = ANSICodes::COLORS
|
|
13
|
+
|
|
14
|
+
# Initializes a new style with an optional options hash. Recognized keys: `:attributes`
|
|
15
|
+
# (array of attribute symbols), `:padding` ([top, right, bottom, left]), `:align`
|
|
16
|
+
# (`:left`/`:right`/`:center`), and any of `:foreground`, `:background`, `:border`,
|
|
17
|
+
# `:border_sides`, `:border_foreground`, `:width`, `:height`.
|
|
35
18
|
def initialize(options = {})
|
|
36
19
|
@options = {
|
|
37
20
|
attributes: [],
|
|
@@ -40,42 +23,58 @@ module Charming
|
|
|
40
23
|
}.merge(options)
|
|
41
24
|
end
|
|
42
25
|
|
|
26
|
+
# Returns a new Style with the foreground *color* set. *color* is a color name (":red"),
|
|
27
|
+
# 256-color index (integer), or hex string ("#rrggbb").
|
|
43
28
|
def foreground(color)
|
|
44
29
|
with(foreground: color)
|
|
45
30
|
end
|
|
46
31
|
alias_method :fg, :foreground
|
|
47
32
|
|
|
33
|
+
# Returns a new Style with the background *color* set.
|
|
48
34
|
def background(color)
|
|
49
35
|
with(background: color)
|
|
50
36
|
end
|
|
51
37
|
alias_method :bg, :background
|
|
52
38
|
|
|
39
|
+
# Attribute methods (bold, italic, underline, …) are defined dynamically by the
|
|
40
|
+
# metaprogramming loop below. Each toggles a single text attribute on the style.
|
|
53
41
|
ATTRIBUTES.each_key do |attribute|
|
|
54
42
|
define_method(attribute) do
|
|
55
43
|
with(attributes: (@options.fetch(:attributes) + [attribute]).uniq)
|
|
56
44
|
end
|
|
57
45
|
end
|
|
58
46
|
|
|
47
|
+
# Returns a new Style with the padding set. Accepts 1, 2, or 4 values following CSS-style
|
|
48
|
+
# shorthand: 1 → all sides, 2 → [vertical, horizontal], 4 → [top, right, bottom, left].
|
|
59
49
|
def padding(*values)
|
|
60
50
|
with(padding: expand_box_values(values))
|
|
61
51
|
end
|
|
62
52
|
|
|
53
|
+
# Returns a new Style with the border set. *style* is a border name (e.g., :normal,
|
|
54
|
+
# :rounded). *sides* optionally restricts the border to specific sides. *foreground*
|
|
55
|
+
# sets the border color.
|
|
63
56
|
def border(style = :normal, sides: nil, foreground: nil)
|
|
64
57
|
with(border: style, border_sides: sides, border_foreground: foreground)
|
|
65
58
|
end
|
|
66
59
|
|
|
60
|
+
# Returns a new Style that fixes the rendered width to *value* (in display columns).
|
|
67
61
|
def width(value)
|
|
68
62
|
with(width: value)
|
|
69
63
|
end
|
|
70
64
|
|
|
65
|
+
# Returns a new Style that fixes the rendered height to *value* (in rows).
|
|
71
66
|
def height(value)
|
|
72
67
|
with(height: value)
|
|
73
68
|
end
|
|
74
69
|
|
|
70
|
+
# Returns a new Style with horizontal alignment set (`:left`, `:right`, or `:center`).
|
|
75
71
|
def align(value)
|
|
76
72
|
with(align: value)
|
|
77
73
|
end
|
|
78
74
|
|
|
75
|
+
# Applies the configured style to *value* and returns the styled string. Steps:
|
|
76
|
+
# 1. wrap to `:width`, 2. align horizontally, 3. expand to `:height`, 4. apply padding,
|
|
77
|
+
# 5. paint border, 6. emit ANSI attribute/foreground/background escapes.
|
|
79
78
|
def render(value)
|
|
80
79
|
lines = apply_dimensions(value.to_s.lines(chomp: true))
|
|
81
80
|
lines = apply_padding(lines)
|
|
@@ -85,28 +84,35 @@ module Charming
|
|
|
85
84
|
|
|
86
85
|
private
|
|
87
86
|
|
|
87
|
+
# Returns a copy of self with *changes* merged into the options hash.
|
|
88
88
|
def with(changes)
|
|
89
89
|
self.class.new(@options.merge(changes))
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
+
# Wraps each line to the target width and applies horizontal alignment, then expands
|
|
93
|
+
# to the target height.
|
|
92
94
|
def apply_dimensions(lines)
|
|
93
95
|
content_width = target_content_width(lines)
|
|
94
96
|
dimensioned = lines.map { |line| align_line(fit_line(line, content_width), content_width) }
|
|
95
97
|
apply_height(dimensioned, content_width)
|
|
96
98
|
end
|
|
97
99
|
|
|
100
|
+
# Returns the target content width: the explicit :width if set, otherwise the natural
|
|
101
|
+
# max display width of the lines.
|
|
98
102
|
def target_content_width(lines)
|
|
99
103
|
explicit_width = @options[:width]
|
|
100
104
|
natural_width = lines.map { |line| Width.measure(line) }.max || 0
|
|
101
105
|
explicit_width || natural_width
|
|
102
106
|
end
|
|
103
107
|
|
|
108
|
+
# Clips *line* to *width* display columns, preserving ANSI styling where possible.
|
|
104
109
|
def fit_line(line, width)
|
|
105
110
|
return line if Width.measure(line) <= width
|
|
106
111
|
|
|
107
112
|
UI.visible_slice(line, 0, width)
|
|
108
113
|
end
|
|
109
114
|
|
|
115
|
+
# Truncates or pads the lines array to *height* rows, filling with blank rows.
|
|
110
116
|
def apply_height(lines, width)
|
|
111
117
|
height = @options[:height]
|
|
112
118
|
return lines unless height
|
|
@@ -115,6 +121,8 @@ module Charming
|
|
|
115
121
|
visible + Array.new([height - visible.length, 0].max) { " " * width }
|
|
116
122
|
end
|
|
117
123
|
|
|
124
|
+
# Applies padding by prepending/appending blank rows (vertical) and indenting each
|
|
125
|
+
# line (horizontal).
|
|
118
126
|
def apply_padding(lines)
|
|
119
127
|
top, right, bottom, left = @options.fetch(:padding)
|
|
120
128
|
inner_width = lines.map { |line| Width.measure(line) }.max || 0
|
|
@@ -126,96 +134,54 @@ module Charming
|
|
|
126
134
|
Array.new(top, empty) + padded + Array.new(bottom, empty)
|
|
127
135
|
end
|
|
128
136
|
|
|
137
|
+
# Paints the configured border around the lines, when :border is set.
|
|
129
138
|
def apply_border(lines)
|
|
130
139
|
border_name = @options[:border]
|
|
131
140
|
return lines unless border_name
|
|
132
141
|
|
|
133
|
-
|
|
134
|
-
sides = Array(@options[:border_sides] || %i[top right bottom left]).map(&:to_sym)
|
|
135
|
-
width = lines.map { |line| Width.measure(line) }.max || 0
|
|
136
|
-
horizontal = border.horizontal * width
|
|
137
|
-
body = lines.map { |line| border_line(line, width, border, sides) }
|
|
138
|
-
|
|
139
|
-
[top_border(border, horizontal, sides), *body, bottom_border(border, horizontal, sides)].compact
|
|
142
|
+
border_painter(border_name).paint(lines, content_width(lines))
|
|
140
143
|
end
|
|
141
144
|
|
|
145
|
+
# Pads a single line to *inner_width*, with *left* and *right* padding spaces.
|
|
142
146
|
def pad_line(line, inner_width, left, right)
|
|
143
147
|
(" " * left) + line + (" " * (inner_width - Width.measure(line) + right))
|
|
144
148
|
end
|
|
145
149
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return unless sides.include?(:top)
|
|
155
|
-
return render_border(horizontal) unless full_horizontal_border?(sides)
|
|
156
|
-
|
|
157
|
-
render_border("#{border.top_left}#{horizontal}#{border.top_right}")
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
def bottom_border(border, horizontal, sides)
|
|
161
|
-
return unless sides.include?(:bottom)
|
|
162
|
-
return render_border(horizontal) unless full_horizontal_border?(sides)
|
|
163
|
-
|
|
164
|
-
render_border("#{border.bottom_left}#{horizontal}#{border.bottom_right}")
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
def full_horizontal_border?(sides)
|
|
168
|
-
sides.include?(:left) && sides.include?(:right)
|
|
150
|
+
# Builds a BorderPainter configured for the current border options.
|
|
151
|
+
def border_painter(border_name)
|
|
152
|
+
BorderPainter.new(
|
|
153
|
+
border: Border.fetch(border_name),
|
|
154
|
+
sides: @options[:border_sides],
|
|
155
|
+
foreground: @options[:border_foreground],
|
|
156
|
+
background: @options[:background]
|
|
157
|
+
)
|
|
169
158
|
end
|
|
170
159
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
Style.new(foreground: border_foreground, background: @options[:background]).render(value)
|
|
160
|
+
# Returns the natural display width of the longest line in *lines*.
|
|
161
|
+
def content_width(lines)
|
|
162
|
+
lines.map { |line| Width.measure(line) }.max || 0
|
|
176
163
|
end
|
|
177
164
|
|
|
165
|
+
# Applies the active ANSI attribute/foreground/background codes to *value*.
|
|
178
166
|
def apply_ansi(value)
|
|
179
|
-
|
|
180
|
-
return value if codes.empty?
|
|
181
|
-
|
|
182
|
-
start = "\e[#{codes.join(";")}m"
|
|
183
|
-
value.split("\n", -1).map { |line| "#{start}#{line.gsub("\e[0m", "\e[0m#{start}")}\e[0m" }.join("\n")
|
|
167
|
+
ansi_codes_obj.apply(value)
|
|
184
168
|
end
|
|
185
169
|
|
|
170
|
+
# The list of active ANSI escape sequence strings (attribute + foreground + background).
|
|
186
171
|
def ansi_codes
|
|
187
|
-
|
|
188
|
-
color_codes(@options[:foreground], foreground: true) +
|
|
189
|
-
color_codes(@options[:background], foreground: false)
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
def color_codes(color, foreground:)
|
|
193
|
-
return [] unless color
|
|
194
|
-
return indexed_color_code(color, foreground: foreground) if color.is_a?(Integer)
|
|
195
|
-
return named_color_code(color, foreground: foreground) if COLORS.key?(color.to_sym)
|
|
196
|
-
return truecolor_codes(color, foreground: foreground) if color.to_s.start_with?("#")
|
|
197
|
-
|
|
198
|
-
raise ArgumentError, "unknown color: #{color.inspect}"
|
|
172
|
+
ansi_codes_obj.codes
|
|
199
173
|
end
|
|
200
174
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
[foreground ? 38 : 48, 5, color]
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
def truecolor_codes(color, foreground:)
|
|
213
|
-
hex = color.to_s.delete_prefix("#")
|
|
214
|
-
raise ArgumentError, "truecolor must be #rrggbb" unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
|
|
215
|
-
|
|
216
|
-
[foreground ? 38 : 48, 2, hex[0..1].to_i(16), hex[2..3].to_i(16), hex[4..5].to_i(16)]
|
|
175
|
+
# Builds an ANSICodes object from the active attributes, foreground, and background.
|
|
176
|
+
def ansi_codes_obj
|
|
177
|
+
ANSICodes.new(
|
|
178
|
+
attributes: @options.fetch(:attributes),
|
|
179
|
+
foreground: @options[:foreground],
|
|
180
|
+
background: @options[:background]
|
|
181
|
+
)
|
|
217
182
|
end
|
|
218
183
|
|
|
184
|
+
# Pads *line* on the left or right (or both, for :center) according to :align.
|
|
219
185
|
def align_line(line, width)
|
|
220
186
|
remaining = width - Width.measure(line)
|
|
221
187
|
return line if remaining <= 0
|
|
@@ -231,6 +197,7 @@ module Charming
|
|
|
231
197
|
end
|
|
232
198
|
end
|
|
233
199
|
|
|
200
|
+
# Normalizes 1/2/4 padding value arguments into a [top, right, bottom, left] array.
|
|
234
201
|
def expand_box_values(values)
|
|
235
202
|
case values.length
|
|
236
203
|
when 1 then [values[0], values[0], values[0], values[0]]
|