charming 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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +11 -0
  4. data/lib/charming/cli.rb +23 -0
  5. data/lib/charming/controller/class_methods.rb +115 -0
  6. data/lib/charming/controller/command_palette.rb +135 -0
  7. data/lib/charming/controller/component_dispatching.rb +81 -0
  8. data/lib/charming/controller/dispatching.rb +60 -0
  9. data/lib/charming/controller/focus_management.rb +30 -0
  10. data/lib/charming/controller/rendering.rb +127 -0
  11. data/lib/charming/controller/session_state.rb +41 -0
  12. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  13. data/lib/charming/controller.rb +35 -559
  14. data/lib/charming/database_commands.rb +16 -0
  15. data/lib/charming/database_installer.rb +27 -0
  16. data/lib/charming/focus.rb +58 -2
  17. data/lib/charming/generators/app_file_generator.rb +13 -0
  18. data/lib/charming/generators/app_generator.rb +123 -47
  19. data/lib/charming/generators/base.rb +26 -0
  20. data/lib/charming/generators/component_generator.rb +10 -10
  21. data/lib/charming/generators/controller_generator.rb +22 -11
  22. data/lib/charming/generators/model_generator.rb +38 -29
  23. data/lib/charming/generators/name.rb +10 -0
  24. data/lib/charming/generators/screen_generator.rb +78 -32
  25. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  26. data/lib/charming/generators/templates/app/README.md.template +9 -0
  27. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  28. data/lib/charming/generators/templates/app/application.template +13 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  30. data/lib/charming/generators/templates/app/application_record.template +7 -0
  31. data/lib/charming/generators/templates/app/application_state.template +6 -0
  32. data/lib/charming/generators/templates/app/database_config.template +12 -0
  33. data/lib/charming/generators/templates/app/executable.template +7 -0
  34. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  35. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  36. data/lib/charming/generators/templates/app/home_state.template +7 -0
  37. data/lib/charming/generators/templates/app/keep.template +0 -0
  38. data/lib/charming/generators/templates/app/layout.template +113 -0
  39. data/lib/charming/generators/templates/app/root_file.template +20 -0
  40. data/lib/charming/generators/templates/app/routes.template +5 -0
  41. data/lib/charming/generators/templates/app/seeds.template +1 -0
  42. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  43. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  44. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  45. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  46. data/lib/charming/generators/templates/app/version.template +5 -0
  47. data/lib/charming/generators/templates/app/view.template +21 -0
  48. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  49. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  50. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  51. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  52. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  53. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  54. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  55. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  56. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  57. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  58. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  59. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  60. data/lib/charming/generators/view_generator.rb +19 -3
  61. data/lib/charming/internal/renderer/differential.rb +15 -0
  62. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  63. data/lib/charming/internal/terminal/adapter.rb +29 -3
  64. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  65. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  66. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  67. data/lib/charming/internal/terminal/tty_backend.rb +43 -113
  68. data/lib/charming/presentation/components/empty_state.rb +13 -0
  69. data/lib/charming/presentation/components/form/builder.rb +14 -0
  70. data/lib/charming/presentation/components/form/confirm.rb +13 -0
  71. data/lib/charming/presentation/components/form/field.rb +25 -0
  72. data/lib/charming/presentation/components/form/input.rb +14 -0
  73. data/lib/charming/presentation/components/form/note.rb +9 -0
  74. data/lib/charming/presentation/components/form/select.rb +23 -0
  75. data/lib/charming/presentation/components/form/textarea.rb +16 -0
  76. data/lib/charming/presentation/components/form.rb +29 -0
  77. data/lib/charming/presentation/components/list.rb +28 -0
  78. data/lib/charming/presentation/components/markdown.rb +6 -0
  79. data/lib/charming/presentation/components/modal.rb +14 -0
  80. data/lib/charming/presentation/components/progressbar.rb +13 -0
  81. data/lib/charming/presentation/components/spinner.rb +10 -0
  82. data/lib/charming/presentation/components/table.rb +25 -0
  83. data/lib/charming/presentation/components/text_area.rb +48 -0
  84. data/lib/charming/presentation/components/text_input.rb +24 -0
  85. data/lib/charming/presentation/components/viewport.rb +52 -0
  86. data/lib/charming/presentation/layout/builder.rb +86 -0
  87. data/lib/charming/presentation/layout/overlay.rb +57 -0
  88. data/lib/charming/presentation/layout/pane.rb +145 -0
  89. data/lib/charming/presentation/layout/rect.rb +23 -0
  90. data/lib/charming/presentation/layout/screen_layout.rb +60 -0
  91. data/lib/charming/presentation/layout/split.rb +134 -0
  92. data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
  93. data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
  94. data/lib/charming/presentation/markdown/render_context.rb +22 -0
  95. data/lib/charming/presentation/markdown/renderer.rb +45 -135
  96. data/lib/charming/presentation/markdown/syntax_highlighter.rb +16 -0
  97. data/lib/charming/presentation/markdown.rb +3 -0
  98. data/lib/charming/presentation/template_view.rb +7 -0
  99. data/lib/charming/presentation/templates.rb +17 -0
  100. data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
  101. data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
  102. data/lib/charming/presentation/ui/border_painter.rb +58 -0
  103. data/lib/charming/presentation/ui/canvas.rb +82 -0
  104. data/lib/charming/presentation/ui/style.rb +62 -95
  105. data/lib/charming/presentation/ui.rb +15 -156
  106. data/lib/charming/presentation/view.rb +17 -0
  107. data/lib/charming/runtime.rb +2 -0
  108. data/lib/charming/tasks/inline_executor.rb +9 -0
  109. data/lib/charming/tasks/task.rb +3 -0
  110. data/lib/charming/tasks/threaded_executor.rb +12 -0
  111. data/lib/charming/version.rb +1 -1
  112. data/lib/charming.rb +13 -0
  113. metadata +59 -10
  114. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
  115. data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
  116. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  117. data/lib/charming/generators/app_generator/controller_template.rb +0 -60
  118. data/lib/charming/generators/app_generator/database_templates.rb +0 -45
  119. data/lib/charming/generators/app_generator/layout_template.rb +0 -66
  120. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
  121. data/lib/charming/generators/app_generator/state_templates.rb +0 -30
  122. data/lib/charming/generators/app_generator/view_template.rb +0 -84
@@ -3,9 +3,17 @@
3
3
  module Charming
4
4
  module Presentation
5
5
  module Components
6
+ # Progressbar renders a fixed-width ASCII progress bar. The bar is sized to the configured
7
+ # *total* (in arbitrary units) and fills proportionally to the current value. Optionally
8
+ # appends a label after the bar.
6
9
  class Progressbar < Component
10
+ # Public accessors: total units, current value, label text, completed and remaining
11
+ # characters, and the bar format symbol.
7
12
  attr_accessor :total, :current, :label, :complete, :incomplete, :bar_format
8
13
 
14
+ # *total* is the maximum unit count. *complete* and *incomplete* are the characters used
15
+ # for filled and unfilled positions (default "=" and " "). *bar_format* is reserved for
16
+ # future format variants. *label* is an optional suffix shown after the bar.
9
17
  def initialize(total:, complete: "=", incomplete: " ", bar_format: :classic, label: nil)
10
18
  super()
11
19
  @total = [total.to_i, 0].max
@@ -16,21 +24,25 @@ module Charming
16
24
  @current = 0
17
25
  end
18
26
 
27
+ # Advances the current value by *count* (default 1), clamping to `[0, total]`. Returns self.
19
28
  def tick(count = 1)
20
29
  update(@current + count)
21
30
  self
22
31
  end
23
32
 
33
+ # Sets the current value, clamping to `[0, total]`. Returns self.
24
34
  def update(value)
25
35
  @current = value.to_i.clamp(0, @total)
26
36
  self
27
37
  end
28
38
 
39
+ # Jumps the bar directly to 100% completion. Returns self.
29
40
  def complete!
30
41
  @current = @total
31
42
  self
32
43
  end
33
44
 
45
+ # Renders the bar as `[==== ]` (with the *label* appended when present).
34
46
  def render
35
47
  width = [@total, 1].max
36
48
  completed = completed_width(width)
@@ -46,6 +58,7 @@ module Charming
46
58
 
47
59
  private
48
60
 
61
+ # Returns the number of `complete` characters to draw, rounded to the nearest integer.
49
62
  def completed_width(width)
50
63
  return 0 unless @total.positive?
51
64
 
@@ -3,11 +3,18 @@
3
3
  module Charming
4
4
  module Presentation
5
5
  module Components
6
+ # Spinner is a simple rotating-frame indicator. The component cycles through a list of
7
+ # frames on each `tick`; pair it with a controller timer to drive animation. An optional
8
+ # *label* is appended after the current frame on each render.
6
9
  class Spinner < Component
10
+ # The default frame set: a 4-frame ASCII spinner.
7
11
  DEFAULT_FRAMES = ["-", "\\", "|", "/"].freeze
8
12
 
13
+ # The current frame list, frame index, and optional label string.
9
14
  attr_reader :frames, :index, :label
10
15
 
16
+ # *frames* defaults to DEFAULT_FRAMES but may be replaced with any array of frame strings.
17
+ # *index* is the starting frame index. *label* is an optional suffix shown after the frame.
11
18
  def initialize(frames: DEFAULT_FRAMES, index: 0, label: nil)
12
19
  super()
13
20
  raise ArgumentError, "frames cannot be empty" if frames.empty?
@@ -17,11 +24,13 @@ module Charming
17
24
  @label = label
18
25
  end
19
26
 
27
+ # Advances the frame index by one position, wrapping around. Returns self for chaining.
20
28
  def tick
21
29
  @index = (index + 1) % frames.length
22
30
  self
23
31
  end
24
32
 
33
+ # Renders the current frame, optionally followed by the label and a space.
25
34
  def render
26
35
  return frame unless label
27
36
 
@@ -30,6 +39,7 @@ module Charming
30
39
 
31
40
  private
32
41
 
42
+ # Returns the current frame string (with index modulo frame count to be safe).
33
43
  def frame
34
44
  frames.fetch(index % frames.length)
35
45
  end
@@ -5,9 +5,14 @@ require "tty-table"
5
5
  module Charming
6
6
  module Presentation
7
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.
8
11
  class Table < Component
9
12
  include KeyboardHandler
10
13
 
14
+ # Maps navigation keys to the instance methods that move the selection. Shared with
15
+ # List and Viewport via KeyboardHandler.
11
16
  KEY_ACTIONS = {
12
17
  up: :move_up,
13
18
  down: :move_down,
@@ -15,10 +20,16 @@ module Charming
15
20
  end: :move_end
16
21
  }.freeze
17
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.
18
25
  HEADER_HEIGHT = 2
19
26
 
27
+ # The header row, the body rows, and the currently selected row index, respectively.
20
28
  attr_reader :header, :rows, :selected_index
21
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).
22
33
  def initialize(header:, rows: [], selected_index: 0, keymap: :vim)
23
34
  super()
24
35
  @header = Array(header).map(&:to_s)
@@ -27,6 +38,8 @@ module Charming
27
38
  @keymap = keymap
28
39
  end
29
40
 
41
+ # Handles key events. Returns `[:selected, row]` on Enter; otherwise delegates to the
42
+ # KeyboardHandler for navigation keys.
30
43
  def handle_key(event)
31
44
  return nil if rows.empty?
32
45
 
@@ -36,6 +49,8 @@ module Charming
36
49
  end
37
50
  end
38
51
 
52
+ # Handles mouse events: a click within the body area selects the clicked row.
53
+ # Returns :handled on a successful click.
39
54
  def handle_mouse(event)
40
55
  return nil if rows.empty?
41
56
  return nil unless event.respond_to?(:click?) && event.click?
@@ -47,10 +62,12 @@ module Charming
47
62
  :handled
48
63
  end
49
64
 
65
+ # Returns the currently selected row, or nil when the table is empty.
50
66
  def selected_row
51
67
  rows[selected_index]
52
68
  end
53
69
 
70
+ # Renders the table to a string. Returns a placeholder when both header and rows are empty.
54
71
  def render
55
72
  return "(empty table)" if header.empty? && rows.empty?
56
73
 
@@ -64,6 +81,8 @@ module Charming
64
81
 
65
82
  private
66
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.
67
86
  def normalize_row(row)
68
87
  cells = case row
69
88
  when Hash then row.values
@@ -77,6 +96,7 @@ module Charming
77
96
  kept + [merged]
78
97
  end
79
98
 
99
+ # Applies the selected-row highlight and trims unused body rows below the actual row count.
80
100
  def compact_layout(lines)
81
101
  return lines.join("\n") if lines.length < 4
82
102
 
@@ -91,22 +111,27 @@ module Charming
91
111
  [top, header_line, *highlighted, bottom].compact.join("\n")
92
112
  end
93
113
 
114
+ # Moves the selection up one row.
94
115
  def move_up
95
116
  @selected_index -= 1 if selected_index.positive?
96
117
  end
97
118
 
119
+ # Moves the selection down one row.
98
120
  def move_down
99
121
  @selected_index += 1 if selected_index < rows.length - 1
100
122
  end
101
123
 
124
+ # Moves the selection to the first row.
102
125
  def move_home
103
126
  @selected_index = 0
104
127
  end
105
128
 
129
+ # Moves the selection to the last row.
106
130
  def move_end
107
131
  @selected_index = rows.length - 1
108
132
  end
109
133
 
134
+ # Clamps *value* to the valid row range, defaulting to 0 when the table is empty.
110
135
  def clamp_index(value)
111
136
  return 0 if rows.empty?
112
137
 
@@ -3,9 +3,19 @@
3
3
  module Charming
4
4
  module Presentation
5
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.
6
10
  class TextArea < Component
11
+ # The current text value, cursor byte offset, top-visible row offset, and remembered
12
+ # column for vertical navigation, respectively.
7
13
  attr_reader :value, :cursor, :offset, :preferred_column
8
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).
9
19
  def initialize(value: "", placeholder: "", width: nil, height: nil, cursor: nil, offset: 0, preferred_column: nil)
10
20
  super()
11
21
  @value = value.dup
@@ -19,6 +29,8 @@ module Charming
19
29
  ensure_cursor_visible
20
30
  end
21
31
 
32
+ # Routes key events to the appropriate cursor/text mutation. Returns :handled when the
33
+ # event was consumed, nil otherwise.
22
34
  def handle_key(event)
23
35
  key = Charming.key_of(event)
24
36
  return :handled if newline_event?(event) && insert("\n")
@@ -41,6 +53,8 @@ module Charming
41
53
  :handled
42
54
  end
43
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.
44
58
  def render
45
59
  visible_lines.map { |line| render_line(line) }.join("\n")
46
60
  end
@@ -49,6 +63,7 @@ module Charming
49
63
 
50
64
  attr_reader :placeholder, :width, :height
51
65
 
66
+ # True when the event represents an explicit newline request: Shift+Enter or Ctrl+J.
52
67
  def newline_event?(event)
53
68
  key = Charming.key_of(event)
54
69
  return true if key == :enter && event.respond_to?(:shift) && event.shift
@@ -57,14 +72,18 @@ module Charming
57
72
  false
58
73
  end
59
74
 
75
+ # True when *event* carries a single printable character.
60
76
  def character_event?(event)
61
77
  event.respond_to?(:char) && event.char && event.char.length == 1 && printable?(event.char)
62
78
  end
63
79
 
80
+ # True when *char* is not a control character.
64
81
  def printable?(char)
65
82
  !char.match?(/[[:cntrl:]]/)
66
83
  end
67
84
 
85
+ # Inserts *text* at the cursor, advances the cursor by its length, resets the preferred
86
+ # column, and ensures the cursor remains visible.
68
87
  def insert(text)
69
88
  @value = value[0...cursor].to_s + text + value[cursor..].to_s
70
89
  @cursor += text.length
@@ -72,26 +91,31 @@ module Charming
72
91
  ensure_cursor_visible
73
92
  end
74
93
 
94
+ # Moves the cursor one character left.
75
95
  def move_left
76
96
  @cursor -= 1 if cursor.positive?
77
97
  reset_preferred_column
78
98
  ensure_cursor_visible
79
99
  end
80
100
 
101
+ # Moves the cursor one character right.
81
102
  def move_right
82
103
  @cursor += 1 if cursor < value.length
83
104
  reset_preferred_column
84
105
  ensure_cursor_visible
85
106
  end
86
107
 
108
+ # Moves the cursor up one line while preserving the preferred column.
87
109
  def move_up
88
110
  move_vertical(-1)
89
111
  end
90
112
 
113
+ # Moves the cursor down one line while preserving the preferred column.
91
114
  def move_down
92
115
  move_vertical(+1)
93
116
  end
94
117
 
118
+ # Moves the cursor to the start of the current line.
95
119
  def move_home
96
120
  row, = cursor_position
97
121
  @cursor = line_start(row)
@@ -99,6 +123,7 @@ module Charming
99
123
  ensure_cursor_visible
100
124
  end
101
125
 
126
+ # Moves the cursor to the end of the current line.
102
127
  def move_end
103
128
  row, = cursor_position
104
129
  @cursor = line_start(row) + line_length(row)
@@ -106,6 +131,7 @@ module Charming
106
131
  ensure_cursor_visible
107
132
  end
108
133
 
134
+ # Deletes the character before the cursor (backspace behavior).
109
135
  def delete_before_cursor
110
136
  return if cursor.zero?
111
137
 
@@ -115,6 +141,7 @@ module Charming
115
141
  ensure_cursor_visible
116
142
  end
117
143
 
144
+ # Deletes the character at the cursor (delete-key behavior).
118
145
  def delete_at_cursor
119
146
  return if cursor >= value.length
120
147
 
@@ -123,16 +150,20 @@ module Charming
123
150
  ensure_cursor_visible
124
151
  end
125
152
 
153
+ # Scrolls the buffer up by one viewport height.
126
154
  def page_up
127
155
  @offset -= viewport_height
128
156
  clamp_offset
129
157
  end
130
158
 
159
+ # Scrolls the buffer down by one viewport height.
131
160
  def page_down
132
161
  @offset += viewport_height
133
162
  clamp_offset
134
163
  end
135
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.
136
167
  def move_vertical(delta)
137
168
  row, column = cursor_position
138
169
  target_row = (row + delta).clamp(0, lines.length - 1)
@@ -141,10 +172,13 @@ module Charming
141
172
  ensure_cursor_visible
142
173
  end
143
174
 
175
+ # Sets the preferred column to the current column (called when horizontal movement happens).
144
176
  def reset_preferred_column
145
177
  @preferred_column = cursor_position.last
146
178
  end
147
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.
148
182
  def cursor_position
149
183
  before = value[0...cursor].to_s
150
184
  row = before.count("\n")
@@ -153,24 +187,30 @@ module Charming
153
187
  [row, column]
154
188
  end
155
189
 
190
+ # Returns the byte offset where line *row* begins in the value.
156
191
  def line_start(row)
157
192
  lines.first(row).sum(&:length) + row
158
193
  end
159
194
 
195
+ # Returns the character length of the line at *row* (empty string when row is past the end).
160
196
  def line_length(row)
161
197
  lines.fetch(row, "").length
162
198
  end
163
199
 
200
+ # Splits the value into an array of lines (preserving trailing empty lines).
164
201
  def lines
165
202
  value.empty? ? [""] : value.split("\n", -1)
166
203
  end
167
204
 
205
+ # Returns the rendered lines (with cursor marker inserted) before viewport slicing.
168
206
  def rendered_lines
169
207
  return [cursor_marker + placeholder] if value.empty?
170
208
 
171
209
  (value[0...cursor].to_s + cursor_marker + value[cursor..].to_s).split("\n", -1)
172
210
  end
173
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.
174
214
  def visible_lines
175
215
  ensure_cursor_visible
176
216
  rendered = rendered_lines.slice(offset, viewport_height) || []
@@ -179,6 +219,7 @@ module Charming
179
219
  rendered + Array.new([height - rendered.length, 0].max, "")
180
220
  end
181
221
 
222
+ # Renders a single line, clipping to *width* and padding with spaces.
182
223
  def render_line(line)
183
224
  return line unless width
184
225
 
@@ -186,6 +227,8 @@ module Charming
186
227
  clipped + (" " * [width - UI::Width.measure(clipped), 0].max)
187
228
  end
188
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.
189
232
  def ensure_cursor_visible
190
233
  row, = cursor_position
191
234
  @offset = row if row < offset
@@ -193,23 +236,28 @@ module Charming
193
236
  clamp_offset
194
237
  end
195
238
 
239
+ # Clamps the cursor and offset to valid bounds.
196
240
  def clamp_position
197
241
  @cursor = cursor.clamp(0, value.length)
198
242
  clamp_offset
199
243
  end
200
244
 
245
+ # Clamps the offset to the valid range `[0, max_offset]`.
201
246
  def clamp_offset
202
247
  @offset = offset.clamp(0, max_offset)
203
248
  end
204
249
 
250
+ # Returns the maximum allowed offset (so the bottom of the buffer is reachable).
205
251
  def max_offset
206
252
  [lines.length - viewport_height, 0].max
207
253
  end
208
254
 
255
+ # Returns the visible row count (the configured *height* or the buffer's line count).
209
256
  def viewport_height
210
257
  height || lines.length
211
258
  end
212
259
 
260
+ # The literal character used to mark the cursor position in `rendered_lines`.
213
261
  def cursor_marker
214
262
  "|"
215
263
  end
@@ -3,6 +3,10 @@
3
3
  module Charming
4
4
  module Presentation
5
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.
6
10
  class TextInput < Component
7
11
  include KeyboardHandler
8
12
 
@@ -18,8 +22,11 @@ module Charming
18
22
  delete: :delete_at_cursor
19
23
  }.freeze
20
24
 
25
+ # The current input string and the byte offset of the cursor within it.
21
26
  attr_reader :value, :cursor
22
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.
23
30
  def initialize(value: "", placeholder: "", width: nil, cursor: nil)
24
31
  super()
25
32
  @value = value.dup
@@ -29,12 +36,16 @@ module Charming
29
36
  clamp_position
30
37
  end
31
38
 
39
+ # Handles key events. Inserts printable characters, otherwise dispatches via KEY_ACTIONS.
40
+ # Returns :handled when the event was consumed, nil otherwise.
32
41
  def handle_key(event)
33
42
  return :handled if character_event?(event) && insert(event.char)
34
43
 
35
44
  super
36
45
  end
37
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.
38
49
  def render
39
50
  rendered = render_value
40
51
  @width ? style.width(@width).render(rendered) : rendered
@@ -44,35 +55,43 @@ module Charming
44
55
 
45
56
  attr_reader :placeholder
46
57
 
58
+ # True when *event* carries a single printable character that should be inserted.
47
59
  def character_event?(event)
48
60
  event.respond_to?(:char) && event.char && event.char.length == 1 && printable?(event.char)
49
61
  end
50
62
 
63
+ # True when *char* is not a control character (and therefore safe to insert).
51
64
  def printable?(char)
52
65
  !char.match?(/[[:cntrl:]]/)
53
66
  end
54
67
 
68
+ # Inserts *char* at the cursor and advances the cursor by its byte length.
55
69
  def insert(char)
56
70
  @value = value[0...cursor] + char + value[cursor..]
57
71
  @cursor += char.length
58
72
  end
59
73
 
74
+ # Moves the cursor one position left, when possible.
60
75
  def move_left
61
76
  @cursor -= 1 if cursor.positive?
62
77
  end
63
78
 
79
+ # Moves the cursor one position right, when possible.
64
80
  def move_right
65
81
  @cursor += 1 if cursor < value.length
66
82
  end
67
83
 
84
+ # Moves the cursor to the start of the value.
68
85
  def move_home
69
86
  @cursor = 0
70
87
  end
71
88
 
89
+ # Moves the cursor to the end of the value.
72
90
  def move_end
73
91
  @cursor = value.length
74
92
  end
75
93
 
94
+ # Deletes the character before the cursor (backspace behavior).
76
95
  def delete_before_cursor
77
96
  return if cursor.zero?
78
97
 
@@ -80,22 +99,27 @@ module Charming
80
99
  @cursor -= 1
81
100
  end
82
101
 
102
+ # Deletes the character at the cursor (delete-key behavior).
83
103
  def delete_at_cursor
84
104
  return if cursor >= value.length
85
105
 
86
106
  @value = value[0...cursor] + value[(cursor + 1)..]
87
107
  end
88
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.
89
111
  def render_value
90
112
  return cursor_marker + placeholder if value.empty?
91
113
 
92
114
  value[0...cursor] + cursor_marker + value[cursor..]
93
115
  end
94
116
 
117
+ # The literal character used to mark the cursor position in `render`.
95
118
  def cursor_marker
96
119
  "|"
97
120
  end
98
121
 
122
+ # Clamps the cursor to the valid range `[0, value.length]`.
99
123
  def clamp_position
100
124
  @cursor = cursor.clamp(0, value.length)
101
125
  end