kumiki 0.1.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 (62) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +256 -0
  4. data/lib/kumiki/animation/animated_state.rb +83 -0
  5. data/lib/kumiki/animation/easing.rb +62 -0
  6. data/lib/kumiki/animation/value_tween.rb +69 -0
  7. data/lib/kumiki/app.rb +381 -0
  8. data/lib/kumiki/box.rb +40 -0
  9. data/lib/kumiki/chart/area_chart.rb +308 -0
  10. data/lib/kumiki/chart/bar_chart.rb +291 -0
  11. data/lib/kumiki/chart/base_chart.rb +213 -0
  12. data/lib/kumiki/chart/chart_helpers.rb +74 -0
  13. data/lib/kumiki/chart/gauge_chart.rb +174 -0
  14. data/lib/kumiki/chart/heatmap_chart.rb +223 -0
  15. data/lib/kumiki/chart/line_chart.rb +292 -0
  16. data/lib/kumiki/chart/pie_chart.rb +222 -0
  17. data/lib/kumiki/chart/scales.rb +79 -0
  18. data/lib/kumiki/chart/scatter_chart.rb +306 -0
  19. data/lib/kumiki/chart/stacked_bar_chart.rb +279 -0
  20. data/lib/kumiki/column.rb +351 -0
  21. data/lib/kumiki/core.rb +2511 -0
  22. data/lib/kumiki/dsl.rb +408 -0
  23. data/lib/kumiki/frame_ranma.rb +570 -0
  24. data/lib/kumiki/markdown/ast.rb +127 -0
  25. data/lib/kumiki/markdown/mermaid/layout.rb +389 -0
  26. data/lib/kumiki/markdown/mermaid/models.rb +235 -0
  27. data/lib/kumiki/markdown/mermaid/parser.rb +522 -0
  28. data/lib/kumiki/markdown/mermaid/renderer.rb +339 -0
  29. data/lib/kumiki/markdown/parser.rb +808 -0
  30. data/lib/kumiki/markdown/renderer.rb +642 -0
  31. data/lib/kumiki/markdown/theme.rb +168 -0
  32. data/lib/kumiki/render_node.rb +262 -0
  33. data/lib/kumiki/row.rb +288 -0
  34. data/lib/kumiki/spacer.rb +20 -0
  35. data/lib/kumiki/style.rb +799 -0
  36. data/lib/kumiki/theme.rb +567 -0
  37. data/lib/kumiki/themes/material.rb +40 -0
  38. data/lib/kumiki/themes/tokyo_night.rb +11 -0
  39. data/lib/kumiki/version.rb +5 -0
  40. data/lib/kumiki/widgets/button.rb +105 -0
  41. data/lib/kumiki/widgets/calendar.rb +1028 -0
  42. data/lib/kumiki/widgets/checkbox.rb +119 -0
  43. data/lib/kumiki/widgets/container.rb +111 -0
  44. data/lib/kumiki/widgets/data_table.rb +670 -0
  45. data/lib/kumiki/widgets/divider.rb +31 -0
  46. data/lib/kumiki/widgets/image.rb +105 -0
  47. data/lib/kumiki/widgets/input.rb +485 -0
  48. data/lib/kumiki/widgets/markdown.rb +58 -0
  49. data/lib/kumiki/widgets/modal.rb +165 -0
  50. data/lib/kumiki/widgets/multiline_input.rb +970 -0
  51. data/lib/kumiki/widgets/multiline_text.rb +180 -0
  52. data/lib/kumiki/widgets/net_image.rb +100 -0
  53. data/lib/kumiki/widgets/progress_bar.rb +72 -0
  54. data/lib/kumiki/widgets/radio_buttons.rb +93 -0
  55. data/lib/kumiki/widgets/slider.rb +135 -0
  56. data/lib/kumiki/widgets/switch.rb +84 -0
  57. data/lib/kumiki/widgets/tabs.rb +175 -0
  58. data/lib/kumiki/widgets/text.rb +120 -0
  59. data/lib/kumiki/widgets/tree.rb +434 -0
  60. data/lib/kumiki/widgets/webview.rb +87 -0
  61. data/lib/kumiki.rb +130 -0
  62. metadata +113 -0
@@ -0,0 +1,105 @@
1
+ module Kumiki
2
+ # Image widget - displays an image from a file path
3
+
4
+ # Image fit constants
5
+ IMAGE_FIT_FILL = 0
6
+ IMAGE_FIT_CONTAIN = 1
7
+ IMAGE_FIT_COVER = 2
8
+
9
+ class ImageWidget < Widget
10
+ def initialize(file_path)
11
+ super()
12
+ @file_path = file_path
13
+ @image_id = 0
14
+ @img_width = 0.0
15
+ @img_height = 0.0
16
+ @fit_mode = IMAGE_FIT_CONTAIN
17
+ end
18
+
19
+ def fit(mode)
20
+ @fit_mode = mode
21
+ self
22
+ end
23
+
24
+ def set_path(path)
25
+ @file_path = path
26
+ @image_id = 0
27
+ @img_width = 0.0
28
+ @img_height = 0.0
29
+ mark_dirty
30
+ end
31
+
32
+ def load_if_needed(painter)
33
+ if @image_id == 0
34
+ @image_id = painter.load_image(@file_path)
35
+ if @image_id != 0
36
+ @img_width = painter.get_image_width(@image_id) * 1.0
37
+ @img_height = painter.get_image_height(@image_id) * 1.0
38
+ end
39
+ end
40
+ end
41
+
42
+ def measure(painter)
43
+ load_if_needed(painter)
44
+ if @image_id != 0
45
+ Size.new(@img_width, @img_height)
46
+ else
47
+ Size.new(100.0, 100.0)
48
+ end
49
+ end
50
+
51
+ def redraw(painter, completely)
52
+ load_if_needed(painter)
53
+ if @image_id == 0
54
+ # Draw placeholder
55
+ painter.fill_round_rect(0.0, 0.0, @width, @height, 4.0, 0x40FFFFFF)
56
+ painter.stroke_round_rect(0.0, 0.0, @width, @height, 4.0, 0x80FFFFFF, 1.0)
57
+ return
58
+ end
59
+
60
+ if @fit_mode == IMAGE_FIT_FILL
61
+ painter.draw_image(@image_id, 0.0, 0.0, @width, @height)
62
+ elsif @fit_mode == IMAGE_FIT_CONTAIN
63
+ draw_fitted(painter, true)
64
+ else
65
+ draw_fitted(painter, false)
66
+ end
67
+ end
68
+
69
+ def draw_fitted(painter, contain)
70
+ if @img_width < 1.0 || @img_height < 1.0 || @width < 1.0 || @height < 1.0
71
+ return
72
+ end
73
+ img_aspect = @img_width / @img_height
74
+ widget_aspect = @width / @height
75
+
76
+ if contain
77
+ if img_aspect > widget_aspect
78
+ new_w = @width
79
+ new_h = @width / img_aspect
80
+ else
81
+ new_h = @height
82
+ new_w = @height * img_aspect
83
+ end
84
+ else
85
+ if img_aspect > widget_aspect
86
+ new_h = @height
87
+ new_w = @height * img_aspect
88
+ else
89
+ new_w = @width
90
+ new_h = @width / img_aspect
91
+ end
92
+ end
93
+
94
+ dx = (@width - new_w) / 2.0
95
+ dy = (@height - new_h) / 2.0
96
+ painter.draw_image(@image_id, dx, dy, new_w, new_h)
97
+ end
98
+ end
99
+
100
+ # Top-level helper
101
+ def Image(file_path)
102
+ ImageWidget.new(file_path)
103
+ end
104
+
105
+ end
@@ -0,0 +1,485 @@
1
+ module Kumiki
2
+ # Input widget - single-line text input with IME, selection, and clipboard support
3
+ #
4
+ # State is held in InputState (defined in core.rb) which persists across
5
+ # Component rebuilds. The widget delegates all text/cursor/selection/IME
6
+ # operations to InputState and handles rendering + event dispatch.
7
+ #
8
+ # Key ordinals used in input_key (see RANMA_KEY_MAP):
9
+ # ENTER=11, BACKSPACE=12, ESCAPE=17, END=21, HOME=22, LEFT=23, RIGHT=25, DELETE=75
10
+ # A=43, C=45, V=64, X=66
11
+
12
+ class Input < Widget
13
+ def initialize(state)
14
+ super()
15
+ @state = state
16
+ @focused = false
17
+ @font_size_val = 14.0
18
+ @bg_color = 0
19
+ @text_color = 0
20
+ @placeholder_color = 0
21
+ @border_color = 0
22
+ @focus_border = 0
23
+ @use_theme = true
24
+ @radius = 4.0
25
+ @focusable = true
26
+ @pad_top = 8.0
27
+ @pad_right = 12.0
28
+ @pad_bottom = 8.0
29
+ @pad_left = 12.0
30
+ # Character position cache for click-to-position
31
+ @char_positions = []
32
+ @text_start_x = 0.0
33
+ # on_change callback
34
+ @on_change_cb = nil
35
+ end
36
+
37
+ def get_text
38
+ @state.value
39
+ end
40
+
41
+ def set_text(t)
42
+ @state.set(t)
43
+ mark_dirty
44
+ end
45
+
46
+ def font_size(s)
47
+ @font_size_val = s
48
+ self
49
+ end
50
+
51
+ def on_change(&block)
52
+ @on_change_cb = block
53
+ self
54
+ end
55
+
56
+ def measure(painter)
57
+ th = painter.measure_text_height(Kumiki.theme.font_family, @font_size_val)
58
+ Size.new(@width, th + @pad_top + @pad_bottom)
59
+ end
60
+
61
+ # --- Rendering ---
62
+
63
+ def redraw(painter, completely)
64
+ # Resolve colors from theme
65
+ bg_c = @use_theme ? Kumiki.theme.bg_primary : @bg_color
66
+ tc = @use_theme ? Kumiki.theme.text_primary : @text_color
67
+ pc = @use_theme ? Kumiki.theme.text_secondary : @placeholder_color
68
+ brd_c = @use_theme ? Kumiki.theme.border : @border_color
69
+ fbc = @use_theme ? Kumiki.theme.border_focus : @focus_border
70
+
71
+ bc = @focused ? fbc : brd_c
72
+ painter.fill_round_rect(0.0, 0.0, @width, @height, @radius, bg_c)
73
+ painter.stroke_round_rect(0.0, 0.0, @width, @height, @radius, bc, 1.0)
74
+
75
+ ascent = painter.get_text_ascent(Kumiki.theme.font_family, @font_size_val)
76
+ display_text = @state.get_display_text
77
+ @text_start_x = @pad_left
78
+
79
+ if display_text.length > 0
80
+ # Draw selection highlight first (behind text)
81
+ if @state.has_selection
82
+ draw_selection_highlight(painter, display_text, ascent)
83
+ end
84
+
85
+ painter.draw_text(display_text, @text_start_x, @pad_top + ascent, Kumiki.theme.font_family, @font_size_val, tc)
86
+
87
+ # Build character position cache for click-to-position
88
+ @char_positions = [0.0]
89
+ i = 0
90
+ while i < display_text.length
91
+ sub = display_text[0, i + 1]
92
+ w = painter.measure_text_width(sub, Kumiki.theme.font_family, @font_size_val)
93
+ @char_positions.push(w)
94
+ i = i + 1
95
+ end
96
+
97
+ # Draw preedit underline
98
+ if @state.has_preedit && @focused
99
+ draw_preedit_underline(painter, ascent, tc)
100
+ end
101
+ else
102
+ painter.draw_text(@state.get_placeholder, @text_start_x, @pad_top + ascent, Kumiki.theme.font_family, @font_size_val, pc)
103
+ @char_positions = [0.0]
104
+ end
105
+
106
+ # Cursor (when focused)
107
+ if @focused
108
+ draw_cursor(painter, tc)
109
+ end
110
+ end
111
+
112
+ def draw_preedit_underline(painter, ascent, tc)
113
+ text_before_preedit = ""
114
+ cursor = @state.get_cursor
115
+ if cursor > 0
116
+ text_before_preedit = @state.value[0, cursor]
117
+ end
118
+ preedit_start_x = @pad_left + painter.measure_text_width(text_before_preedit, Kumiki.theme.font_family, @font_size_val)
119
+ preedit_width = painter.measure_text_width(@state.get_preedit_text, Kumiki.theme.font_family, @font_size_val)
120
+ underline_y = @pad_top + ascent + 2.0
121
+ painter.fill_rect(preedit_start_x, underline_y, preedit_width, 2.0, tc)
122
+ end
123
+
124
+ def draw_cursor(painter, tc)
125
+ text_before_caret = compute_text_before_caret
126
+ cursor_x = @pad_left + painter.measure_text_width(text_before_caret, Kumiki.theme.font_family, @font_size_val)
127
+ painter.draw_line(cursor_x, @pad_top, cursor_x, @height - @pad_bottom, tc, 1.0)
128
+
129
+ # Notify IME of cursor position
130
+ notify_ime_cursor_rect(cursor_x)
131
+ end
132
+
133
+ def compute_text_before_caret
134
+ result = ""
135
+ cursor = @state.get_cursor
136
+ if @state.has_preedit
137
+ if cursor > 0
138
+ result = @state.value[0, cursor]
139
+ end
140
+ result = result + @state.get_preedit_text[0, @state.get_preedit_cursor]
141
+ else
142
+ if cursor > 0
143
+ result = @state.value[0, cursor]
144
+ end
145
+ end
146
+ result
147
+ end
148
+
149
+ def draw_selection_highlight(painter, display_text, ascent)
150
+ if @state.has_selection
151
+ range = @state.get_selection_range
152
+ s = range[0]
153
+ e = range[1]
154
+ # Clamp to display text
155
+ if s > display_text.length
156
+ s = display_text.length
157
+ end
158
+ if e > display_text.length
159
+ e = display_text.length
160
+ end
161
+ if s < e
162
+ x_start = @pad_left
163
+ if s > 0
164
+ x_start = @pad_left + painter.measure_text_width(display_text[0, s], Kumiki.theme.font_family, @font_size_val)
165
+ end
166
+ x_end = @pad_left + painter.measure_text_width(display_text[0, e], Kumiki.theme.font_family, @font_size_val)
167
+
168
+ sel_color = Kumiki.theme.bg_selected
169
+ painter.fill_rect(x_start, @pad_top, x_end - x_start, @height - @pad_top - @pad_bottom, sel_color)
170
+ end
171
+ end
172
+ end
173
+
174
+ def notify_ime_cursor_rect(cursor_x)
175
+ app = App.current
176
+ if app != nil
177
+ app.set_ime_cursor_rect(
178
+ (@x + cursor_x).to_i,
179
+ @y.to_i,
180
+ 1,
181
+ @height.to_i
182
+ )
183
+ end
184
+ end
185
+
186
+ # --- Focus ---
187
+
188
+ def focused
189
+ @focused = true
190
+ @state.start_editing
191
+ app = App.current
192
+ if app != nil
193
+ app.enable_text_input
194
+ end
195
+ mark_dirty
196
+ update
197
+ end
198
+
199
+ def restore_focus
200
+ @focused = true
201
+ app = App.current
202
+ if app != nil
203
+ app.enable_text_input
204
+ end
205
+ mark_dirty
206
+ end
207
+
208
+ def unfocused
209
+ @focused = false
210
+ @state.finish_editing
211
+ app = App.current
212
+ if app != nil
213
+ app.disable_text_input
214
+ end
215
+ mark_dirty
216
+ update
217
+ end
218
+
219
+ # --- Mouse events ---
220
+
221
+ def mouse_down(ev)
222
+ @focused = true
223
+ # Click-to-position
224
+ click_x = ev.pos.x
225
+ rel_x = click_x - @text_start_x
226
+ char_pos = pos_from_click(rel_x)
227
+
228
+ # Clear preedit on click
229
+ if @state.has_preedit
230
+ @state.clear_preedit
231
+ end
232
+
233
+ # Start selection
234
+ @state.start_selection(char_pos)
235
+
236
+ mark_dirty
237
+ update
238
+ end
239
+
240
+ def mouse_drag(ev)
241
+ if @state.is_selecting
242
+ rel_x = ev.pos.x - @text_start_x
243
+ char_pos = pos_from_click(rel_x)
244
+ @state.update_selection(char_pos)
245
+ mark_dirty
246
+ update
247
+ end
248
+ end
249
+
250
+ def mouse_up(ev)
251
+ @state.end_selection
252
+ end
253
+
254
+ def pos_from_click(rel_x)
255
+ result = @state.value.length
256
+ if rel_x <= 0.0
257
+ result = 0
258
+ else
259
+ found = false
260
+ i = 1
261
+ while i < @char_positions.length && !found
262
+ pos = @char_positions[i]
263
+ if pos > rel_x
264
+ prev_pos = @char_positions[i - 1]
265
+ if (rel_x - prev_pos) < (pos - rel_x)
266
+ result = i - 1
267
+ else
268
+ result = i
269
+ end
270
+ found = true
271
+ end
272
+ i = i + 1
273
+ end
274
+ end
275
+ result
276
+ end
277
+
278
+ # --- IME ---
279
+
280
+ def ime_preedit(text, sel_start, sel_end)
281
+ if text != nil && text.length > 0
282
+ @state.set_preedit(text, sel_start)
283
+ else
284
+ @state.clear_preedit
285
+ end
286
+ mark_dirty
287
+ update
288
+ end
289
+
290
+ # --- Text input ---
291
+
292
+ def input_char(text)
293
+ # Clear preedit when text is committed
294
+ if @state.has_preedit
295
+ @state.clear_preedit
296
+ end
297
+ # Delete selection if any
298
+ if @state.has_selection
299
+ @state.delete_selection
300
+ end
301
+ @state.insert(text)
302
+ @on_change_cb.call(@state.value) if @on_change_cb
303
+ mark_dirty
304
+ update
305
+ end
306
+
307
+ # --- Key input ---
308
+
309
+ def input_key(key_code, modifiers)
310
+ # During IME preedit, let IME handle key events
311
+ if @state.has_preedit
312
+ handle_preedit_key(key_code)
313
+ return
314
+ end
315
+
316
+ # Check for Cmd (bit 3 = MAC_COMMAND) or Ctrl (bit 1) modifier
317
+ is_cmd = (modifiers & 8) != 0 || (modifiers & 2) != 0
318
+
319
+ if is_cmd
320
+ handle_cmd_key(key_code)
321
+ return
322
+ end
323
+
324
+ # Clear selection on navigation keys
325
+ if key_code == 23 || key_code == 25
326
+ @state.clear_selection
327
+ end
328
+
329
+ # Delete selection on content-modifying keys
330
+ if (key_code == 12 || key_code == 75) && @state.has_selection
331
+ @state.delete_selection
332
+ @on_change_cb.call(@state.value) if @on_change_cb
333
+ mark_dirty
334
+ update
335
+ return
336
+ end
337
+
338
+ handle_navigation_key(key_code)
339
+ end
340
+
341
+ def handle_preedit_key(key_code)
342
+ # Workaround: single-char preedit + backspace/escape
343
+ if @state.get_preedit_text.length == 1
344
+ if key_code == 12 || key_code == 17
345
+ @state.clear_preedit
346
+ mark_dirty
347
+ update
348
+ end
349
+ end
350
+ end
351
+
352
+ def handle_cmd_key(key_code)
353
+ # Cmd+C (Copy) - C ordinal = 45
354
+ if key_code == 45
355
+ handle_copy
356
+ # Cmd+X (Cut) - X ordinal = 66
357
+ elsif key_code == 66
358
+ handle_cut
359
+ # Cmd+V (Paste) - V ordinal = 64
360
+ elsif key_code == 64
361
+ handle_paste
362
+ # Cmd+A (Select All) - A ordinal = 43
363
+ elsif key_code == 43
364
+ @state.select_all
365
+ mark_dirty
366
+ update
367
+ end
368
+ end
369
+
370
+ def handle_navigation_key(key_code)
371
+ # Backspace (key ordinal 12)
372
+ if key_code == 12
373
+ if @state.delete_prev
374
+ @on_change_cb.call(@state.value) if @on_change_cb
375
+ mark_dirty
376
+ update
377
+ end
378
+ # Delete (key ordinal 75)
379
+ elsif key_code == 75
380
+ if @state.delete_next
381
+ @on_change_cb.call(@state.value) if @on_change_cb
382
+ mark_dirty
383
+ update
384
+ end
385
+ # Left arrow (key ordinal 23)
386
+ elsif key_code == 23
387
+ if @state.move_prev
388
+ mark_dirty
389
+ update
390
+ end
391
+ # Right arrow (key ordinal 25)
392
+ elsif key_code == 25
393
+ if @state.move_next
394
+ mark_dirty
395
+ update
396
+ end
397
+ # Home (key ordinal 22) - move to beginning
398
+ elsif key_code == 22
399
+ if @state.move_home
400
+ mark_dirty
401
+ update
402
+ end
403
+ # End (key ordinal 21) - move to end
404
+ elsif key_code == 21
405
+ if @state.move_end
406
+ mark_dirty
407
+ update
408
+ end
409
+ end
410
+ end
411
+
412
+ # --- Clipboard ---
413
+
414
+ def handle_copy
415
+ text = @state.get_selected_text
416
+ if text.length > 0
417
+ app = App.current
418
+ if app != nil
419
+ app.set_clipboard_text(text)
420
+ end
421
+ end
422
+ end
423
+
424
+ def handle_cut
425
+ text = @state.get_selected_text
426
+ if text.length > 0
427
+ @state.delete_selection
428
+ app = App.current
429
+ if app != nil
430
+ app.set_clipboard_text(text)
431
+ end
432
+ @on_change_cb.call(@state.value) if @on_change_cb
433
+ mark_dirty
434
+ update
435
+ end
436
+ end
437
+
438
+ def handle_paste
439
+ app = App.current
440
+ if app == nil
441
+ return
442
+ end
443
+ text = app.get_clipboard_text
444
+ if text == nil
445
+ return
446
+ end
447
+ if text.length == 0
448
+ return
449
+ end
450
+ if @state.has_selection
451
+ @state.delete_selection
452
+ end
453
+ paste_text(text)
454
+ end
455
+
456
+ def paste_text(text)
457
+ # Single line: take first line only
458
+ first_line = find_first_line(text)
459
+ @state.insert(first_line)
460
+ @on_change_cb.call(@state.value) if @on_change_cb
461
+ mark_dirty
462
+ update
463
+ end
464
+
465
+ def find_first_line(text)
466
+ result = text
467
+ found = false
468
+ newline_idx = 0
469
+ while newline_idx < text.length && !found
470
+ if text[newline_idx] == "\n"
471
+ result = text[0, newline_idx]
472
+ found = true
473
+ end
474
+ newline_idx = newline_idx + 1
475
+ end
476
+ result
477
+ end
478
+ end
479
+
480
+ # Top-level helper — accepts placeholder string for backward compatibility
481
+ def Input(placeholder)
482
+ Input.new(InputState.new(placeholder))
483
+ end
484
+
485
+ end
@@ -0,0 +1,58 @@
1
+ module Kumiki
2
+ # Markdown widget - renders markdown text
3
+ # Combines MarkdownParser + MarkdownRenderer
4
+
5
+ class Markdown < Widget
6
+ def initialize(text)
7
+ super()
8
+ @source = text
9
+ @md_theme = MarkdownTheme.new
10
+ @parser = MarkdownParser.new
11
+ @renderer = MarkdownRenderer.new(@md_theme)
12
+ @ast = nil
13
+ @content_height = 0.0
14
+ @padding_val = 12.0
15
+ end
16
+
17
+ def padding(p)
18
+ @padding_val = p
19
+ self
20
+ end
21
+
22
+ def set_text(t)
23
+ @source = t
24
+ @ast = nil
25
+ mark_dirty
26
+ end
27
+
28
+ def get_text
29
+ @source
30
+ end
31
+
32
+ def measure(painter)
33
+ ensure_parsed
34
+ h = @renderer.measure_height(painter, @ast, @width, @padding_val)
35
+ Size.new(@width, h)
36
+ end
37
+
38
+ def redraw(painter, completely)
39
+ ensure_parsed
40
+ # Background
41
+ painter.fill_rect(0.0, 0.0, @width, @height, Kumiki.theme.bg_canvas)
42
+ # Render markdown
43
+ @content_height = @renderer.render(painter, @ast, @width, @padding_val)
44
+ end
45
+
46
+ def ensure_parsed
47
+ if @ast == nil
48
+ @ast = @parser.parse(@source)
49
+ end
50
+ end
51
+ end
52
+
53
+ # Top-level helper
54
+ def Markdown(text)
55
+ Markdown.new(text)
56
+ end
57
+
58
+ end