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
|
@@ -5,10 +5,18 @@ require "unicode/display_width"
|
|
|
5
5
|
module Charming
|
|
6
6
|
module Presentation
|
|
7
7
|
module Components
|
|
8
|
+
# Viewport is a scrollable region over multi-line content. Supports keyboard scrolling
|
|
9
|
+
# (up/down/left/right, page up/down, home/end) and mouse interactions (scroll wheel and
|
|
10
|
+
# click-to-position). Lines are clipped with ANSI awareness via `UI::ANSISlicer` so styled
|
|
11
|
+
# text is preserved across horizontal scrolls. When `wrap:` is true, long lines are wrapped
|
|
12
|
+
# to the configured *width* before scrolling.
|
|
8
13
|
class Viewport < Component
|
|
9
14
|
include KeyboardHandler
|
|
10
15
|
|
|
16
|
+
# Matches an ANSI SGR escape sequence (e.g., "\e[31m" for red foreground).
|
|
11
17
|
ANSI_PATTERN = /\e\[[0-9;]*m/
|
|
18
|
+
|
|
19
|
+
# Maps scroll keys to the instance methods that perform them via KeyboardHandler.
|
|
12
20
|
KEY_ACTIONS = {
|
|
13
21
|
up: :scroll_up,
|
|
14
22
|
down: :scroll_down,
|
|
@@ -20,8 +28,12 @@ module Charming
|
|
|
20
28
|
right: :scroll_right
|
|
21
29
|
}.freeze
|
|
22
30
|
|
|
31
|
+
# The current top-visible row and left-visible column, respectively.
|
|
23
32
|
attr_reader :offset, :column
|
|
24
33
|
|
|
34
|
+
# *content* may be a string, an array of lines, or any object responding to `render`.
|
|
35
|
+
# *width* and *height* constrain the visible window; *offset* is the top-visible row
|
|
36
|
+
# and *column* is the left-visible column. *wrap* enables soft-wrapping of long lines.
|
|
25
37
|
def initialize(content:, width: nil, height: nil, offset: 0, column: 0, wrap: false, keymap: :vim)
|
|
26
38
|
super()
|
|
27
39
|
@content = content
|
|
@@ -34,10 +46,13 @@ module Charming
|
|
|
34
46
|
clamp_position
|
|
35
47
|
end
|
|
36
48
|
|
|
49
|
+
# Renders the visible window of content as a multi-line string.
|
|
37
50
|
def render
|
|
38
51
|
visible_lines.map { |line| render_line(line) }.join("\n")
|
|
39
52
|
end
|
|
40
53
|
|
|
54
|
+
# Handles mouse events: scroll wheel adjusts the row offset, click moves the top
|
|
55
|
+
# visible row to the clicked position. Returns :handled on success.
|
|
41
56
|
def handle_mouse(event)
|
|
42
57
|
return nil unless height
|
|
43
58
|
|
|
@@ -62,51 +77,61 @@ module Charming
|
|
|
62
77
|
|
|
63
78
|
attr_reader :content, :width, :height
|
|
64
79
|
|
|
80
|
+
# Scrolls the viewport up by one row.
|
|
65
81
|
def scroll_up
|
|
66
82
|
@offset -= 1
|
|
67
83
|
clamp_position
|
|
68
84
|
end
|
|
69
85
|
|
|
86
|
+
# Scrolls the viewport down by one row.
|
|
70
87
|
def scroll_down
|
|
71
88
|
@offset += 1
|
|
72
89
|
clamp_position
|
|
73
90
|
end
|
|
74
91
|
|
|
92
|
+
# Scrolls up by one viewport page.
|
|
75
93
|
def page_up
|
|
76
94
|
@offset -= page_size
|
|
77
95
|
clamp_position
|
|
78
96
|
end
|
|
79
97
|
|
|
98
|
+
# Scrolls down by one viewport page.
|
|
80
99
|
def page_down
|
|
81
100
|
@offset += page_size
|
|
82
101
|
clamp_position
|
|
83
102
|
end
|
|
84
103
|
|
|
104
|
+
# Scrolls to the top-left of the content.
|
|
85
105
|
def scroll_home
|
|
86
106
|
@offset = 0
|
|
87
107
|
@column = 0
|
|
88
108
|
end
|
|
89
109
|
|
|
110
|
+
# Scrolls to the bottom-right of the content.
|
|
90
111
|
def scroll_end
|
|
91
112
|
@offset = max_offset
|
|
92
113
|
@column = max_column
|
|
93
114
|
end
|
|
94
115
|
|
|
116
|
+
# Scrolls one column left.
|
|
95
117
|
def scroll_left
|
|
96
118
|
@column -= 1
|
|
97
119
|
clamp_position
|
|
98
120
|
end
|
|
99
121
|
|
|
122
|
+
# Scrolls one column right.
|
|
100
123
|
def scroll_right
|
|
101
124
|
@column += 1
|
|
102
125
|
clamp_position
|
|
103
126
|
end
|
|
104
127
|
|
|
128
|
+
# Clamps both the row offset and the column to their valid ranges.
|
|
105
129
|
def clamp_position
|
|
106
130
|
@offset = offset.clamp(0, max_offset)
|
|
107
131
|
@column = column.clamp(0, max_column)
|
|
108
132
|
end
|
|
109
133
|
|
|
134
|
+
# Returns the slice of content lines visible in the current viewport, padded to *height*.
|
|
110
135
|
def visible_lines
|
|
111
136
|
lines = content_lines.slice(offset, viewport_height) || []
|
|
112
137
|
return lines unless height
|
|
@@ -114,6 +139,8 @@ module Charming
|
|
|
114
139
|
lines + Array.new([height - lines.length, 0].max, "")
|
|
115
140
|
end
|
|
116
141
|
|
|
142
|
+
# Renders a single line according to the configured width and wrap mode: clips to the
|
|
143
|
+
# visible column window when not wrapping, otherwise wraps the line to the width.
|
|
117
144
|
def render_line(line)
|
|
118
145
|
return line unless width
|
|
119
146
|
return pad_line(line, width) if wrap?
|
|
@@ -121,11 +148,14 @@ module Charming
|
|
|
121
148
|
pad_line(clip_line(line), width)
|
|
122
149
|
end
|
|
123
150
|
|
|
151
|
+
# Clips *line* to the visible column window while preserving active ANSI styling.
|
|
124
152
|
def clip_line(line)
|
|
125
153
|
clipped = clip_tokens(line.to_s)
|
|
126
154
|
needs_reset?(clipped) ? "#{clipped}\e[0m" : clipped
|
|
127
155
|
end
|
|
128
156
|
|
|
157
|
+
# Walks *line* token-by-token, copying ANSI escapes through and emitting only the
|
|
158
|
+
# characters that fall inside the visible column window.
|
|
129
159
|
def clip_tokens(line)
|
|
130
160
|
state = {cursor: 0, output: +""}
|
|
131
161
|
line.scan(/#{ANSI_PATTERN}|./mo) do |token|
|
|
@@ -134,10 +164,13 @@ module Charming
|
|
|
134
164
|
state.fetch(:output)
|
|
135
165
|
end
|
|
136
166
|
|
|
167
|
+
# Appends an ANSI escape token to the output buffer unchanged.
|
|
137
168
|
def append_ansi(state, token)
|
|
138
169
|
state.fetch(:output) << token
|
|
139
170
|
end
|
|
140
171
|
|
|
172
|
+
# Appends a single character token to the output buffer when it falls inside the
|
|
173
|
+
# visible column window, advancing the visual cursor.
|
|
141
174
|
def append_character(state, char)
|
|
142
175
|
char_width = Unicode::DisplayWidth.of(char)
|
|
143
176
|
cursor = state.fetch(:cursor)
|
|
@@ -145,28 +178,36 @@ module Charming
|
|
|
145
178
|
state[:cursor] = cursor + char_width
|
|
146
179
|
end
|
|
147
180
|
|
|
181
|
+
# True when the character at *cursor* (with the given display *char_width*) is within
|
|
182
|
+
# the visible column window.
|
|
148
183
|
def visible?(cursor, char_width)
|
|
149
184
|
cursor >= column && cursor + char_width <= column + width
|
|
150
185
|
end
|
|
151
186
|
|
|
187
|
+
# True when *value* contains ANSI codes but does not end with a reset — needed because
|
|
188
|
+
# the clip may truncate styling in the middle of a styled run.
|
|
152
189
|
def needs_reset?(value)
|
|
153
190
|
value.match?(ANSI_PATTERN) && !value.end_with?("\e[0m")
|
|
154
191
|
end
|
|
155
192
|
|
|
193
|
+
# Pads *line* to *target_width* with trailing spaces, leaving the line itself unchanged.
|
|
156
194
|
def pad_line(line, target_width)
|
|
157
195
|
line + (" " * [target_width - UI::Width.measure(line), 0].max)
|
|
158
196
|
end
|
|
159
197
|
|
|
198
|
+
# Returns the content lines, wrapped to *width* when wrap is enabled.
|
|
160
199
|
def content_lines
|
|
161
200
|
return wrapped_content_lines if wrap?
|
|
162
201
|
|
|
163
202
|
rendered_content.lines(chomp: true)
|
|
164
203
|
end
|
|
165
204
|
|
|
205
|
+
# Wraps the content to *width* via UI::visible_slice, returning an array of wrapped lines.
|
|
166
206
|
def wrapped_content_lines
|
|
167
207
|
rendered_content.lines(chomp: true).flat_map { |line| wrap_line(line) }
|
|
168
208
|
end
|
|
169
209
|
|
|
210
|
+
# Wraps a single *line* into chunks of *width* display columns.
|
|
170
211
|
def wrap_line(line)
|
|
171
212
|
line_width = UI::Width.measure(line)
|
|
172
213
|
return [""] if line_width.zero?
|
|
@@ -180,22 +221,30 @@ module Charming
|
|
|
180
221
|
out
|
|
181
222
|
end
|
|
182
223
|
|
|
224
|
+
# Returns the rendered content string, calling `render.to_s` on the content object when
|
|
225
|
+
# it responds to render.
|
|
183
226
|
def rendered_content
|
|
184
227
|
content.respond_to?(:render) ? content.render.to_s : content.to_s
|
|
185
228
|
end
|
|
186
229
|
|
|
230
|
+
# Returns the visible row count (the configured *height* or the content's line count).
|
|
187
231
|
def viewport_height
|
|
188
232
|
height || content_lines.length
|
|
189
233
|
end
|
|
190
234
|
|
|
235
|
+
# Returns the number of rows to advance on a page up/down: at least 1, otherwise the
|
|
236
|
+
# viewport height.
|
|
191
237
|
def page_size
|
|
192
238
|
[viewport_height, 1].max
|
|
193
239
|
end
|
|
194
240
|
|
|
241
|
+
# Returns the maximum allowed row offset (so the bottom of the content is reachable).
|
|
195
242
|
def max_offset
|
|
196
243
|
[content_lines.length - viewport_height, 0].max
|
|
197
244
|
end
|
|
198
245
|
|
|
246
|
+
# Returns the maximum allowed column offset. Returns 0 when wrapping is enabled or
|
|
247
|
+
# when no width is configured.
|
|
199
248
|
def max_column
|
|
200
249
|
return 0 if wrap?
|
|
201
250
|
return 0 unless width
|
|
@@ -203,14 +252,17 @@ module Charming
|
|
|
203
252
|
[content_width - width, 0].max
|
|
204
253
|
end
|
|
205
254
|
|
|
255
|
+
# Returns the maximum display width across all content lines.
|
|
206
256
|
def content_width
|
|
207
257
|
content_lines.map { |line| UI::Width.measure(line) }.max || 0
|
|
208
258
|
end
|
|
209
259
|
|
|
260
|
+
# True when *token* is an ANSI escape sequence.
|
|
210
261
|
def ansi?(token)
|
|
211
262
|
token.match?(ANSI_PATTERN)
|
|
212
263
|
end
|
|
213
264
|
|
|
265
|
+
# True when soft-wrapping is enabled and a positive width is configured.
|
|
214
266
|
def wrap?
|
|
215
267
|
@wrap && width&.positive?
|
|
216
268
|
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Layout
|
|
6
|
+
# Builder turns a declarative `screen_layout { ... }` block into a layout tree of
|
|
7
|
+
# ScreenLayout → Split → Pane nodes. The block DSL is `split(direction) { ... }`,
|
|
8
|
+
# `pane(name) { ... }`, and `overlay { ... }`. Unknown method calls in the block are
|
|
9
|
+
# forwarded to the underlying view so view helpers (e.g., `text`) work inside layout blocks.
|
|
10
|
+
class Builder
|
|
11
|
+
# Builds the layout tree by evaluating the *block* in the builder's context.
|
|
12
|
+
# Returns the root ScreenLayout node.
|
|
13
|
+
def self.build(screen:, view:, background: nil, &)
|
|
14
|
+
new(screen: screen, view: view, background: background).build(&)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(screen:, view:, background: nil)
|
|
18
|
+
@view = view
|
|
19
|
+
@root = ScreenLayout.new(screen: screen, background: background)
|
|
20
|
+
@stack = [@root]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Evaluates *block* in the builder's context, then returns the root ScreenLayout node.
|
|
24
|
+
def build(&)
|
|
25
|
+
instance_eval(&) if block_given?
|
|
26
|
+
root
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Adds a Split node to the current scope. *direction* is `:horizontal` or `:vertical`.
|
|
30
|
+
# *gap* (in cells) is inserted between children. Additional *options* are forwarded
|
|
31
|
+
# to Split. The block, if given, is evaluated in the split's scope (for nested children).
|
|
32
|
+
def split(direction, gap: 0, **options, &)
|
|
33
|
+
node = Split.new(direction: direction, gap: gap, **options)
|
|
34
|
+
append(node)
|
|
35
|
+
within(node, &)
|
|
36
|
+
node
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Adds a Pane leaf node to the current scope. *name* (optional) is the focus slot name;
|
|
40
|
+
# *content* (or a *block*) is the body. *options* are forwarded to Pane.
|
|
41
|
+
def pane(name = nil, content = nil, **options, &block)
|
|
42
|
+
node = Pane.new(name: name, content: content, block: block, view: view, **options)
|
|
43
|
+
append(node)
|
|
44
|
+
node
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Adds an Overlay node to the root ScreenLayout. *top* and *left* default to :center.
|
|
48
|
+
# The block, if given, is evaluated in the view's context.
|
|
49
|
+
def overlay(content = nil, top: :center, left: :center, **options, &block)
|
|
50
|
+
root.add_overlay(Overlay.new(content: content, block: block, view: view, top: top, left: left, **options))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Forwards unknown method calls to the underlying view so helpers like `text` work
|
|
54
|
+
# inside layout blocks.
|
|
55
|
+
def respond_to_missing?(name, include_private = false)
|
|
56
|
+
view.respond_to?(name, include_private) || super
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def method_missing(name, ...)
|
|
60
|
+
return view.__send__(name, ...) if view.respond_to?(name, true)
|
|
61
|
+
|
|
62
|
+
super
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
attr_reader :root, :stack, :view
|
|
68
|
+
|
|
69
|
+
# Appends *node* to the topmost scope on the stack.
|
|
70
|
+
def append(node)
|
|
71
|
+
stack.last.add_child(node)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Pushes *node* onto the stack, evaluates *block* in the builder's context, then pops it.
|
|
75
|
+
def within(node, &)
|
|
76
|
+
return unless block_given?
|
|
77
|
+
|
|
78
|
+
stack.push(node)
|
|
79
|
+
instance_eval(&)
|
|
80
|
+
ensure
|
|
81
|
+
stack.pop
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Layout
|
|
6
|
+
# Overlay is a compositing node used by ScreenLayout for floating elements (modals,
|
|
7
|
+
# dialogs, command palettes). It positions its content at *top*/*left* (each may be
|
|
8
|
+
# `:center` or an absolute cell offset) and optionally sizes it via *width*/*height*
|
|
9
|
+
# with an outer *style*.
|
|
10
|
+
class Overlay
|
|
11
|
+
# The vertical and horizontal offset (cell count or `:center`) of the overlay
|
|
12
|
+
# within the parent canvas.
|
|
13
|
+
attr_reader :top, :left
|
|
14
|
+
|
|
15
|
+
# *content* (or a *block*) provides the body. *top*/*left* default to :center.
|
|
16
|
+
# *width*/*height* fix the overlay's dimensions; when unset, the content's natural
|
|
17
|
+
# size is used. *style* wraps the rendered content in a UI::Style.
|
|
18
|
+
def initialize(content: nil, block: nil, view: nil, top: :center, left: :center, width: nil, height: nil, style: nil)
|
|
19
|
+
@content = content
|
|
20
|
+
@block = block
|
|
21
|
+
@view = view
|
|
22
|
+
@top = top
|
|
23
|
+
@left = left
|
|
24
|
+
@width = width
|
|
25
|
+
@height = height
|
|
26
|
+
@style = style
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Renders the overlay's content; when *width* or *height* is set, places the rendered
|
|
30
|
+
# content into a sized canvas before returning.
|
|
31
|
+
def render
|
|
32
|
+
return styled_content unless width || height
|
|
33
|
+
|
|
34
|
+
UI.place(styled_content, width: width || UI.block_width(styled_content.lines(chomp: true)), height: height || styled_content.lines.count)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# The raw content, body block, view, and sizing/style options.
|
|
40
|
+
attr_reader :content, :block, :view, :width, :height, :style
|
|
41
|
+
|
|
42
|
+
# Returns the rendered content wrapped in the configured *style* (when present).
|
|
43
|
+
def styled_content
|
|
44
|
+
return rendered_content unless style
|
|
45
|
+
|
|
46
|
+
style.render(rendered_content)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Evaluates the content (block or constant) and returns its rendered string.
|
|
50
|
+
def rendered_content
|
|
51
|
+
value = block ? view.instance_exec(&block) : content
|
|
52
|
+
value.respond_to?(:render) ? value.render.to_s : value.to_s
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Layout
|
|
6
|
+
# Pane is a leaf layout node: a single rectangle with optional border, padding, and
|
|
7
|
+
# styling, containing a piece of content (a string, a View, or a block evaluated in the
|
|
8
|
+
# view's context). Panes with a `name` and `focus: true` are registered as focusable
|
|
9
|
+
# slots in the controller's focus ring.
|
|
10
|
+
class Pane
|
|
11
|
+
# The pane's focus slot name, fixed width, fixed height, and grow weight.
|
|
12
|
+
attr_reader :name, :width, :height, :grow
|
|
13
|
+
|
|
14
|
+
# *name* is the focus slot identifier (optional). *content* or *block* provides the body.
|
|
15
|
+
# *width*/*height*/*grow* control sizing. *border* may be `true` (normal border) or a
|
|
16
|
+
# border name symbol. *padding* may be 1, 2, or 4 values (CSS-style shorthand).
|
|
17
|
+
# *style* sets the base style; *focused_style* overrides it when the pane is focused.
|
|
18
|
+
# *focus: true* marks the pane as focusable. *scroll*/*clip*/*wrap* control how
|
|
19
|
+
# overflow content is rendered (via the embedded Viewport).
|
|
20
|
+
def initialize(name: nil, content: nil, block: nil, view: nil, width: nil, height: nil, grow: nil, border: nil, padding: nil, style: nil, focused_style: nil, focus: false, scroll: false, clip: true, wrap: false)
|
|
21
|
+
@name = name
|
|
22
|
+
@content = content
|
|
23
|
+
@block = block
|
|
24
|
+
@view = view
|
|
25
|
+
@width = width
|
|
26
|
+
@height = height
|
|
27
|
+
@grow = grow
|
|
28
|
+
@border = border
|
|
29
|
+
@padding = padding
|
|
30
|
+
@style = style
|
|
31
|
+
@focused_style = focused_style
|
|
32
|
+
@focus = focus
|
|
33
|
+
@scroll = scroll
|
|
34
|
+
@clip = clip
|
|
35
|
+
@wrap = wrap
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Raises ArgumentError — panes are leaves and cannot contain layout children.
|
|
39
|
+
def add_child(_node)
|
|
40
|
+
raise ArgumentError, "pane cannot contain layout children"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns [name] when the pane is marked focusable and has a name, otherwise [].
|
|
44
|
+
def focusable_names
|
|
45
|
+
(focus && name) ? [name] : []
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Renders the pane into *rect*, applying the configured style, border, and padding
|
|
49
|
+
# around the evaluated content.
|
|
50
|
+
def render(rect)
|
|
51
|
+
outer_style(rect).render(rendered_content(rect))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# The raw content, the body block, the view used for instance_exec, and styling options.
|
|
57
|
+
attr_reader :content, :block, :view, :border, :padding, :style, :focused_style, :focus, :scroll, :clip, :wrap
|
|
58
|
+
|
|
59
|
+
# Returns the content string for *rect*, optionally clipped/scrolled by an embedded Viewport.
|
|
60
|
+
def rendered_content(rect)
|
|
61
|
+
value = evaluate_content
|
|
62
|
+
return value unless clip || scroll
|
|
63
|
+
|
|
64
|
+
Components::Viewport.new(content: value, width: inner_rect(rect).width, height: inner_rect(rect).height, wrap: wrap).render
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Evaluates the configured content (block or constant) and renders it to a string.
|
|
68
|
+
def evaluate_content
|
|
69
|
+
value = block ? view.instance_exec(&block) : content
|
|
70
|
+
value.respond_to?(:render) ? value.render.to_s : value.to_s
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Builds the outer style object with optional border and padding, sized to the
|
|
74
|
+
# inner rect of the pane.
|
|
75
|
+
def outer_style(rect)
|
|
76
|
+
styled = current_style
|
|
77
|
+
styled = styled.border(border_style) if border
|
|
78
|
+
styled = styled.padding(*padding_values) if padding
|
|
79
|
+
styled.width(inner_rect(rect).width).height(inner_rect(rect).height)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Returns the active style: the focused variant when the pane is focused, otherwise
|
|
83
|
+
# the configured style or a default UI::Style.
|
|
84
|
+
def current_style
|
|
85
|
+
return focused_pane_style if focused?
|
|
86
|
+
|
|
87
|
+
style || UI.style
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns the focused-pane style: the focused_style override, or the theme's title style.
|
|
91
|
+
def focused_pane_style
|
|
92
|
+
focused_style || view.__send__(:theme).title
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# True when the pane is configured for focus and the view reports it as currently focused.
|
|
96
|
+
def focused?
|
|
97
|
+
focus && name && view.focused?(name)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Returns the inner Rect after border and padding insets are applied.
|
|
101
|
+
def inner_rect(rect)
|
|
102
|
+
rect.inset(
|
|
103
|
+
top: border_top + padding_top,
|
|
104
|
+
right: border_right + padding_right,
|
|
105
|
+
bottom: border_bottom + padding_bottom,
|
|
106
|
+
left: border_left + padding_left
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Resolves the border style symbol: :normal when border is `true`, otherwise the configured value.
|
|
111
|
+
def border_style
|
|
112
|
+
(border == true) ? :normal : border
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Border thickness on each side (1 when a border is configured, 0 otherwise).
|
|
116
|
+
def border_top = border ? 1 : 0
|
|
117
|
+
def border_right = border ? 1 : 0
|
|
118
|
+
def border_bottom = border ? 1 : 0
|
|
119
|
+
def border_left = border ? 1 : 0
|
|
120
|
+
|
|
121
|
+
# The padding values normalized to [top, right, bottom, left] form.
|
|
122
|
+
def padding_values
|
|
123
|
+
@padding_values ||= expand_padding(Array(padding))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Per-side padding values (0 when no padding is configured).
|
|
127
|
+
def padding_top = padding ? padding_values[0] : 0
|
|
128
|
+
def padding_right = padding ? padding_values[1] : 0
|
|
129
|
+
def padding_bottom = padding ? padding_values[2] : 0
|
|
130
|
+
def padding_left = padding ? padding_values[3] : 0
|
|
131
|
+
|
|
132
|
+
# Normalizes 1/2/4 padding arguments to [top, right, bottom, left].
|
|
133
|
+
def expand_padding(values)
|
|
134
|
+
case values.length
|
|
135
|
+
when 1 then [values[0], values[0], values[0], values[0]]
|
|
136
|
+
when 2 then [values[0], values[1], values[0], values[1]]
|
|
137
|
+
when 4 then values
|
|
138
|
+
else
|
|
139
|
+
raise ArgumentError, "padding expects 1, 2, or 4 values"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Layout
|
|
6
|
+
# Rect is an immutable rectangle with a top-left position (x, y) and dimensions
|
|
7
|
+
# (width, height). Layout operations produce new Rect instances rather than mutating
|
|
8
|
+
# existing ones.
|
|
9
|
+
Rect = Data.define(:x, :y, :width, :height) do
|
|
10
|
+
# Returns a new Rect inset by *top*/*right*/*bottom*/*left* cells. The result is
|
|
11
|
+
# clamped to a minimum width/height of 0.
|
|
12
|
+
def inset(top: 0, right: 0, bottom: 0, left: 0)
|
|
13
|
+
Rect.new(
|
|
14
|
+
x: x + left,
|
|
15
|
+
y: y + top,
|
|
16
|
+
width: [width - left - right, 0].max,
|
|
17
|
+
height: [height - top - bottom, 0].max
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Layout
|
|
6
|
+
# ScreenLayout is the root of a layout tree. It owns a single child (typically a Split
|
|
7
|
+
# or Pane) rendered into the full terminal screen, and an ordered list of Overlays
|
|
8
|
+
# composited on top of the rendered body.
|
|
9
|
+
class ScreenLayout
|
|
10
|
+
# *screen* is the Charming::Screen whose dimensions define the layout area.
|
|
11
|
+
# *background* (optional) is a UI::Style applied to the empty canvas behind the body.
|
|
12
|
+
def initialize(screen:, background: nil)
|
|
13
|
+
@screen = screen
|
|
14
|
+
@background = background
|
|
15
|
+
@child = nil
|
|
16
|
+
@overlays = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Sets the single root child. Raises ArgumentError when a child is already present.
|
|
20
|
+
def add_child(node)
|
|
21
|
+
raise ArgumentError, "screen_layout accepts one root layout node" if child
|
|
22
|
+
|
|
23
|
+
@child = node
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Appends an overlay to be composited on top of the body, in registration order.
|
|
27
|
+
def add_overlay(node)
|
|
28
|
+
overlays << node
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns the focusable names from the child, or [] when no child has been added.
|
|
32
|
+
def focusable_names
|
|
33
|
+
child ? child.focusable_names : []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Renders the child into the full-screen rect, then overlays each registered overlay
|
|
37
|
+
# on top in order.
|
|
38
|
+
def render
|
|
39
|
+
body = UI.place(render_child, width: screen.width, height: screen.height, background: background)
|
|
40
|
+
|
|
41
|
+
overlays.reduce(body) do |current, overlay|
|
|
42
|
+
UI.overlay(current, overlay.render, top: overlay.top, left: overlay.left)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# The screen, background style, the single child, and the list of overlays.
|
|
49
|
+
attr_reader :screen, :background, :child, :overlays
|
|
50
|
+
|
|
51
|
+
# Renders the child into a full-screen Rect, or returns an empty string when no child.
|
|
52
|
+
def render_child
|
|
53
|
+
return "" unless child
|
|
54
|
+
|
|
55
|
+
child.render(Rect.new(x: 0, y: 0, width: screen.width, height: screen.height))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|