konpeito 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d7f18a84d8cfece0c51becca2067ab474195205b4d5ce09b7a4e197b129851a
4
- data.tar.gz: ca0f8a113919d8ca6b28e8109fd2ed031979de67e695c9aaad9f659b425dab89
3
+ metadata.gz: 53a3bbb24175ed554f9f560c38e6f8138206da3fb6385f87358e9dfa3eed4f10
4
+ data.tar.gz: 80026dca6352dcb9effa9e7bac8e7ebc58e517ae84cb1f15627935fbb3d5fee4
5
5
  SHA512:
6
- metadata.gz: 62a1bd26cf53ddc918f1129772574021154079e492c33f48437290179fd71bdba65ca78c95890f16c224cf58930fbc879040c595580eb73a1d7e6868f0460cd9
7
- data.tar.gz: 295d6ac004cda8c7823ad711dca5015a95c78302fd0f3c2da0796cb30ef0c638a13bac53f0c8ae439fc50556bb1262e6a304facdd4dfaa3c6b425f1d3842f85a
6
+ metadata.gz: 4233ae433c5730c64da97806275ca0300d80f77e5af8c338cf2185f54205d69b99091447a097506b5d2944dac437396a8a4e04ae9a97a90df60f8a93d60e730d
7
+ data.tar.gz: 911c32b9ac29989060b08fe6fc352074a02038ae20f8b516723e56a263bf602b0ee70206cf41a3bbe682044ab4c7511552fa255ef9d7b137da952dcea271f860
data/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ 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
+
8
21
  ## [0.1.1] - 2026-02-16
9
22
 
10
23
  ### Fixed
@@ -86,5 +99,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
86
99
  - `%a{extern}` - external C struct wrappers
87
100
  - `%a{simd}` - SIMD vectorization
88
101
 
102
+ [0.1.2]: https://github.com/i2y/konpeito/compare/v0.1.1...v0.1.2
89
103
  [0.1.1]: https://github.com/i2y/konpeito/compare/v0.1.0...v0.1.1
90
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
 
@@ -3148,6 +3148,22 @@ module Konpeito
3148
3148
  ensure_slot(result_var, ret_type)
3149
3149
  instructions << store_instruction(result_var, ret_type)
3150
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
3151
3167
  end
3152
3168
  end
3153
3169
 
@@ -5114,8 +5130,16 @@ module Konpeito
5114
5130
  # checkcast when return type is a user class (descriptor returns Object but actual is specific class)
5115
5131
  if ret_type == :value
5116
5132
  rbs_class = resolve_rbs_return_class_name(owner_class, method_name)
5117
- # Also check inferred return class from function_return_type (self-returning methods)
5118
- 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
5119
5143
  if rbs_class && @class_info.key?(rbs_class)
5120
5144
  instructions << { "op" => "checkcast", "type" => @class_info[rbs_class][:jvm_name] }
5121
5145
  @variable_class_types[result_var] = rbs_class
@@ -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,21 +68,43 @@ 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
110
  # Account for padding
@@ -90,23 +120,28 @@ class Column < Layout
90
120
  remaining = inner_h
91
121
  expanding_total_flex = 0
92
122
 
93
- # First pass: measure FIXED/CONTENT children, collect EXPANDING
94
- # In a Column, non-FIXED children fill the column width (for centering, word-wrap, etc.)
123
+ # First pass: measure CONTENT/FIXED children, collect EXPANDING flex totals
95
124
  i = 0
96
125
  while i < @children.length
97
126
  c = @children[i]
98
127
  if c.get_height_policy != EXPANDING
99
- # Set width before measure so word-wrapping can calculate line count
128
+ # Set width before measure (for word-wrap, centering, etc.)
100
129
  if c.get_width_policy != FIXED
101
130
  c.resize_wh(inner_w, c.get_height)
102
131
  end
103
132
  cs = c.measure(painter)
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
104
139
  if c.get_width_policy == FIXED
105
- c.resize_wh(cs.width, cs.height)
140
+ c.resize_wh(cs.width, child_h)
106
141
  else
107
- c.resize_wh(inner_w, cs.height)
142
+ c.resize_wh(inner_w, child_h)
108
143
  end
109
- remaining = remaining - cs.height
144
+ remaining = remaining - child_h
110
145
  else
111
146
  expanding_total_flex = expanding_total_flex + c.get_flex
112
147
  end
@@ -118,7 +153,7 @@ class Column < Layout
118
153
  remaining = 0.0
119
154
  end
120
155
 
121
- # Second pass: distribute remaining space to EXPANDING children, position all
156
+ # Second pass: distribute remaining space to EXPANDING, position all
122
157
  cy = @y + @pad_top
123
158
  if @is_scrollable
124
159
  cy = cy - @scroll_offset
@@ -1658,6 +1658,16 @@ class Widget
1658
1658
  @enable_to_detach = false
1659
1659
  end
1660
1660
 
1661
+ #: (bool v) -> void
1662
+ def set_cached(v)
1663
+ @cached = v
1664
+ end
1665
+
1666
+ #: () -> bool
1667
+ def is_cached
1668
+ @cached
1669
+ end
1670
+
1661
1671
  # --- Observer Protocol ---
1662
1672
 
1663
1673
  #: (untyped o) -> void
@@ -1923,6 +1933,9 @@ class Layout < Widget
1923
1933
 
1924
1934
  #: (untyped w) -> Layout
1925
1935
  def add(w)
1936
+ if w == nil
1937
+ return self
1938
+ end
1926
1939
  # Remove from old parent if needed
1927
1940
  old_parent = w.get_parent
1928
1941
  if old_parent != nil && old_parent != self
@@ -2103,9 +2116,144 @@ class Layout < Widget
2103
2116
  end
2104
2117
  end
2105
2118
 
2119
+ # ===== BuildOwner =====
2120
+ # Port of ~/castella/castella/build_owner.py BuildOwner
2121
+ # Batches multiple state changes into a single rebuild pass.
2122
+ #
2123
+ # Usage:
2124
+ # owner = BuildOwner.get
2125
+ # owner.build_scope {
2126
+ # state1.set(value1)
2127
+ # state2.set(value2)
2128
+ # }
2129
+ # # → Only ONE rebuild for all affected components
2130
+
2131
+ class BuildOwner
2132
+ @@instance = nil
2133
+
2134
+ #: () -> BuildOwner
2135
+ def self.get
2136
+ if @@instance == nil
2137
+ @@instance = BuildOwner.new
2138
+ end
2139
+ @@instance
2140
+ end
2141
+
2142
+ #: () -> void
2143
+ def self.reset
2144
+ @@instance = nil
2145
+ end
2146
+
2147
+ def initialize
2148
+ @dirty_components = []
2149
+ @in_build_scope = false
2150
+ @scope_depth = 0
2151
+ end
2152
+
2153
+ #: () -> bool
2154
+ def is_in_build_scope
2155
+ @in_build_scope
2156
+ end
2157
+
2158
+ # Schedule a component for rebuild. Deduplicates.
2159
+ # Inside build_scope: just adds to dirty list.
2160
+ # Outside build_scope: immediate mode (backward compatibility).
2161
+ #: (untyped component) -> void
2162
+ def schedule_build_for(component)
2163
+ # Dedup: skip if already in dirty list
2164
+ i = 0
2165
+ while i < @dirty_components.length
2166
+ if @dirty_components[i] == component
2167
+ return
2168
+ end
2169
+ i = i + 1
2170
+ end
2171
+
2172
+ if !@in_build_scope
2173
+ # Immediate mode: mark and trigger redraw now (don't accumulate)
2174
+ component.mark_pending_rebuild
2175
+ app = App.current
2176
+ if app != nil
2177
+ app.post_update(component)
2178
+ end
2179
+ else
2180
+ # Batched mode: just add to dirty list for flush_builds
2181
+ @dirty_components << component
2182
+ end
2183
+ end
2184
+
2185
+ # Execute a block with batched rebuilds.
2186
+ # Supports nesting: only the outermost scope triggers flush.
2187
+ #: () -> void
2188
+ def build_scope
2189
+ @scope_depth = @scope_depth + 1
2190
+ @in_build_scope = true
2191
+ yield
2192
+ @scope_depth = @scope_depth - 1
2193
+ if @scope_depth == 0
2194
+ @in_build_scope = false
2195
+ flush_builds
2196
+ end
2197
+ end
2198
+
2199
+ # Process all pending rebuilds: mark as pending, trigger one redraw.
2200
+ # Components are sorted by depth (parents before children) so parent
2201
+ # rebuilds don't cause redundant child rebuilds.
2202
+ #: () -> void
2203
+ def flush_builds
2204
+ while @dirty_components.length > 0
2205
+ # Sort by depth (parents first)
2206
+ sorted = sort_by_depth(@dirty_components)
2207
+ @dirty_components = []
2208
+
2209
+ # Mark all as pending rebuild
2210
+ i = 0
2211
+ while i < sorted.length
2212
+ sorted[i].mark_pending_rebuild
2213
+ i = i + 1
2214
+ end
2215
+
2216
+ # Trigger single redraw
2217
+ if sorted.length > 0
2218
+ app = App.current
2219
+ if app != nil
2220
+ app.post_update(sorted[0])
2221
+ end
2222
+ end
2223
+ end
2224
+ end
2225
+
2226
+ private
2227
+
2228
+ # Insertion sort by widget depth (ascending)
2229
+ #: (Array components) -> Array
2230
+ def sort_by_depth(components)
2231
+ result = []
2232
+ i = 0
2233
+ while i < components.length
2234
+ result << components[i]
2235
+ i = i + 1
2236
+ end
2237
+ i = 1
2238
+ while i < result.length
2239
+ j = i
2240
+ while j > 0
2241
+ if result[j].get_depth < result[j - 1].get_depth
2242
+ tmp = result[j]
2243
+ result[j] = result[j - 1]
2244
+ result[j - 1] = tmp
2245
+ end
2246
+ j = j - 1
2247
+ end
2248
+ i = i + 1
2249
+ end
2250
+ result
2251
+ end
2252
+ end
2253
+
2106
2254
  # ===== Component =====
2107
2255
  # Port of ~/castella/castella/core.py Component
2108
- # Now with pending_rebuild flag and proper detach on rebuild
2256
+ # Now with cache() for widget reuse and BuildOwner integration
2109
2257
 
2110
2258
  class Component < Layout
2111
2259
  def initialize
@@ -2114,6 +2262,8 @@ class Component < Layout
2114
2262
  @height_policy = EXPANDING
2115
2263
  @child = nil
2116
2264
  @pending_rebuild = false
2265
+ @cache_data = [] # Array of [keys_array, widgets_array] pairs per cache() call
2266
+ @cache_counter = 0 # Reset each view() call
2117
2267
  end
2118
2268
 
2119
2269
  # Helper: create State + auto-attach
@@ -2130,22 +2280,116 @@ class Component < Layout
2130
2280
  nil
2131
2281
  end
2132
2282
 
2133
- # State change notification -> schedule rebuild
2283
+ # Cache widget instances across view() rebuilds.
2284
+ # Returns an array of widgets, reusing existing ones for matching items.
2285
+ # Items are matched by == comparison.
2286
+ #
2287
+ # Usage in view():
2288
+ # widgets = cache(items) { |item| Text.new(item.label) }
2289
+ # i = 0
2290
+ # while i < widgets.length
2291
+ # embed(widgets[i])
2292
+ # i = i + 1
2293
+ # end
2294
+ #
2295
+ #: (Array items) -> Array
2296
+ def cache(items)
2297
+ slot = @cache_counter
2298
+ @cache_counter = @cache_counter + 1
2299
+
2300
+ # Get old cache for this slot
2301
+ old_keys = nil
2302
+ old_widgets = nil
2303
+ if slot < @cache_data.length
2304
+ entry = @cache_data[slot]
2305
+ if entry != nil
2306
+ old_keys = entry[0]
2307
+ old_widgets = entry[1]
2308
+ end
2309
+ end
2310
+
2311
+ new_keys = []
2312
+ new_widgets = []
2313
+
2314
+ i = 0
2315
+ while i < items.length
2316
+ item = items[i]
2317
+ # Look up in old cache by == comparison
2318
+ found = nil
2319
+ if old_keys != nil
2320
+ j = 0
2321
+ while j < old_keys.length
2322
+ if old_keys[j] != nil && old_keys[j] == item
2323
+ found = old_widgets[j]
2324
+ old_keys[j] = nil # Mark as used
2325
+ break
2326
+ end
2327
+ j = j + 1
2328
+ end
2329
+ end
2330
+
2331
+ if found != nil
2332
+ found.set_cached(true) # Safety: prevent do_unmount if somehow reached
2333
+ new_keys << item
2334
+ new_widgets << found
2335
+ else
2336
+ widget = yield(item)
2337
+ new_keys << item
2338
+ new_widgets << widget
2339
+ end
2340
+ i = i + 1
2341
+ end
2342
+
2343
+ # Old widgets not reused will be detached when old tree is destroyed.
2344
+ # Clear cached flag so they can be properly cleaned up.
2345
+ if old_keys != nil
2346
+ j = 0
2347
+ while j < old_keys.length
2348
+ if old_keys[j] != nil
2349
+ old_widgets[j].set_cached(false)
2350
+ end
2351
+ j = j + 1
2352
+ end
2353
+ end
2354
+
2355
+ # Store updated cache
2356
+ while @cache_data.length <= slot
2357
+ @cache_data << nil
2358
+ end
2359
+ @cache_data[slot] = [new_keys, new_widgets]
2360
+
2361
+ new_widgets
2362
+ end
2363
+
2364
+ # Mark this component as needing rebuild (called by BuildOwner)
2134
2365
  #: () -> void
2135
- def on_notify
2366
+ def mark_pending_rebuild
2136
2367
  @pending_rebuild = true
2137
2368
  mark_paint_dirty
2138
- app = App.current
2139
- if app != nil
2140
- app.post_update(self)
2141
- end
2369
+ end
2370
+
2371
+ # State change notification -> route to BuildOwner for batched rebuild
2372
+ #: () -> void
2373
+ def on_notify
2374
+ owner = BuildOwner.get
2375
+ owner.schedule_build_for(self)
2142
2376
  end
2143
2377
 
2144
2378
  #: (untyped painter, bool completely) -> void
2145
2379
  def redraw(painter, completely)
2146
- # Handle pending rebuild
2380
+ needs_build = false
2147
2381
  if @pending_rebuild
2148
2382
  @pending_rebuild = false
2383
+ needs_build = true
2384
+ end
2385
+ if @child == nil
2386
+ needs_build = true
2387
+ end
2388
+
2389
+ if needs_build
2390
+ # Reset cache counter for view()
2391
+ @cache_counter = 0
2392
+
2149
2393
  # Save focused widget's tab_index
2150
2394
  saved_focus_tab = -1
2151
2395
  app = App.current
@@ -2156,37 +2400,39 @@ class Component < Layout
2156
2400
  end
2157
2401
  end
2158
2402
 
2159
- # Destroy old tree, build new tree
2403
+ # Build new tree FIRST (cache() may reuse widgets from old tree).
2404
+ # Reused widgets are removed from old tree by Layout#add() when
2405
+ # they are added to the new tree, so they won't be affected by
2406
+ # the subsequent old tree destruction.
2407
+ new_child = view
2408
+
2409
+ # Destroy old tree (cached widgets already removed from it)
2160
2410
  if @child != nil
2161
2411
  remove(@child)
2162
2412
  @child.detach
2163
2413
  @child = nil
2164
2414
  end
2165
- @child = view
2415
+
2416
+ # Install new tree
2417
+ @child = new_child
2166
2418
  if @child != nil
2167
2419
  add(@child)
2168
2420
  completely = true
2169
2421
  end
2170
2422
 
2171
2423
  # Restore focus (text restoration not needed — InputState persists)
2172
- if saved_focus_tab > 0 && app != nil
2173
- focus_target = find_focusable_by_tab_index(@child, saved_focus_tab)
2174
- if focus_target != nil
2175
- app.set_focused(focus_target)
2176
- focus_target.restore_focus
2424
+ if saved_focus_tab > 0
2425
+ app = App.current
2426
+ if app != nil
2427
+ focus_target = find_focusable_by_tab_index(@child, saved_focus_tab)
2428
+ if focus_target != nil
2429
+ app.set_focused(focus_target)
2430
+ focus_target.restore_focus
2431
+ end
2177
2432
  end
2178
2433
  end
2179
2434
  end
2180
2435
 
2181
- # Build view if needed
2182
- if @child == nil
2183
- @child = view
2184
- if @child != nil
2185
- add(@child)
2186
- completely = true
2187
- end
2188
- end
2189
-
2190
2436
  # Relocate + redraw
2191
2437
  if @children.length > 0
2192
2438
  relocate_children(painter)
@@ -22,6 +22,14 @@ class Row < Layout
22
22
  #: () -> Row
23
23
  def scrollable
24
24
  @is_scrollable = true
25
+ # Retroactively downgrade existing EXPANDING children to CONTENT
26
+ i = 0
27
+ while i < @children.length
28
+ if @children[i].get_width_policy == EXPANDING
29
+ @children[i].set_width_policy(CONTENT)
30
+ end
31
+ i = i + 1
32
+ end
25
33
  self
26
34
  end
27
35
 
@@ -57,21 +65,48 @@ class Row < Layout
57
65
  update
58
66
  end
59
67
 
68
+ # Override add: auto-downgrade EXPANDING width to CONTENT in scrollable Row
69
+ #: (untyped w) -> Row
70
+ def add(w)
71
+ if w == nil
72
+ return self
73
+ end
74
+ if @is_scrollable && w.get_width_policy == EXPANDING
75
+ w.set_width_policy(CONTENT)
76
+ end
77
+ super(w)
78
+ self
79
+ end
80
+
60
81
  #: (untyped painter) -> Size
61
82
  def measure(painter)
62
83
  total_w = 0.0
63
84
  max_h = 0.0
64
85
  i = 0
65
86
  while i < @children.length
66
- cs = @children[i].measure(painter)
67
- total_w = total_w + cs.width
87
+ c = @children[i]
88
+ cs = c.measure(painter)
89
+ if c.get_width_policy == FIXED
90
+ child_w = c.get_width
91
+ else
92
+ child_w = cs.width
93
+ end
94
+ total_w = total_w + child_w
68
95
  total_w = total_w + @spacing if i > 0
69
- max_h = cs.height if cs.height > max_h
96
+ if c.get_height_policy == FIXED
97
+ child_h = c.get_height
98
+ else
99
+ child_h = cs.height
100
+ end
101
+ max_h = child_h if child_h > max_h
70
102
  i = i + 1
71
103
  end
72
- Size.new(total_w, max_h)
104
+ Size.new(total_w + @pad_left + @pad_right, max_h + @pad_top + @pad_bottom)
73
105
  end
74
106
 
107
+ # Unified layout: two-pass flex distribution + scroll offset.
108
+ # With approach C (auto-downgrade), scrollable containers have no EXPANDING
109
+ # width children, so flex distribution is a no-op and content stacks sequentially.
75
110
  #: (untyped painter) -> void
76
111
  def relocate_children(painter)
77
112
  # Account for padding
@@ -87,22 +122,28 @@ class Row < Layout
87
122
  remaining = inner_w
88
123
  expanding_total_flex = 0
89
124
 
90
- # First pass: measure FIXED/CONTENT children, collect EXPANDING
125
+ # First pass: measure CONTENT/FIXED children, collect EXPANDING flex totals
91
126
  i = 0
92
127
  while i < @children.length
93
128
  c = @children[i]
94
129
  if c.get_width_policy != EXPANDING
95
130
  # Set height before measure so height-dependent layouts work
96
- if c.get_height_policy == EXPANDING
131
+ if c.get_height_policy != FIXED
97
132
  c.resize_wh(c.get_width, inner_h)
98
133
  end
99
134
  cs = c.measure(painter)
100
- if c.get_height_policy == EXPANDING
101
- c.resize_wh(cs.width, inner_h)
135
+ # Use explicit width for FIXED, measured width for CONTENT
136
+ if c.get_width_policy == FIXED
137
+ child_w = c.get_width
138
+ else
139
+ child_w = cs.width
140
+ end
141
+ if c.get_height_policy == FIXED
142
+ c.resize_wh(child_w, c.get_height)
102
143
  else
103
- c.resize_wh(cs.width, cs.height)
144
+ c.resize_wh(child_w, inner_h)
104
145
  end
105
- remaining = remaining - cs.width
146
+ remaining = remaining - child_w
106
147
  else
107
148
  expanding_total_flex = expanding_total_flex + c.get_flex
108
149
  end
@@ -114,7 +155,7 @@ class Row < Layout
114
155
  remaining = 0.0
115
156
  end
116
157
 
117
- # Second pass: distribute remaining space, position all
158
+ # Second pass: distribute remaining space to EXPANDING, position all
118
159
  cx = @x + @pad_left
119
160
  if @is_scrollable
120
161
  cx = cx - @scroll_offset
@@ -130,7 +171,8 @@ class Row < Layout
130
171
  end
131
172
  c.resize_wh(w, inner_h)
132
173
  else
133
- if c.get_height_policy == EXPANDING
174
+ # In a Row, non-FIXED height children fill the row height
175
+ if c.get_height_policy != FIXED
134
176
  c.resize_wh(c.get_width, inner_h)
135
177
  end
136
178
  end
@@ -50,7 +50,7 @@ class Theme
50
50
  @text_on_danger = 0xFF1A1B26
51
51
 
52
52
  # Kind-specific hover colors
53
- @hover_normal = 0xFF414868
53
+ @hover_normal = 0xFF89B4FA
54
54
  @hover_info = 0xFF89B4FA
55
55
  @hover_success = 0xFFB9F27C
56
56
  @hover_warning = 0xFFFFD280
@@ -462,7 +462,7 @@ def theme_light
462
462
  t.hover_success = 0xFF567236
463
463
  t.hover_warning = 0xFFA87020
464
464
  t.hover_danger = 0xFFA35060
465
- t.hover_normal = 0xFFC4C5CB
465
+ t.hover_normal = 0xFF4A6EA0
466
466
  t.scrollbar_bg = 0xFFC4C5CB
467
467
  t.scrollbar_fg = 0xFF9699A3
468
468
  t.bg_selected = 0x8034548A
@@ -496,7 +496,7 @@ def theme_nord
496
496
  t.hover_success = 0xFFB4D89C
497
497
  t.hover_warning = 0xFFF5D9A0
498
498
  t.hover_danger = 0xFFD08770
499
- t.hover_normal = 0xFF4C566A
499
+ t.hover_normal = 0xFF9DD0DE
500
500
  t
501
501
  end
502
502
 
@@ -527,7 +527,7 @@ def theme_dracula
527
527
  t.hover_success = 0xFF69FF94
528
528
  t.hover_warning = 0xFFFFFFA5
529
529
  t.hover_danger = 0xFFFF6E6E
530
- t.hover_normal = 0xFF6272A4
530
+ t.hover_normal = 0xFFD0ABFF
531
531
  t
532
532
  end
533
533
 
@@ -558,6 +558,6 @@ def theme_catppuccin
558
558
  t.hover_success = 0xFFB9F0B4
559
559
  t.hover_warning = 0xFFFFF0CC
560
560
  t.hover_danger = 0xFFFFA0B8
561
- t.hover_normal = 0xFF585B70
561
+ t.hover_normal = 0xFFDEC0FF
562
562
  t
563
563
  end
@@ -15,8 +15,6 @@ class Button < Widget
15
15
  @radius = 4.0
16
16
  @hovered = false
17
17
  @click_handler = nil
18
- @width_policy = CONTENT
19
- @height_policy = CONTENT
20
18
  @pad_top = 8.0
21
19
  @pad_right = 16.0
22
20
  @pad_bottom = 8.0
@@ -76,8 +74,10 @@ class Button < Widget
76
74
  painter.fill_round_rect(0.0, 0.0, @width, @height, @radius, bg_c)
77
75
  ascent = painter.get_text_ascent($theme.font_family, @font_size_val)
78
76
  th = painter.measure_text_height($theme.font_family, @font_size_val)
77
+ tw = painter.measure_text_width(@label, $theme.font_family, @font_size_val)
78
+ text_x = (@width - tw) / 2.0
79
79
  text_y = (@height - th) / 2.0 + ascent
80
- painter.draw_text(@label, @pad_left, text_y, $theme.font_family, @font_size_val, tc)
80
+ painter.draw_text(@label, text_x, text_y, $theme.font_family, @font_size_val, tc)
81
81
  end
82
82
 
83
83
  def mouse_up(ev)
@@ -16,8 +16,6 @@ class Checkbox < Widget
16
16
  @custom_check = false
17
17
  @custom_text = false
18
18
  @hovered = false
19
- @width_policy = CONTENT
20
- @height_policy = CONTENT
21
19
  end
22
20
 
23
21
  def checked(v)
@@ -42,6 +42,24 @@ class Container < Layout
42
42
  self
43
43
  end
44
44
 
45
+ def measure(painter)
46
+ if @children.length > 0
47
+ c = @children[0]
48
+ inner_w = @width - @pad_left - @pad_right
49
+ if inner_w < 0.0
50
+ inner_w = 0.0
51
+ end
52
+ # Set child width before measuring for word-wrap support
53
+ if c.get_width_policy != FIXED
54
+ c.resize_wh(inner_w, c.get_height)
55
+ end
56
+ cs = c.measure(painter)
57
+ Size.new(cs.width + @pad_left + @pad_right, cs.height + @pad_top + @pad_bottom)
58
+ else
59
+ Size.new(@pad_left + @pad_right, @pad_top + @pad_bottom)
60
+ end
61
+ end
62
+
45
63
  def relocate_children(painter)
46
64
  if @children.length > 0
47
65
  c = @children[0]
@@ -14,8 +14,6 @@ class ImageWidget < Widget
14
14
  @img_width = 0.0
15
15
  @img_height = 0.0
16
16
  @fit_mode = IMAGE_FIT_CONTAIN
17
- @width_policy = CONTENT
18
- @height_policy = CONTENT
19
17
  end
20
18
 
21
19
  def fit(mode)
@@ -23,8 +23,6 @@ class Input < Widget
23
23
  @use_theme = true
24
24
  @radius = 4.0
25
25
  @focusable = true
26
- @width_policy = EXPANDING
27
- @height_policy = CONTENT
28
26
  @pad_top = 8.0
29
27
  @pad_right = 12.0
30
28
  @pad_bottom = 8.0
@@ -11,8 +11,6 @@ class Markdown < Widget
11
11
  @ast = nil
12
12
  @content_height = 0.0
13
13
  @padding_val = 12.0
14
- @width_policy = EXPANDING
15
- @height_policy = CONTENT
16
14
  end
17
15
 
18
16
  def padding(p)
@@ -14,8 +14,6 @@ class MultilineText < Widget
14
14
  @line_spacing = 4.0
15
15
  @wrap_enabled = false
16
16
  @border_width_val = 1.0
17
- @width_policy = EXPANDING
18
- @height_policy = CONTENT
19
17
  @cached_lines = []
20
18
  end
21
19
 
@@ -9,8 +9,6 @@ class NetImageWidget < Widget
9
9
  @img_width = 0.0
10
10
  @img_height = 0.0
11
11
  @fit_mode = IMAGE_FIT_CONTAIN
12
- @width_policy = CONTENT
13
- @height_policy = CONTENT
14
12
  end
15
13
 
16
14
  def fit(mode)
@@ -10,8 +10,6 @@ class RadioButtons < Widget
10
10
  @selected = 0
11
11
  @change_handler = nil
12
12
  @hovered_index = -1
13
- @width_policy = EXPANDING
14
- @height_policy = CONTENT
15
13
  # Colors (Tokyo Night)
16
14
  @text_color = 0xFFC0CAF5
17
15
  @ring_color = 0xFF565F89
@@ -6,8 +6,6 @@ class Switch < Widget
6
6
  super()
7
7
  @on = false
8
8
  @change_handler = nil
9
- @width_policy = CONTENT
10
- @height_policy = CONTENT
11
9
  # Colors (Tokyo Night)
12
10
  @on_color = 0xFF7AA2F7
13
11
  @off_color = 0xFF414868
@@ -18,8 +18,6 @@ class Text < Widget
18
18
  @text_align = TEXT_ALIGN_LEFT
19
19
  @font_weight = 0
20
20
  @font_slant = 0
21
- @width_policy = CONTENT
22
- @height_policy = CONTENT
23
21
  end
24
22
 
25
23
  def font_size(s)
@@ -85,6 +83,16 @@ class Text < Widget
85
83
  def redraw(painter, completely)
86
84
  ff = resolved_font_family
87
85
  ascent = painter.get_text_ascent(ff, @font_size_val)
86
+ th = painter.measure_text_height(ff, @font_size_val)
87
+
88
+ # Vertical centering when widget is taller than text
89
+ if @height > th
90
+ y_offset = (@height - th) / 2.0 + ascent
91
+ else
92
+ y_offset = ascent
93
+ end
94
+
95
+ # Horizontal alignment
88
96
  x_offset = 0.0
89
97
  if @text_align == TEXT_ALIGN_CENTER
90
98
  text_w = painter.measure_text_width(@text, ff, @font_size_val)
@@ -100,7 +108,7 @@ class Text < Widget
100
108
  end
101
109
  end
102
110
  c = @custom_color ? @color_val : $theme.text_color_for_kind(@kind_val)
103
- painter.draw_text(@text, x_offset, ascent, ff, @font_size_val, c, @font_weight, @font_slant)
111
+ painter.draw_text(@text, x_offset, y_offset, ff, @font_size_val, c, @font_weight, @font_slant)
104
112
  end
105
113
  end
106
114
 
@@ -179,6 +179,12 @@ class Tree < Widget
179
179
  update
180
180
  end
181
181
 
182
+ def measure(painter)
183
+ rebuild_visible
184
+ h = @visible_nodes.length * 1.0 * TREE_ROW_HEIGHT
185
+ Size.new(@width, h)
186
+ end
187
+
182
188
  def get_scrollable
183
189
  @scrollable_flag
184
190
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Konpeito
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: konpeito
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yasushi Itoh