charming 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.
Files changed (163) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -378
  3. data/lib/charming/application.rb +14 -3
  4. data/lib/charming/{application_model.rb → application_state.rb} +3 -3
  5. data/lib/charming/cli.rb +62 -3
  6. data/lib/charming/controller/class_methods.rb +115 -0
  7. data/lib/charming/controller/command_palette.rb +135 -0
  8. data/lib/charming/controller/component_dispatching.rb +81 -0
  9. data/lib/charming/controller/dispatching.rb +60 -0
  10. data/lib/charming/controller/focus_management.rb +30 -0
  11. data/lib/charming/controller/rendering.rb +127 -0
  12. data/lib/charming/controller/session_state.rb +41 -0
  13. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  14. data/lib/charming/controller.rb +46 -448
  15. data/lib/charming/database_commands.rb +103 -0
  16. data/lib/charming/database_installer.rb +152 -0
  17. data/lib/charming/events/key_event.rb +15 -0
  18. data/lib/charming/events/mouse_event.rb +42 -0
  19. data/lib/charming/events/resize_event.rb +9 -0
  20. data/lib/charming/events/task_event.rb +19 -0
  21. data/lib/charming/events/timer_event.rb +9 -0
  22. data/lib/charming/focus.rb +58 -2
  23. data/lib/charming/generators/app_file_generator.rb +13 -0
  24. data/lib/charming/generators/app_generator.rb +147 -45
  25. data/lib/charming/generators/base.rb +26 -0
  26. data/lib/charming/generators/component_generator.rb +10 -10
  27. data/lib/charming/generators/controller_generator.rb +22 -14
  28. data/lib/charming/generators/model_generator.rb +128 -0
  29. data/lib/charming/generators/name.rb +10 -4
  30. data/lib/charming/generators/screen_generator.rb +84 -52
  31. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  32. data/lib/charming/generators/templates/app/README.md.template +9 -0
  33. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  34. data/lib/charming/generators/templates/app/application.template +13 -0
  35. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  36. data/lib/charming/generators/templates/app/application_record.template +7 -0
  37. data/lib/charming/generators/templates/app/application_state.template +6 -0
  38. data/lib/charming/generators/templates/app/database_config.template +12 -0
  39. data/lib/charming/generators/templates/app/executable.template +7 -0
  40. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  41. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  42. data/lib/charming/generators/templates/app/home_state.template +7 -0
  43. data/lib/charming/generators/templates/app/keep.template +0 -0
  44. data/lib/charming/generators/templates/app/layout.template +113 -0
  45. data/lib/charming/generators/templates/app/root_file.template +20 -0
  46. data/lib/charming/generators/templates/app/routes.template +5 -0
  47. data/lib/charming/generators/templates/app/seeds.template +1 -0
  48. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  49. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  50. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  51. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  52. data/lib/charming/generators/templates/app/version.template +5 -0
  53. data/lib/charming/generators/templates/app/view.template +21 -0
  54. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  55. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  56. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  57. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  58. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  59. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  60. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  61. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  62. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  63. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  64. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  65. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  66. data/lib/charming/generators/view_generator.rb +26 -13
  67. data/lib/charming/internal/renderer/differential.rb +17 -3
  68. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  69. data/lib/charming/internal/terminal/adapter.rb +29 -3
  70. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  71. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  72. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  73. data/lib/charming/internal/terminal/tty_backend.rb +62 -115
  74. data/lib/charming/presentation/component.rb +10 -0
  75. data/lib/charming/presentation/components/activity_indicator.rb +160 -0
  76. data/lib/charming/presentation/components/command_palette.rb +120 -0
  77. data/lib/charming/presentation/components/empty_state.rb +56 -0
  78. data/lib/charming/presentation/components/form/builder.rb +62 -0
  79. data/lib/charming/presentation/components/form/confirm.rb +69 -0
  80. data/lib/charming/presentation/components/form/field.rb +121 -0
  81. data/lib/charming/presentation/components/form/input.rb +71 -0
  82. data/lib/charming/presentation/components/form/note.rb +41 -0
  83. data/lib/charming/presentation/components/form/select.rb +112 -0
  84. data/lib/charming/presentation/components/form/textarea.rb +86 -0
  85. data/lib/charming/presentation/components/form.rb +156 -0
  86. data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
  87. data/lib/charming/presentation/components/list.rb +132 -0
  88. data/lib/charming/presentation/components/markdown.rb +31 -0
  89. data/lib/charming/presentation/components/modal.rb +64 -0
  90. data/lib/charming/presentation/components/progressbar.rb +70 -0
  91. data/lib/charming/presentation/components/spinner.rb +49 -0
  92. data/lib/charming/presentation/components/table.rb +143 -0
  93. data/lib/charming/presentation/components/text_area.rb +267 -0
  94. data/lib/charming/presentation/components/text_input.rb +129 -0
  95. data/lib/charming/presentation/components/viewport.rb +272 -0
  96. data/lib/charming/presentation/layout/builder.rb +86 -0
  97. data/lib/charming/presentation/layout/overlay.rb +57 -0
  98. data/lib/charming/presentation/layout/pane.rb +145 -0
  99. data/lib/charming/presentation/layout/rect.rb +23 -0
  100. data/lib/charming/presentation/layout/screen_layout.rb +60 -0
  101. data/lib/charming/presentation/layout/split.rb +134 -0
  102. data/lib/charming/presentation/layout.rb +43 -0
  103. data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
  104. data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
  105. data/lib/charming/presentation/markdown/render_context.rb +22 -0
  106. data/lib/charming/presentation/markdown/renderer.rb +113 -0
  107. data/lib/charming/presentation/markdown/syntax_highlighter.rb +79 -0
  108. data/lib/charming/presentation/markdown.rb +11 -0
  109. data/lib/charming/presentation/template_view.rb +34 -0
  110. data/lib/charming/presentation/templates/erb_handler.rb +15 -0
  111. data/lib/charming/presentation/templates.rb +68 -0
  112. data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
  113. data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
  114. data/lib/charming/presentation/ui/border.rb +35 -0
  115. data/lib/charming/presentation/ui/border_painter.rb +58 -0
  116. data/lib/charming/presentation/ui/canvas.rb +82 -0
  117. data/lib/charming/presentation/ui/style.rb +213 -0
  118. data/lib/charming/presentation/ui/theme.rb +180 -0
  119. data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
  120. data/lib/charming/presentation/ui/width.rb +26 -0
  121. data/lib/charming/presentation/ui.rb +91 -0
  122. data/lib/charming/presentation/view.rb +135 -0
  123. data/lib/charming/runtime.rb +9 -7
  124. data/lib/charming/screen.rb +5 -1
  125. data/lib/charming/tasks/inline_executor.rb +37 -0
  126. data/lib/charming/tasks/task.rb +12 -0
  127. data/lib/charming/tasks/threaded_executor.rb +51 -0
  128. data/lib/charming/version.rb +1 -1
  129. data/lib/charming.rb +17 -0
  130. metadata +170 -36
  131. data/lib/charming/component.rb +0 -8
  132. data/lib/charming/components/activity_indicator.rb +0 -158
  133. data/lib/charming/components/command_palette.rb +0 -118
  134. data/lib/charming/components/keyboard_handler.rb +0 -22
  135. data/lib/charming/components/list.rb +0 -105
  136. data/lib/charming/components/modal.rb +0 -48
  137. data/lib/charming/components/progressbar.rb +0 -55
  138. data/lib/charming/components/spinner.rb +0 -37
  139. data/lib/charming/components/table.rb +0 -115
  140. data/lib/charming/components/text_input.rb +0 -103
  141. data/lib/charming/components/viewport.rb +0 -191
  142. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -86
  143. data/lib/charming/generators/app_generator/basic_templates.rb +0 -69
  144. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  145. data/lib/charming/generators/app_generator/controller_template.rb +0 -69
  146. data/lib/charming/generators/app_generator/layout_template.rb +0 -160
  147. data/lib/charming/generators/app_generator/model_templates.rb +0 -30
  148. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -70
  149. data/lib/charming/generators/app_generator/view_template.rb +0 -90
  150. data/lib/charming/key_event.rb +0 -13
  151. data/lib/charming/mouse_event.rb +0 -40
  152. data/lib/charming/resize_event.rb +0 -7
  153. data/lib/charming/task.rb +0 -7
  154. data/lib/charming/task_event.rb +0 -17
  155. data/lib/charming/task_executor.rb +0 -62
  156. data/lib/charming/timer_event.rb +0 -7
  157. data/lib/charming/ui/border.rb +0 -33
  158. data/lib/charming/ui/style.rb +0 -244
  159. data/lib/charming/ui/theme.rb +0 -178
  160. data/lib/charming/ui/width.rb +0 -24
  161. data/lib/charming/ui.rb +0 -230
  162. data/lib/charming/view.rb +0 -116
  163. /data/lib/charming/{generators.rb → generators/error.rb} +0 -0
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-table"
4
+
5
+ module Charming
6
+ module Presentation
7
+ module Components
8
+ # Table renders tabular data with a header row, a selected row highlight, and keyboard
9
+ # navigation. Mouse clicks within the body area also select rows. The table is rendered
10
+ # via tty-table and the selected row is overlaid with reverse-video ANSI styling.
11
+ class Table < Component
12
+ include KeyboardHandler
13
+
14
+ # Maps navigation keys to the instance methods that move the selection. Shared with
15
+ # List and Viewport via KeyboardHandler.
16
+ KEY_ACTIONS = {
17
+ up: :move_up,
18
+ down: :move_down,
19
+ home: :move_home,
20
+ end: :move_end
21
+ }.freeze
22
+
23
+ # Number of terminal rows occupied by the table's top border and header line. Used by
24
+ # the mouse handler to translate absolute row coordinates to body rows.
25
+ HEADER_HEIGHT = 2
26
+
27
+ # The header row, the body rows, and the currently selected row index, respectively.
28
+ attr_reader :header, :rows, :selected_index
29
+
30
+ # *header* is an array of column labels. *rows* is the array of body rows (each either a
31
+ # String, an Array, or a Hash of column-value pairs). *selected_index* defaults to 0.
32
+ # *keymap* selects the keybinding style (`:vim` enables h/j/k/l → left/down/up/right).
33
+ def initialize(header:, rows: [], selected_index: 0, keymap: :vim)
34
+ super()
35
+ @header = Array(header).map(&:to_s)
36
+ @rows = Array(rows)
37
+ @selected_index = clamp_index(selected_index)
38
+ @keymap = keymap
39
+ end
40
+
41
+ # Handles key events. Returns `[:selected, row]` on Enter; otherwise delegates to the
42
+ # KeyboardHandler for navigation keys.
43
+ def handle_key(event)
44
+ return nil if rows.empty?
45
+
46
+ case Charming.key_of(event)
47
+ when :enter then [:selected, selected_row]
48
+ else super
49
+ end
50
+ end
51
+
52
+ # Handles mouse events: a click within the body area selects the clicked row.
53
+ # Returns :handled on a successful click.
54
+ def handle_mouse(event)
55
+ return nil if rows.empty?
56
+ return nil unless event.respond_to?(:click?) && event.click?
57
+
58
+ clicked = event.y - HEADER_HEIGHT
59
+ return nil if clicked.negative? || clicked >= rows.length
60
+
61
+ @selected_index = clicked
62
+ :handled
63
+ end
64
+
65
+ # Returns the currently selected row, or nil when the table is empty.
66
+ def selected_row
67
+ rows[selected_index]
68
+ end
69
+
70
+ # Renders the table to a string. Returns a placeholder when both header and rows are empty.
71
+ def render
72
+ return "(empty table)" if header.empty? && rows.empty?
73
+
74
+ normalized = rows.map { |row| normalize_row(row) }
75
+ lines = TTY::Table.new(header: header, rows: normalized)
76
+ .render(:unicode)
77
+ .lines(chomp: true)
78
+
79
+ compact_layout(lines)
80
+ end
81
+
82
+ private
83
+
84
+ # Coerces a *row* (Hash / String / Array) into a flat cell array matching the header.
85
+ # Excess cells are merged into the last column with a space separator.
86
+ def normalize_row(row)
87
+ cells = case row
88
+ when Hash then row.values
89
+ when String then [row]
90
+ else Array(row)
91
+ end
92
+ return cells if header.length <= 1 || cells.length <= header.length
93
+
94
+ kept = cells.first(header.length - 1)
95
+ merged = cells[(header.length - 1)..].join(" ")
96
+ kept + [merged]
97
+ end
98
+
99
+ # Applies the selected-row highlight and trims unused body rows below the actual row count.
100
+ def compact_layout(lines)
101
+ return lines.join("\n") if lines.length < 4
102
+
103
+ top, header_line, _separator, *rest = lines
104
+ body = rest.first(rows.length)
105
+ bottom = rest[rows.length]
106
+
107
+ highlighted = body.each_with_index.map do |line, index|
108
+ (index == selected_index) ? "\e[7m#{line}\e[m" : line
109
+ end
110
+
111
+ [top, header_line, *highlighted, bottom].compact.join("\n")
112
+ end
113
+
114
+ # Moves the selection up one row.
115
+ def move_up
116
+ @selected_index -= 1 if selected_index.positive?
117
+ end
118
+
119
+ # Moves the selection down one row.
120
+ def move_down
121
+ @selected_index += 1 if selected_index < rows.length - 1
122
+ end
123
+
124
+ # Moves the selection to the first row.
125
+ def move_home
126
+ @selected_index = 0
127
+ end
128
+
129
+ # Moves the selection to the last row.
130
+ def move_end
131
+ @selected_index = rows.length - 1
132
+ end
133
+
134
+ # Clamps *value* to the valid row range, defaulting to 0 when the table is empty.
135
+ def clamp_index(value)
136
+ return 0 if rows.empty?
137
+
138
+ value.to_i.clamp(0, rows.length - 1)
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Components
6
+ # TextArea is a multi-line text editor component. Supports character insertion (with
7
+ # newline insertion via Shift+Enter or Ctrl+J), cursor movement (left/right/up/down,
8
+ # home/end, page up/down), deletion (backspace/delete), and scrolling for long buffers.
9
+ # Vertical movement preserves a "preferred column" so left/right navigation feels stable.
10
+ class TextArea < Component
11
+ # The current text value, cursor byte offset, top-visible row offset, and remembered
12
+ # column for vertical navigation, respectively.
13
+ attr_reader :value, :cursor, :offset, :preferred_column
14
+
15
+ # *value* is the initial text. *placeholder* is shown when the value is empty. *width* and
16
+ # *height* constrain the rendered output. *cursor* defaults to the end of the value.
17
+ # *offset* is the top-visible row. *preferred_column* is the column to resume at on
18
+ # vertical movement (defaults to the current column on first use).
19
+ def initialize(value: "", placeholder: "", width: nil, height: nil, cursor: nil, offset: 0, preferred_column: nil)
20
+ super()
21
+ @value = value.dup
22
+ @placeholder = placeholder
23
+ @width = width
24
+ @height = height
25
+ @cursor = cursor || @value.length
26
+ @offset = offset
27
+ @preferred_column = preferred_column
28
+ clamp_position
29
+ ensure_cursor_visible
30
+ end
31
+
32
+ # Routes key events to the appropriate cursor/text mutation. Returns :handled when the
33
+ # event was consumed, nil otherwise.
34
+ def handle_key(event)
35
+ key = Charming.key_of(event)
36
+ return :handled if newline_event?(event) && insert("\n")
37
+ return :handled if character_event?(event) && insert(event.char)
38
+
39
+ case key
40
+ when :left then move_left
41
+ when :right then move_right
42
+ when :up then move_up
43
+ when :down then move_down
44
+ when :home then move_home
45
+ when :end then move_end
46
+ when :backspace then delete_before_cursor
47
+ when :delete then delete_at_cursor
48
+ when :page_up then page_up
49
+ when :page_down then page_down
50
+ else return nil
51
+ end
52
+
53
+ :handled
54
+ end
55
+
56
+ # Renders the visible portion of the text buffer (scrolled to `offset`), with each
57
+ # visible line either clipped to `width` or padded to it.
58
+ def render
59
+ visible_lines.map { |line| render_line(line) }.join("\n")
60
+ end
61
+
62
+ private
63
+
64
+ attr_reader :placeholder, :width, :height
65
+
66
+ # True when the event represents an explicit newline request: Shift+Enter or Ctrl+J.
67
+ def newline_event?(event)
68
+ key = Charming.key_of(event)
69
+ return true if key == :enter && event.respond_to?(:shift) && event.shift
70
+ return true if key == :j && event.respond_to?(:ctrl) && event.ctrl
71
+
72
+ false
73
+ end
74
+
75
+ # True when *event* carries a single printable character.
76
+ def character_event?(event)
77
+ event.respond_to?(:char) && event.char && event.char.length == 1 && printable?(event.char)
78
+ end
79
+
80
+ # True when *char* is not a control character.
81
+ def printable?(char)
82
+ !char.match?(/[[:cntrl:]]/)
83
+ end
84
+
85
+ # Inserts *text* at the cursor, advances the cursor by its length, resets the preferred
86
+ # column, and ensures the cursor remains visible.
87
+ def insert(text)
88
+ @value = value[0...cursor].to_s + text + value[cursor..].to_s
89
+ @cursor += text.length
90
+ reset_preferred_column
91
+ ensure_cursor_visible
92
+ end
93
+
94
+ # Moves the cursor one character left.
95
+ def move_left
96
+ @cursor -= 1 if cursor.positive?
97
+ reset_preferred_column
98
+ ensure_cursor_visible
99
+ end
100
+
101
+ # Moves the cursor one character right.
102
+ def move_right
103
+ @cursor += 1 if cursor < value.length
104
+ reset_preferred_column
105
+ ensure_cursor_visible
106
+ end
107
+
108
+ # Moves the cursor up one line while preserving the preferred column.
109
+ def move_up
110
+ move_vertical(-1)
111
+ end
112
+
113
+ # Moves the cursor down one line while preserving the preferred column.
114
+ def move_down
115
+ move_vertical(+1)
116
+ end
117
+
118
+ # Moves the cursor to the start of the current line.
119
+ def move_home
120
+ row, = cursor_position
121
+ @cursor = line_start(row)
122
+ reset_preferred_column
123
+ ensure_cursor_visible
124
+ end
125
+
126
+ # Moves the cursor to the end of the current line.
127
+ def move_end
128
+ row, = cursor_position
129
+ @cursor = line_start(row) + line_length(row)
130
+ reset_preferred_column
131
+ ensure_cursor_visible
132
+ end
133
+
134
+ # Deletes the character before the cursor (backspace behavior).
135
+ def delete_before_cursor
136
+ return if cursor.zero?
137
+
138
+ @value = value[0...(cursor - 1)].to_s + value[cursor..].to_s
139
+ @cursor -= 1
140
+ reset_preferred_column
141
+ ensure_cursor_visible
142
+ end
143
+
144
+ # Deletes the character at the cursor (delete-key behavior).
145
+ def delete_at_cursor
146
+ return if cursor >= value.length
147
+
148
+ @value = value[0...cursor].to_s + value[(cursor + 1)..].to_s
149
+ reset_preferred_column
150
+ ensure_cursor_visible
151
+ end
152
+
153
+ # Scrolls the buffer up by one viewport height.
154
+ def page_up
155
+ @offset -= viewport_height
156
+ clamp_offset
157
+ end
158
+
159
+ # Scrolls the buffer down by one viewport height.
160
+ def page_down
161
+ @offset += viewport_height
162
+ clamp_offset
163
+ end
164
+
165
+ # Moves the cursor vertically by *delta* rows. Stays within the line count and uses
166
+ # `preferred_column` so up/down movement feels stable on short lines.
167
+ def move_vertical(delta)
168
+ row, column = cursor_position
169
+ target_row = (row + delta).clamp(0, lines.length - 1)
170
+ @preferred_column ||= column
171
+ @cursor = line_start(target_row) + [@preferred_column, line_length(target_row)].min
172
+ ensure_cursor_visible
173
+ end
174
+
175
+ # Sets the preferred column to the current column (called when horizontal movement happens).
176
+ def reset_preferred_column
177
+ @preferred_column = cursor_position.last
178
+ end
179
+
180
+ # Returns the cursor's current position as `[row, column]`, where row is the zero-based
181
+ # line index and column is the character offset within that line.
182
+ def cursor_position
183
+ before = value[0...cursor].to_s
184
+ row = before.count("\n")
185
+ last_newline = before.rindex("\n")
186
+ column = last_newline ? before.length - last_newline - 1 : before.length
187
+ [row, column]
188
+ end
189
+
190
+ # Returns the byte offset where line *row* begins in the value.
191
+ def line_start(row)
192
+ lines.first(row).sum(&:length) + row
193
+ end
194
+
195
+ # Returns the character length of the line at *row* (empty string when row is past the end).
196
+ def line_length(row)
197
+ lines.fetch(row, "").length
198
+ end
199
+
200
+ # Splits the value into an array of lines (preserving trailing empty lines).
201
+ def lines
202
+ value.empty? ? [""] : value.split("\n", -1)
203
+ end
204
+
205
+ # Returns the rendered lines (with cursor marker inserted) before viewport slicing.
206
+ def rendered_lines
207
+ return [cursor_marker + placeholder] if value.empty?
208
+
209
+ (value[0...cursor].to_s + cursor_marker + value[cursor..].to_s).split("\n", -1)
210
+ end
211
+
212
+ # Returns the lines that should be visible in the current viewport, padded to *height*
213
+ # with empty strings when the buffer is shorter.
214
+ def visible_lines
215
+ ensure_cursor_visible
216
+ rendered = rendered_lines.slice(offset, viewport_height) || []
217
+ return rendered unless height
218
+
219
+ rendered + Array.new([height - rendered.length, 0].max, "")
220
+ end
221
+
222
+ # Renders a single line, clipping to *width* and padding with spaces.
223
+ def render_line(line)
224
+ return line unless width
225
+
226
+ clipped = UI.visible_slice(line, 0, width)
227
+ clipped + (" " * [width - UI::Width.measure(clipped), 0].max)
228
+ end
229
+
230
+ # Adjusts the top-visible offset so the cursor row is in view. Scrolling is performed
231
+ # one row at a time when needed.
232
+ def ensure_cursor_visible
233
+ row, = cursor_position
234
+ @offset = row if row < offset
235
+ @offset = row - viewport_height + 1 if row >= offset + viewport_height
236
+ clamp_offset
237
+ end
238
+
239
+ # Clamps the cursor and offset to valid bounds.
240
+ def clamp_position
241
+ @cursor = cursor.clamp(0, value.length)
242
+ clamp_offset
243
+ end
244
+
245
+ # Clamps the offset to the valid range `[0, max_offset]`.
246
+ def clamp_offset
247
+ @offset = offset.clamp(0, max_offset)
248
+ end
249
+
250
+ # Returns the maximum allowed offset (so the bottom of the buffer is reachable).
251
+ def max_offset
252
+ [lines.length - viewport_height, 0].max
253
+ end
254
+
255
+ # Returns the visible row count (the configured *height* or the buffer's line count).
256
+ def viewport_height
257
+ height || lines.length
258
+ end
259
+
260
+ # The literal character used to mark the cursor position in `rendered_lines`.
261
+ def cursor_marker
262
+ "|"
263
+ end
264
+ end
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Presentation
5
+ module Components
6
+ # TextInput is a single-line text editor component. Supports printable character insertion,
7
+ # cursor movement (left/right/home/end), and deletion (backspace/delete). The component
8
+ # exposes its `value` and `cursor` positions as reader methods; when an explicit `width:`
9
+ # is given, the rendered output is padded to that width via a UI::Style.
10
+ class TextInput < Component
11
+ include KeyboardHandler
12
+
13
+ # Maps editing keys (left/right/home/end/backspace/delete) to the instance
14
+ # methods they dispatch via KeyboardHandler. Each symbol key (e.g., :left)
15
+ # maps to a method (e.g., :move_left) that adjusts cursor position or text content.
16
+ KEY_ACTIONS = {
17
+ left: :move_left,
18
+ right: :move_right,
19
+ home: :move_home,
20
+ end: :move_end,
21
+ backspace: :delete_before_cursor,
22
+ delete: :delete_at_cursor
23
+ }.freeze
24
+
25
+ # The current input string and the byte offset of the cursor within it.
26
+ attr_reader :value, :cursor
27
+
28
+ # *value* is the initial text. *placeholder* is shown when the value is empty.
29
+ # *width* optionally constrains the rendered output width; *cursor* defaults to the end.
30
+ def initialize(value: "", placeholder: "", width: nil, cursor: nil)
31
+ super()
32
+ @value = value.dup
33
+ @placeholder = placeholder
34
+ @width = width
35
+ @cursor = cursor || @value.length
36
+ clamp_position
37
+ end
38
+
39
+ # Handles key events. Inserts printable characters, otherwise dispatches via KEY_ACTIONS.
40
+ # Returns :handled when the event was consumed, nil otherwise.
41
+ def handle_key(event)
42
+ return :handled if character_event?(event) && insert(event.char)
43
+
44
+ super
45
+ end
46
+
47
+ # Renders the value with a cursor marker. When *width* was given at construction, the
48
+ # output is padded to that width via the configured style.
49
+ def render
50
+ rendered = render_value
51
+ @width ? style.width(@width).render(rendered) : rendered
52
+ end
53
+
54
+ private
55
+
56
+ attr_reader :placeholder
57
+
58
+ # True when *event* carries a single printable character that should be inserted.
59
+ def character_event?(event)
60
+ event.respond_to?(:char) && event.char && event.char.length == 1 && printable?(event.char)
61
+ end
62
+
63
+ # True when *char* is not a control character (and therefore safe to insert).
64
+ def printable?(char)
65
+ !char.match?(/[[:cntrl:]]/)
66
+ end
67
+
68
+ # Inserts *char* at the cursor and advances the cursor by its byte length.
69
+ def insert(char)
70
+ @value = value[0...cursor] + char + value[cursor..]
71
+ @cursor += char.length
72
+ end
73
+
74
+ # Moves the cursor one position left, when possible.
75
+ def move_left
76
+ @cursor -= 1 if cursor.positive?
77
+ end
78
+
79
+ # Moves the cursor one position right, when possible.
80
+ def move_right
81
+ @cursor += 1 if cursor < value.length
82
+ end
83
+
84
+ # Moves the cursor to the start of the value.
85
+ def move_home
86
+ @cursor = 0
87
+ end
88
+
89
+ # Moves the cursor to the end of the value.
90
+ def move_end
91
+ @cursor = value.length
92
+ end
93
+
94
+ # Deletes the character before the cursor (backspace behavior).
95
+ def delete_before_cursor
96
+ return if cursor.zero?
97
+
98
+ @value = value[0...(cursor - 1)] + value[cursor..]
99
+ @cursor -= 1
100
+ end
101
+
102
+ # Deletes the character at the cursor (delete-key behavior).
103
+ def delete_at_cursor
104
+ return if cursor >= value.length
105
+
106
+ @value = value[0...cursor] + value[(cursor + 1)..]
107
+ end
108
+
109
+ # Renders the value with a "|" cursor marker at the current position. When the value is
110
+ # empty, the placeholder is rendered instead, preceded by the cursor marker.
111
+ def render_value
112
+ return cursor_marker + placeholder if value.empty?
113
+
114
+ value[0...cursor] + cursor_marker + value[cursor..]
115
+ end
116
+
117
+ # The literal character used to mark the cursor position in `render`.
118
+ def cursor_marker
119
+ "|"
120
+ end
121
+
122
+ # Clamps the cursor to the valid range `[0, value.length]`.
123
+ def clamp_position
124
+ @cursor = cursor.clamp(0, value.length)
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end