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.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +14 -3
  4. data/lib/charming/cli.rb +23 -0
  5. data/lib/charming/controller/class_methods.rb +115 -0
  6. data/lib/charming/controller/command_palette.rb +135 -0
  7. data/lib/charming/controller/component_dispatching.rb +81 -0
  8. data/lib/charming/controller/dispatching.rb +60 -0
  9. data/lib/charming/controller/focus_management.rb +30 -0
  10. data/lib/charming/controller/rendering.rb +127 -0
  11. data/lib/charming/controller/session_state.rb +41 -0
  12. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  13. data/lib/charming/controller.rb +35 -559
  14. data/lib/charming/database_commands.rb +16 -0
  15. data/lib/charming/database_installer.rb +27 -0
  16. data/lib/charming/focus.rb +58 -2
  17. data/lib/charming/generators/app_file_generator.rb +13 -0
  18. data/lib/charming/generators/app_generator.rb +123 -47
  19. data/lib/charming/generators/base.rb +26 -0
  20. data/lib/charming/generators/component_generator.rb +10 -10
  21. data/lib/charming/generators/controller_generator.rb +22 -11
  22. data/lib/charming/generators/model_generator.rb +38 -29
  23. data/lib/charming/generators/name.rb +10 -0
  24. data/lib/charming/generators/screen_generator.rb +78 -32
  25. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  26. data/lib/charming/generators/templates/app/README.md.template +9 -0
  27. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  28. data/lib/charming/generators/templates/app/application.template +13 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  30. data/lib/charming/generators/templates/app/application_record.template +7 -0
  31. data/lib/charming/generators/templates/app/application_state.template +6 -0
  32. data/lib/charming/generators/templates/app/database_config.template +12 -0
  33. data/lib/charming/generators/templates/app/executable.template +7 -0
  34. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  35. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  36. data/lib/charming/generators/templates/app/home_state.template +7 -0
  37. data/lib/charming/generators/templates/app/keep.template +0 -0
  38. data/lib/charming/generators/templates/app/layout.template +110 -0
  39. data/lib/charming/generators/templates/app/root_file.template +20 -0
  40. data/lib/charming/generators/templates/app/routes.template +5 -0
  41. data/lib/charming/generators/templates/app/seeds.template +1 -0
  42. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  43. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  44. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  45. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  46. data/lib/charming/generators/templates/app/version.template +5 -0
  47. data/lib/charming/generators/templates/app/view.template +21 -0
  48. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  49. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  50. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  51. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  52. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  53. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  54. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  55. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  56. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  57. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  58. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  59. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  60. data/lib/charming/generators/view_generator.rb +19 -3
  61. data/lib/charming/internal/renderer/differential.rb +25 -2
  62. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  63. data/lib/charming/internal/terminal/adapter.rb +29 -3
  64. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  65. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  66. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  67. data/lib/charming/internal/terminal/tty_backend.rb +65 -115
  68. data/lib/charming/presentation/component.rb +3 -5
  69. data/lib/charming/presentation/components/activity_indicator.rb +173 -134
  70. data/lib/charming/presentation/components/command_palette.rb +94 -96
  71. data/lib/charming/presentation/components/command_palette_modal.rb +33 -0
  72. data/lib/charming/presentation/components/empty_state.rb +47 -36
  73. data/lib/charming/presentation/components/form/builder.rb +52 -40
  74. data/lib/charming/presentation/components/form/confirm.rb +50 -39
  75. data/lib/charming/presentation/components/form/field.rb +94 -71
  76. data/lib/charming/presentation/components/form/input.rb +61 -49
  77. data/lib/charming/presentation/components/form/note.rb +27 -20
  78. data/lib/charming/presentation/components/form/select.rb +84 -63
  79. data/lib/charming/presentation/components/form/textarea.rb +67 -53
  80. data/lib/charming/presentation/components/form.rb +120 -93
  81. data/lib/charming/presentation/components/keyboard_handler.rb +41 -43
  82. data/lib/charming/presentation/components/list.rb +123 -97
  83. data/lib/charming/presentation/components/markdown.rb +21 -17
  84. data/lib/charming/presentation/components/modal.rb +55 -43
  85. data/lib/charming/presentation/components/progressbar.rb +61 -50
  86. data/lib/charming/presentation/components/spinner.rb +35 -27
  87. data/lib/charming/presentation/components/table.rb +108 -85
  88. data/lib/charming/presentation/components/text_area.rb +219 -173
  89. data/lib/charming/presentation/components/text_input.rb +120 -98
  90. data/lib/charming/presentation/components/viewport.rb +218 -168
  91. data/lib/charming/presentation/layout/builder.rb +84 -0
  92. data/lib/charming/presentation/layout/overlay.rb +55 -0
  93. data/lib/charming/presentation/layout/pane.rb +149 -0
  94. data/lib/charming/presentation/layout/rect.rb +21 -0
  95. data/lib/charming/presentation/layout/screen_layout.rb +58 -0
  96. data/lib/charming/presentation/layout/split.rb +132 -0
  97. data/lib/charming/presentation/layout.rb +28 -30
  98. data/lib/charming/presentation/markdown/block_renderers.rb +118 -0
  99. data/lib/charming/presentation/markdown/inline_renderers.rb +66 -0
  100. data/lib/charming/presentation/markdown/render_context.rb +20 -0
  101. data/lib/charming/presentation/markdown/renderer.rb +82 -174
  102. data/lib/charming/presentation/markdown/syntax_highlighter.rb +58 -44
  103. data/lib/charming/presentation/markdown.rb +4 -3
  104. data/lib/charming/presentation/template_view.rb +22 -17
  105. data/lib/charming/presentation/templates/erb_handler.rb +4 -6
  106. data/lib/charming/presentation/templates.rb +47 -32
  107. data/lib/charming/presentation/ui/ansi_codes.rb +87 -0
  108. data/lib/charming/presentation/ui/ansi_slicer.rb +92 -0
  109. data/lib/charming/presentation/ui/border.rb +24 -26
  110. data/lib/charming/presentation/ui/border_painter.rb +56 -0
  111. data/lib/charming/presentation/ui/canvas.rb +80 -0
  112. data/lib/charming/presentation/ui/style.rb +170 -205
  113. data/lib/charming/presentation/ui/theme.rb +133 -135
  114. data/lib/charming/presentation/ui/width.rb +12 -14
  115. data/lib/charming/presentation/ui.rb +70 -213
  116. data/lib/charming/presentation/view.rb +107 -92
  117. data/lib/charming/runtime.rb +25 -10
  118. data/lib/charming/tasks/inline_executor.rb +9 -0
  119. data/lib/charming/tasks/task.rb +3 -0
  120. data/lib/charming/tasks/threaded_executor.rb +12 -0
  121. data/lib/charming/version.rb +1 -1
  122. data/lib/charming.rb +16 -2
  123. metadata +60 -10
  124. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
  125. data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
  126. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  127. data/lib/charming/generators/app_generator/controller_template.rb +0 -60
  128. data/lib/charming/generators/app_generator/database_templates.rb +0 -45
  129. data/lib/charming/generators/app_generator/layout_template.rb +0 -66
  130. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
  131. data/lib/charming/generators/app_generator/state_templates.rb +0 -30
  132. 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
- module Presentation
5
- # Layout contains generic screen-size math and composition helpers. It is
6
- # intentionally unaware of application shells such as sidebars or nav panes.
7
- module Layout
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
- def clamp_size(value, min: nil, max: nil)
11
- size = value.to_i
12
- size = [size, min].max if min
13
- size = [size, max].min if max
14
- size
15
- end
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
- def available_width(screen, reserved: 0, min: nil, max: nil)
18
- clamp_size(screen.width - reserved, min: min, max: max)
19
- end
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
- def available_height(screen, reserved: 0, min: nil, max: nil)
22
- clamp_size(screen.height - reserved, min: min, max: max)
23
- end
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
- def stack_or_row(*blocks, narrow:, gap: 0)
26
- if narrow
27
- UI.join_vertical(*blocks, gap: gap)
28
- else
29
- UI.join_horizontal(*blocks, gap: gap)
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
- def selected_window_start(selected_index:, item_count:, window_size:)
34
- count = item_count.to_i
35
- size = [window_size.to_i, 1].max
36
- selected = selected_index.to_i.clamp(0, [count - 1, 0].max)
37
- max_start = [count - size, 0].max
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
- (selected - size + 1).clamp(0, max_start)
40
- end
38
+ (selected - size + 1).clamp(0, max_start)
41
39
  end
42
40
  end
43
41
  end