konpeito 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e06886ab2b9bd1bcc2638dcc1c0788c7a0bbe40c0416a630eb89ca8e86945a7a
4
- data.tar.gz: 3921d4b460e10864329173fcdf6ba0c925c89ad9d7959fab5ac74c640041e0e4
3
+ metadata.gz: 53a3bbb24175ed554f9f560c38e6f8138206da3fb6385f87358e9dfa3eed4f10
4
+ data.tar.gz: 80026dca6352dcb9effa9e7bac8e7ebc58e517ae84cb1f15627935fbb3d5fee4
5
5
  SHA512:
6
- metadata.gz: 970fff312f787c003978377350ff5cbeeccd46aab454999b1214d1c17a2e04530c2ec9149a4015b6c477cd21984b43ebf03390120cd1ce9b22670fe767845338
7
- data.tar.gz: 32b1f3c137e224a89f57df986f667a3bc4fbe9ea12997b340927ed70e7b00458b10106be3b1f827f86ff4e147e79eafc94601b4026ce225284e4fe2d06a4597c
6
+ metadata.gz: 4233ae433c5730c64da97806275ca0300d80f77e5af8c338cf2185f54205d69b99091447a097506b5d2944dac437396a8a4e04ae9a97a90df60f8a93d60e730d
7
+ data.tar.gz: 911c32b9ac29989060b08fe6fc352074a02038ae20f8b516723e56a263bf602b0ee70206cf41a3bbe682044ab4c7511552fa255ef9d7b137da952dcea271f860
data/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ All notable changes to Konpeito will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.2] - 2026-02-17
9
+
10
+ ### Fixed
11
+ - Castella UI: include padding and respect FIXED sizes in Column/Row measure
12
+ - Castella UI: use lighter accent variant for normal button hover across all themes
13
+ - JVM backend: preserve subclass type through inherited self-returning method chains
14
+
15
+ ### Changed
16
+ - Documentation: reorganize Castella UI section in README with DSL / Reactive State / Style Composition subheadings
17
+ - Documentation: add Style Composition example and screenshot to README
18
+ - Documentation: remove filler spacers from counter demos (EXPANDING default makes them unnecessary)
19
+ - Documentation: update counter screenshot
20
+
21
+ ## [0.1.1] - 2026-02-16
22
+
23
+ ### Fixed
24
+ - JVM backend: detect and resolve field type conflicts in `dedup_inherited_fields` to prevent runtime NPE when parent/child classes use same ivar with different types
25
+ - JVM backend: UI layout fixes for Castella dashboard (`.class` dispatch, `attr_accessor` field access, scrollbar thumb rendering, etc.)
26
+ - Remove duplicate `source_code_uri` from gemspec metadata
27
+ - Remove debug `puts` from data_table widget
28
+
29
+ ### Added
30
+ - Castella UI: visual properties (`bg_color`, `border_radius`, `border_color`, `border_width`) directly on Column/Row layouts, eliminating Container wrapper boilerplate
31
+
32
+ ### Changed
33
+ - Documentation: note that JVM backend might be more mature than LLVM backend
34
+
8
35
  ## [0.1.0] - 2026-02-13
9
36
 
10
37
  ### Added
@@ -72,4 +99,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
72
99
  - `%a{extern}` - external C struct wrappers
73
100
  - `%a{simd}` - SIMD vectorization
74
101
 
102
+ [0.1.2]: https://github.com/i2y/konpeito/compare/v0.1.1...v0.1.2
103
+ [0.1.1]: https://github.com/i2y/konpeito/compare/v0.1.0...v0.1.1
75
104
  [0.1.0]: https://github.com/i2y/konpeito/releases/tag/v0.1.0
data/README.md CHANGED
@@ -135,43 +135,100 @@ The JVM backend supports seamless Java interop — call Java libraries directly
135
135
 
136
136
  ## Castella UI
137
137
 
138
- Castella is a reactive GUI framework that runs on the JVM backend, powered by [JWM](https://github.com/nicenote/jwm) + [Skija](https://github.com/nicenote/skija) for cross-platform rendering. Based on a port of [Castella for Python](https://github.com/i2y/castella).
138
+ A reactive GUI framework for the JVM backend, powered by [JWM](https://github.com/nicenote/jwm) + [Skija](https://github.com/nicenote/skija).
139
+
140
+ ### DSL
141
+
142
+ Ruby's block syntax becomes a UI DSL — `column`, `row`, `text`, `button` etc. nest naturally with keyword arguments. A plain Ruby method is a reusable component.
143
+
144
+ <p align="center">
145
+ <img src="docs/screenshots/dashboard.png" alt="Analytics Dashboard" width="720" />
146
+ </p>
139
147
 
140
148
  ```ruby
141
- class CounterApp < Component
149
+ def view
150
+ column(padding: 20.0, spacing: 16.0) {
151
+ row(spacing: 12.0) {
152
+ text("Analytics Dashboard", font_size: 26.0, bold: true)
153
+ spacer
154
+ button("Refresh", width: 90.0) {}
155
+ }.fixed_height(40.0)
156
+
157
+ # KPI Cards — extract a method, and it's a reusable component
158
+ row(spacing: 12.0) {
159
+ kpi_card("Revenue", "$48,250", "+12.5%", $theme.accent)
160
+ kpi_card("Users", "3,842", "+8.1%", $theme.success)
161
+ kpi_card("Orders", "1,205", "-2.3%", $theme.error)
162
+ }
163
+
164
+ # Charts, tables, and layouts compose with blocks
165
+ row(spacing: 12.0) {
166
+ column(expanding_width: true, bg_color: $theme.bg_primary, border_radius: 10.0, padding: 14.0) {
167
+ bar_chart(labels, data, ["Revenue", "Costs"]).title("Monthly Overview").fixed_height(220.0)
168
+ }
169
+ column(expanding_width: true, bg_color: $theme.bg_primary, border_radius: 10.0, padding: 14.0) {
170
+ data_table(headers, widths, rows).fixed_height(200.0)
171
+ }
172
+ }
173
+ }
174
+ end
175
+
176
+ # A Ruby method is a reusable component
177
+ def kpi_card(label, value, change, color)
178
+ column(spacing: 6.0, bg_color: $theme.bg_primary, border_radius: 10.0, padding: 16.0, expanding_width: true) {
179
+ text(label, font_size: 12.0, color: $theme.text_secondary)
180
+ text(value, font_size: 24.0, bold: true)
181
+ text(change, font_size: 13.0, color: color)
182
+ }
183
+ end
184
+ ```
185
+
186
+ ### Reactive State
187
+
188
+ `state(0)` creates an observable value, and the UI re-renders automatically when it changes:
189
+
190
+ <p align="center">
191
+ <img src="docs/screenshots/counter.png" alt="Counter App" width="320" />
192
+ </p>
193
+
194
+ ```ruby
195
+ class Counter < Component
142
196
  def initialize
143
197
  super
144
198
  @count = state(0)
145
199
  end
146
200
 
147
201
  def view
148
- label = "Count: " + @count.value.to_s
149
- Column(
150
- Text(label).font_size(32),
151
- Row(
152
- Button(" - ").on_click { @count -= 1 },
153
- Button(" + ").on_click { @count += 1 }
154
- )
155
- )
202
+ column(padding: 16.0, spacing: 8.0) {
203
+ text "Count: #{@count}", font_size: 32.0, align: :center
204
+ row(spacing: 8.0) {
205
+ button(" - ") { @count -= 1 }
206
+ button(" + ") { @count += 1 }
207
+ }
208
+ }
156
209
  end
157
210
  end
158
211
  ```
159
212
 
160
- A block-based DSL is also available:
213
+ An OOP-style API (`Column(...)`, `Row(...)`) is also available. Available widgets: `Text`, `Button`, `TextInput`, `MultilineText`, `Column`, `Row`, `Image`, `Checkbox`, `Slider`, `ProgressBar`, `Tabs`, `DataTable`, `TreeView`, `BarChart`, `LineChart`, `PieChart`, `Markdown`, and more.
214
+
215
+ ### Style Composition
216
+
217
+ Styles are first-class objects — store them in variables and compose with `+`:
218
+
219
+ <p align="center">
220
+ <img src="docs/screenshots/style_composition.png" alt="Style Composition" width="480" />
221
+ </p>
161
222
 
162
223
  ```ruby
163
- def view
164
- column(padding: 16.0) do
165
- text "Counter: #{@count}", font_size: 24.0, color: 0xFFC0CAF5
166
- row(spacing: 8.0) do
167
- button("-", width: 60.0) { @count -= 1 }
168
- button("+", width: 60.0) { @count += 1 }
169
- end
170
- end
171
- end
172
- ```
224
+ card = Style.new.bg_color($theme.bg_primary).border_radius(10.0).padding(16.0)
225
+ green_card = card + Style.new.border_color($theme.success)
173
226
 
174
- Available widgets: `Text`, `Button`, `TextInput`, `MultilineText`, `Column`, `Row`, `Image`, `Checkbox`, `Slider`, `ProgressBar`, `Tabs`, `DataTable`, `TreeView`, `BarChart`, `Markdown`, and more.
227
+ column(spacing: 12.0) {
228
+ container(card) { text "Default card" }
229
+ container(green_card) { text "Green border variant" }
230
+ }
231
+ ```
175
232
 
176
233
  ## Performance
177
234
 
@@ -187,6 +187,10 @@ module Konpeito
187
187
  # Classes are generated before main class, so we need this early.
188
188
  prescan_top_level_constants(hir_program)
189
189
 
190
+ # Pre-scan: register global variable static fields early so that
191
+ # global variables referenced inside blocks have fields available.
192
+ prescan_global_variables(hir_program)
193
+
190
194
  # Generate module interfaces FIRST (before classes that may implement them)
191
195
  hir_program.modules.each do |module_def|
192
196
  @block_methods = []
@@ -294,17 +298,43 @@ module Konpeito
294
298
  }
295
299
  end
296
300
 
301
+ # Yield every instruction in basic_blocks, recursing into block bodies
302
+ # attached to HIR::Call nodes.
303
+ def each_instruction_recursive(basic_blocks, &blk)
304
+ basic_blocks.each do |bb|
305
+ bb.instructions.each do |inst|
306
+ blk.call(inst)
307
+ if inst.is_a?(HIR::Call) && inst.block
308
+ each_instruction_recursive(inst.block.body, &blk)
309
+ end
310
+ end
311
+ end
312
+ end
313
+
297
314
  # Pre-scan top-level StoreConstant instructions to populate @constant_fields early.
298
315
  # This is needed because user classes are generated before the main class, and
299
316
  # class constructors may reference top-level constants (e.g. EXPANDING = 1).
317
+ # Recurses into block bodies so constants defined inside lambdas are also found.
300
318
  def prescan_top_level_constants(hir_program)
301
319
  hir_program.functions.each do |func|
302
320
  next if func.owner_class || func.owner_module
303
- func.body.each do |bb|
304
- bb.instructions.each do |inst|
305
- if inst.is_a?(HIR::StoreConstant) && inst.scope.nil?
306
- @constant_fields << inst.name.to_s
307
- end
321
+ each_instruction_recursive(func.body) do |inst|
322
+ if inst.is_a?(HIR::StoreConstant) && inst.scope.nil?
323
+ @constant_fields << inst.name.to_s
324
+ end
325
+ end
326
+ end
327
+ end
328
+
329
+ # Pre-scan all functions for global variable access (LoadGlobalVar/StoreGlobalVar)
330
+ # and register the corresponding static fields early. This ensures global variables
331
+ # referenced inside blocks have their fields available during code generation.
332
+ def prescan_global_variables(hir_program)
333
+ hir_program.functions.each do |func|
334
+ each_instruction_recursive(func.body) do |inst|
335
+ if inst.is_a?(HIR::LoadGlobalVar) || inst.is_a?(HIR::StoreGlobalVar)
336
+ field_name = inst.name.sub(/^\$/, "GLOBAL_")
337
+ register_global_field(field_name)
308
338
  end
309
339
  end
310
340
  end
@@ -3118,6 +3148,22 @@ module Konpeito
3118
3148
  ensure_slot(result_var, ret_type)
3119
3149
  instructions << store_instruction(result_var, ret_type)
3120
3150
  @variable_types[result_var] = ret_type
3151
+
3152
+ # Propagate class type so subsequent method chains resolve the receiver.
3153
+ # Without this, `Button("Animate!").kind(1)` fails because the receiver
3154
+ # class of `.kind(1)` is unknown after the static call to `Button()`.
3155
+ if ret_type.is_a?(Symbol) && ret_type.to_s.start_with?("class:")
3156
+ cls = ret_type.to_s.sub("class:", "")
3157
+ @variable_class_types[result_var] = cls if @class_info.key?(cls)
3158
+ end
3159
+ # Also check HM-inferred type from the call instruction
3160
+ if !@variable_class_types[result_var] && inst.respond_to?(:type) && inst.type
3161
+ call_type = inst.type
3162
+ call_type = call_type.prune if call_type.respond_to?(:prune)
3163
+ if call_type.is_a?(TypeChecker::Types::ClassInstance) && @class_info.key?(call_type.name.to_s)
3164
+ @variable_class_types[result_var] = call_type.name.to_s
3165
+ end
3166
+ end
3121
3167
  end
3122
3168
  end
3123
3169
 
@@ -3709,6 +3755,11 @@ module Konpeito
3709
3755
  # Remove fields from child classes that are already declared on a parent class.
3710
3756
  # Must run AFTER all classes are registered (register_class_info) since the
3711
3757
  # HIR class order may register children before parents.
3758
+ #
3759
+ # Type conflict detection: when a child class assigns a concrete but different type
3760
+ # to a field that the parent typed narrowly (e.g. parent :i64, child :string), widen
3761
+ # the parent's field to :value (Object) to prevent runtime NPE.
3762
+ # If the child type is :value (unresolved/unknown), keep the parent's concrete type.
3712
3763
  def dedup_inherited_fields
3713
3764
  @class_info.each do |class_name, info|
3714
3765
  parent = info[:super_name]
@@ -3719,6 +3770,16 @@ module Konpeito
3719
3770
  if parent_info[:fields]
3720
3771
  parent_info[:fields].each_key do |fname|
3721
3772
  if info[:fields].key?(fname)
3773
+ child_type = info[:fields][fname]
3774
+ parent_type = parent_info[:fields][fname]
3775
+ # Only widen when both types are concrete but different.
3776
+ # If child is :value (unknown), trust the parent's type.
3777
+ # If parent is :value, trust it (already wide enough).
3778
+ if child_type != parent_type && child_type != :value && parent_type != :value
3779
+ warn "[konpeito] JVM field type conflict: #{parent_name}##{fname} is :#{parent_type}, " \
3780
+ "but subclass #{class_name} uses :#{child_type}. Widening to :value (Object)."
3781
+ parent_info[:fields][fname] = :value
3782
+ end
3722
3783
  info[:fields].delete(fname)
3723
3784
  end
3724
3785
  end
@@ -5069,8 +5130,16 @@ module Konpeito
5069
5130
  # checkcast when return type is a user class (descriptor returns Object but actual is specific class)
5070
5131
  if ret_type == :value
5071
5132
  rbs_class = resolve_rbs_return_class_name(owner_class, method_name)
5072
- # Also check inferred return class from function_return_type (self-returning methods)
5073
- rbs_class ||= inferred_return_class
5133
+ # When function returns self (SelfRef), preserve the receiver's actual class type.
5134
+ # RBS annotations declare the defining class (e.g., Widget#fixed_height → Widget)
5135
+ # but `self` at runtime is the receiver's class (e.g., Button). Without this,
5136
+ # method chains like Button(...).fixed_height(36.0).on_click { ... } break because
5137
+ # the receiver type narrows to Widget and on_click (defined on Button) is not found.
5138
+ if inferred_return_class
5139
+ rbs_class = inferred_return_class
5140
+ else
5141
+ rbs_class ||= inferred_return_class
5142
+ end
5074
5143
  if rbs_class && @class_info.key?(rbs_class)
5075
5144
  instructions << { "op" => "checkcast", "type" => @class_info[rbs_class][:jvm_name] }
5076
5145
  @variable_class_types[result_var] = rbs_class
@@ -5189,8 +5258,13 @@ module Konpeito
5189
5258
  end
5190
5259
  instructions = []
5191
5260
 
5192
- # Load self
5193
- instructions << { "op" => "aload", "var" => 0 }
5261
+ # Load self — use @block_self_slot if inside a block that captured self
5262
+ self_slot = @block_self_slot || 0
5263
+ instructions << { "op" => "aload", "var" => self_slot }
5264
+ # If self was captured as Object, checkcast to the expected class
5265
+ if @block_self_slot
5266
+ instructions << { "op" => "checkcast", "type" => jvm_class }
5267
+ end
5194
5268
 
5195
5269
  # Prefer registered descriptor for consistency with method definition
5196
5270
  registered = @method_descriptors["#{@current_class_name}##{method_name}"]
@@ -6478,7 +6552,8 @@ module Konpeito
6478
6552
  instructions
6479
6553
  end
6480
6554
 
6481
- # Check if a block body accesses instance variables (needs self reference).
6555
+ # Check if a block body accesses instance variables or calls methods on
6556
+ # implicit self (receiver-less calls like `kpi_card(label, value)`).
6482
6557
  # Recursively checks nested blocks (e.g., on_click inside a yield block)
6483
6558
  # so that outer blocks capture self when inner blocks need it.
6484
6559
  def block_needs_self?(block_def)
@@ -6486,6 +6561,8 @@ module Konpeito
6486
6561
  bb.instructions.any? { |inst|
6487
6562
  if inst.is_a?(HIR::LoadInstanceVar) || inst.is_a?(HIR::StoreInstanceVar)
6488
6563
  true
6564
+ elsif inst.is_a?(HIR::Call) && (inst.receiver.nil? || inst.receiver.is_a?(HIR::SelfRef))
6565
+ true
6489
6566
  elsif inst.is_a?(HIR::Call) && inst.block
6490
6567
  block_needs_self?(inst.block)
6491
6568
  else
@@ -2072,12 +2072,14 @@ module Konpeito
2072
2072
  # Check if this is a keyword hash argument
2073
2073
  if arg.node.is_a?(Prism::KeywordHashNode)
2074
2074
  # Extract keyword arguments from the hash
2075
- arg.node.elements.each do |elem|
2075
+ # arg.children are typed AssocNode wrappers; each has children[0]=key, children[1]=value
2076
+ arg.node.elements.each_with_index do |elem, ei|
2076
2077
  if elem.is_a?(Prism::AssocNode) && elem.key.is_a?(Prism::SymbolNode)
2077
2078
  key_name = elem.key.unescaped.to_sym
2078
- # Find the typed value from the children
2079
- value_typed = arg.children.find { |c| c.node == elem.value }
2080
- if value_typed
2079
+ # Navigate through the typed AssocNode to get the typed value child
2080
+ typed_assoc = arg.children[ei]
2081
+ if typed_assoc && typed_assoc.children && typed_assoc.children.size >= 2
2082
+ value_typed = typed_assoc.children[1]
2081
2083
  keyword_args[key_name] = visit(value_typed)
2082
2084
  else
2083
2085
  # Fallback: create typed node for the value
@@ -1429,6 +1429,10 @@ module Konpeito
1429
1429
  # Score how well an argument type matches an RBS parameter type
1430
1430
  def overload_match_score(arg_type, rbs_param_type)
1431
1431
  case rbs_param_type
1432
+ when RBS::Types::Alias
1433
+ # Resolve common aliases: ::int -> Integer, ::string -> String, etc.
1434
+ resolved = resolve_rbs_alias(rbs_param_type)
1435
+ return overload_match_score(arg_type, resolved) if resolved
1432
1436
  when RBS::Types::ClassInstance
1433
1437
  param_name = rbs_param_type.name.name
1434
1438
  if arg_type.is_a?(Types::ClassInstance)
@@ -1444,6 +1448,24 @@ module Konpeito
1444
1448
  0
1445
1449
  end
1446
1450
 
1451
+ # Resolve RBS type aliases to their underlying ClassInstance types
1452
+ def resolve_rbs_alias(alias_type)
1453
+ alias_name = alias_type.name.name
1454
+ class_name = case alias_name
1455
+ when :int then :Integer
1456
+ when :string then :String
1457
+ when :float then :Float
1458
+ when :bool then :Bool
1459
+ when :boolish then :Bool
1460
+ end
1461
+ return nil unless class_name
1462
+ RBS::Types::ClassInstance.new(
1463
+ name: RBS::TypeName.new(name: class_name, namespace: RBS::Namespace.root),
1464
+ args: [],
1465
+ location: alias_type.location
1466
+ )
1467
+ end
1468
+
1447
1469
  def numeric_compatible?(arg_name, param_name)
1448
1470
  numerics = %i[Integer Float Rational Complex Numeric]
1449
1471
  numerics.include?(arg_name) && numerics.include?(param_name)
@@ -186,6 +186,9 @@ module Konpeito
186
186
  elsif singleton_value_compatible?(t1, t2)
187
187
  # ClassSingleton (value constant like EXPANDING) is compatible with its base type.
188
188
  # e.g., singleton(EXPANDING) ↔ Integer when EXPANDING = 2
189
+ elsif literal_compatible?(t1, t2)
190
+ # Literals are subtypes of their base type (e.g., Literal(1) <: Integer, Literal("x") <: String)
191
+ # Unions of same-type literals are also compatible (e.g., -1 | 0 | 1 <: Integer)
189
192
  else
190
193
  raise UnificationError.new(t1, t2)
191
194
  end
@@ -207,6 +210,38 @@ module Konpeito
207
210
  (t2.is_a?(Types::ClassSingleton) && t1.is_a?(Types::ClassInstance))
208
211
  end
209
212
 
213
+ # Check if a literal type (or union of literals) is compatible with its base class type.
214
+ # e.g., Literal(1) <: Integer, Literal("hello") <: String, Literal(1.0) <: Float
215
+ # Union(Literal(-1), Literal(0), Literal(1)) <: Integer
216
+ def literal_compatible?(t1, t2)
217
+ (literal_subtype_of_class?(t1, t2)) || (literal_subtype_of_class?(t2, t1))
218
+ end
219
+
220
+ def literal_subtype_of_class?(literal_side, class_side)
221
+ return false unless class_side.is_a?(Types::ClassInstance)
222
+
223
+ if literal_side.is_a?(Types::Literal)
224
+ literal_base_type_name(literal_side.value) == class_side.name
225
+ elsif literal_side.is_a?(Types::Union)
226
+ literal_side.types.all? do |sub|
227
+ sub.is_a?(Types::Literal) && literal_base_type_name(sub.value) == class_side.name
228
+ end
229
+ else
230
+ false
231
+ end
232
+ end
233
+
234
+ # Map a Ruby literal value to its base type name
235
+ def literal_base_type_name(value)
236
+ case value
237
+ when Integer then :Integer
238
+ when Float then :Float
239
+ when String then :String
240
+ when Symbol then :Symbol
241
+ when true, false then :Bool
242
+ end
243
+ end
244
+
210
245
  # Check if `from` can be widened to `to` (Java/Kotlin-style widening primitive conversion).
211
246
  # Integer → Float is safe (no precision loss for typical values).
212
247
  # Float → Integer is NOT supported (requires explicit conversion).
@@ -25,6 +25,14 @@ class Column < Layout
25
25
  #: () -> Column
26
26
  def scrollable
27
27
  @is_scrollable = true
28
+ # Retroactively downgrade existing EXPANDING children to CONTENT
29
+ i = 0
30
+ while i < @children.length
31
+ if @children[i].get_height_policy == EXPANDING
32
+ @children[i].set_height_policy(CONTENT)
33
+ end
34
+ i = i + 1
35
+ end
28
36
  self
29
37
  end
30
38
 
@@ -60,38 +68,80 @@ class Column < Layout
60
68
  update
61
69
  end
62
70
 
71
+ # Override add: auto-downgrade EXPANDING height to CONTENT in scrollable Column
72
+ #: (untyped w) -> Column
73
+ def add(w)
74
+ if w == nil
75
+ return self
76
+ end
77
+ if @is_scrollable && w.get_height_policy == EXPANDING
78
+ w.set_height_policy(CONTENT)
79
+ end
80
+ super(w)
81
+ self
82
+ end
83
+
63
84
  #: (untyped painter) -> Size
64
85
  def measure(painter)
65
86
  total_h = 0.0
66
87
  max_w = 0.0
67
88
  i = 0
68
89
  while i < @children.length
69
- cs = @children[i].measure(painter)
70
- total_h = total_h + cs.height
90
+ c = @children[i]
91
+ cs = c.measure(painter)
92
+ if c.get_height_policy == FIXED
93
+ child_h = c.get_height
94
+ else
95
+ child_h = cs.height
96
+ end
97
+ total_h = total_h + child_h
71
98
  total_h = total_h + @spacing if i > 0
72
99
  max_w = cs.width if cs.width > max_w
73
100
  i = i + 1
74
101
  end
75
- Size.new(max_w, total_h)
102
+ Size.new(max_w + @pad_left + @pad_right, total_h + @pad_top + @pad_bottom)
76
103
  end
77
104
 
105
+ # Unified layout: two-pass flex distribution + scroll offset.
106
+ # With approach C (auto-downgrade), scrollable containers have no EXPANDING
107
+ # children, so flex distribution is a no-op and content stacks sequentially.
78
108
  #: (untyped painter) -> void
79
109
  def relocate_children(painter)
80
- remaining = @height
110
+ # Account for padding
111
+ inner_w = @width - @pad_left - @pad_right
112
+ inner_h = @height - @pad_top - @pad_bottom
113
+ if inner_w < 0.0
114
+ inner_w = 0.0
115
+ end
116
+ if inner_h < 0.0
117
+ inner_h = 0.0
118
+ end
119
+
120
+ remaining = inner_h
81
121
  expanding_total_flex = 0
82
122
 
83
- # First pass: measure FIXED/CONTENT children, collect EXPANDING
123
+ # First pass: measure CONTENT/FIXED children, collect EXPANDING flex totals
84
124
  i = 0
85
125
  while i < @children.length
86
126
  c = @children[i]
87
127
  if c.get_height_policy != EXPANDING
88
- # Set width before measure so word-wrapping can calculate line count
89
- if c.get_width_policy == EXPANDING
90
- c.resize_wh(@width, c.get_height)
128
+ # Set width before measure (for word-wrap, centering, etc.)
129
+ if c.get_width_policy != FIXED
130
+ c.resize_wh(inner_w, c.get_height)
91
131
  end
92
132
  cs = c.measure(painter)
93
- c.resize_wh(@width, cs.height)
94
- remaining = remaining - cs.height
133
+ # Use explicit height for FIXED, measured height for CONTENT
134
+ if c.get_height_policy == FIXED
135
+ child_h = c.get_height
136
+ else
137
+ child_h = cs.height
138
+ end
139
+ if c.get_width_policy == FIXED
140
+ c.resize_wh(cs.width, child_h)
141
+ else
142
+ c.resize_wh(inner_w, child_h)
143
+ end
144
+ remaining = remaining - child_h
95
145
  else
96
146
  expanding_total_flex = expanding_total_flex + c.get_flex
97
147
  end
@@ -103,8 +153,8 @@ class Column < Layout
103
153
  remaining = 0.0
104
154
  end
105
155
 
106
- # Second pass: distribute remaining space to EXPANDING children, position all
107
- cy = @y
156
+ # Second pass: distribute remaining space to EXPANDING, position all
157
+ cy = @y + @pad_top
108
158
  if @is_scrollable
109
159
  cy = cy - @scroll_offset
110
160
  end
@@ -117,14 +167,14 @@ class Column < Layout
117
167
  if expanding_total_flex > 0 && remaining > 0.0
118
168
  h = remaining * c.get_flex / expanding_total_flex
119
169
  end
120
- c.resize_wh(@width, h)
170
+ c.resize_wh(inner_w, h)
121
171
  else
122
- # Set width for non-expanding too
123
- if c.get_width_policy == EXPANDING
124
- c.resize_wh(@width, c.get_height)
172
+ # In a Column, non-FIXED children fill the column width
173
+ if c.get_width_policy != FIXED
174
+ c.resize_wh(inner_w, c.get_height)
125
175
  end
126
176
  end
127
- c.move_xy(@x, cy)
177
+ c.move_xy(@x + @pad_left, cy)
128
178
  cy = cy + c.get_height + @spacing
129
179
  total_content_h = total_content_h + c.get_height
130
180
  total_content_h = total_content_h + @spacing if i > 0
@@ -134,7 +184,7 @@ class Column < Layout
134
184
 
135
185
  # Auto-scroll to bottom when pinned
136
186
  if @pin_bottom && @is_scrollable
137
- max_scroll = @content_height - @height
187
+ max_scroll = @content_height - inner_h
138
188
  if max_scroll > 0.0
139
189
  @scroll_offset = max_scroll
140
190
  end
@@ -143,9 +193,24 @@ class Column < Layout
143
193
 
144
194
  #: (untyped painter, bool completely) -> void
145
195
  def redraw(painter, completely)
196
+ saved_bg = $__bg_clear_color
197
+ # When this layout has a custom background and is dirty, we handle clearing
198
+ # ourselves to preserve rounded corners. Clear dirty flag so redraw_children
199
+ # won't overwrite with a solid fill_rect.
200
+ if @custom_bg && is_dirty
201
+ parent_bg = saved_bg
202
+ if parent_bg == nil || parent_bg == 0
203
+ parent_bg = $theme.bg_canvas
204
+ end
205
+ painter.fill_rect(0.0, 0.0, @width, @height, parent_bg)
206
+ set_dirty(false)
207
+ completely = true
208
+ end
209
+ draw_visual_background(painter)
146
210
  relocate_children(painter)
147
211
  redraw_children(painter, completely)
148
212
  draw_scrollbar(painter) if @is_scrollable
213
+ $__bg_clear_color = saved_bg
149
214
  end
150
215
 
151
216
  #: (untyped painter) -> void