charming 0.2.0 → 0.2.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +144 -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/key_dispatch.rb +113 -0
  13. data/lib/charming/controller/session_state.rb +27 -1
  14. data/lib/charming/controller/sidebar_navigation.rb +63 -28
  15. data/lib/charming/controller/terminal.rb +33 -0
  16. data/lib/charming/controller.rb +43 -18
  17. data/lib/charming/database/commands.rb +123 -11
  18. data/lib/charming/escape.rb +81 -0
  19. data/lib/charming/events/focus_event.rb +12 -0
  20. data/lib/charming/events/mouse_event.rb +22 -9
  21. data/lib/charming/events/paste_event.rb +11 -0
  22. data/lib/charming/events/task_progress_event.rb +21 -0
  23. data/lib/charming/generators/app_generator.rb +41 -3
  24. data/lib/charming/generators/database_installer.rb +4 -15
  25. data/lib/charming/generators/migration_generator.rb +116 -0
  26. data/lib/charming/generators/migration_timestamp.rb +29 -0
  27. data/lib/charming/generators/model_generator.rb +4 -2
  28. data/lib/charming/generators/templates/app/Gemfile.template +6 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +1 -1
  30. data/lib/charming/generators/templates/app/database_config.template +3 -1
  31. data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
  32. data/lib/charming/generators/templates/app/layout.template +1 -1
  33. data/lib/charming/generators/templates/app/spec_helper.template +2 -1
  34. data/lib/charming/generators/templates/app/view.template +1 -1
  35. data/lib/charming/image/protocol/kitty.rb +126 -0
  36. data/lib/charming/image/protocol.rb +18 -0
  37. data/lib/charming/image/source.rb +85 -0
  38. data/lib/charming/image/terminal.rb +52 -0
  39. data/lib/charming/image/transmit.rb +11 -0
  40. data/lib/charming/image.rb +21 -0
  41. data/lib/charming/internal/event_loop.rb +155 -0
  42. data/lib/charming/internal/terminal/adapter.rb +14 -0
  43. data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
  44. data/lib/charming/internal/terminal/memory_backend.rb +27 -3
  45. data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
  46. data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
  47. data/lib/charming/internal/terminal/tty_backend.rb +143 -4
  48. data/lib/charming/presentation/component.rb +7 -0
  49. data/lib/charming/presentation/components/activity_indicator.rb +2 -19
  50. data/lib/charming/presentation/components/audio.rb +31 -0
  51. data/lib/charming/presentation/components/autocomplete.rb +108 -0
  52. data/lib/charming/presentation/components/badge.rb +31 -0
  53. data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
  54. data/lib/charming/presentation/components/chart.rb +80 -0
  55. data/lib/charming/presentation/components/command_palette.rb +8 -5
  56. data/lib/charming/presentation/components/error_screen.rb +72 -0
  57. data/lib/charming/presentation/components/filepicker.rb +101 -0
  58. data/lib/charming/presentation/components/form/builder.rb +5 -0
  59. data/lib/charming/presentation/components/form/multiselect.rb +105 -0
  60. data/lib/charming/presentation/components/form.rb +9 -0
  61. data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
  62. data/lib/charming/presentation/components/help_overlay.rb +65 -0
  63. data/lib/charming/presentation/components/image.rb +38 -0
  64. data/lib/charming/presentation/components/list.rb +22 -4
  65. data/lib/charming/presentation/components/markdown.rb +6 -2
  66. data/lib/charming/presentation/components/modal.rb +45 -5
  67. data/lib/charming/presentation/components/multi_select_list.rb +85 -0
  68. data/lib/charming/presentation/components/paginator.rb +54 -0
  69. data/lib/charming/presentation/components/progressbar.rb +26 -5
  70. data/lib/charming/presentation/components/sparkline.rb +38 -0
  71. data/lib/charming/presentation/components/spinner.rb +22 -3
  72. data/lib/charming/presentation/components/status_bar.rb +75 -0
  73. data/lib/charming/presentation/components/stopwatch.rb +55 -0
  74. data/lib/charming/presentation/components/tab_bar.rb +103 -0
  75. data/lib/charming/presentation/components/table.rb +82 -10
  76. data/lib/charming/presentation/components/text_area.rb +48 -12
  77. data/lib/charming/presentation/components/text_input.rb +85 -5
  78. data/lib/charming/presentation/components/time_display.rb +20 -0
  79. data/lib/charming/presentation/components/timer.rb +43 -0
  80. data/lib/charming/presentation/components/toast.rb +51 -0
  81. data/lib/charming/presentation/components/tree.rb +176 -0
  82. data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
  83. data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
  84. data/lib/charming/presentation/components/viewport/position.rb +67 -0
  85. data/lib/charming/presentation/components/viewport.rb +37 -122
  86. data/lib/charming/presentation/layout/builder.rb +4 -1
  87. data/lib/charming/presentation/layout/overlay.rb +6 -4
  88. data/lib/charming/presentation/layout/pane.rb +2 -1
  89. data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
  90. data/lib/charming/presentation/layout/screen_layout.rb +12 -3
  91. data/lib/charming/presentation/layout/split.rb +37 -3
  92. data/lib/charming/presentation/markdown/renderer.rb +99 -63
  93. data/lib/charming/presentation/markdown/style_config.rb +11 -5
  94. data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
  95. data/lib/charming/presentation/markdown/table_renderer.rb +59 -0
  96. data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
  97. data/lib/charming/presentation/templates/erb_handler.rb +35 -2
  98. data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
  99. data/lib/charming/presentation/ui/ansi_codes.rb +15 -1
  100. data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
  101. data/lib/charming/presentation/ui/background.rb +58 -0
  102. data/lib/charming/presentation/ui/border.rb +14 -1
  103. data/lib/charming/presentation/ui/border_painter.rb +23 -11
  104. data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
  105. data/lib/charming/presentation/ui/canvas.rb +1 -0
  106. data/lib/charming/presentation/ui/color_support.rb +129 -0
  107. data/lib/charming/presentation/ui/gradient.rb +47 -0
  108. data/lib/charming/presentation/ui/style.rb +119 -19
  109. data/lib/charming/presentation/ui/text_wrapper.rb +40 -0
  110. data/lib/charming/presentation/ui/theme.rb +7 -0
  111. data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
  112. data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
  113. data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
  114. data/lib/charming/presentation/ui/themes/nord.json +32 -0
  115. data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
  116. data/lib/charming/presentation/ui/truncate.rb +29 -0
  117. data/lib/charming/presentation/ui/width.rb +38 -2
  118. data/lib/charming/presentation/ui.rb +52 -11
  119. data/lib/charming/presentation/view.rb +8 -6
  120. data/lib/charming/response.rb +11 -6
  121. data/lib/charming/router.rb +1 -1
  122. data/lib/charming/runtime.rb +236 -63
  123. data/lib/charming/tasks/cancelled.rb +11 -0
  124. data/lib/charming/tasks/inline_executor.rb +10 -4
  125. data/lib/charming/tasks/progress.rb +30 -0
  126. data/lib/charming/tasks/task.rb +24 -4
  127. data/lib/charming/tasks/threaded_executor.rb +35 -11
  128. data/lib/charming/test_helper.rb +120 -0
  129. data/lib/charming/version.rb +1 -1
  130. data/lib/charming.rb +43 -1
  131. metadata +63 -50
@@ -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
+ UI::Width.widest(lines)
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
+ UI::Width.pad_to(line, target_width)
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
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "unicode/display_width"
4
-
5
3
  module Charming
6
4
  module Components
7
5
  # Viewport is a scrollable region over multi-line content. Supports keyboard scrolling
@@ -12,9 +10,6 @@ module Charming
12
10
  class Viewport < Component
13
11
  include KeyboardHandler
14
12
 
15
- # Matches an ANSI SGR escape sequence (e.g., "\e[31m" for red foreground).
16
- ANSI_PATTERN = /\e\[[0-9;]*m/
17
-
18
13
  # Maps scroll keys to the instance methods that perform them via KeyboardHandler.
19
14
  KEY_ACTIONS = {
20
15
  up: :scroll_up,
@@ -27,9 +22,6 @@ module Charming
27
22
  right: :scroll_right
28
23
  }.freeze
29
24
 
30
- # The current top-visible row and left-visible column, respectively.
31
- attr_reader :offset, :column
32
-
33
25
  # *content* may be a string, an array of lines, or any object responding to `render`.
34
26
  # *width* and *height* constrain the visible window; *offset* is the top-visible row
35
27
  # and *column* is the left-visible column. *wrap* enables soft-wrapping of long lines.
@@ -38,11 +30,10 @@ module Charming
38
30
  @content = content
39
31
  @width = width
40
32
  @height = height
41
- @offset = offset
42
- @column = column
33
+ @position = Position.new(offset: offset, column: column)
43
34
  @wrap = wrap
44
35
  @keymap = keymap
45
- clamp_position
36
+ position.clamp(bounds)
46
37
  end
47
38
 
48
39
  # Renders the visible window of content as a multi-line string.
@@ -50,6 +41,16 @@ module Charming
50
41
  visible_lines.map { |line| render_line(line) }.join("\n")
51
42
  end
52
43
 
44
+ # The current top-visible row.
45
+ def offset
46
+ @position.offset
47
+ end
48
+
49
+ # The current left-visible column.
50
+ def column
51
+ @position.column
52
+ end
53
+
53
54
  # Handles mouse events: scroll wheel adjusts the row offset, click moves the top
54
55
  # visible row to the clicked position. Returns :handled on success.
55
56
  def handle_mouse(event)
@@ -57,77 +58,61 @@ module Charming
57
58
 
58
59
  if event.scroll?
59
60
  scroll_delta = (event.button_name == :scroll_up) ? -1 : 1
60
- @offset += scroll_delta
61
- clamp_position
61
+ position.move_to(offset + scroll_delta, bounds)
62
62
  return :handled
63
63
  end
64
64
 
65
65
  return nil unless event.click?
66
66
 
67
67
  clicked_row = event.y
68
- return nil if clicked_row < offset || clicked_row >= offset + viewport_height
68
+ return nil if clicked_row < 0 || clicked_row >= viewport_height
69
69
 
70
- @offset = clicked_row
71
- clamp_position
70
+ position.move_to(offset + clicked_row, bounds)
72
71
  :handled
73
72
  end
74
73
 
75
74
  private
76
75
 
77
- attr_reader :content, :width, :height
76
+ attr_reader :content, :width, :height, :position
78
77
 
79
78
  # Scrolls the viewport up by one row.
80
79
  def scroll_up
81
- @offset -= 1
82
- clamp_position
80
+ position.scroll_up(bounds)
83
81
  end
84
82
 
85
83
  # Scrolls the viewport down by one row.
86
84
  def scroll_down
87
- @offset += 1
88
- clamp_position
85
+ position.scroll_down(bounds)
89
86
  end
90
87
 
91
88
  # Scrolls up by one viewport page.
92
89
  def page_up
93
- @offset -= page_size
94
- clamp_position
90
+ position.page_up(page_size, bounds)
95
91
  end
96
92
 
97
93
  # Scrolls down by one viewport page.
98
94
  def page_down
99
- @offset += page_size
100
- clamp_position
95
+ position.page_down(page_size, bounds)
101
96
  end
102
97
 
103
98
  # Scrolls to the top-left of the content.
104
99
  def scroll_home
105
- @offset = 0
106
- @column = 0
100
+ position.home
107
101
  end
108
102
 
109
103
  # Scrolls to the bottom-right of the content.
110
104
  def scroll_end
111
- @offset = max_offset
112
- @column = max_column
105
+ position.end_at(bounds)
113
106
  end
114
107
 
115
108
  # Scrolls one column left.
116
109
  def scroll_left
117
- @column -= 1
118
- clamp_position
110
+ position.scroll_left(bounds)
119
111
  end
120
112
 
121
113
  # Scrolls one column right.
122
114
  def scroll_right
123
- @column += 1
124
- clamp_position
125
- end
126
-
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)
115
+ position.scroll_right(bounds)
131
116
  end
132
117
 
133
118
  # Returns the slice of content lines visible in the current viewport, padded to *height*.
@@ -141,89 +126,12 @@ module Charming
141
126
  # Renders a single line according to the configured width and wrap mode: clips to the
142
127
  # visible column window when not wrapping, otherwise wraps the line to the width.
143
128
  def render_line(line)
144
- return line unless width
145
- return pad_line(line, width) if wrap?
146
-
147
- pad_line(clip_line(line), width)
148
- end
149
-
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
155
-
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)
162
- end
163
- state.fetch(:output)
164
- end
165
-
166
- # Appends an ANSI escape token to the output buffer unchanged.
167
- def append_ansi(state, token)
168
- state.fetch(:output) << token
169
- end
170
-
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
179
-
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
185
-
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
191
-
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)
129
+ line_window.render(line)
195
130
  end
196
131
 
197
132
  # Returns the content lines, wrapped to *width* when wrap is enabled.
198
133
  def content_lines
199
- return wrapped_content_lines if wrap?
200
-
201
- rendered_content.lines(chomp: true)
202
- end
203
-
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
208
-
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?
213
-
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
219
- end
220
- out
221
- end
222
-
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
134
+ content_source.lines
227
135
  end
228
136
 
229
137
  # Returns the visible row count (the configured *height* or the content's line count).
@@ -253,18 +161,25 @@ module Charming
253
161
 
254
162
  # Returns the maximum display width across all content lines.
255
163
  def content_width
256
- content_lines.map { |line| UI::Width.measure(line) }.max || 0
164
+ content_source.display_width
257
165
  end
258
166
 
259
- # True when *token* is an ANSI escape sequence.
260
- def ansi?(token)
261
- token.match?(ANSI_PATTERN)
167
+ def bounds
168
+ {max_offset: max_offset, max_column: max_column}
262
169
  end
263
170
 
264
171
  # True when soft-wrapping is enabled and a positive width is configured.
265
172
  def wrap?
266
173
  @wrap && width&.positive?
267
174
  end
175
+
176
+ def line_window
177
+ LineWindow.new(width: width, column: column, wrap: wrap?)
178
+ end
179
+
180
+ def content_source
181
+ ContentLines.new(content: content, width: width, wrap: @wrap)
182
+ end
268
183
  end
269
184
  end
270
185
  end
@@ -41,7 +41,10 @@ module Charming
41
41
  def pane(name = nil, content = nil, **options, &block)
42
42
  node = Pane.new(
43
43
  name: name, content: content, block: block, view: view,
44
- geometry: PaneGeometry.build(**options.slice(:width, :height, :grow, :border, :padding)),
44
+ geometry: PaneGeometry.build(**options.slice(
45
+ :width, :height, :grow, :border, :padding,
46
+ :min_width, :max_width, :min_height, :max_height
47
+ )),
45
48
  style: PaneStyle.build(**options.slice(:style, :focused_style)),
46
49
  behavior: PaneBehavior.build(**options.slice(:focus, :scroll, :clip, :wrap))
47
50
  )
@@ -8,13 +8,14 @@ module Charming
8
8
  # with an outer *style*.
9
9
  class Overlay
10
10
  # The vertical and horizontal offset (cell count or `:center`) of the overlay
11
- # within the parent canvas.
12
- attr_reader :top, :left
11
+ # within the parent canvas, and its stacking order (higher paints later/on top).
12
+ attr_reader :top, :left, :z_index
13
13
 
14
14
  # *content* (or a *block*) provides the body. *top*/*left* default to :center.
15
15
  # *width*/*height* fix the overlay's dimensions; when unset, the content's natural
16
- # size is used. *style* wraps the rendered content in a UI::Style.
17
- def initialize(content: nil, block: nil, view: nil, top: :center, left: :center, width: nil, height: nil, style: nil)
16
+ # size is used. *style* wraps the rendered content in a UI::Style. *z_index*
17
+ # controls stacking: higher values composite on top (ties keep registration order).
18
+ def initialize(content: nil, block: nil, view: nil, top: :center, left: :center, width: nil, height: nil, style: nil, z_index: 0)
18
19
  @content = content
19
20
  @block = block
20
21
  @view = view
@@ -23,6 +24,7 @@ module Charming
23
24
  @width = width
24
25
  @height = height
25
26
  @style = style
27
+ @z_index = z_index
26
28
  end
27
29
 
28
30
  # Renders the overlay's content; when *width* or *height* is set, places the rendered
@@ -8,7 +8,8 @@ module Charming
8
8
  # focusable slots in the controller's focus ring.
9
9
  class Pane
10
10
  attr_reader :name
11
- delegate :width, :height, :grow, to: :geometry
11
+ delegate :width, :height, :grow,
12
+ :min_width, :max_width, :min_height, :max_height, to: :geometry
12
13
 
13
14
  # *name* is the focus slot identifier. *content* (or a *block*) is the body; *view*
14
15
  # is the view used for instance_exec when the block is given. *geometry*, *style*, and