charming 0.1.0 → 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 +38 -378
- data/lib/charming/application.rb +14 -3
- data/lib/charming/{application_model.rb → application_state.rb} +3 -3
- data/lib/charming/cli.rb +62 -3
- 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 +46 -448
- data/lib/charming/database_commands.rb +103 -0
- data/lib/charming/database_installer.rb +152 -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/focus.rb +58 -2
- data/lib/charming/generators/app_file_generator.rb +13 -0
- data/lib/charming/generators/app_generator.rb +147 -45
- 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 -14
- data/lib/charming/generators/model_generator.rb +128 -0
- data/lib/charming/generators/name.rb +10 -4
- data/lib/charming/generators/screen_generator.rb +84 -52
- 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 +26 -13
- data/lib/charming/internal/renderer/differential.rb +17 -3
- 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 +62 -115
- 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 +56 -0
- data/lib/charming/presentation/components/form/builder.rb +62 -0
- data/lib/charming/presentation/components/form/confirm.rb +69 -0
- data/lib/charming/presentation/components/form/field.rb +121 -0
- data/lib/charming/presentation/components/form/input.rb +71 -0
- data/lib/charming/presentation/components/form/note.rb +41 -0
- data/lib/charming/presentation/components/form/select.rb +112 -0
- data/lib/charming/presentation/components/form/textarea.rb +86 -0
- data/lib/charming/presentation/components/form.rb +156 -0
- data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
- data/lib/charming/presentation/components/list.rb +132 -0
- data/lib/charming/presentation/components/markdown.rb +31 -0
- data/lib/charming/presentation/components/modal.rb +64 -0
- data/lib/charming/presentation/components/progressbar.rb +70 -0
- data/lib/charming/presentation/components/spinner.rb +49 -0
- data/lib/charming/presentation/components/table.rb +143 -0
- data/lib/charming/presentation/components/text_area.rb +267 -0
- data/lib/charming/presentation/components/text_input.rb +129 -0
- data/lib/charming/presentation/components/viewport.rb +272 -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/layout.rb +43 -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 +113 -0
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +79 -0
- data/lib/charming/presentation/markdown.rb +11 -0
- data/lib/charming/presentation/template_view.rb +34 -0
- data/lib/charming/presentation/templates/erb_handler.rb +15 -0
- data/lib/charming/presentation/templates.rb +68 -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.rb +35 -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 +213 -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 +91 -0
- data/lib/charming/presentation/view.rb +135 -0
- data/lib/charming/runtime.rb +9 -7
- data/lib/charming/screen.rb +5 -1
- data/lib/charming/tasks/inline_executor.rb +37 -0
- data/lib/charming/tasks/task.rb +12 -0
- data/lib/charming/tasks/threaded_executor.rb +51 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +17 -0
- metadata +170 -36
- 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/generators/app_generator/app_spec_templates.rb +0 -86
- data/lib/charming/generators/app_generator/basic_templates.rb +0 -69
- data/lib/charming/generators/app_generator/component_templates.rb +0 -36
- data/lib/charming/generators/app_generator/controller_template.rb +0 -69
- data/lib/charming/generators/app_generator/layout_template.rb +0 -160
- data/lib/charming/generators/app_generator/model_templates.rb +0 -30
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -70
- data/lib/charming/generators/app_generator/view_template.rb +0 -90
- 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/task_executor.rb +0 -62
- 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
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
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.
|
|
9
|
+
module Templates
|
|
10
|
+
# A resolved template: an on-disk *path* paired with the *handler* responsible for rendering it.
|
|
11
|
+
ResolvedTemplate = Data.define(:path, :handler) do
|
|
12
|
+
# Renders the template against *view* by delegating to the registered handler.
|
|
13
|
+
def render(view)
|
|
14
|
+
handler.render(path, view)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Raised when no template file matches the given name under the application root.
|
|
19
|
+
MissingTemplateError = Class.new(Error)
|
|
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
|
|
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.
|
|
30
|
+
def resolve(name, root: nil)
|
|
31
|
+
views_root = File.join(root || Dir.pwd, "app", "views")
|
|
32
|
+
searched_paths = candidate_paths(views_root, name.to_s)
|
|
33
|
+
|
|
34
|
+
searched_paths.each do |path|
|
|
35
|
+
next unless File.file?(path)
|
|
36
|
+
|
|
37
|
+
return ResolvedTemplate.new(path: path, handler: handler_for(path))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
raise MissingTemplateError, "Missing template #{name.inspect}. Searched: #{searched_paths.join(", ")}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Hash of registered handlers keyed by extension. Populated by `register`.
|
|
44
|
+
def handlers
|
|
45
|
+
@handlers ||= {}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
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).
|
|
53
|
+
def candidate_paths(views_root, name)
|
|
54
|
+
path = File.expand_path(name, views_root)
|
|
55
|
+
return [path] if handler_for(path)
|
|
56
|
+
|
|
57
|
+
handlers.keys.map { |extension| "#{path}#{extension}" }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Looks up the handler whose registered extension matches the end of *path*. Returns nil
|
|
61
|
+
# when no handler matches.
|
|
62
|
+
def handler_for(path)
|
|
63
|
+
handlers.find { |extension, _handler| path.end_with?(extension) }&.last
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
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,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module UI
|
|
6
|
+
class Border
|
|
7
|
+
attr_reader :top_left, :top_right, :bottom_left, :bottom_right, :horizontal, :vertical
|
|
8
|
+
|
|
9
|
+
def initialize(corners:, edges:)
|
|
10
|
+
@top_left, @top_right, @bottom_left, @bottom_right = corners
|
|
11
|
+
@horizontal, @vertical = edges
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.fetch(name)
|
|
15
|
+
STYLES.fetch(name.to_sym)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
Border::STYLES = {
|
|
20
|
+
normal: Border.new(
|
|
21
|
+
corners: ["+", "+", "+", "+"], edges: ["-", "|"]
|
|
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
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
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
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
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.
|
|
9
|
+
class Style
|
|
10
|
+
ATTRIBUTES = ANSICodes::ATTRIBUTES
|
|
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`.
|
|
18
|
+
def initialize(options = {})
|
|
19
|
+
@options = {
|
|
20
|
+
attributes: [],
|
|
21
|
+
padding: [0, 0, 0, 0],
|
|
22
|
+
align: :left
|
|
23
|
+
}.merge(options)
|
|
24
|
+
end
|
|
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").
|
|
28
|
+
def foreground(color)
|
|
29
|
+
with(foreground: color)
|
|
30
|
+
end
|
|
31
|
+
alias_method :fg, :foreground
|
|
32
|
+
|
|
33
|
+
# Returns a new Style with the background *color* set.
|
|
34
|
+
def background(color)
|
|
35
|
+
with(background: color)
|
|
36
|
+
end
|
|
37
|
+
alias_method :bg, :background
|
|
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.
|
|
41
|
+
ATTRIBUTES.each_key do |attribute|
|
|
42
|
+
define_method(attribute) do
|
|
43
|
+
with(attributes: (@options.fetch(:attributes) + [attribute]).uniq)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
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].
|
|
49
|
+
def padding(*values)
|
|
50
|
+
with(padding: expand_box_values(values))
|
|
51
|
+
end
|
|
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.
|
|
56
|
+
def border(style = :normal, sides: nil, foreground: nil)
|
|
57
|
+
with(border: style, border_sides: sides, border_foreground: foreground)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns a new Style that fixes the rendered width to *value* (in display columns).
|
|
61
|
+
def width(value)
|
|
62
|
+
with(width: value)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Returns a new Style that fixes the rendered height to *value* (in rows).
|
|
66
|
+
def height(value)
|
|
67
|
+
with(height: value)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Returns a new Style with horizontal alignment set (`:left`, `:right`, or `:center`).
|
|
71
|
+
def align(value)
|
|
72
|
+
with(align: value)
|
|
73
|
+
end
|
|
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.
|
|
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
|
+
# Returns a copy of self with *changes* merged into the options hash.
|
|
88
|
+
def with(changes)
|
|
89
|
+
self.class.new(@options.merge(changes))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Wraps each line to the target width and applies horizontal alignment, then expands
|
|
93
|
+
# to the target height.
|
|
94
|
+
def apply_dimensions(lines)
|
|
95
|
+
content_width = target_content_width(lines)
|
|
96
|
+
dimensioned = lines.map { |line| align_line(fit_line(line, content_width), content_width) }
|
|
97
|
+
apply_height(dimensioned, content_width)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Returns the target content width: the explicit :width if set, otherwise the natural
|
|
101
|
+
# max display width of the lines.
|
|
102
|
+
def target_content_width(lines)
|
|
103
|
+
explicit_width = @options[:width]
|
|
104
|
+
natural_width = lines.map { |line| Width.measure(line) }.max || 0
|
|
105
|
+
explicit_width || natural_width
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Clips *line* to *width* display columns, preserving ANSI styling where possible.
|
|
109
|
+
def fit_line(line, width)
|
|
110
|
+
return line if Width.measure(line) <= width
|
|
111
|
+
|
|
112
|
+
UI.visible_slice(line, 0, width)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Truncates or pads the lines array to *height* rows, filling with blank rows.
|
|
116
|
+
def apply_height(lines, width)
|
|
117
|
+
height = @options[:height]
|
|
118
|
+
return lines unless height
|
|
119
|
+
|
|
120
|
+
visible = lines.first(height)
|
|
121
|
+
visible + Array.new([height - visible.length, 0].max) { " " * width }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Applies padding by prepending/appending blank rows (vertical) and indenting each
|
|
125
|
+
# line (horizontal).
|
|
126
|
+
def apply_padding(lines)
|
|
127
|
+
top, right, bottom, left = @options.fetch(:padding)
|
|
128
|
+
inner_width = lines.map { |line| Width.measure(line) }.max || 0
|
|
129
|
+
empty = " " * (left + inner_width + right)
|
|
130
|
+
padded = lines.map do |line|
|
|
131
|
+
pad_line(line, inner_width, left, right)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
Array.new(top, empty) + padded + Array.new(bottom, empty)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Paints the configured border around the lines, when :border is set.
|
|
138
|
+
def apply_border(lines)
|
|
139
|
+
border_name = @options[:border]
|
|
140
|
+
return lines unless border_name
|
|
141
|
+
|
|
142
|
+
border_painter(border_name).paint(lines, content_width(lines))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Pads a single line to *inner_width*, with *left* and *right* padding spaces.
|
|
146
|
+
def pad_line(line, inner_width, left, right)
|
|
147
|
+
(" " * left) + line + (" " * (inner_width - Width.measure(line) + right))
|
|
148
|
+
end
|
|
149
|
+
|
|
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
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
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
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Applies the active ANSI attribute/foreground/background codes to *value*.
|
|
166
|
+
def apply_ansi(value)
|
|
167
|
+
ansi_codes_obj.apply(value)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The list of active ANSI escape sequence strings (attribute + foreground + background).
|
|
171
|
+
def ansi_codes
|
|
172
|
+
ansi_codes_obj.codes
|
|
173
|
+
end
|
|
174
|
+
|
|
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
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Pads *line* on the left or right (or both, for :center) according to :align.
|
|
185
|
+
def align_line(line, width)
|
|
186
|
+
remaining = width - Width.measure(line)
|
|
187
|
+
return line if remaining <= 0
|
|
188
|
+
|
|
189
|
+
case @options.fetch(:align)
|
|
190
|
+
when :right
|
|
191
|
+
(" " * remaining) + line
|
|
192
|
+
when :center
|
|
193
|
+
left = remaining / 2
|
|
194
|
+
(" " * left) + line + (" " * (remaining - left))
|
|
195
|
+
else
|
|
196
|
+
line + (" " * remaining)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Normalizes 1/2/4 padding value arguments into a [top, right, bottom, left] array.
|
|
201
|
+
def expand_box_values(values)
|
|
202
|
+
case values.length
|
|
203
|
+
when 1 then [values[0], values[0], values[0], values[0]]
|
|
204
|
+
when 2 then [values[0], values[1], values[0], values[1]]
|
|
205
|
+
when 4 then values
|
|
206
|
+
else
|
|
207
|
+
raise ArgumentError, "padding expects 1, 2, or 4 values"
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|