charming 0.2.0 → 0.2.1

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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +96 -9
  4. data/lib/charming/audio/player.rb +104 -0
  5. data/lib/charming/audio/system.rb +69 -0
  6. data/lib/charming/cli.rb +63 -7
  7. data/lib/charming/controller/action_hooks.rb +124 -0
  8. data/lib/charming/controller/class_methods.rb +15 -1
  9. data/lib/charming/controller/dispatching.rb +31 -5
  10. data/lib/charming/controller/focus.rb +9 -0
  11. data/lib/charming/controller/focus_management.rb +0 -7
  12. data/lib/charming/controller/session_state.rb +16 -1
  13. data/lib/charming/controller/sidebar_navigation.rb +63 -28
  14. data/lib/charming/controller.rb +62 -10
  15. data/lib/charming/database/commands.rb +123 -11
  16. data/lib/charming/events/focus_event.rb +12 -0
  17. data/lib/charming/events/paste_event.rb +11 -0
  18. data/lib/charming/events/task_progress_event.rb +21 -0
  19. data/lib/charming/generators/app_generator.rb +38 -1
  20. data/lib/charming/generators/database_installer.rb +4 -15
  21. data/lib/charming/generators/migration_generator.rb +116 -0
  22. data/lib/charming/generators/migration_timestamp.rb +29 -0
  23. data/lib/charming/generators/model_generator.rb +4 -2
  24. data/lib/charming/generators/templates/app/application_controller.template +1 -1
  25. data/lib/charming/generators/templates/app/database_config.template +3 -1
  26. data/lib/charming/generators/templates/app/layout.template +1 -1
  27. data/lib/charming/generators/templates/app/spec_helper.template +2 -1
  28. data/lib/charming/generators/templates/app/view.template +1 -1
  29. data/lib/charming/internal/terminal/memory_backend.rb +6 -0
  30. data/lib/charming/internal/terminal/tty_backend.rb +64 -2
  31. data/lib/charming/presentation/component.rb +7 -0
  32. data/lib/charming/presentation/components/audio.rb +31 -0
  33. data/lib/charming/presentation/components/autocomplete.rb +108 -0
  34. data/lib/charming/presentation/components/badge.rb +31 -0
  35. data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
  36. data/lib/charming/presentation/components/command_palette.rb +8 -5
  37. data/lib/charming/presentation/components/error_screen.rb +72 -0
  38. data/lib/charming/presentation/components/form.rb +9 -0
  39. data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
  40. data/lib/charming/presentation/components/help_overlay.rb +65 -0
  41. data/lib/charming/presentation/components/markdown.rb +6 -2
  42. data/lib/charming/presentation/components/modal.rb +45 -5
  43. data/lib/charming/presentation/components/multi_select_list.rb +85 -0
  44. data/lib/charming/presentation/components/progressbar.rb +0 -1
  45. data/lib/charming/presentation/components/status_bar.rb +75 -0
  46. data/lib/charming/presentation/components/tab_bar.rb +103 -0
  47. data/lib/charming/presentation/components/table.rb +40 -9
  48. data/lib/charming/presentation/components/text_area.rb +47 -10
  49. data/lib/charming/presentation/components/text_input.rb +79 -4
  50. data/lib/charming/presentation/components/toast.rb +51 -0
  51. data/lib/charming/presentation/components/tree.rb +176 -0
  52. data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
  53. data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
  54. data/lib/charming/presentation/components/viewport/position.rb +67 -0
  55. data/lib/charming/presentation/components/viewport.rb +37 -122
  56. data/lib/charming/presentation/layout/builder.rb +4 -1
  57. data/lib/charming/presentation/layout/overlay.rb +6 -4
  58. data/lib/charming/presentation/layout/pane.rb +2 -1
  59. data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
  60. data/lib/charming/presentation/layout/screen_layout.rb +12 -3
  61. data/lib/charming/presentation/layout/split.rb +37 -3
  62. data/lib/charming/presentation/markdown/renderer.rb +99 -63
  63. data/lib/charming/presentation/markdown/style_config.rb +10 -5
  64. data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
  65. data/lib/charming/presentation/markdown/table_renderer.rb +60 -0
  66. data/lib/charming/presentation/markdown/text_wrapper.rb +40 -0
  67. data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
  68. data/lib/charming/presentation/templates/erb_handler.rb +35 -2
  69. data/lib/charming/presentation/ui/ansi_codes.rb +11 -0
  70. data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
  71. data/lib/charming/presentation/ui/color_support.rb +129 -0
  72. data/lib/charming/presentation/ui/theme.rb +7 -0
  73. data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
  74. data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
  75. data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
  76. data/lib/charming/presentation/ui/themes/nord.json +32 -0
  77. data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
  78. data/lib/charming/presentation/ui/width.rb +27 -2
  79. data/lib/charming/router.rb +1 -1
  80. data/lib/charming/runtime.rb +122 -15
  81. data/lib/charming/tasks/cancelled.rb +11 -0
  82. data/lib/charming/tasks/inline_executor.rb +10 -4
  83. data/lib/charming/tasks/progress.rb +30 -0
  84. data/lib/charming/tasks/task.rb +24 -4
  85. data/lib/charming/tasks/threaded_executor.rb +35 -11
  86. data/lib/charming/test_helper.rb +120 -0
  87. data/lib/charming/version.rb +1 -1
  88. data/lib/charming.rb +43 -1
  89. metadata +36 -49
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "unicode/display_width"
4
-
5
3
  module Charming
6
4
  module Components
7
5
  # Viewport is a scrollable region over multi-line content. Supports keyboard scrolling
@@ -12,9 +10,6 @@ module Charming
12
10
  class Viewport < Component
13
11
  include KeyboardHandler
14
12
 
15
- # Matches an ANSI SGR escape sequence (e.g., "\e[31m" for red foreground).
16
- ANSI_PATTERN = /\e\[[0-9;]*m/
17
-
18
13
  # Maps scroll keys to the instance methods that perform them via KeyboardHandler.
19
14
  KEY_ACTIONS = {
20
15
  up: :scroll_up,
@@ -27,9 +22,6 @@ module Charming
27
22
  right: :scroll_right
28
23
  }.freeze
29
24
 
30
- # The current top-visible row and left-visible column, respectively.
31
- attr_reader :offset, :column
32
-
33
25
  # *content* may be a string, an array of lines, or any object responding to `render`.
34
26
  # *width* and *height* constrain the visible window; *offset* is the top-visible row
35
27
  # and *column* is the left-visible column. *wrap* enables soft-wrapping of long lines.
@@ -38,11 +30,10 @@ module Charming
38
30
  @content = content
39
31
  @width = width
40
32
  @height = height
41
- @offset = offset
42
- @column = column
33
+ @position = Position.new(offset: offset, column: column)
43
34
  @wrap = wrap
44
35
  @keymap = keymap
45
- clamp_position
36
+ position.clamp(bounds)
46
37
  end
47
38
 
48
39
  # Renders the visible window of content as a multi-line string.
@@ -50,6 +41,16 @@ module Charming
50
41
  visible_lines.map { |line| render_line(line) }.join("\n")
51
42
  end
52
43
 
44
+ # The current top-visible row.
45
+ def offset
46
+ @position.offset
47
+ end
48
+
49
+ # The current left-visible column.
50
+ def column
51
+ @position.column
52
+ end
53
+
53
54
  # Handles mouse events: scroll wheel adjusts the row offset, click moves the top
54
55
  # visible row to the clicked position. Returns :handled on success.
55
56
  def handle_mouse(event)
@@ -57,77 +58,61 @@ module Charming
57
58
 
58
59
  if event.scroll?
59
60
  scroll_delta = (event.button_name == :scroll_up) ? -1 : 1
60
- @offset += scroll_delta
61
- clamp_position
61
+ position.move_to(offset + scroll_delta, bounds)
62
62
  return :handled
63
63
  end
64
64
 
65
65
  return nil unless event.click?
66
66
 
67
67
  clicked_row = event.y
68
- return nil if clicked_row < offset || clicked_row >= offset + viewport_height
68
+ return nil if clicked_row < 0 || clicked_row >= viewport_height
69
69
 
70
- @offset = clicked_row
71
- clamp_position
70
+ position.move_to(offset + clicked_row, bounds)
72
71
  :handled
73
72
  end
74
73
 
75
74
  private
76
75
 
77
- attr_reader :content, :width, :height
76
+ attr_reader :content, :width, :height, :position
78
77
 
79
78
  # Scrolls the viewport up by one row.
80
79
  def scroll_up
81
- @offset -= 1
82
- clamp_position
80
+ position.scroll_up(bounds)
83
81
  end
84
82
 
85
83
  # Scrolls the viewport down by one row.
86
84
  def scroll_down
87
- @offset += 1
88
- clamp_position
85
+ position.scroll_down(bounds)
89
86
  end
90
87
 
91
88
  # Scrolls up by one viewport page.
92
89
  def page_up
93
- @offset -= page_size
94
- clamp_position
90
+ position.page_up(page_size, bounds)
95
91
  end
96
92
 
97
93
  # Scrolls down by one viewport page.
98
94
  def page_down
99
- @offset += page_size
100
- clamp_position
95
+ position.page_down(page_size, bounds)
101
96
  end
102
97
 
103
98
  # Scrolls to the top-left of the content.
104
99
  def scroll_home
105
- @offset = 0
106
- @column = 0
100
+ position.home
107
101
  end
108
102
 
109
103
  # Scrolls to the bottom-right of the content.
110
104
  def scroll_end
111
- @offset = max_offset
112
- @column = max_column
105
+ position.end_at(bounds)
113
106
  end
114
107
 
115
108
  # Scrolls one column left.
116
109
  def scroll_left
117
- @column -= 1
118
- clamp_position
110
+ position.scroll_left(bounds)
119
111
  end
120
112
 
121
113
  # Scrolls one column right.
122
114
  def scroll_right
123
- @column += 1
124
- clamp_position
125
- end
126
-
127
- # Clamps both the row offset and the column to their valid ranges.
128
- def clamp_position
129
- @offset = offset.clamp(0, max_offset)
130
- @column = column.clamp(0, max_column)
115
+ position.scroll_right(bounds)
131
116
  end
132
117
 
133
118
  # Returns the slice of content lines visible in the current viewport, padded to *height*.
@@ -141,89 +126,12 @@ module Charming
141
126
  # Renders a single line according to the configured width and wrap mode: clips to the
142
127
  # visible column window when not wrapping, otherwise wraps the line to the width.
143
128
  def render_line(line)
144
- return line unless width
145
- return pad_line(line, width) if wrap?
146
-
147
- pad_line(clip_line(line), width)
148
- end
149
-
150
- # Clips *line* to the visible column window while preserving active ANSI styling.
151
- def clip_line(line)
152
- clipped = clip_tokens(line.to_s)
153
- needs_reset?(clipped) ? "#{clipped}\e[0m" : clipped
154
- end
155
-
156
- # Walks *line* token-by-token, copying ANSI escapes through and emitting only the
157
- # characters that fall inside the visible column window.
158
- def clip_tokens(line)
159
- state = {cursor: 0, output: +""}
160
- line.scan(/#{ANSI_PATTERN}|./mo) do |token|
161
- ansi?(token) ? append_ansi(state, token) : append_character(state, token)
162
- end
163
- state.fetch(:output)
164
- end
165
-
166
- # Appends an ANSI escape token to the output buffer unchanged.
167
- def append_ansi(state, token)
168
- state.fetch(:output) << token
169
- end
170
-
171
- # Appends a single character token to the output buffer when it falls inside the
172
- # visible column window, advancing the visual cursor.
173
- def append_character(state, char)
174
- char_width = Unicode::DisplayWidth.of(char)
175
- cursor = state.fetch(:cursor)
176
- state.fetch(:output) << char if visible?(cursor, char_width)
177
- state[:cursor] = cursor + char_width
178
- end
179
-
180
- # True when the character at *cursor* (with the given display *char_width*) is within
181
- # the visible column window.
182
- def visible?(cursor, char_width)
183
- cursor >= column && cursor + char_width <= column + width
184
- end
185
-
186
- # True when *value* contains ANSI codes but does not end with a reset — needed because
187
- # the clip may truncate styling in the middle of a styled run.
188
- def needs_reset?(value)
189
- value.match?(ANSI_PATTERN) && !value.end_with?("\e[0m")
190
- end
191
-
192
- # Pads *line* to *target_width* with trailing spaces, leaving the line itself unchanged.
193
- def pad_line(line, target_width)
194
- line + (" " * [target_width - UI::Width.measure(line), 0].max)
129
+ line_window.render(line)
195
130
  end
196
131
 
197
132
  # Returns the content lines, wrapped to *width* when wrap is enabled.
198
133
  def content_lines
199
- return wrapped_content_lines if wrap?
200
-
201
- rendered_content.lines(chomp: true)
202
- end
203
-
204
- # Wraps the content to *width* via UI::visible_slice, returning an array of wrapped lines.
205
- def wrapped_content_lines
206
- rendered_content.lines(chomp: true).flat_map { |line| wrap_line(line) }
207
- end
208
-
209
- # Wraps a single *line* into chunks of *width* display columns.
210
- def wrap_line(line)
211
- line_width = UI::Width.measure(line)
212
- return [""] if line_width.zero?
213
-
214
- start_column = 0
215
- out = []
216
- while start_column < line_width
217
- out << UI.visible_slice(line, start_column, width)
218
- start_column += width
219
- end
220
- out
221
- end
222
-
223
- # Returns the rendered content string, calling `render.to_s` on the content object when
224
- # it responds to render.
225
- def rendered_content
226
- content.respond_to?(:render) ? content.render.to_s : content.to_s
134
+ content_source.lines
227
135
  end
228
136
 
229
137
  # Returns the visible row count (the configured *height* or the content's line count).
@@ -253,18 +161,25 @@ module Charming
253
161
 
254
162
  # Returns the maximum display width across all content lines.
255
163
  def content_width
256
- content_lines.map { |line| UI::Width.measure(line) }.max || 0
164
+ content_source.display_width
257
165
  end
258
166
 
259
- # True when *token* is an ANSI escape sequence.
260
- def ansi?(token)
261
- token.match?(ANSI_PATTERN)
167
+ def bounds
168
+ {max_offset: max_offset, max_column: max_column}
262
169
  end
263
170
 
264
171
  # True when soft-wrapping is enabled and a positive width is configured.
265
172
  def wrap?
266
173
  @wrap && width&.positive?
267
174
  end
175
+
176
+ def line_window
177
+ LineWindow.new(width: width, column: column, wrap: wrap?)
178
+ end
179
+
180
+ def content_source
181
+ ContentLines.new(content: content, width: width, wrap: @wrap)
182
+ end
268
183
  end
269
184
  end
270
185
  end
@@ -41,7 +41,10 @@ module Charming
41
41
  def pane(name = nil, content = nil, **options, &block)
42
42
  node = Pane.new(
43
43
  name: name, content: content, block: block, view: view,
44
- geometry: PaneGeometry.build(**options.slice(:width, :height, :grow, :border, :padding)),
44
+ geometry: PaneGeometry.build(**options.slice(
45
+ :width, :height, :grow, :border, :padding,
46
+ :min_width, :max_width, :min_height, :max_height
47
+ )),
45
48
  style: PaneStyle.build(**options.slice(:style, :focused_style)),
46
49
  behavior: PaneBehavior.build(**options.slice(:focus, :scroll, :clip, :wrap))
47
50
  )
@@ -8,13 +8,14 @@ module Charming
8
8
  # with an outer *style*.
9
9
  class Overlay
10
10
  # The vertical and horizontal offset (cell count or `:center`) of the overlay
11
- # within the parent canvas.
12
- attr_reader :top, :left
11
+ # within the parent canvas, and its stacking order (higher paints later/on top).
12
+ attr_reader :top, :left, :z_index
13
13
 
14
14
  # *content* (or a *block*) provides the body. *top*/*left* default to :center.
15
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)
16
+ # size is used. *style* wraps the rendered content in a UI::Style. *z_index*
17
+ # controls stacking: higher values composite on top (ties keep registration order).
18
+ def initialize(content: nil, block: nil, view: nil, top: :center, left: :center, width: nil, height: nil, style: nil, z_index: 0)
18
19
  @content = content
19
20
  @block = block
20
21
  @view = view
@@ -23,6 +24,7 @@ module Charming
23
24
  @width = width
24
25
  @height = height
25
26
  @style = style
27
+ @z_index = z_index
26
28
  end
27
29
 
28
30
  # Renders the overlay's content; when *width* or *height* is set, places the rendered
@@ -8,7 +8,8 @@ module Charming
8
8
  # focusable slots in the controller's focus ring.
9
9
  class Pane
10
10
  attr_reader :name
11
- delegate :width, :height, :grow, to: :geometry
11
+ delegate :width, :height, :grow,
12
+ :min_width, :max_width, :min_height, :max_height, to: :geometry
12
13
 
13
14
  # *name* is the focus slot identifier. *content* (or a *block*) is the body; *view*
14
15
  # is the view used for instance_exec when the block is given. *geometry*, *style*, and
@@ -2,19 +2,25 @@
2
2
 
3
3
  module Charming
4
4
  module Layout
5
- # PaneGeometry holds a Pane's sizing (width, height, grow) and inset
6
- # configuration (border + padding). It knows how to inset a Rect for the
5
+ # PaneGeometry holds a Pane's sizing (width, height, grow, min/max constraints)
6
+ # and inset configuration (border + padding). It knows how to inset a Rect for the
7
7
  # content area and how to expand CSS-style 1/2/4-value padding.
8
8
  class PaneGeometry
9
- attr_reader :width, :height, :grow, :border, :padding
9
+ attr_reader :width, :height, :grow, :border, :padding,
10
+ :min_width, :max_width, :min_height, :max_height
10
11
 
11
- def self.build(width: nil, height: nil, grow: nil, border: nil, padding: nil)
12
+ def self.build(width: nil, height: nil, grow: nil, border: nil, padding: nil,
13
+ min_width: nil, max_width: nil, min_height: nil, max_height: nil)
12
14
  new(width: width, height: height, grow: grow,
13
- border: (border == true) ? :normal : border, padding: padding)
15
+ border: (border == true) ? :normal : border, padding: padding,
16
+ min_width: min_width, max_width: max_width,
17
+ min_height: min_height, max_height: max_height)
14
18
  end
15
19
 
16
- def initialize(width:, height:, grow:, border:, padding:)
20
+ def initialize(width:, height:, grow:, border:, padding:,
21
+ min_width: nil, max_width: nil, min_height: nil, max_height: nil)
17
22
  @width, @height, @grow, @border, @padding = width, height, grow, border, padding
23
+ @min_width, @max_width, @min_height, @max_height = min_width, max_width, min_height, max_height
18
24
  @padding_values = padding ? expand_padding(Array(padding)) : [0, 0, 0, 0]
19
25
  freeze
20
26
  end
@@ -22,12 +28,14 @@ module Charming
22
28
  def ==(other)
23
29
  other.is_a?(PaneGeometry) &&
24
30
  width == other.width && height == other.height && grow == other.grow &&
25
- border == other.border && padding == other.padding
31
+ border == other.border && padding == other.padding &&
32
+ min_width == other.min_width && max_width == other.max_width &&
33
+ min_height == other.min_height && max_height == other.max_height
26
34
  end
27
35
  alias_method :eql?, :==
28
36
 
29
37
  def hash
30
- [width, height, grow, border, padding].hash
38
+ [width, height, grow, border, padding, min_width, max_width, min_height, max_height].hash
31
39
  end
32
40
 
33
41
  def border_top = border ? 1 : 0
@@ -39,12 +39,12 @@ module Charming
39
39
  child.mouse_targets(Rect.new(x: 0, y: 0, width: screen.width, height: screen.height))
40
40
  end
41
41
 
42
- # Renders the child into the full-screen rect, then overlays each registered overlay
43
- # on top in order.
42
+ # Renders the child into the full-screen rect, then composites each overlay on top —
43
+ # ordered by z_index (higher paints last), with registration order breaking ties.
44
44
  def render
45
45
  body = UI.place(render_child, width: screen.width, height: screen.height, background: background)
46
46
 
47
- overlays.reduce(body) do |current, overlay|
47
+ stacked_overlays.reduce(body) do |current, overlay|
48
48
  UI.overlay(current, overlay.render, top: overlay.top, left: overlay.left)
49
49
  end
50
50
  end
@@ -60,6 +60,15 @@ module Charming
60
60
 
61
61
  child.render(Rect.new(x: 0, y: 0, width: screen.width, height: screen.height))
62
62
  end
63
+
64
+ # Overlays sorted by z_index (stable: registration order breaks ties). Overlays
65
+ # without a z_index reader (custom nodes) sort at 0.
66
+ def stacked_overlays
67
+ overlays.each_with_index.sort_by do |overlay, index|
68
+ z = overlay.respond_to?(:z_index) ? overlay.z_index.to_i : 0
69
+ [z, index]
70
+ end.map(&:first)
71
+ end
63
72
  end
64
73
  end
65
74
  end
@@ -90,8 +90,10 @@ module Charming
90
90
  end
91
91
 
92
92
  # Computes the size of each child along the *axis* given the *available* cells.
93
- # Subtracts the total gap, allocates fixed sizes first, and distributes the remainder
94
- # among flexible (non-fixed) children by their grow weights.
93
+ # Subtracts the total gap, allocates fixed sizes first, distributes the remainder
94
+ # among flexible (non-fixed) children by their grow weights, then clamps every
95
+ # child to its min/max constraints (re-balancing the difference onto flexible
96
+ # children with remaining slack).
95
97
  def child_sizes(axis:, available:)
96
98
  gap_size = gap * [children.length - 1, 0].max
97
99
  available_for_children = [available - gap_size, 0].max
@@ -100,7 +102,39 @@ module Charming
100
102
  sizes = fixed.map { |size| size&.to_i }
101
103
  remaining = [available_for_children - sizes.compact.sum, 0].max
102
104
 
103
- distribute_remaining(sizes, flexible_indexes, remaining)
105
+ sizes = distribute_remaining(sizes, flexible_indexes, remaining)
106
+ apply_constraints(sizes, flexible_indexes, axis, available_for_children)
107
+ end
108
+
109
+ # Clamps each child's size to its min/max constraints, then pushes the resulting
110
+ # surplus or deficit onto the last flexible child that can absorb it without
111
+ # violating its own constraints.
112
+ def apply_constraints(sizes, flexible_indexes, axis, available)
113
+ constrained = sizes.each_with_index.map { |size, index| clamp_size(size, children[index], axis) }
114
+ difference = available - constrained.sum
115
+ return constrained if difference.zero?
116
+
117
+ absorber = flexible_indexes.rfind do |index|
118
+ adjusted = constrained[index] + difference
119
+ adjusted >= 0 && adjusted == clamp_size(adjusted, children[index], axis)
120
+ end
121
+ constrained[absorber] += difference if absorber
122
+ constrained
123
+ end
124
+
125
+ # Clamps *size* to the child's min/max constraint along *axis* (when declared).
126
+ def clamp_size(size, child, axis)
127
+ min = constraint(child, (axis == :horizontal) ? :min_width : :min_height)
128
+ max = constraint(child, (axis == :horizontal) ? :max_width : :max_height)
129
+ size = [size, min].max if min
130
+ size = [size, max].min if max
131
+ size
132
+ end
133
+
134
+ # Reads a constraint reader from *child* when it responds to it (Panes do, nested
135
+ # Splits currently don't).
136
+ def constraint(child, name)
137
+ child.respond_to?(name) ? child.public_send(name) : nil
104
138
  end
105
139
 
106
140
  # Returns the fixed size of *child* along *axis* (`:horizontal` reads width, `:vertical` reads height).