charming 0.1.1 → 0.1.3
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 +14 -3
- 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 +110 -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 +25 -2
- 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 +65 -115
- data/lib/charming/presentation/component.rb +3 -5
- data/lib/charming/presentation/components/activity_indicator.rb +173 -134
- data/lib/charming/presentation/components/command_palette.rb +94 -96
- data/lib/charming/presentation/components/command_palette_modal.rb +33 -0
- data/lib/charming/presentation/components/empty_state.rb +47 -36
- data/lib/charming/presentation/components/form/builder.rb +52 -40
- data/lib/charming/presentation/components/form/confirm.rb +50 -39
- data/lib/charming/presentation/components/form/field.rb +94 -71
- data/lib/charming/presentation/components/form/input.rb +61 -49
- data/lib/charming/presentation/components/form/note.rb +27 -20
- data/lib/charming/presentation/components/form/select.rb +84 -63
- data/lib/charming/presentation/components/form/textarea.rb +67 -53
- data/lib/charming/presentation/components/form.rb +120 -93
- data/lib/charming/presentation/components/keyboard_handler.rb +41 -43
- data/lib/charming/presentation/components/list.rb +123 -97
- data/lib/charming/presentation/components/markdown.rb +21 -17
- data/lib/charming/presentation/components/modal.rb +55 -43
- data/lib/charming/presentation/components/progressbar.rb +61 -50
- data/lib/charming/presentation/components/spinner.rb +35 -27
- data/lib/charming/presentation/components/table.rb +108 -85
- data/lib/charming/presentation/components/text_area.rb +219 -173
- data/lib/charming/presentation/components/text_input.rb +120 -98
- data/lib/charming/presentation/components/viewport.rb +218 -168
- data/lib/charming/presentation/layout/builder.rb +84 -0
- data/lib/charming/presentation/layout/overlay.rb +55 -0
- data/lib/charming/presentation/layout/pane.rb +149 -0
- data/lib/charming/presentation/layout/rect.rb +21 -0
- data/lib/charming/presentation/layout/screen_layout.rb +58 -0
- data/lib/charming/presentation/layout/split.rb +132 -0
- data/lib/charming/presentation/layout.rb +28 -30
- data/lib/charming/presentation/markdown/block_renderers.rb +118 -0
- data/lib/charming/presentation/markdown/inline_renderers.rb +66 -0
- data/lib/charming/presentation/markdown/render_context.rb +20 -0
- data/lib/charming/presentation/markdown/renderer.rb +82 -174
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +58 -44
- data/lib/charming/presentation/markdown.rb +4 -3
- data/lib/charming/presentation/template_view.rb +22 -17
- data/lib/charming/presentation/templates/erb_handler.rb +4 -6
- data/lib/charming/presentation/templates.rb +47 -32
- data/lib/charming/presentation/ui/ansi_codes.rb +87 -0
- data/lib/charming/presentation/ui/ansi_slicer.rb +92 -0
- data/lib/charming/presentation/ui/border.rb +24 -26
- data/lib/charming/presentation/ui/border_painter.rb +56 -0
- data/lib/charming/presentation/ui/canvas.rb +80 -0
- data/lib/charming/presentation/ui/style.rb +170 -205
- data/lib/charming/presentation/ui/theme.rb +133 -135
- data/lib/charming/presentation/ui/width.rb +12 -14
- data/lib/charming/presentation/ui.rb +70 -213
- data/lib/charming/presentation/view.rb +107 -92
- data/lib/charming/runtime.rb +25 -10
- 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 +16 -2
- metadata +60 -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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Layout
|
|
5
|
+
# Builder turns a declarative `screen_layout { ... }` block into a layout tree of
|
|
6
|
+
# ScreenLayout → Split → Pane nodes. The block DSL is `split(direction) { ... }`,
|
|
7
|
+
# `pane(name) { ... }`, and `overlay { ... }`. Unknown method calls in the block are
|
|
8
|
+
# forwarded to the underlying view so view helpers (e.g., `text`) work inside layout blocks.
|
|
9
|
+
class Builder
|
|
10
|
+
# Builds the layout tree by evaluating the *block* in the builder's context.
|
|
11
|
+
# Returns the root ScreenLayout node.
|
|
12
|
+
def self.build(screen:, view:, background: nil, &)
|
|
13
|
+
new(screen: screen, view: view, background: background).build(&)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(screen:, view:, background: nil)
|
|
17
|
+
@view = view
|
|
18
|
+
@root = ScreenLayout.new(screen: screen, background: background)
|
|
19
|
+
@stack = [@root]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Evaluates *block* in the builder's context, then returns the root ScreenLayout node.
|
|
23
|
+
def build(&)
|
|
24
|
+
instance_eval(&) if block_given?
|
|
25
|
+
root
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Adds a Split node to the current scope. *direction* is `:horizontal` or `:vertical`.
|
|
29
|
+
# *gap* (in cells) is inserted between children. Additional *options* are forwarded
|
|
30
|
+
# to Split. The block, if given, is evaluated in the split's scope (for nested children).
|
|
31
|
+
def split(direction, gap: 0, **options, &)
|
|
32
|
+
node = Split.new(direction: direction, gap: gap, **options)
|
|
33
|
+
append(node)
|
|
34
|
+
within(node, &)
|
|
35
|
+
node
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Adds a Pane leaf node to the current scope. *name* (optional) is the focus slot name;
|
|
39
|
+
# *content* (or a *block*) is the body. *options* are forwarded to Pane.
|
|
40
|
+
def pane(name = nil, content = nil, **options, &block)
|
|
41
|
+
node = Pane.new(name: name, content: content, block: block, view: view, **options)
|
|
42
|
+
append(node)
|
|
43
|
+
node
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Adds an Overlay node to the root ScreenLayout. *top* and *left* default to :center.
|
|
47
|
+
# The block, if given, is evaluated in the view's context.
|
|
48
|
+
def overlay(content = nil, top: :center, left: :center, **options, &block)
|
|
49
|
+
root.add_overlay(Overlay.new(content: content, block: block, view: view, top: top, left: left, **options))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Forwards unknown method calls to the underlying view so helpers like `text` work
|
|
53
|
+
# inside layout blocks.
|
|
54
|
+
def respond_to_missing?(name, include_private = false)
|
|
55
|
+
view.respond_to?(name, include_private) || super
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def method_missing(name, ...)
|
|
59
|
+
return view.__send__(name, ...) if view.respond_to?(name, true)
|
|
60
|
+
|
|
61
|
+
super
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
attr_reader :root, :stack, :view
|
|
67
|
+
|
|
68
|
+
# Appends *node* to the topmost scope on the stack.
|
|
69
|
+
def append(node)
|
|
70
|
+
stack.last.add_child(node)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Pushes *node* onto the stack, evaluates *block* in the builder's context, then pops it.
|
|
74
|
+
def within(node, &)
|
|
75
|
+
return unless block_given?
|
|
76
|
+
|
|
77
|
+
stack.push(node)
|
|
78
|
+
instance_eval(&)
|
|
79
|
+
ensure
|
|
80
|
+
stack.pop
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Layout
|
|
5
|
+
# Overlay is a compositing node used by ScreenLayout for floating elements (modals,
|
|
6
|
+
# dialogs, command palettes). It positions its content at *top*/*left* (each may be
|
|
7
|
+
# `:center` or an absolute cell offset) and optionally sizes it via *width*/*height*
|
|
8
|
+
# with an outer *style*.
|
|
9
|
+
class Overlay
|
|
10
|
+
# The vertical and horizontal offset (cell count or `:center`) of the overlay
|
|
11
|
+
# within the parent canvas.
|
|
12
|
+
attr_reader :top, :left
|
|
13
|
+
|
|
14
|
+
# *content* (or a *block*) provides the body. *top*/*left* default to :center.
|
|
15
|
+
# *width*/*height* fix the overlay's dimensions; when unset, the content's natural
|
|
16
|
+
# size is used. *style* wraps the rendered content in a UI::Style.
|
|
17
|
+
def initialize(content: nil, block: nil, view: nil, top: :center, left: :center, width: nil, height: nil, style: nil)
|
|
18
|
+
@content = content
|
|
19
|
+
@block = block
|
|
20
|
+
@view = view
|
|
21
|
+
@top = top
|
|
22
|
+
@left = left
|
|
23
|
+
@width = width
|
|
24
|
+
@height = height
|
|
25
|
+
@style = style
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Renders the overlay's content; when *width* or *height* is set, places the rendered
|
|
29
|
+
# content into a sized canvas before returning.
|
|
30
|
+
def render
|
|
31
|
+
return styled_content unless width || height
|
|
32
|
+
|
|
33
|
+
UI.place(styled_content, width: width || UI.block_width(styled_content.lines(chomp: true)), height: height || styled_content.lines.count)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# The raw content, body block, view, and sizing/style options.
|
|
39
|
+
attr_reader :content, :block, :view, :width, :height, :style
|
|
40
|
+
|
|
41
|
+
# Returns the rendered content wrapped in the configured *style* (when present).
|
|
42
|
+
def styled_content
|
|
43
|
+
return rendered_content unless style
|
|
44
|
+
|
|
45
|
+
style.render(rendered_content)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Evaluates the content (block or constant) and returns its rendered string.
|
|
49
|
+
def rendered_content
|
|
50
|
+
value = block ? view.instance_exec(&block) : content
|
|
51
|
+
value.respond_to?(:render) ? value.render.to_s : value.to_s
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Layout
|
|
5
|
+
# Pane is a leaf layout node: a single rectangle with optional border, padding, and
|
|
6
|
+
# styling, containing a piece of content (a string, a View, or a block evaluated in the
|
|
7
|
+
# view's context). Panes with a `name` and `focus: true` are registered as focusable
|
|
8
|
+
# slots in the controller's focus ring.
|
|
9
|
+
class Pane
|
|
10
|
+
# The pane's focus slot name, fixed width, fixed height, and grow weight.
|
|
11
|
+
attr_reader :name, :width, :height, :grow
|
|
12
|
+
|
|
13
|
+
# *name* is the focus slot identifier (optional). *content* or *block* provides the body.
|
|
14
|
+
# *width*/*height*/*grow* control sizing. *border* may be `true` (normal border) or a
|
|
15
|
+
# border name symbol. *padding* may be 1, 2, or 4 values (CSS-style shorthand).
|
|
16
|
+
# *style* sets the base style; *focused_style* overrides it when the pane is focused.
|
|
17
|
+
# *focus: true* marks the pane as focusable. *scroll*/*clip*/*wrap* control how
|
|
18
|
+
# overflow content is rendered (via the embedded Viewport).
|
|
19
|
+
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)
|
|
20
|
+
@name = name
|
|
21
|
+
@content = content
|
|
22
|
+
@block = block
|
|
23
|
+
@view = view
|
|
24
|
+
@width = width
|
|
25
|
+
@height = height
|
|
26
|
+
@grow = grow
|
|
27
|
+
@border = border
|
|
28
|
+
@padding = padding
|
|
29
|
+
@style = style
|
|
30
|
+
@focused_style = focused_style
|
|
31
|
+
@focus = focus
|
|
32
|
+
@scroll = scroll
|
|
33
|
+
@clip = clip
|
|
34
|
+
@wrap = wrap
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Raises ArgumentError — panes are leaves and cannot contain layout children.
|
|
38
|
+
def add_child(_node)
|
|
39
|
+
raise ArgumentError, "pane cannot contain layout children"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns [name] when the pane is marked focusable and has a name, otherwise [].
|
|
43
|
+
def focusable_names
|
|
44
|
+
(focus && name) ? [name] : []
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Renders the pane into *rect*, applying the configured style, border, and padding
|
|
48
|
+
# around the evaluated content.
|
|
49
|
+
def render(rect)
|
|
50
|
+
outer_style(rect).render(rendered_content(rect))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# The raw content, the body block, the view used for instance_exec, and styling options.
|
|
56
|
+
attr_reader :content, :block, :view, :border, :padding, :style, :focused_style, :focus, :scroll, :clip, :wrap
|
|
57
|
+
|
|
58
|
+
# Returns the content string for *rect*, optionally clipped/scrolled by an embedded Viewport.
|
|
59
|
+
def rendered_content(rect)
|
|
60
|
+
content_rect = inner_rect(rect)
|
|
61
|
+
value = evaluate_content(content_rect)
|
|
62
|
+
return value unless clip || scroll
|
|
63
|
+
|
|
64
|
+
Components::Viewport.new(content: value, width: content_rect.width, height: content_rect.height, wrap: wrap).render
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Evaluates the configured content (block or constant) and renders it to a string.
|
|
68
|
+
# Pane blocks may accept the inner content Rect when they need exact available dimensions.
|
|
69
|
+
def evaluate_content(content_rect)
|
|
70
|
+
value = if block
|
|
71
|
+
block.arity.zero? ? view.instance_exec(&block) : view.instance_exec(content_rect, &block)
|
|
72
|
+
else
|
|
73
|
+
content
|
|
74
|
+
end
|
|
75
|
+
value.respond_to?(:render) ? value.render.to_s : value.to_s
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Builds the outer style object with optional border and padding, sized to the
|
|
79
|
+
# inner rect of the pane.
|
|
80
|
+
def outer_style(rect)
|
|
81
|
+
styled = current_style
|
|
82
|
+
styled = styled.border(border_style) if border
|
|
83
|
+
styled = styled.padding(*padding_values) if padding
|
|
84
|
+
styled.width(inner_rect(rect).width).height(inner_rect(rect).height)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Returns the active style: the focused variant when the pane is focused, otherwise
|
|
88
|
+
# the configured style or a default UI::Style.
|
|
89
|
+
def current_style
|
|
90
|
+
return focused_pane_style if focused?
|
|
91
|
+
|
|
92
|
+
style || UI.style
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Returns the focused-pane style: the focused_style override, or the theme's title style.
|
|
96
|
+
def focused_pane_style
|
|
97
|
+
focused_style || view.__send__(:theme).title
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# True when the pane is configured for focus and the view reports it as currently focused.
|
|
101
|
+
def focused?
|
|
102
|
+
focus && name && view.focused?(name)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Returns the inner Rect after border and padding insets are applied.
|
|
106
|
+
def inner_rect(rect)
|
|
107
|
+
rect.inset(
|
|
108
|
+
top: border_top + padding_top,
|
|
109
|
+
right: border_right + padding_right,
|
|
110
|
+
bottom: border_bottom + padding_bottom,
|
|
111
|
+
left: border_left + padding_left
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Resolves the border style symbol: :normal when border is `true`, otherwise the configured value.
|
|
116
|
+
def border_style
|
|
117
|
+
(border == true) ? :normal : border
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Border thickness on each side (1 when a border is configured, 0 otherwise).
|
|
121
|
+
def border_top = border ? 1 : 0
|
|
122
|
+
def border_right = border ? 1 : 0
|
|
123
|
+
def border_bottom = border ? 1 : 0
|
|
124
|
+
def border_left = border ? 1 : 0
|
|
125
|
+
|
|
126
|
+
# The padding values normalized to [top, right, bottom, left] form.
|
|
127
|
+
def padding_values
|
|
128
|
+
@padding_values ||= expand_padding(Array(padding))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Per-side padding values (0 when no padding is configured).
|
|
132
|
+
def padding_top = padding ? padding_values[0] : 0
|
|
133
|
+
def padding_right = padding ? padding_values[1] : 0
|
|
134
|
+
def padding_bottom = padding ? padding_values[2] : 0
|
|
135
|
+
def padding_left = padding ? padding_values[3] : 0
|
|
136
|
+
|
|
137
|
+
# Normalizes 1/2/4 padding arguments to [top, right, bottom, left].
|
|
138
|
+
def expand_padding(values)
|
|
139
|
+
case values.length
|
|
140
|
+
when 1 then [values[0], values[0], values[0], values[0]]
|
|
141
|
+
when 2 then [values[0], values[1], values[0], values[1]]
|
|
142
|
+
when 4 then values
|
|
143
|
+
else
|
|
144
|
+
raise ArgumentError, "padding expects 1, 2, or 4 values"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Layout
|
|
5
|
+
# Rect is an immutable rectangle with a top-left position (x, y) and dimensions
|
|
6
|
+
# (width, height). Layout operations produce new Rect instances rather than mutating
|
|
7
|
+
# existing ones.
|
|
8
|
+
Rect = Data.define(:x, :y, :width, :height) do
|
|
9
|
+
# Returns a new Rect inset by *top*/*right*/*bottom*/*left* cells. The result is
|
|
10
|
+
# clamped to a minimum width/height of 0.
|
|
11
|
+
def inset(top: 0, right: 0, bottom: 0, left: 0)
|
|
12
|
+
Rect.new(
|
|
13
|
+
x: x + left,
|
|
14
|
+
y: y + top,
|
|
15
|
+
width: [width - left - right, 0].max,
|
|
16
|
+
height: [height - top - bottom, 0].max
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Layout
|
|
5
|
+
# ScreenLayout is the root of a layout tree. It owns a single child (typically a Split
|
|
6
|
+
# or Pane) rendered into the full terminal screen, and an ordered list of Overlays
|
|
7
|
+
# composited on top of the rendered body.
|
|
8
|
+
class ScreenLayout
|
|
9
|
+
# *screen* is the Charming::Screen whose dimensions define the layout area.
|
|
10
|
+
# *background* (optional) is a UI::Style applied to the empty canvas behind the body.
|
|
11
|
+
def initialize(screen:, background: nil)
|
|
12
|
+
@screen = screen
|
|
13
|
+
@background = background
|
|
14
|
+
@child = nil
|
|
15
|
+
@overlays = []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Sets the single root child. Raises ArgumentError when a child is already present.
|
|
19
|
+
def add_child(node)
|
|
20
|
+
raise ArgumentError, "screen_layout accepts one root layout node" if child
|
|
21
|
+
|
|
22
|
+
@child = node
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Appends an overlay to be composited on top of the body, in registration order.
|
|
26
|
+
def add_overlay(node)
|
|
27
|
+
overlays << node
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the focusable names from the child, or [] when no child has been added.
|
|
31
|
+
def focusable_names
|
|
32
|
+
child ? child.focusable_names : []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Renders the child into the full-screen rect, then overlays each registered overlay
|
|
36
|
+
# on top in order.
|
|
37
|
+
def render
|
|
38
|
+
body = UI.place(render_child, width: screen.width, height: screen.height, background: background)
|
|
39
|
+
|
|
40
|
+
overlays.reduce(body) do |current, overlay|
|
|
41
|
+
UI.overlay(current, overlay.render, top: overlay.top, left: overlay.left)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# The screen, background style, the single child, and the list of overlays.
|
|
48
|
+
attr_reader :screen, :background, :child, :overlays
|
|
49
|
+
|
|
50
|
+
# Renders the child into a full-screen Rect, or returns an empty string when no child.
|
|
51
|
+
def render_child
|
|
52
|
+
return "" unless child
|
|
53
|
+
|
|
54
|
+
child.render(Rect.new(x: 0, y: 0, width: screen.width, height: screen.height))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Layout
|
|
5
|
+
# Split divides a parent Rect among its child nodes horizontally or vertically.
|
|
6
|
+
# Children with a configured `width`/`height` are placed at that fixed size; children
|
|
7
|
+
# without a fixed size share the remaining space according to their `grow` weight.
|
|
8
|
+
class Split
|
|
9
|
+
# The fixed width/height of the split (when set) and the grow weight for the split itself.
|
|
10
|
+
attr_reader :width, :height, :grow
|
|
11
|
+
|
|
12
|
+
# *direction* is `:horizontal` or `:vertical`. *gap* (in cells) separates children.
|
|
13
|
+
# *width*/*height* are optional fixed dimensions for the split as a whole.
|
|
14
|
+
# *grow* is the weight for distributing remaining space (used when this Split is a
|
|
15
|
+
# child of another Split).
|
|
16
|
+
def initialize(direction:, gap: 0, width: nil, height: nil, grow: nil)
|
|
17
|
+
@direction = direction.to_sym
|
|
18
|
+
@gap = gap.to_i
|
|
19
|
+
@width = width
|
|
20
|
+
@height = height
|
|
21
|
+
@grow = grow
|
|
22
|
+
@children = []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Appends *node* (a child Split or Pane) to this Split.
|
|
26
|
+
def add_child(node)
|
|
27
|
+
children << node
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the flattened list of focusable names from all child nodes.
|
|
31
|
+
def focusable_names
|
|
32
|
+
children.flat_map(&:focusable_names)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Renders each child into its own sub-rect, then overlays them on a blank canvas
|
|
36
|
+
# of the parent's dimensions.
|
|
37
|
+
def render(rect)
|
|
38
|
+
frame = UI.place("", width: rect.width, height: rect.height)
|
|
39
|
+
|
|
40
|
+
child_rects(rect).zip(children).each do |child_rect, child|
|
|
41
|
+
frame = UI.overlay(frame, child.render(child_rect), top: child_rect.y - rect.y, left: child_rect.x - rect.x)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
frame
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# The split direction (`:horizontal` or `:vertical`) and the inter-child gap.
|
|
50
|
+
attr_reader :direction, :gap, :children
|
|
51
|
+
|
|
52
|
+
# Returns an array of child rects sized according to each child's fixed dimensions
|
|
53
|
+
# and grow weights. Raises ArgumentError when *direction* is neither horizontal nor vertical.
|
|
54
|
+
def child_rects(rect)
|
|
55
|
+
return horizontal_rects(rect) if direction == :horizontal
|
|
56
|
+
return vertical_rects(rect) if direction == :vertical
|
|
57
|
+
|
|
58
|
+
raise ArgumentError, "unknown split direction: #{direction.inspect}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Computes per-child rects for a horizontal split.
|
|
62
|
+
def horizontal_rects(rect)
|
|
63
|
+
sizes = child_sizes(axis: :horizontal, available: rect.width)
|
|
64
|
+
left = rect.x
|
|
65
|
+
|
|
66
|
+
sizes.map do |width|
|
|
67
|
+
child_rect = Rect.new(x: left, y: rect.y, width: width, height: rect.height)
|
|
68
|
+
left += width + gap
|
|
69
|
+
child_rect
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Computes per-child rects for a vertical split.
|
|
74
|
+
def vertical_rects(rect)
|
|
75
|
+
sizes = child_sizes(axis: :vertical, available: rect.height)
|
|
76
|
+
top = rect.y
|
|
77
|
+
|
|
78
|
+
sizes.map do |height|
|
|
79
|
+
child_rect = Rect.new(x: rect.x, y: top, width: rect.width, height: height)
|
|
80
|
+
top += height + gap
|
|
81
|
+
child_rect
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Computes the size of each child along the *axis* given the *available* cells.
|
|
86
|
+
# Subtracts the total gap, allocates fixed sizes first, and distributes the remainder
|
|
87
|
+
# among flexible (non-fixed) children by their grow weights.
|
|
88
|
+
def child_sizes(axis:, available:)
|
|
89
|
+
gap_size = gap * [children.length - 1, 0].max
|
|
90
|
+
available_for_children = [available - gap_size, 0].max
|
|
91
|
+
fixed = children.map { |child| fixed_size(child, axis) }
|
|
92
|
+
flexible_indexes = fixed.each_index.select { |index| fixed[index].nil? }
|
|
93
|
+
sizes = fixed.map { |size| size&.to_i }
|
|
94
|
+
remaining = [available_for_children - sizes.compact.sum, 0].max
|
|
95
|
+
|
|
96
|
+
distribute_remaining(sizes, flexible_indexes, remaining)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Returns the fixed size of *child* along *axis* (`:horizontal` reads width, `:vertical` reads height).
|
|
100
|
+
def fixed_size(child, axis)
|
|
101
|
+
(axis == :horizontal) ? child.width : child.height
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Distributes the *remaining* cells across *flexible_indexes* by grow weight, with the
|
|
105
|
+
# last flexible child absorbing any rounding remainder.
|
|
106
|
+
def distribute_remaining(sizes, flexible_indexes, remaining)
|
|
107
|
+
return sizes.map { |size| size || 0 } if flexible_indexes.empty?
|
|
108
|
+
|
|
109
|
+
total_grow = flexible_indexes.sum { |index| grow_weight(children[index]) }
|
|
110
|
+
used = 0
|
|
111
|
+
|
|
112
|
+
flexible_indexes.each_with_index do |index, flexible_index|
|
|
113
|
+
size = if flexible_index == flexible_indexes.length - 1
|
|
114
|
+
remaining - used
|
|
115
|
+
else
|
|
116
|
+
(remaining * grow_weight(children[index]) / total_grow).floor
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
sizes[index] = size
|
|
120
|
+
used += size
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
sizes
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Returns the grow weight of *child*, defaulting to 1 when unset.
|
|
127
|
+
def grow_weight(child)
|
|
128
|
+
[child.grow.to_i, 1].max
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -1,43 +1,41 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
module_function
|
|
4
|
+
# Layout contains generic screen-size math and composition helpers. It is
|
|
5
|
+
# intentionally unaware of application shells such as sidebars or nav panes.
|
|
6
|
+
module Layout
|
|
7
|
+
module_function
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
def clamp_size(value, min: nil, max: nil)
|
|
10
|
+
size = value.to_i
|
|
11
|
+
size = [size, min].max if min
|
|
12
|
+
size = [size, max].min if max
|
|
13
|
+
size
|
|
14
|
+
end
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
def available_width(screen, reserved: 0, min: nil, max: nil)
|
|
17
|
+
clamp_size(screen.width - reserved, min: min, max: max)
|
|
18
|
+
end
|
|
20
19
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
def available_height(screen, reserved: 0, min: nil, max: nil)
|
|
21
|
+
clamp_size(screen.height - reserved, min: min, max: max)
|
|
22
|
+
end
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
end
|
|
24
|
+
def stack_or_row(*blocks, narrow:, gap: 0)
|
|
25
|
+
if narrow
|
|
26
|
+
UI.join_vertical(*blocks, gap: gap)
|
|
27
|
+
else
|
|
28
|
+
UI.join_horizontal(*blocks, gap: gap)
|
|
31
29
|
end
|
|
30
|
+
end
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
def selected_window_start(selected_index:, item_count:, window_size:)
|
|
33
|
+
count = item_count.to_i
|
|
34
|
+
size = [window_size.to_i, 1].max
|
|
35
|
+
selected = selected_index.to_i.clamp(0, [count - 1, 0].max)
|
|
36
|
+
max_start = [count - size, 0].max
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
end
|
|
38
|
+
(selected - size + 1).clamp(0, max_start)
|
|
41
39
|
end
|
|
42
40
|
end
|
|
43
41
|
end
|