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,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Charming
|
|
6
|
+
module Presentation
|
|
7
|
+
module UI
|
|
8
|
+
class Theme
|
|
9
|
+
BUILT_IN_ROOT = File.expand_path("themes", __dir__)
|
|
10
|
+
|
|
11
|
+
DEFAULT_TOKENS = {
|
|
12
|
+
text: {foreground: :bright_white},
|
|
13
|
+
title: {foreground: :bright_cyan, bold: true},
|
|
14
|
+
muted: {foreground: :bright_black},
|
|
15
|
+
border: {foreground: :bright_magenta},
|
|
16
|
+
selected: {reverse: true},
|
|
17
|
+
info: {foreground: :bright_cyan},
|
|
18
|
+
warn: {foreground: :yellow}
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def self.default
|
|
22
|
+
@default ||= load_builtin("phosphor")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.load_file(path)
|
|
26
|
+
from_hash(JSON.parse(File.read(path)))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.load_builtin(name)
|
|
30
|
+
load_file(built_in_path(name))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.built_in_names
|
|
34
|
+
Dir.glob(File.join(BUILT_IN_ROOT, "*.json")).map { |path| File.basename(path, ".json") }.sort
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.from_hash(value)
|
|
38
|
+
raise ArgumentError, "theme file must contain an object" unless value.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
styles = value.fetch("styles") do
|
|
41
|
+
raise ArgumentError, "theme file must contain styles"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
palette = value.fetch("palette", {})
|
|
45
|
+
new(
|
|
46
|
+
resolve_palette_references(styles, palette),
|
|
47
|
+
background: resolve_background(value["background"], palette)
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.resolve_background(value, palette)
|
|
52
|
+
return unless value
|
|
53
|
+
|
|
54
|
+
deep_resolve_colors(value, normalize_colors(palette))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.built_in_path(name)
|
|
58
|
+
slug = name.to_s
|
|
59
|
+
raise ArgumentError, "unknown built-in theme: #{name.inspect}" unless built_in_names.include?(slug)
|
|
60
|
+
|
|
61
|
+
File.join(BUILT_IN_ROOT, "#{slug}.json")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.resolve_palette_references(styles, palette)
|
|
65
|
+
palette = normalize_colors(palette)
|
|
66
|
+
deep_resolve_colors(styles, palette)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.deep_resolve_colors(value, palette)
|
|
70
|
+
case value
|
|
71
|
+
when Hash
|
|
72
|
+
value.transform_values { |item| deep_resolve_colors(item, palette) }
|
|
73
|
+
when Array
|
|
74
|
+
value.map { |item| deep_resolve_colors(item, palette) }
|
|
75
|
+
when String
|
|
76
|
+
palette.fetch(value, normalize_color(value) || value)
|
|
77
|
+
else
|
|
78
|
+
value
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.normalize_colors(values)
|
|
83
|
+
values.transform_values { |value| normalize_color(value) }.compact
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.normalize_color(value)
|
|
87
|
+
return unless value.is_a?(String)
|
|
88
|
+
|
|
89
|
+
case value
|
|
90
|
+
when /\A#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])(?:[0-9a-fA-F])?\z/
|
|
91
|
+
"#{$1 * 2}#{$2 * 2}#{$3 * 2}".prepend("#")
|
|
92
|
+
when /\A#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?\z/
|
|
93
|
+
value[0, 7]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
attr_reader :background
|
|
98
|
+
|
|
99
|
+
def initialize(tokens = {}, background: nil)
|
|
100
|
+
@tokens = symbolize_keys(tokens)
|
|
101
|
+
@background = background
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def style(name)
|
|
105
|
+
spec = @tokens.fetch(name.to_sym) do
|
|
106
|
+
raise ArgumentError, "unknown theme token: #{name.inspect}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
build_style(spec)
|
|
110
|
+
end
|
|
111
|
+
alias_method :[], :style
|
|
112
|
+
|
|
113
|
+
def method_missing(name, ...)
|
|
114
|
+
return style(name) if @tokens.key?(name)
|
|
115
|
+
|
|
116
|
+
super
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def respond_to_missing?(name, include_private = false)
|
|
120
|
+
@tokens.key?(name) || super
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def build_style(spec)
|
|
126
|
+
return spec if spec.is_a?(Style)
|
|
127
|
+
return UI.style.foreground(spec) unless spec.is_a?(Hash)
|
|
128
|
+
|
|
129
|
+
apply_options(UI.style, symbolize_keys(spec))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def apply_options(base_style, spec)
|
|
133
|
+
styled = apply_colors(base_style, spec)
|
|
134
|
+
styled = apply_attributes(styled, spec)
|
|
135
|
+
apply_layout(styled, spec)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def apply_colors(base_style, spec)
|
|
139
|
+
styled = base_style
|
|
140
|
+
styled = styled.foreground(spec[:foreground] || spec[:fg]) if spec.key?(:foreground) || spec.key?(:fg)
|
|
141
|
+
styled = styled.background(spec[:background] || spec[:bg]) if spec.key?(:background) || spec.key?(:bg)
|
|
142
|
+
styled
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def apply_attributes(base_style, spec)
|
|
146
|
+
Style::ATTRIBUTES.each_key.reduce(base_style) do |styled, attribute|
|
|
147
|
+
spec[attribute] ? styled.public_send(attribute) : styled
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def apply_layout(base_style, spec)
|
|
152
|
+
styled = base_style
|
|
153
|
+
styled = styled.padding(*Array(spec[:padding])) if spec.key?(:padding)
|
|
154
|
+
styled = apply_border(styled, spec[:border]) if spec.key?(:border)
|
|
155
|
+
styled = styled.width(spec[:width]) if spec.key?(:width)
|
|
156
|
+
styled = styled.height(spec[:height]) if spec.key?(:height)
|
|
157
|
+
styled = styled.align(spec[:align].to_sym) if spec.key?(:align)
|
|
158
|
+
styled
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def apply_border(base_style, border_spec)
|
|
162
|
+
return base_style.border(border_spec) unless border_spec.is_a?(Hash)
|
|
163
|
+
|
|
164
|
+
border_spec = symbolize_keys(border_spec)
|
|
165
|
+
base_style.border(
|
|
166
|
+
border_spec.fetch(:style, :normal),
|
|
167
|
+
sides: border_spec[:sides],
|
|
168
|
+
foreground: border_spec[:foreground] || border_spec[:fg]
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def symbolize_keys(value)
|
|
173
|
+
value.each_with_object({}) do |(key, item), result|
|
|
174
|
+
result[key.to_sym] = item.is_a?(Hash) ? symbolize_keys(item) : item
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
"background": "background",
|
|
5
5
|
"palette": {
|
|
6
6
|
"bright": "#9FE8B0",
|
|
7
|
-
"muted": "#
|
|
7
|
+
"muted": "#7FB98C",
|
|
8
8
|
"subtle": "#788E80",
|
|
9
9
|
"background": "#111A2C",
|
|
10
10
|
"selected": "#18233D",
|
|
11
|
-
"divider": "#
|
|
11
|
+
"divider": "#536B91",
|
|
12
12
|
"amber": "#FFB347",
|
|
13
13
|
"cyan": "#6FD0E3"
|
|
14
14
|
},
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "unicode/display_width"
|
|
4
|
+
|
|
5
|
+
module Charming
|
|
6
|
+
module Presentation
|
|
7
|
+
module UI
|
|
8
|
+
# Width is a namespace for measuring and normalising the visual width of strings that may contain
|
|
9
|
+
# ANSI escape sequences. It delegates to `Unicode::DisplayWidth` while automatically stripping
|
|
10
|
+
# formatting codes so layout primitives can calculate exact character positions.
|
|
11
|
+
module Width
|
|
12
|
+
ANSI_PATTERN = /\e\[[0-9;]*m/
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def measure(value)
|
|
17
|
+
Unicode::DisplayWidth.of(strip_ansi(value.to_s))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def strip_ansi(value)
|
|
21
|
+
value.to_s.gsub(ANSI_PATTERN, "")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
# UI is a module of layout primitives for composing and positioning ANSI-styled
|
|
6
|
+
# terminal text. It provides functions to join blocks horizontally or vertically,
|
|
7
|
+
# place content on fixed-size canvases, overlay elements, and slice strings that
|
|
8
|
+
# contain ANSI escape sequences while preserving their styling.
|
|
9
|
+
module UI
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Builds a new {Style} instance for chaining color, padding, alignment, and other visual properties.
|
|
13
|
+
def style
|
|
14
|
+
Style.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Horizontally concatenates *blocks* into a single multi-line string, padding each block's
|
|
18
|
+
# rows to match the widest row. A *gap* argument (in spaces) can separate adjacent columns.
|
|
19
|
+
def join_horizontal(*blocks, gap: 0)
|
|
20
|
+
normalized = normalize_blocks(blocks)
|
|
21
|
+
widths = block_widths(normalized)
|
|
22
|
+
separator = " " * gap
|
|
23
|
+
|
|
24
|
+
Array.new(block_height(normalized)) do |index|
|
|
25
|
+
horizontal_line(normalized, widths, index).join(separator)
|
|
26
|
+
end.join("\n")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Stacks *blocks* vertically separated by one or more blank lines. A *gap* of N inserts N
|
|
30
|
+
# extra newline characters between blocks (1 gap = 1 blank line, 2 gaps = 2 blank lines, etc.).
|
|
31
|
+
def join_vertical(*blocks, gap: 0)
|
|
32
|
+
blocks.join("\n" * (gap + 1))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Places *block* onto a blank canvas of *width* × *height* at an offset determined by *top* (row)
|
|
36
|
+
# and *left* (column). Non-:center values are treated as absolute positions. When *background* is
|
|
37
|
+
# given, the assembled frame is wrapped so the theme bg paints the entire canvas — overlay content
|
|
38
|
+
# with its own bg overrides per-cell; resets re-apply the canvas bg.
|
|
39
|
+
def place(block, width:, height:, top: 0, left: 0, background: nil)
|
|
40
|
+
Canvas.new(width, height).place(block, top: top, left: left, background: background)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Draws *overlay* on top of a base at the specified *top* (row) and *left* (column) coordinates,
|
|
44
|
+
# defaulting to center in both directions. ANSI styling on the base content is preserved underneath.
|
|
45
|
+
def overlay(base, overlay, top: :center, left: :center)
|
|
46
|
+
Canvas.parse(base).overlay(overlay, top: top, left: left).to_s
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Centers a *block* within a canvas of the given *width* and *height*, then returns the result.
|
|
50
|
+
def center(block, width:, height:, background: nil)
|
|
51
|
+
place(block, width: width, height: height, top: :center, left: :center, background: background)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Returns a visible-slice of *line* starting at *start_column* spanning *width* characters, preserving any
|
|
55
|
+
# ANSI escape sequences that were active at the start of the slice. Non-positive widths return `""`.
|
|
56
|
+
def visible_slice(line, start_column, width)
|
|
57
|
+
ANSISlicer.slice(line, start_column, width)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Normalizes an array of mixed objects into arrays of lines by calling `#to_s` on each element.
|
|
61
|
+
def normalize_blocks(blocks)
|
|
62
|
+
blocks.map { |block| block.to_s.lines(chomp: true) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Measures the displayed (visual) width of each normalised block, returning an array of integer widths.
|
|
66
|
+
def block_widths(blocks)
|
|
67
|
+
blocks.map { |lines| lines.map { |line| Width.measure(line) }.max || 0 }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Returns the maximum visual character width across all *lines*, accounting for multi-column characters
|
|
71
|
+
# (e.g., full-width CJK glyphs) and invisible ANSI escape sequences.
|
|
72
|
+
def block_width(lines)
|
|
73
|
+
lines.map { |line| Width.measure(line) }.max || 0
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Returns the height in rows of each normalised block, taking the maximum across all blocks.
|
|
77
|
+
def block_height(blocks)
|
|
78
|
+
blocks.map(&:length).max || 0
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Builds a single horizontal row by concatenating one line from each *block* at index *index*, padding
|
|
82
|
+
# every segment to its corresponding *width* in spaces. Returns the assembled array of padded segments.
|
|
83
|
+
def horizontal_line(blocks, widths, index)
|
|
84
|
+
blocks.each_with_index.map do |lines, block_index|
|
|
85
|
+
line = lines[index] || ""
|
|
86
|
+
line + (" " * (widths[block_index] - Width.measure(line)))
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
# View is the base class for all screen view implementations. It provides assign injection (via `initialize`),
|
|
6
|
+
# rendering hooks, layout composition helpers (`row`, `column`, `render_component`, `yield_content`),
|
|
7
|
+
# and access to controller theme, style, and focus state from within views.
|
|
8
|
+
class View
|
|
9
|
+
# Initializes the view with named assigns injected as instance-local accessor methods via
|
|
10
|
+
# `define_singleton_method`. Called when a controller instantiates a view for rendering.
|
|
11
|
+
def initialize(**assigns)
|
|
12
|
+
@assigns = assigns
|
|
13
|
+
define_assign_readers
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Returns all view assigns as a hash, used by layouts to compose the full template (content + screen + controller).
|
|
17
|
+
def layout_assigns
|
|
18
|
+
assigns
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Renders the view's body. Default is empty — subclasses override to return visible text.
|
|
22
|
+
def render
|
|
23
|
+
""
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Delegates focus checking to the controller in assigns, allowing views to determine which slot (sidebar, content) has focus.
|
|
27
|
+
def focused?(slot)
|
|
28
|
+
ctrl = assigns[:focus_controller] || assigns[:controller]
|
|
29
|
+
ctrl ? ctrl.focused?(slot) : false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
attr_reader :assigns
|
|
35
|
+
|
|
36
|
+
# Returns the shared UI style configuration used by components and views for visual rendering (colors, borders).
|
|
37
|
+
def style
|
|
38
|
+
UI.style
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns the active theme: uses `theme` from assigns or controller, falling back to `UI::Theme.default`.
|
|
42
|
+
def theme
|
|
43
|
+
assigns[:theme] || assigns[:controller]&.theme || UI::Theme.default
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Outputs styled text through the view's rendering pipeline. Accepts a named `style:` for inline formatting.
|
|
47
|
+
# Appends the rendered value to the output buffer and returns it.
|
|
48
|
+
def text(value, style: nil)
|
|
49
|
+
rendered = apply_style(value.to_s, style)
|
|
50
|
+
append_to_buffer(rendered)
|
|
51
|
+
rendered
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Renders a box with optional styling. Accepts an inline block for complex content or a plain value.
|
|
55
|
+
# Used for bordered containers and field groups in views.
|
|
56
|
+
def box(value = nil, style: nil, &)
|
|
57
|
+
content = block_given? ? capture(&) : value.to_s
|
|
58
|
+
apply_style(content, style)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Joins items horizontally (side-by-side) using the UI rendering engine. Supports a `gap:` parameter.
|
|
62
|
+
def row(*items, gap: 0)
|
|
63
|
+
UI.join_horizontal(*items, gap: gap)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Stacks items vertically using the UI rendering engine. Supports a `gap:` parameter for spacing.
|
|
67
|
+
def column(*items, gap: 0)
|
|
68
|
+
UI.join_vertical(*items, gap: gap)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Renders a component (e.g., a ProgressBar, Spinner, Modal) and returns its string output.
|
|
72
|
+
def render_component(component)
|
|
73
|
+
component.render.to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Renders a partial view component. An alias for `render_component` used in layout templates.
|
|
77
|
+
def render_partial(partial)
|
|
78
|
+
render_component(partial)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Builds a declarative layout tree for the current terminal screen and renders it.
|
|
82
|
+
def screen_layout(background: nil, &)
|
|
83
|
+
layout = Layout::Builder.build(screen: layout_screen, view: self, background: background, &)
|
|
84
|
+
register_layout_focus(layout)
|
|
85
|
+
layout.render
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Yields the layout's `content` slot — used by view templates to inject their body into a layout wrapper (e.g., sidebar).
|
|
89
|
+
def yield_content
|
|
90
|
+
assigns.fetch(:content, "")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Evaluates a block in the view's context with a clean output buffer. Captures text written via `text`/`box`
|
|
94
|
+
# and returns joined content. Resets buffer afterward for parent rendering.
|
|
95
|
+
def capture(&)
|
|
96
|
+
previous_buffer = @output_buffer
|
|
97
|
+
@output_buffer = []
|
|
98
|
+
result = instance_eval(&)
|
|
99
|
+
@output_buffer.empty? ? result.to_s : @output_buffer.join("\n")
|
|
100
|
+
ensure
|
|
101
|
+
@output_buffer = previous_buffer
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Appends a value to the current output buffer (if one is active). Used by rendering helpers.
|
|
105
|
+
def append_to_buffer(value)
|
|
106
|
+
@output_buffer << value if @output_buffer
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Applies a style object's `render` method to a string, returning styled output or raw text when style is nil.
|
|
110
|
+
def apply_style(value, style_object)
|
|
111
|
+
style_object ? style_object.render(value) : value
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Dynamically defines read-only accessor methods for each assign key as singleton methods on self.
|
|
115
|
+
# Skips keys where the view already responds (controller methods take precedence).
|
|
116
|
+
def define_assign_readers
|
|
117
|
+
assigns.each_key do |name|
|
|
118
|
+
next if respond_to?(name, true)
|
|
119
|
+
|
|
120
|
+
define_singleton_method(name) { assigns.fetch(name) }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def layout_screen
|
|
125
|
+
assigns[:screen] || assigns[:controller]&.screen || Charming::Screen.new(width: 80, height: 24)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def register_layout_focus(layout)
|
|
129
|
+
return unless assigns[:controller]
|
|
130
|
+
|
|
131
|
+
assigns[:controller].focus.define_layout(layout.focusable_names)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
data/lib/charming/runtime.rb
CHANGED
|
@@ -75,17 +75,19 @@ module Charming
|
|
|
75
75
|
controller(event: event).dispatch_mouse
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
+
# Instantiates a fresh controller for the active route, passing the application, current *event*,
|
|
79
|
+
# route params, screen dimensions, and route object. Called by every dispatch path.
|
|
78
80
|
def controller(event: nil)
|
|
79
|
-
@route.controller_class.new(application: @application, event: event, params: @route.params, screen: screen)
|
|
81
|
+
@route.controller_class.new(application: @application, event: event, params: @route.params, screen: screen, route: @route)
|
|
80
82
|
end
|
|
81
83
|
|
|
82
84
|
# Type-based dispatcher: routes resize, task, timer, mouse, and key events
|
|
83
85
|
# to the appropriate handler. Falls back to key dispatch for unclassified events.
|
|
84
86
|
def dispatch_event(event)
|
|
85
|
-
return dispatch_resize(event) if event.is_a?(ResizeEvent)
|
|
86
|
-
return dispatch_task(event) if event.is_a?(TaskEvent)
|
|
87
|
-
return dispatch_timer(event) if event.is_a?(TimerEvent)
|
|
88
|
-
return dispatch_mouse(event) if event.is_a?(MouseEvent)
|
|
87
|
+
return dispatch_resize(event) if event.is_a?(Events::ResizeEvent)
|
|
88
|
+
return dispatch_task(event) if event.is_a?(Events::TaskEvent)
|
|
89
|
+
return dispatch_timer(event) if event.is_a?(Events::TimerEvent)
|
|
90
|
+
return dispatch_mouse(event) if event.is_a?(Events::MouseEvent)
|
|
89
91
|
|
|
90
92
|
dispatch_key(event)
|
|
91
93
|
end
|
|
@@ -133,7 +135,7 @@ module Charming
|
|
|
133
135
|
|
|
134
136
|
now = clock_now
|
|
135
137
|
timer[:next_at] = now + timer.fetch(:binding).interval
|
|
136
|
-
TimerEvent.new(name: timer.fetch(:binding).name, now: now)
|
|
138
|
+
Events::TimerEvent.new(name: timer.fetch(:binding).name, now: now)
|
|
137
139
|
end
|
|
138
140
|
|
|
139
141
|
# Pops a task event from the thread-safe queue if one is available.
|
|
@@ -161,7 +163,7 @@ module Charming
|
|
|
161
163
|
|
|
162
164
|
# Constructs a task executor: supports explicit instances, callable factories, or the default Threaded executor.
|
|
163
165
|
def build_task_executor(task_executor)
|
|
164
|
-
return
|
|
166
|
+
return Tasks::ThreadedExecutor.new(@task_queue) unless task_executor
|
|
165
167
|
return task_executor if task_executor.respond_to?(:submit)
|
|
166
168
|
return task_executor.call(@task_queue) if task_executor.respond_to?(:call) && !task_executor.respond_to?(:new)
|
|
167
169
|
|
data/lib/charming/screen.rb
CHANGED
|
@@ -4,5 +4,9 @@ module Charming
|
|
|
4
4
|
# Screen represents the terminal viewport dimensions as a simple Data class.
|
|
5
5
|
# The `width` and `height` values flow from the backend through the runtime
|
|
6
6
|
# loop into every controller dispatch for layout calculations.
|
|
7
|
-
Screen = Data.define(:width, :height)
|
|
7
|
+
Screen = Data.define(:width, :height) do
|
|
8
|
+
def narrow?(below:, min_height: nil)
|
|
9
|
+
width < below && (min_height.nil? || height >= min_height)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
8
12
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Tasks
|
|
5
|
+
# InlineExecutor runs submitted tasks synchronously on the calling thread, pushing
|
|
6
|
+
# the resulting TaskEvent directly into the runtime's *queue*. Used for testing and
|
|
7
|
+
# for environments where spawning background threads is undesirable.
|
|
8
|
+
class InlineExecutor
|
|
9
|
+
# *queue* is the thread-safe Queue (typically `runtime.@task_queue`) into which
|
|
10
|
+
# completed TaskEvents are pushed.
|
|
11
|
+
def initialize(queue)
|
|
12
|
+
@queue = queue
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Wraps *block* in a Task, invokes it immediately, and pushes the resulting
|
|
16
|
+
# TaskEvent (value or error) onto the queue. Returns nil.
|
|
17
|
+
def submit(name, &block)
|
|
18
|
+
task = Task.new(name: name.to_sym, block: block)
|
|
19
|
+
@queue << run(task)
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# No-op stub for the shutdown contract; nothing to join since tasks run on the caller.
|
|
24
|
+
def shutdown(timeout: 0.0)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# Invokes the task's block and wraps the result (or raised exception) in a TaskEvent.
|
|
30
|
+
def run(task)
|
|
31
|
+
Events::TaskEvent.new(name: task.name, value: task.call)
|
|
32
|
+
rescue StandardError, ScriptError => e
|
|
33
|
+
Events::TaskEvent.new(name: task.name, error: e)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Tasks
|
|
5
|
+
# Task is the unit of work submitted to a task executor. It pairs a *name* (used by
|
|
6
|
+
# `on_task` handlers to route the result) with a *block* to invoke on the executor.
|
|
7
|
+
Task = Data.define(:name, :block) do
|
|
8
|
+
# Invokes the task's block in the executor's thread and returns its value (or raises).
|
|
9
|
+
def call = block.call
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Tasks
|
|
5
|
+
# ThreadedExecutor runs submitted tasks on background Ruby threads. Each submission
|
|
6
|
+
# creates a new thread that invokes the block and pushes the resulting TaskEvent
|
|
7
|
+
# onto the shared *queue*. Threads are tracked so `shutdown` can wait (or kill)
|
|
8
|
+
# in-flight work.
|
|
9
|
+
class ThreadedExecutor
|
|
10
|
+
# *queue* is the thread-safe Queue (typically `runtime.@task_queue`) into which
|
|
11
|
+
# completed TaskEvents are pushed.
|
|
12
|
+
def initialize(queue)
|
|
13
|
+
@queue = queue
|
|
14
|
+
@threads = []
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Wraps *block* in a Task and spawns a new thread to invoke it. The thread's
|
|
19
|
+
# return value (or rescued exception) is pushed onto the queue as a TaskEvent.
|
|
20
|
+
# Returns nil immediately.
|
|
21
|
+
def submit(name, &block)
|
|
22
|
+
task = Task.new(name: name.to_sym, block: block)
|
|
23
|
+
thread = Thread.new(task) { |t| @queue << run(t) }
|
|
24
|
+
@mutex.synchronize { @threads << thread }
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Waits up to *timeout* seconds for in-flight threads to finish, then kills any
|
|
29
|
+
# remaining live threads. Used by Runtime during teardown.
|
|
30
|
+
def shutdown(timeout: 0.0)
|
|
31
|
+
threads = @mutex.synchronize { @threads.dup }
|
|
32
|
+
threads.each { |thread| thread.join(timeout) }
|
|
33
|
+
threads.each do |thread|
|
|
34
|
+
next unless thread.alive?
|
|
35
|
+
|
|
36
|
+
thread.kill
|
|
37
|
+
thread.join(0)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# Invokes the task's block and wraps the result (or rescued exception) in a TaskEvent.
|
|
44
|
+
def run(task)
|
|
45
|
+
Events::TaskEvent.new(name: task.name, value: task.call)
|
|
46
|
+
rescue StandardError, ScriptError => e
|
|
47
|
+
Events::TaskEvent.new(name: task.name, error: e)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/charming/version.rb
CHANGED
data/lib/charming.rb
CHANGED
|
@@ -6,19 +6,36 @@ loader = Zeitwerk::Loader.for_gem
|
|
|
6
6
|
loader.inflector.inflect(
|
|
7
7
|
"cli" => "CLI",
|
|
8
8
|
"ui" => "UI",
|
|
9
|
+
"ansi_codes" => "ANSICodes",
|
|
10
|
+
"ansi_slicer" => "ANSISlicer",
|
|
11
|
+
"border_painter" => "BorderPainter",
|
|
12
|
+
"block_renderers" => "BlockRenderer",
|
|
13
|
+
"inline_renderers" => "InlineRenderer",
|
|
14
|
+
"render_context" => "RenderContext",
|
|
15
|
+
"erb_handler" => "ErbHandler",
|
|
16
|
+
"key_normalizer" => "KeyNormalizer",
|
|
17
|
+
"mouse_parser" => "MouseParser",
|
|
9
18
|
"tty_backend" => "TTYBackend"
|
|
10
19
|
)
|
|
11
20
|
loader.setup
|
|
12
21
|
|
|
13
22
|
module Charming
|
|
23
|
+
# Base error class for all Charming-specific exceptions (used by templates, generators, runtime, etc.).
|
|
14
24
|
class Error < StandardError; end
|
|
15
25
|
|
|
26
|
+
# Entry point for running a Charming application. Instantiates a Runtime for *application* and starts
|
|
27
|
+
# the event loop. *backend* defaults to TTYBackend; tests pass MemoryBackend directly via `Charming::Runtime.new`.
|
|
16
28
|
def self.run(application, backend: nil)
|
|
17
29
|
Runtime.new(application, backend: backend).run
|
|
18
30
|
end
|
|
19
31
|
|
|
32
|
+
# Returns the normalized key symbol for an event-like object — `event.key` when the object responds
|
|
33
|
+
# to it, otherwise `event.to_sym`. Lets components treat raw strings and KeyEvent objects uniformly.
|
|
20
34
|
def self.key_of(event)
|
|
21
35
|
key = event.respond_to?(:key) ? event.key : event
|
|
22
36
|
key.to_sym
|
|
23
37
|
end
|
|
24
38
|
end
|
|
39
|
+
|
|
40
|
+
Charming::Presentation::Templates.register ".tui.erb", Charming::Presentation::Templates::ErbHandler
|
|
41
|
+
Charming::Presentation::Templates.register ".txt.erb", Charming::Presentation::Templates::ErbHandler
|