charming 0.2.0 → 0.2.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +96 -9
  4. data/lib/charming/audio/player.rb +104 -0
  5. data/lib/charming/audio/system.rb +69 -0
  6. data/lib/charming/cli.rb +63 -7
  7. data/lib/charming/controller/action_hooks.rb +124 -0
  8. data/lib/charming/controller/class_methods.rb +15 -1
  9. data/lib/charming/controller/dispatching.rb +31 -5
  10. data/lib/charming/controller/focus.rb +9 -0
  11. data/lib/charming/controller/focus_management.rb +0 -7
  12. data/lib/charming/controller/session_state.rb +16 -1
  13. data/lib/charming/controller/sidebar_navigation.rb +63 -28
  14. data/lib/charming/controller.rb +62 -10
  15. data/lib/charming/database/commands.rb +123 -11
  16. data/lib/charming/events/focus_event.rb +12 -0
  17. data/lib/charming/events/paste_event.rb +11 -0
  18. data/lib/charming/events/task_progress_event.rb +21 -0
  19. data/lib/charming/generators/app_generator.rb +38 -1
  20. data/lib/charming/generators/database_installer.rb +4 -15
  21. data/lib/charming/generators/migration_generator.rb +116 -0
  22. data/lib/charming/generators/migration_timestamp.rb +29 -0
  23. data/lib/charming/generators/model_generator.rb +4 -2
  24. data/lib/charming/generators/templates/app/application_controller.template +1 -1
  25. data/lib/charming/generators/templates/app/database_config.template +3 -1
  26. data/lib/charming/generators/templates/app/layout.template +1 -1
  27. data/lib/charming/generators/templates/app/spec_helper.template +2 -1
  28. data/lib/charming/generators/templates/app/view.template +1 -1
  29. data/lib/charming/internal/terminal/memory_backend.rb +6 -0
  30. data/lib/charming/internal/terminal/tty_backend.rb +64 -2
  31. data/lib/charming/presentation/component.rb +7 -0
  32. data/lib/charming/presentation/components/audio.rb +31 -0
  33. data/lib/charming/presentation/components/autocomplete.rb +108 -0
  34. data/lib/charming/presentation/components/badge.rb +31 -0
  35. data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
  36. data/lib/charming/presentation/components/command_palette.rb +8 -5
  37. data/lib/charming/presentation/components/error_screen.rb +72 -0
  38. data/lib/charming/presentation/components/form.rb +9 -0
  39. data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
  40. data/lib/charming/presentation/components/help_overlay.rb +65 -0
  41. data/lib/charming/presentation/components/markdown.rb +6 -2
  42. data/lib/charming/presentation/components/modal.rb +45 -5
  43. data/lib/charming/presentation/components/multi_select_list.rb +85 -0
  44. data/lib/charming/presentation/components/progressbar.rb +0 -1
  45. data/lib/charming/presentation/components/status_bar.rb +75 -0
  46. data/lib/charming/presentation/components/tab_bar.rb +103 -0
  47. data/lib/charming/presentation/components/table.rb +40 -9
  48. data/lib/charming/presentation/components/text_area.rb +47 -10
  49. data/lib/charming/presentation/components/text_input.rb +79 -4
  50. data/lib/charming/presentation/components/toast.rb +51 -0
  51. data/lib/charming/presentation/components/tree.rb +176 -0
  52. data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
  53. data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
  54. data/lib/charming/presentation/components/viewport/position.rb +67 -0
  55. data/lib/charming/presentation/components/viewport.rb +37 -122
  56. data/lib/charming/presentation/layout/builder.rb +4 -1
  57. data/lib/charming/presentation/layout/overlay.rb +6 -4
  58. data/lib/charming/presentation/layout/pane.rb +2 -1
  59. data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
  60. data/lib/charming/presentation/layout/screen_layout.rb +12 -3
  61. data/lib/charming/presentation/layout/split.rb +37 -3
  62. data/lib/charming/presentation/markdown/renderer.rb +99 -63
  63. data/lib/charming/presentation/markdown/style_config.rb +10 -5
  64. data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
  65. data/lib/charming/presentation/markdown/table_renderer.rb +60 -0
  66. data/lib/charming/presentation/markdown/text_wrapper.rb +40 -0
  67. data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
  68. data/lib/charming/presentation/templates/erb_handler.rb +35 -2
  69. data/lib/charming/presentation/ui/ansi_codes.rb +11 -0
  70. data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
  71. data/lib/charming/presentation/ui/color_support.rb +129 -0
  72. data/lib/charming/presentation/ui/theme.rb +7 -0
  73. data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
  74. data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
  75. data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
  76. data/lib/charming/presentation/ui/themes/nord.json +32 -0
  77. data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
  78. data/lib/charming/presentation/ui/width.rb +27 -2
  79. data/lib/charming/router.rb +1 -1
  80. data/lib/charming/runtime.rb +122 -15
  81. data/lib/charming/tasks/cancelled.rb +11 -0
  82. data/lib/charming/tasks/inline_executor.rb +10 -4
  83. data/lib/charming/tasks/progress.rb +30 -0
  84. data/lib/charming/tasks/task.rb +24 -4
  85. data/lib/charming/tasks/threaded_executor.rb +35 -11
  86. data/lib/charming/test_helper.rb +120 -0
  87. data/lib/charming/version.rb +1 -1
  88. data/lib/charming.rb +43 -1
  89. metadata +36 -49
@@ -2,10 +2,11 @@
2
2
 
3
3
  module Charming
4
4
  module Components
5
- # TextArea is a multi-line text editor component. Supports character insertion (with
6
- # newline insertion via Shift+Enter or Ctrl+J), cursor movement (left/right/up/down,
7
- # home/end, page up/down), deletion (backspace/delete), and scrolling for long buffers.
8
- # Vertical movement preserves a "preferred column" so left/right navigation feels stable.
5
+ # TextArea is a multi-line text editor component. Supports character insertion, newline
6
+ # insertion (plain Enter by default, plus Shift+Enter/Ctrl+J/Ctrl+N), cursor movement
7
+ # (left/right/up/down, home/end, page up/down), deletion (backspace/delete), and scrolling
8
+ # for long buffers. Vertical movement preserves a "preferred column" so left/right
9
+ # navigation feels stable.
9
10
  class TextArea < Component
10
11
  # The current text value, cursor byte offset, top-visible row offset, and remembered
11
12
  # column for vertical navigation, respectively.
@@ -15,7 +16,9 @@ module Charming
15
16
  # *height* constrain the rendered output. *cursor* defaults to the end of the value.
16
17
  # *offset* is the top-visible row. *preferred_column* is the column to resume at on
17
18
  # vertical movement (defaults to the current column on first use).
18
- def initialize(value: "", placeholder: "", width: nil, height: nil, cursor: nil, offset: 0, preferred_column: nil)
19
+ # *enter_newline* (default true) makes plain Enter insert a newline set false when a
20
+ # host widget wants Enter for itself (the key then falls through unhandled).
21
+ def initialize(value: "", placeholder: "", width: nil, height: nil, cursor: nil, offset: 0, preferred_column: nil, enter_newline: true)
19
22
  super()
20
23
  @value = value.dup
21
24
  @placeholder = placeholder
@@ -24,10 +27,16 @@ module Charming
24
27
  @cursor = cursor || @value.length
25
28
  @offset = offset
26
29
  @preferred_column = preferred_column
30
+ @enter_newline = enter_newline
27
31
  clamp_position
28
32
  ensure_cursor_visible
29
33
  end
30
34
 
35
+ # Free-typed characters belong to this component while it is focused.
36
+ def captures_text?
37
+ true
38
+ end
39
+
31
40
  # Routes key events to the appropriate cursor/text mutation. Returns :handled when the
32
41
  # event was consumed, nil otherwise.
33
42
  def handle_key(event)
@@ -52,6 +61,14 @@ module Charming
52
61
  :handled
53
62
  end
54
63
 
64
+ # Inserts pasted text at the cursor. Newlines are preserved; other control
65
+ # characters (and CRLF carriage returns) are stripped. Returns :handled.
66
+ def handle_paste(event)
67
+ sanitized = event.text.to_s.tr("\r", "").gsub(/[^[:print:]\n]/, "")
68
+ insert(sanitized) unless sanitized.empty?
69
+ :handled
70
+ end
71
+
55
72
  # Renders the visible portion of the text buffer (scrolled to `offset`), with each
56
73
  # visible line either clipped to `width` or padded to it.
57
74
  def render
@@ -62,11 +79,17 @@ module Charming
62
79
 
63
80
  attr_reader :placeholder, :width, :height
64
81
 
65
- # True when the event represents an explicit newline request: Shift+Enter or Ctrl+J.
82
+ # True when the event represents a newline request. Plain Enter inserts a newline
83
+ # by default (the natural expectation in a text editor); Shift+Enter, Ctrl+J, and
84
+ # Ctrl+N always work, even when `enter_newline: false` reserves plain Enter for the
85
+ # host widget. (Shift+Enter is indistinguishable from Enter in many terminals, so
86
+ # Ctrl+N remains the TTY-safe fallback in that mode.)
66
87
  def newline_event?(event)
67
88
  key = Charming.key_of(event)
89
+ return true if key == :enter && @enter_newline
68
90
  return true if key == :enter && event.respond_to?(:shift) && event.shift
69
91
  return true if key == :j && event.respond_to?(:ctrl) && event.ctrl
92
+ return true if key == :n && event.respond_to?(:ctrl) && event.ctrl
70
93
 
71
94
  false
72
95
  end
@@ -162,12 +185,13 @@ module Charming
162
185
  end
163
186
 
164
187
  # Moves the cursor vertically by *delta* rows. Stays within the line count and uses
165
- # `preferred_column` so up/down movement feels stable on short lines.
188
+ # `preferred_column` (in display columns) so up/down movement feels stable on short lines.
166
189
  def move_vertical(delta)
167
190
  row, column = cursor_position
168
191
  target_row = (row + delta).clamp(0, lines.length - 1)
169
192
  @preferred_column ||= column
170
- @cursor = line_start(target_row) + [@preferred_column, line_length(target_row)].min
193
+ target_line = lines.fetch(target_row, "")
194
+ @cursor = line_start(target_row) + char_offset_at_display_col(target_line, @preferred_column)
171
195
  ensure_cursor_visible
172
196
  end
173
197
 
@@ -177,12 +201,14 @@ module Charming
177
201
  end
178
202
 
179
203
  # Returns the cursor's current position as `[row, column]`, where row is the zero-based
180
- # line index and column is the character offset within that line.
204
+ # line index and column is the *display-column* offset within that line (wide characters
205
+ # such as emoji or CJK occupy two display columns each).
181
206
  def cursor_position
182
207
  before = value[0...cursor].to_s
183
208
  row = before.count("\n")
184
209
  last_newline = before.rindex("\n")
185
- column = last_newline ? before.length - last_newline - 1 : before.length
210
+ line_before_cursor = last_newline ? before[(last_newline + 1)..] : before
211
+ column = UI::Width.measure(line_before_cursor)
186
212
  [row, column]
187
213
  end
188
214
 
@@ -196,6 +222,17 @@ module Charming
196
222
  lines.fetch(row, "").length
197
223
  end
198
224
 
225
+ # Returns the character offset within *line* where the display column reaches *display_col*.
226
+ # Stops at the last character when the line is shorter than *display_col*.
227
+ def char_offset_at_display_col(line, display_col)
228
+ col = 0
229
+ line.each_char.with_index do |char, idx|
230
+ return idx if col >= display_col
231
+ col += UI::Width.measure(char)
232
+ end
233
+ line.length
234
+ end
235
+
199
236
  # Splits the value into an array of lines (preserving trailing empty lines).
200
237
  def lines
201
238
  value.empty? ? [""] : value.split("\n", -1)
@@ -6,6 +6,10 @@ module Charming
6
6
  # cursor movement (left/right/home/end), and deletion (backspace/delete). The component
7
7
  # exposes its `value` and `cursor` positions as reader methods; when an explicit `width:`
8
8
  # is given, the rendered output is padded to that width via a UI::Style.
9
+ #
10
+ # Options:
11
+ # - `masked: true` renders every character as `*` (password entry)
12
+ # - `history: [...]` enables REPL-style recall — up/down cycle through prior values
9
13
  class TextInput < Component
10
14
  include KeyboardHandler
11
15
 
@@ -26,23 +30,44 @@ module Charming
26
30
 
27
31
  # *value* is the initial text. *placeholder* is shown when the value is empty.
28
32
  # *width* optionally constrains the rendered output width; *cursor* defaults to the end.
29
- def initialize(value: "", placeholder: "", width: nil, cursor: nil)
33
+ # *masked* renders characters as `*`. *history* is an array of prior values cycled
34
+ # with up/down (most recent last, like a shell).
35
+ def initialize(value: "", placeholder: "", width: nil, cursor: nil, masked: false, history: nil)
30
36
  super()
31
37
  @value = value.dup
32
38
  @placeholder = placeholder
33
39
  @width = width
34
40
  @cursor = cursor || @value.length
41
+ @masked = masked
42
+ @history = history
43
+ @history_index = nil
44
+ @draft = nil
35
45
  clamp_position
36
46
  end
37
47
 
38
- # Handles key events. Inserts printable characters, otherwise dispatches via KEY_ACTIONS.
48
+ # Free-typed characters belong to this component while it is focused.
49
+ def captures_text?
50
+ true
51
+ end
52
+
53
+ # Handles key events. Inserts printable characters, recalls history on up/down
54
+ # (when enabled), otherwise dispatches via KEY_ACTIONS.
39
55
  # Returns :handled when the event was consumed, nil otherwise.
40
56
  def handle_key(event)
41
57
  return :handled if character_event?(event) && insert(event.char)
58
+ return :handled if history_event(Charming.key_of(event))
42
59
 
43
60
  super
44
61
  end
45
62
 
63
+ # Inserts pasted text at the cursor (newlines and control characters are
64
+ # stripped — this is a single-line input). Returns :handled.
65
+ def handle_paste(event)
66
+ sanitized = event.text.to_s.gsub(/[[:cntrl:]]/, "")
67
+ insert(sanitized) unless sanitized.empty?
68
+ :handled
69
+ end
70
+
46
71
  # Renders the value with a cursor marker. When *width* was given at construction, the
47
72
  # output is padded to that width via the configured style.
48
73
  def render
@@ -105,12 +130,62 @@ module Charming
105
130
  @value = value[0...cursor] + value[(cursor + 1)..]
106
131
  end
107
132
 
133
+ # Cycles through history on :up / :down. Returns true when the event was consumed.
134
+ def history_event(key)
135
+ return false unless @history && !@history.empty?
136
+
137
+ case key
138
+ when :up then recall_previous
139
+ when :down then recall_next
140
+ else false
141
+ end
142
+ end
143
+
144
+ # Steps back through history (saving the in-progress draft first).
145
+ def recall_previous
146
+ if @history_index.nil?
147
+ @draft = value
148
+ @history_index = @history.length - 1
149
+ elsif @history_index.positive?
150
+ @history_index -= 1
151
+ end
152
+ replace_value(@history[@history_index])
153
+ true
154
+ end
155
+
156
+ # Steps forward through history; past the newest entry restores the draft.
157
+ def recall_next
158
+ return false if @history_index.nil?
159
+
160
+ @history_index += 1
161
+ if @history_index >= @history.length
162
+ @history_index = nil
163
+ replace_value(@draft.to_s)
164
+ else
165
+ replace_value(@history[@history_index])
166
+ end
167
+ true
168
+ end
169
+
170
+ # Replaces the value and moves the cursor to the end.
171
+ def replace_value(new_value)
172
+ @value = new_value.dup
173
+ @cursor = @value.length
174
+ end
175
+
108
176
  # 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.
177
+ # empty, the placeholder is rendered instead, preceded by the cursor marker. Masked
178
+ # inputs render `*` per character.
110
179
  def render_value
111
180
  return cursor_marker + placeholder if value.empty?
112
181
 
113
- value[0...cursor] + cursor_marker + value[cursor..]
182
+ shown = display_value
183
+ shown[0...cursor] + cursor_marker + shown[cursor..]
184
+ end
185
+
186
+ # The value as displayed: masked inputs substitute `*` for every character.
187
+ def display_value
188
+ @masked ? "*" * value.length : value
114
189
  end
115
190
 
116
191
  # The literal character used to mark the cursor position in `render`.
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Toast is a small auto-dismissing notification panel, usually composited as an
6
+ # overlay anchored to a screen corner. Controllers manage its lifetime with the
7
+ # `show_toast` / `dismiss_toast` helpers (which pair it with a timer); the component
8
+ # itself just renders the styled box.
9
+ #
10
+ # Toast.new(message: "Saved!", kind: :success)
11
+ #
12
+ # *kind* picks the accent style: :info (default), :success, :warn, or :error.
13
+ class Toast < Component
14
+ KINDS = %i[info success warn error].freeze
15
+
16
+ attr_reader :message, :kind
17
+
18
+ # *message* is the toast text. *kind* is the visual accent. *width* optionally
19
+ # fixes the box width (otherwise it hugs the message).
20
+ def initialize(message:, kind: :info, width: nil, theme: nil)
21
+ super(theme: theme)
22
+ @message = message.to_s
23
+ @kind = KINDS.include?(kind) ? kind : :info
24
+ @width = width
25
+ end
26
+
27
+ # Renders the bordered toast box with a kind-colored border.
28
+ def render
29
+ box(message, style: toast_style)
30
+ end
31
+
32
+ private
33
+
34
+ # A rounded-border box accented by the kind's theme style.
35
+ def toast_style
36
+ base = style.border(:rounded, foreground: accent_color).padding(0, 1)
37
+ @width ? base.width(@width) : base
38
+ end
39
+
40
+ # Maps the kind to a border color: info/cyan-ish, success/green, warn/yellow, error/red.
41
+ def accent_color
42
+ case kind
43
+ when :success then :green
44
+ when :warn then :yellow
45
+ when :error then :red
46
+ else :cyan
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Tree renders a collapsible hierarchy (file explorers, nested data). Nodes are
6
+ # hashes: `{label: "src", children: [...], expanded: true}` — `children` and
7
+ # `expanded` are optional. Navigation: up/down move the cursor through *visible*
8
+ # nodes, right expands, left collapses (or jumps to the parent), Enter returns
9
+ # `[:selected, node]` for leaves and toggles branches. Mouse clicks move the cursor
10
+ # and toggle branches.
11
+ class Tree < Component
12
+ include KeyboardHandler
13
+
14
+ KEY_ACTIONS = {
15
+ up: :move_up,
16
+ down: :move_down,
17
+ left: :collapse_or_parent,
18
+ right: :expand,
19
+ home: :move_home,
20
+ end: :move_end
21
+ }.freeze
22
+
23
+ # The root node list and the cursor index into the visible-node list.
24
+ attr_reader :nodes, :cursor_index
25
+
26
+ # *nodes* is the array of root node hashes (mutated in place to track expansion).
27
+ # *height* optionally constrains the visible window.
28
+ def initialize(nodes:, cursor_index: 0, height: nil, keymap: :vim, theme: nil)
29
+ super(theme: theme)
30
+ @nodes = nodes
31
+ @cursor_index = cursor_index
32
+ @height = height
33
+ @keymap = keymap
34
+ clamp_cursor
35
+ end
36
+
37
+ # Enter selects a leaf (`[:selected, node]`) or toggles a branch. Navigation keys
38
+ # are handled by KeyboardHandler.
39
+ def handle_key(event)
40
+ node = current_node
41
+ return nil unless node
42
+ return select_or_toggle(node) if Charming.key_of(event) == :enter
43
+
44
+ super
45
+ end
46
+
47
+ # A click moves the cursor to the clicked row; clicking a branch toggles it.
48
+ def handle_mouse(event)
49
+ return nil unless event.respond_to?(:click?) && event.click?
50
+
51
+ clicked = viewport_start + event.y
52
+ return nil if clicked >= visible_nodes.length || event.y.negative?
53
+
54
+ @cursor_index = clicked
55
+ node = current_node
56
+ toggle(node) if branch?(node)
57
+ :handled
58
+ end
59
+
60
+ # The node under the cursor (a node hash), or nil for an empty tree.
61
+ def current_node
62
+ visible_nodes[cursor_index]&.fetch(:node)
63
+ end
64
+
65
+ # Renders the visible window of the flattened tree.
66
+ def render
67
+ window = visible_nodes[viewport_start, viewport_height] || []
68
+ window.each_with_index.map do |entry, index|
69
+ render_node(entry, viewport_start + index)
70
+ end.join("\n")
71
+ end
72
+
73
+ private
74
+
75
+ # Flattens the tree into visible rows: `{node:, depth:}` entries, skipping
76
+ # children of collapsed branches.
77
+ def visible_nodes
78
+ flatten(nodes, 0)
79
+ end
80
+
81
+ def flatten(list, depth)
82
+ Array(list).flat_map do |node|
83
+ entry = [{node: node, depth: depth}]
84
+ entry += flatten(node[:children], depth + 1) if branch?(node) && node[:expanded]
85
+ entry
86
+ end
87
+ end
88
+
89
+ # Renders one row: indentation, expansion marker, label; cursor row uses the
90
+ # selected style.
91
+ def render_node(entry, index)
92
+ node = entry.fetch(:node)
93
+ marker = if branch?(node)
94
+ node[:expanded] ? "▾ " : "▸ "
95
+ else
96
+ " "
97
+ end
98
+ line = "#{" " * entry.fetch(:depth)}#{marker}#{node[:label]}"
99
+ (index == cursor_index) ? theme.selected.render(line) : line
100
+ end
101
+
102
+ def branch?(node)
103
+ node && node[:children] && !node[:children].empty?
104
+ end
105
+
106
+ def select_or_toggle(node)
107
+ return [:selected, node] unless branch?(node)
108
+
109
+ toggle(node)
110
+ :handled
111
+ end
112
+
113
+ def toggle(node)
114
+ node[:expanded] = !node[:expanded]
115
+ end
116
+
117
+ def expand
118
+ node = current_node
119
+ node[:expanded] = true if branch?(node)
120
+ end
121
+
122
+ # Collapses an expanded branch, or moves the cursor to the parent of a leaf or
123
+ # collapsed node.
124
+ def collapse_or_parent
125
+ node = current_node
126
+ if branch?(node) && node[:expanded]
127
+ node[:expanded] = false
128
+ else
129
+ parent = parent_index(cursor_index)
130
+ @cursor_index = parent if parent
131
+ end
132
+ end
133
+
134
+ # Index of the nearest row above with a smaller depth (the parent), or nil.
135
+ def parent_index(index)
136
+ rows = visible_nodes
137
+ depth = rows[index]&.fetch(:depth)
138
+ return nil unless depth&.positive?
139
+
140
+ (index - 1).downto(0).find { |candidate| rows[candidate].fetch(:depth) < depth }
141
+ end
142
+
143
+ def move_up
144
+ @cursor_index -= 1 if cursor_index.positive?
145
+ end
146
+
147
+ def move_down
148
+ @cursor_index += 1 if cursor_index < visible_nodes.length - 1
149
+ end
150
+
151
+ def move_home
152
+ @cursor_index = 0
153
+ end
154
+
155
+ def move_end
156
+ @cursor_index = [visible_nodes.length - 1, 0].max
157
+ end
158
+
159
+ # Top row of the visible window, keeping the cursor in view.
160
+ def viewport_start
161
+ return 0 unless @height
162
+
163
+ Layout.selected_window_start(selected_index: cursor_index, item_count: visible_nodes.length, window_size: @height)
164
+ end
165
+
166
+ def viewport_height
167
+ @height || visible_nodes.length
168
+ end
169
+
170
+ def clamp_cursor
171
+ max = [visible_nodes.length - 1, 0].max
172
+ @cursor_index = cursor_index.clamp(0, max)
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ class Viewport
6
+ # ContentLines normalizes viewport content into display lines.
7
+ class ContentLines
8
+ def initialize(content:, width:, wrap:)
9
+ @content = content
10
+ @window_width = width
11
+ @wrap = wrap
12
+ end
13
+
14
+ def lines
15
+ return wrapped_lines if wrap?
16
+
17
+ rendered_content.lines(chomp: true)
18
+ end
19
+
20
+ def display_width
21
+ lines.map { |line| UI::Width.measure(line) }.max || 0
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :content, :window_width, :wrap
27
+
28
+ def wrapped_lines
29
+ rendered_content.lines(chomp: true).flat_map { |line| wrap_line(line) }
30
+ end
31
+
32
+ def wrap_line(line)
33
+ line_width = UI::Width.measure(line)
34
+ return [""] if line_width.zero?
35
+
36
+ wrap_slices(line, line_width)
37
+ end
38
+
39
+ def wrap_slices(line, line_width)
40
+ (0...line_width).step(window_width).map do |start_column|
41
+ UI.visible_slice(line, start_column, window_width)
42
+ end
43
+ end
44
+
45
+ def rendered_content
46
+ content.respond_to?(:render) ? content.render.to_s : content.to_s
47
+ end
48
+
49
+ def wrap?
50
+ wrap && window_width&.positive?
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unicode/display_width"
4
+
5
+ module Charming
6
+ module Components
7
+ class Viewport
8
+ # LineWindow renders one content line inside the viewport's horizontal window.
9
+ class LineWindow
10
+ ANSI_PATTERN = /\e\[[0-9;]*m/
11
+
12
+ def initialize(width:, column:, wrap:)
13
+ @width = width
14
+ @column = column
15
+ @wrap = wrap
16
+ end
17
+
18
+ def render(line)
19
+ return line unless width
20
+ return pad_line(line, width) if wrap
21
+
22
+ pad_line(clip_line(line), width)
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :width, :column, :wrap
28
+
29
+ def clip_line(line)
30
+ clipped = clip_tokens(line.to_s)
31
+ needs_reset?(clipped) ? "#{clipped}\e[0m" : clipped
32
+ end
33
+
34
+ def clip_tokens(line)
35
+ state = {cursor: 0, output: +""}
36
+ line.scan(/#{ANSI_PATTERN}|./mo) do |token|
37
+ ansi?(token) ? append_ansi(state, token) : append_character(state, token)
38
+ end
39
+ state.fetch(:output)
40
+ end
41
+
42
+ def append_ansi(state, token)
43
+ state.fetch(:output) << token
44
+ end
45
+
46
+ def append_character(state, char)
47
+ char_width = Unicode::DisplayWidth.of(char)
48
+ cursor = state.fetch(:cursor)
49
+ state.fetch(:output) << char if visible?(cursor, char_width)
50
+ state[:cursor] = cursor + char_width
51
+ end
52
+
53
+ def visible?(cursor, char_width)
54
+ cursor >= column && cursor + char_width <= column + width
55
+ end
56
+
57
+ def needs_reset?(value)
58
+ value.match?(ANSI_PATTERN) && !value.end_with?("\e[0m")
59
+ end
60
+
61
+ def pad_line(line, target_width)
62
+ line + (" " * [target_width - UI::Width.measure(line), 0].max)
63
+ end
64
+
65
+ def ansi?(token)
66
+ token.match?(ANSI_PATTERN)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ class Viewport
6
+ # Position owns the viewport's mutable row and column offsets.
7
+ class Position
8
+ attr_reader :offset, :column
9
+
10
+ def initialize(offset:, column:)
11
+ @offset = offset
12
+ @column = column
13
+ end
14
+
15
+ def scroll_up(bounds)
16
+ @offset -= 1
17
+ clamp(bounds)
18
+ end
19
+
20
+ def scroll_down(bounds)
21
+ @offset += 1
22
+ clamp(bounds)
23
+ end
24
+
25
+ def page_up(page_size, bounds)
26
+ @offset -= page_size
27
+ clamp(bounds)
28
+ end
29
+
30
+ def page_down(page_size, bounds)
31
+ @offset += page_size
32
+ clamp(bounds)
33
+ end
34
+
35
+ def home
36
+ @offset = 0
37
+ @column = 0
38
+ end
39
+
40
+ def end_at(bounds)
41
+ @offset = bounds.fetch(:max_offset)
42
+ @column = bounds.fetch(:max_column)
43
+ end
44
+
45
+ def scroll_left(bounds)
46
+ @column -= 1
47
+ clamp(bounds)
48
+ end
49
+
50
+ def scroll_right(bounds)
51
+ @column += 1
52
+ clamp(bounds)
53
+ end
54
+
55
+ def move_to(row, bounds)
56
+ @offset = row
57
+ clamp(bounds)
58
+ end
59
+
60
+ def clamp(bounds)
61
+ @offset = offset.clamp(0, bounds.fetch(:max_offset))
62
+ @column = column.clamp(0, bounds.fetch(:max_column))
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end