charming 0.1.1 → 0.1.3

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +14 -3
  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 +110 -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 +25 -2
  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 +65 -115
  68. data/lib/charming/presentation/component.rb +3 -5
  69. data/lib/charming/presentation/components/activity_indicator.rb +173 -134
  70. data/lib/charming/presentation/components/command_palette.rb +94 -96
  71. data/lib/charming/presentation/components/command_palette_modal.rb +33 -0
  72. data/lib/charming/presentation/components/empty_state.rb +47 -36
  73. data/lib/charming/presentation/components/form/builder.rb +52 -40
  74. data/lib/charming/presentation/components/form/confirm.rb +50 -39
  75. data/lib/charming/presentation/components/form/field.rb +94 -71
  76. data/lib/charming/presentation/components/form/input.rb +61 -49
  77. data/lib/charming/presentation/components/form/note.rb +27 -20
  78. data/lib/charming/presentation/components/form/select.rb +84 -63
  79. data/lib/charming/presentation/components/form/textarea.rb +67 -53
  80. data/lib/charming/presentation/components/form.rb +120 -93
  81. data/lib/charming/presentation/components/keyboard_handler.rb +41 -43
  82. data/lib/charming/presentation/components/list.rb +123 -97
  83. data/lib/charming/presentation/components/markdown.rb +21 -17
  84. data/lib/charming/presentation/components/modal.rb +55 -43
  85. data/lib/charming/presentation/components/progressbar.rb +61 -50
  86. data/lib/charming/presentation/components/spinner.rb +35 -27
  87. data/lib/charming/presentation/components/table.rb +108 -85
  88. data/lib/charming/presentation/components/text_area.rb +219 -173
  89. data/lib/charming/presentation/components/text_input.rb +120 -98
  90. data/lib/charming/presentation/components/viewport.rb +218 -168
  91. data/lib/charming/presentation/layout/builder.rb +84 -0
  92. data/lib/charming/presentation/layout/overlay.rb +55 -0
  93. data/lib/charming/presentation/layout/pane.rb +149 -0
  94. data/lib/charming/presentation/layout/rect.rb +21 -0
  95. data/lib/charming/presentation/layout/screen_layout.rb +58 -0
  96. data/lib/charming/presentation/layout/split.rb +132 -0
  97. data/lib/charming/presentation/layout.rb +28 -30
  98. data/lib/charming/presentation/markdown/block_renderers.rb +118 -0
  99. data/lib/charming/presentation/markdown/inline_renderers.rb +66 -0
  100. data/lib/charming/presentation/markdown/render_context.rb +20 -0
  101. data/lib/charming/presentation/markdown/renderer.rb +82 -174
  102. data/lib/charming/presentation/markdown/syntax_highlighter.rb +58 -44
  103. data/lib/charming/presentation/markdown.rb +4 -3
  104. data/lib/charming/presentation/template_view.rb +22 -17
  105. data/lib/charming/presentation/templates/erb_handler.rb +4 -6
  106. data/lib/charming/presentation/templates.rb +47 -32
  107. data/lib/charming/presentation/ui/ansi_codes.rb +87 -0
  108. data/lib/charming/presentation/ui/ansi_slicer.rb +92 -0
  109. data/lib/charming/presentation/ui/border.rb +24 -26
  110. data/lib/charming/presentation/ui/border_painter.rb +56 -0
  111. data/lib/charming/presentation/ui/canvas.rb +80 -0
  112. data/lib/charming/presentation/ui/style.rb +170 -205
  113. data/lib/charming/presentation/ui/theme.rb +133 -135
  114. data/lib/charming/presentation/ui/width.rb +12 -14
  115. data/lib/charming/presentation/ui.rb +70 -213
  116. data/lib/charming/presentation/view.rb +107 -92
  117. data/lib/charming/runtime.rb +25 -10
  118. data/lib/charming/tasks/inline_executor.rb +9 -0
  119. data/lib/charming/tasks/task.rb +3 -0
  120. data/lib/charming/tasks/threaded_executor.rb +12 -0
  121. data/lib/charming/version.rb +1 -1
  122. data/lib/charming.rb +16 -2
  123. metadata +60 -10
  124. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
  125. data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
  126. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  127. data/lib/charming/generators/app_generator/controller_template.rb +0 -60
  128. data/lib/charming/generators/app_generator/database_templates.rb +0 -45
  129. data/lib/charming/generators/app_generator/layout_template.rb +0 -66
  130. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
  131. data/lib/charming/generators/app_generator/state_templates.rb +0 -30
  132. data/lib/charming/generators/app_generator/view_template.rb +0 -84
@@ -1,104 +1,126 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- module Presentation
5
- module Components
6
- class TextInput < Component
7
- include KeyboardHandler
8
-
9
- # Maps editing keys (left/right/home/end/backspace/delete) to the instance
10
- # methods they dispatch via KeyboardHandler. Each symbol key (e.g., :left)
11
- # maps to a method (e.g., :move_left) that adjusts cursor position or text content.
12
- KEY_ACTIONS = {
13
- left: :move_left,
14
- right: :move_right,
15
- home: :move_home,
16
- end: :move_end,
17
- backspace: :delete_before_cursor,
18
- delete: :delete_at_cursor
19
- }.freeze
20
-
21
- attr_reader :value, :cursor
22
-
23
- def initialize(value: "", placeholder: "", width: nil, cursor: nil)
24
- super()
25
- @value = value.dup
26
- @placeholder = placeholder
27
- @width = width
28
- @cursor = cursor || @value.length
29
- clamp_position
30
- end
31
-
32
- def handle_key(event)
33
- return :handled if character_event?(event) && insert(event.char)
34
-
35
- super
36
- end
37
-
38
- def render
39
- rendered = render_value
40
- @width ? style.width(@width).render(rendered) : rendered
41
- end
42
-
43
- private
44
-
45
- attr_reader :placeholder
46
-
47
- def character_event?(event)
48
- event.respond_to?(:char) && event.char && event.char.length == 1 && printable?(event.char)
49
- end
50
-
51
- def printable?(char)
52
- !char.match?(/[[:cntrl:]]/)
53
- end
54
-
55
- def insert(char)
56
- @value = value[0...cursor] + char + value[cursor..]
57
- @cursor += char.length
58
- end
59
-
60
- def move_left
61
- @cursor -= 1 if cursor.positive?
62
- end
63
-
64
- def move_right
65
- @cursor += 1 if cursor < value.length
66
- end
67
-
68
- def move_home
69
- @cursor = 0
70
- end
71
-
72
- def move_end
73
- @cursor = value.length
74
- end
75
-
76
- def delete_before_cursor
77
- return if cursor.zero?
78
-
79
- @value = value[0...(cursor - 1)] + value[cursor..]
80
- @cursor -= 1
81
- end
82
-
83
- def delete_at_cursor
84
- return if cursor >= value.length
85
-
86
- @value = value[0...cursor] + value[(cursor + 1)..]
87
- end
88
-
89
- def render_value
90
- return cursor_marker + placeholder if value.empty?
91
-
92
- value[0...cursor] + cursor_marker + value[cursor..]
93
- end
94
-
95
- def cursor_marker
96
- "|"
97
- end
98
-
99
- def clamp_position
100
- @cursor = cursor.clamp(0, value.length)
101
- end
4
+ module Components
5
+ # TextInput is a single-line text editor component. Supports printable character insertion,
6
+ # cursor movement (left/right/home/end), and deletion (backspace/delete). The component
7
+ # exposes its `value` and `cursor` positions as reader methods; when an explicit `width:`
8
+ # is given, the rendered output is padded to that width via a UI::Style.
9
+ class TextInput < Component
10
+ include KeyboardHandler
11
+
12
+ # Maps editing keys (left/right/home/end/backspace/delete) to the instance
13
+ # methods they dispatch via KeyboardHandler. Each symbol key (e.g., :left)
14
+ # maps to a method (e.g., :move_left) that adjusts cursor position or text content.
15
+ KEY_ACTIONS = {
16
+ left: :move_left,
17
+ right: :move_right,
18
+ home: :move_home,
19
+ end: :move_end,
20
+ backspace: :delete_before_cursor,
21
+ delete: :delete_at_cursor
22
+ }.freeze
23
+
24
+ # The current input string and the byte offset of the cursor within it.
25
+ attr_reader :value, :cursor
26
+
27
+ # *value* is the initial text. *placeholder* is shown when the value is empty.
28
+ # *width* optionally constrains the rendered output width; *cursor* defaults to the end.
29
+ def initialize(value: "", placeholder: "", width: nil, cursor: nil)
30
+ super()
31
+ @value = value.dup
32
+ @placeholder = placeholder
33
+ @width = width
34
+ @cursor = cursor || @value.length
35
+ clamp_position
36
+ end
37
+
38
+ # Handles key events. Inserts printable characters, otherwise dispatches via KEY_ACTIONS.
39
+ # Returns :handled when the event was consumed, nil otherwise.
40
+ def handle_key(event)
41
+ return :handled if character_event?(event) && insert(event.char)
42
+
43
+ super
44
+ end
45
+
46
+ # Renders the value with a cursor marker. When *width* was given at construction, the
47
+ # output is padded to that width via the configured style.
48
+ def render
49
+ rendered = render_value
50
+ @width ? style.width(@width).render(rendered) : rendered
51
+ end
52
+
53
+ private
54
+
55
+ attr_reader :placeholder
56
+
57
+ # True when *event* carries a single printable character that should be inserted.
58
+ def character_event?(event)
59
+ event.respond_to?(:char) && event.char && event.char.length == 1 && printable?(event.char)
60
+ end
61
+
62
+ # True when *char* is not a control character (and therefore safe to insert).
63
+ def printable?(char)
64
+ !char.match?(/[[:cntrl:]]/)
65
+ end
66
+
67
+ # Inserts *char* at the cursor and advances the cursor by its byte length.
68
+ def insert(char)
69
+ @value = value[0...cursor] + char + value[cursor..]
70
+ @cursor += char.length
71
+ end
72
+
73
+ # Moves the cursor one position left, when possible.
74
+ def move_left
75
+ @cursor -= 1 if cursor.positive?
76
+ end
77
+
78
+ # Moves the cursor one position right, when possible.
79
+ def move_right
80
+ @cursor += 1 if cursor < value.length
81
+ end
82
+
83
+ # Moves the cursor to the start of the value.
84
+ def move_home
85
+ @cursor = 0
86
+ end
87
+
88
+ # Moves the cursor to the end of the value.
89
+ def move_end
90
+ @cursor = value.length
91
+ end
92
+
93
+ # Deletes the character before the cursor (backspace behavior).
94
+ def delete_before_cursor
95
+ return if cursor.zero?
96
+
97
+ @value = value[0...(cursor - 1)] + value[cursor..]
98
+ @cursor -= 1
99
+ end
100
+
101
+ # Deletes the character at the cursor (delete-key behavior).
102
+ def delete_at_cursor
103
+ return if cursor >= value.length
104
+
105
+ @value = value[0...cursor] + value[(cursor + 1)..]
106
+ end
107
+
108
+ # Renders the value with a "|" cursor marker at the current position. When the value is
109
+ # empty, the placeholder is rendered instead, preceded by the cursor marker.
110
+ def render_value
111
+ return cursor_marker + placeholder if value.empty?
112
+
113
+ value[0...cursor] + cursor_marker + value[cursor..]
114
+ end
115
+
116
+ # The literal character used to mark the cursor position in `render`.
117
+ def cursor_marker
118
+ "|"
119
+ end
120
+
121
+ # Clamps the cursor to the valid range `[0, value.length]`.
122
+ def clamp_position
123
+ @cursor = cursor.clamp(0, value.length)
102
124
  end
103
125
  end
104
126
  end
@@ -3,217 +3,267 @@
3
3
  require "unicode/display_width"
4
4
 
5
5
  module Charming
6
- module Presentation
7
- module Components
8
- class Viewport < Component
9
- include KeyboardHandler
10
-
11
- ANSI_PATTERN = /\e\[[0-9;]*m/
12
- KEY_ACTIONS = {
13
- up: :scroll_up,
14
- down: :scroll_down,
15
- page_up: :page_up,
16
- page_down: :page_down,
17
- home: :scroll_home,
18
- end: :scroll_end,
19
- left: :scroll_left,
20
- right: :scroll_right
21
- }.freeze
22
-
23
- attr_reader :offset, :column
24
-
25
- def initialize(content:, width: nil, height: nil, offset: 0, column: 0, wrap: false, keymap: :vim)
26
- super()
27
- @content = content
28
- @width = width
29
- @height = height
30
- @offset = offset
31
- @column = column
32
- @wrap = wrap
33
- @keymap = keymap
34
- clamp_position
35
- end
6
+ module Components
7
+ # Viewport is a scrollable region over multi-line content. Supports keyboard scrolling
8
+ # (up/down/left/right, page up/down, home/end) and mouse interactions (scroll wheel and
9
+ # click-to-position). Lines are clipped with ANSI awareness via `UI::ANSISlicer` so styled
10
+ # text is preserved across horizontal scrolls. When `wrap:` is true, long lines are wrapped
11
+ # to the configured *width* before scrolling.
12
+ class Viewport < Component
13
+ include KeyboardHandler
14
+
15
+ # Matches an ANSI SGR escape sequence (e.g., "\e[31m" for red foreground).
16
+ ANSI_PATTERN = /\e\[[0-9;]*m/
17
+
18
+ # Maps scroll keys to the instance methods that perform them via KeyboardHandler.
19
+ KEY_ACTIONS = {
20
+ up: :scroll_up,
21
+ down: :scroll_down,
22
+ page_up: :page_up,
23
+ page_down: :page_down,
24
+ home: :scroll_home,
25
+ end: :scroll_end,
26
+ left: :scroll_left,
27
+ right: :scroll_right
28
+ }.freeze
29
+
30
+ # The current top-visible row and left-visible column, respectively.
31
+ attr_reader :offset, :column
32
+
33
+ # *content* may be a string, an array of lines, or any object responding to `render`.
34
+ # *width* and *height* constrain the visible window; *offset* is the top-visible row
35
+ # and *column* is the left-visible column. *wrap* enables soft-wrapping of long lines.
36
+ def initialize(content:, width: nil, height: nil, offset: 0, column: 0, wrap: false, keymap: :vim)
37
+ super()
38
+ @content = content
39
+ @width = width
40
+ @height = height
41
+ @offset = offset
42
+ @column = column
43
+ @wrap = wrap
44
+ @keymap = keymap
45
+ clamp_position
46
+ end
36
47
 
37
- def render
38
- visible_lines.map { |line| render_line(line) }.join("\n")
39
- end
48
+ # Renders the visible window of content as a multi-line string.
49
+ def render
50
+ visible_lines.map { |line| render_line(line) }.join("\n")
51
+ end
40
52
 
41
- def handle_mouse(event)
42
- return nil unless height
53
+ # Handles mouse events: scroll wheel adjusts the row offset, click moves the top
54
+ # visible row to the clicked position. Returns :handled on success.
55
+ def handle_mouse(event)
56
+ return nil unless height
43
57
 
44
- if event.scroll?
45
- scroll_delta = (event.button_name == :scroll_up) ? -1 : 1
46
- @offset += scroll_delta
47
- clamp_position
48
- return :handled
49
- end
58
+ if event.scroll?
59
+ scroll_delta = (event.button_name == :scroll_up) ? -1 : 1
60
+ @offset += scroll_delta
61
+ clamp_position
62
+ return :handled
63
+ end
50
64
 
51
- return nil unless event.click?
65
+ return nil unless event.click?
52
66
 
53
- clicked_row = event.y
54
- return nil if clicked_row < offset || clicked_row >= offset + viewport_height
67
+ clicked_row = event.y
68
+ return nil if clicked_row < offset || clicked_row >= offset + viewport_height
55
69
 
56
- @offset = clicked_row
57
- clamp_position
58
- :handled
59
- end
70
+ @offset = clicked_row
71
+ clamp_position
72
+ :handled
73
+ end
60
74
 
61
- private
75
+ private
62
76
 
63
- attr_reader :content, :width, :height
77
+ attr_reader :content, :width, :height
64
78
 
65
- def scroll_up
66
- @offset -= 1
67
- clamp_position
68
- end
79
+ # Scrolls the viewport up by one row.
80
+ def scroll_up
81
+ @offset -= 1
82
+ clamp_position
83
+ end
69
84
 
70
- def scroll_down
71
- @offset += 1
72
- clamp_position
73
- end
85
+ # Scrolls the viewport down by one row.
86
+ def scroll_down
87
+ @offset += 1
88
+ clamp_position
89
+ end
74
90
 
75
- def page_up
76
- @offset -= page_size
77
- clamp_position
78
- end
91
+ # Scrolls up by one viewport page.
92
+ def page_up
93
+ @offset -= page_size
94
+ clamp_position
95
+ end
79
96
 
80
- def page_down
81
- @offset += page_size
82
- clamp_position
83
- end
97
+ # Scrolls down by one viewport page.
98
+ def page_down
99
+ @offset += page_size
100
+ clamp_position
101
+ end
84
102
 
85
- def scroll_home
86
- @offset = 0
87
- @column = 0
88
- end
103
+ # Scrolls to the top-left of the content.
104
+ def scroll_home
105
+ @offset = 0
106
+ @column = 0
107
+ end
89
108
 
90
- def scroll_end
91
- @offset = max_offset
92
- @column = max_column
93
- end
109
+ # Scrolls to the bottom-right of the content.
110
+ def scroll_end
111
+ @offset = max_offset
112
+ @column = max_column
113
+ end
94
114
 
95
- def scroll_left
96
- @column -= 1
97
- clamp_position
98
- end
115
+ # Scrolls one column left.
116
+ def scroll_left
117
+ @column -= 1
118
+ clamp_position
119
+ end
99
120
 
100
- def scroll_right
101
- @column += 1
102
- clamp_position
103
- end
121
+ # Scrolls one column right.
122
+ def scroll_right
123
+ @column += 1
124
+ clamp_position
125
+ end
104
126
 
105
- def clamp_position
106
- @offset = offset.clamp(0, max_offset)
107
- @column = column.clamp(0, max_column)
108
- end
127
+ # Clamps both the row offset and the column to their valid ranges.
128
+ def clamp_position
129
+ @offset = offset.clamp(0, max_offset)
130
+ @column = column.clamp(0, max_column)
131
+ end
109
132
 
110
- def visible_lines
111
- lines = content_lines.slice(offset, viewport_height) || []
112
- return lines unless height
133
+ # Returns the slice of content lines visible in the current viewport, padded to *height*.
134
+ def visible_lines
135
+ lines = content_lines.slice(offset, viewport_height) || []
136
+ return lines unless height
113
137
 
114
- lines + Array.new([height - lines.length, 0].max, "")
115
- end
138
+ lines + Array.new([height - lines.length, 0].max, "")
139
+ end
116
140
 
117
- def render_line(line)
118
- return line unless width
119
- return pad_line(line, width) if wrap?
141
+ # Renders a single line according to the configured width and wrap mode: clips to the
142
+ # visible column window when not wrapping, otherwise wraps the line to the width.
143
+ def render_line(line)
144
+ return line unless width
145
+ return pad_line(line, width) if wrap?
120
146
 
121
- pad_line(clip_line(line), width)
122
- end
147
+ pad_line(clip_line(line), width)
148
+ end
123
149
 
124
- def clip_line(line)
125
- clipped = clip_tokens(line.to_s)
126
- needs_reset?(clipped) ? "#{clipped}\e[0m" : clipped
127
- end
150
+ # Clips *line* to the visible column window while preserving active ANSI styling.
151
+ def clip_line(line)
152
+ clipped = clip_tokens(line.to_s)
153
+ needs_reset?(clipped) ? "#{clipped}\e[0m" : clipped
154
+ end
128
155
 
129
- def clip_tokens(line)
130
- state = {cursor: 0, output: +""}
131
- line.scan(/#{ANSI_PATTERN}|./mo) do |token|
132
- ansi?(token) ? append_ansi(state, token) : append_character(state, token)
133
- end
134
- state.fetch(:output)
156
+ # Walks *line* token-by-token, copying ANSI escapes through and emitting only the
157
+ # characters that fall inside the visible column window.
158
+ def clip_tokens(line)
159
+ state = {cursor: 0, output: +""}
160
+ line.scan(/#{ANSI_PATTERN}|./mo) do |token|
161
+ ansi?(token) ? append_ansi(state, token) : append_character(state, token)
135
162
  end
163
+ state.fetch(:output)
164
+ end
136
165
 
137
- def append_ansi(state, token)
138
- state.fetch(:output) << token
139
- end
166
+ # Appends an ANSI escape token to the output buffer unchanged.
167
+ def append_ansi(state, token)
168
+ state.fetch(:output) << token
169
+ end
140
170
 
141
- def append_character(state, char)
142
- char_width = Unicode::DisplayWidth.of(char)
143
- cursor = state.fetch(:cursor)
144
- state.fetch(:output) << char if visible?(cursor, char_width)
145
- state[:cursor] = cursor + char_width
146
- end
171
+ # Appends a single character token to the output buffer when it falls inside the
172
+ # visible column window, advancing the visual cursor.
173
+ def append_character(state, char)
174
+ char_width = Unicode::DisplayWidth.of(char)
175
+ cursor = state.fetch(:cursor)
176
+ state.fetch(:output) << char if visible?(cursor, char_width)
177
+ state[:cursor] = cursor + char_width
178
+ end
147
179
 
148
- def visible?(cursor, char_width)
149
- cursor >= column && cursor + char_width <= column + width
150
- end
180
+ # True when the character at *cursor* (with the given display *char_width*) is within
181
+ # the visible column window.
182
+ def visible?(cursor, char_width)
183
+ cursor >= column && cursor + char_width <= column + width
184
+ end
151
185
 
152
- def needs_reset?(value)
153
- value.match?(ANSI_PATTERN) && !value.end_with?("\e[0m")
154
- end
186
+ # True when *value* contains ANSI codes but does not end with a reset — needed because
187
+ # the clip may truncate styling in the middle of a styled run.
188
+ def needs_reset?(value)
189
+ value.match?(ANSI_PATTERN) && !value.end_with?("\e[0m")
190
+ end
155
191
 
156
- def pad_line(line, target_width)
157
- line + (" " * [target_width - UI::Width.measure(line), 0].max)
158
- end
192
+ # Pads *line* to *target_width* with trailing spaces, leaving the line itself unchanged.
193
+ def pad_line(line, target_width)
194
+ line + (" " * [target_width - UI::Width.measure(line), 0].max)
195
+ end
159
196
 
160
- def content_lines
161
- return wrapped_content_lines if wrap?
197
+ # Returns the content lines, wrapped to *width* when wrap is enabled.
198
+ def content_lines
199
+ return wrapped_content_lines if wrap?
162
200
 
163
- rendered_content.lines(chomp: true)
164
- end
201
+ rendered_content.lines(chomp: true)
202
+ end
165
203
 
166
- def wrapped_content_lines
167
- rendered_content.lines(chomp: true).flat_map { |line| wrap_line(line) }
168
- end
204
+ # Wraps the content to *width* via UI::visible_slice, returning an array of wrapped lines.
205
+ def wrapped_content_lines
206
+ rendered_content.lines(chomp: true).flat_map { |line| wrap_line(line) }
207
+ end
169
208
 
170
- def wrap_line(line)
171
- line_width = UI::Width.measure(line)
172
- return [""] if line_width.zero?
173
-
174
- start_column = 0
175
- out = []
176
- while start_column < line_width
177
- out << UI.visible_slice(line, start_column, width)
178
- start_column += width
179
- end
180
- out
181
- end
209
+ # Wraps a single *line* into chunks of *width* display columns.
210
+ def wrap_line(line)
211
+ line_width = UI::Width.measure(line)
212
+ return [""] if line_width.zero?
182
213
 
183
- def rendered_content
184
- content.respond_to?(:render) ? content.render.to_s : content.to_s
214
+ start_column = 0
215
+ out = []
216
+ while start_column < line_width
217
+ out << UI.visible_slice(line, start_column, width)
218
+ start_column += width
185
219
  end
220
+ out
221
+ end
186
222
 
187
- def viewport_height
188
- height || content_lines.length
189
- end
223
+ # Returns the rendered content string, calling `render.to_s` on the content object when
224
+ # it responds to render.
225
+ def rendered_content
226
+ content.respond_to?(:render) ? content.render.to_s : content.to_s
227
+ end
190
228
 
191
- def page_size
192
- [viewport_height, 1].max
193
- end
229
+ # Returns the visible row count (the configured *height* or the content's line count).
230
+ def viewport_height
231
+ height || content_lines.length
232
+ end
194
233
 
195
- def max_offset
196
- [content_lines.length - viewport_height, 0].max
197
- end
234
+ # Returns the number of rows to advance on a page up/down: at least 1, otherwise the
235
+ # viewport height.
236
+ def page_size
237
+ [viewport_height, 1].max
238
+ end
198
239
 
199
- def max_column
200
- return 0 if wrap?
201
- return 0 unless width
240
+ # Returns the maximum allowed row offset (so the bottom of the content is reachable).
241
+ def max_offset
242
+ [content_lines.length - viewport_height, 0].max
243
+ end
202
244
 
203
- [content_width - width, 0].max
204
- end
245
+ # Returns the maximum allowed column offset. Returns 0 when wrapping is enabled or
246
+ # when no width is configured.
247
+ def max_column
248
+ return 0 if wrap?
249
+ return 0 unless width
205
250
 
206
- def content_width
207
- content_lines.map { |line| UI::Width.measure(line) }.max || 0
208
- end
251
+ [content_width - width, 0].max
252
+ end
209
253
 
210
- def ansi?(token)
211
- token.match?(ANSI_PATTERN)
212
- end
254
+ # Returns the maximum display width across all content lines.
255
+ def content_width
256
+ content_lines.map { |line| UI::Width.measure(line) }.max || 0
257
+ end
213
258
 
214
- def wrap?
215
- @wrap && width&.positive?
216
- end
259
+ # True when *token* is an ANSI escape sequence.
260
+ def ansi?(token)
261
+ token.match?(ANSI_PATTERN)
262
+ end
263
+
264
+ # True when soft-wrapping is enabled and a positive width is configured.
265
+ def wrap?
266
+ @wrap && width&.positive?
217
267
  end
218
268
  end
219
269
  end