charming 0.1.0

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 (64) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +421 -0
  4. data/exe/charming +6 -0
  5. data/lib/charming/application.rb +90 -0
  6. data/lib/charming/application_model.rb +13 -0
  7. data/lib/charming/cli.rb +60 -0
  8. data/lib/charming/component.rb +8 -0
  9. data/lib/charming/components/activity_indicator.rb +158 -0
  10. data/lib/charming/components/command_palette.rb +118 -0
  11. data/lib/charming/components/keyboard_handler.rb +22 -0
  12. data/lib/charming/components/list.rb +105 -0
  13. data/lib/charming/components/modal.rb +48 -0
  14. data/lib/charming/components/progressbar.rb +55 -0
  15. data/lib/charming/components/spinner.rb +37 -0
  16. data/lib/charming/components/table.rb +115 -0
  17. data/lib/charming/components/text_input.rb +103 -0
  18. data/lib/charming/components/viewport.rb +191 -0
  19. data/lib/charming/controller.rb +523 -0
  20. data/lib/charming/focus.rb +65 -0
  21. data/lib/charming/generators/app_file_generator.rb +28 -0
  22. data/lib/charming/generators/app_generator/app_spec_templates.rb +86 -0
  23. data/lib/charming/generators/app_generator/basic_templates.rb +69 -0
  24. data/lib/charming/generators/app_generator/component_templates.rb +36 -0
  25. data/lib/charming/generators/app_generator/controller_template.rb +69 -0
  26. data/lib/charming/generators/app_generator/layout_template.rb +160 -0
  27. data/lib/charming/generators/app_generator/model_templates.rb +30 -0
  28. data/lib/charming/generators/app_generator/screen_spec_templates.rb +70 -0
  29. data/lib/charming/generators/app_generator/view_template.rb +90 -0
  30. data/lib/charming/generators/app_generator.rb +76 -0
  31. data/lib/charming/generators/base.rb +29 -0
  32. data/lib/charming/generators/component_generator.rb +30 -0
  33. data/lib/charming/generators/controller_generator.rb +50 -0
  34. data/lib/charming/generators/name.rb +32 -0
  35. data/lib/charming/generators/screen_generator.rb +154 -0
  36. data/lib/charming/generators/view_generator.rb +34 -0
  37. data/lib/charming/generators.rb +7 -0
  38. data/lib/charming/internal/renderer/differential.rb +53 -0
  39. data/lib/charming/internal/renderer/full_repaint.rb +19 -0
  40. data/lib/charming/internal/terminal/adapter.rb +52 -0
  41. data/lib/charming/internal/terminal/memory_backend.rb +91 -0
  42. data/lib/charming/internal/terminal/tty_backend.rb +250 -0
  43. data/lib/charming/key_event.rb +13 -0
  44. data/lib/charming/mouse_event.rb +40 -0
  45. data/lib/charming/resize_event.rb +7 -0
  46. data/lib/charming/response.rb +33 -0
  47. data/lib/charming/router.rb +137 -0
  48. data/lib/charming/runtime.rb +192 -0
  49. data/lib/charming/screen.rb +8 -0
  50. data/lib/charming/task.rb +7 -0
  51. data/lib/charming/task_event.rb +17 -0
  52. data/lib/charming/task_executor.rb +62 -0
  53. data/lib/charming/timer_event.rb +7 -0
  54. data/lib/charming/ui/border.rb +33 -0
  55. data/lib/charming/ui/style.rb +244 -0
  56. data/lib/charming/ui/theme.rb +178 -0
  57. data/lib/charming/ui/themes/phosphor.json +100 -0
  58. data/lib/charming/ui/width.rb +24 -0
  59. data/lib/charming/ui.rb +230 -0
  60. data/lib/charming/version.rb +5 -0
  61. data/lib/charming/view.rb +116 -0
  62. data/lib/charming.rb +24 -0
  63. data/sig/charming.rbs +3 -0
  64. metadata +225 -0
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # ActivityIndicator renders a color-gradient progress or loading indicator
6
+ # as styled text. It produces a fixed-width row of characters whose colors
7
+ # interpolate between two gradient endpoints (or cycle through a single
8
+ # color). A label can be appended after the bar and an ellipsis that cycles
9
+ # through frames, useful for "loading" state display. Call `tick` to advance
10
+ # the frame counter, and call `render` to produce the styled output string.
11
+ class ActivityIndicator < Component
12
+ # Default character pool used for generating each position's character via stable hashing.
13
+ DEFAULT_CHARS = "0123456789abcdefABCDEF~!@#$%^&*+=_".chars.freeze
14
+
15
+ # The default two-color gradient applied across the bar width (red to cyan).
16
+ # The cyan endpoint mirrors the Phosphor theme palette's "cyan" token so the bar
17
+ # remains legible on Phosphor's dark navy background; gradient: accepts raw hex,
18
+ # so callers using a different theme should pass their own endpoints.
19
+ DEFAULT_GRADIENT = ["#ff0000", "#6FD0E3"].freeze
20
+
21
+ # The default label color for ellipsis and text portions when no custom
22
+ # label_style is provided.
23
+ DEFAULT_LABEL_COLOR = "#cccccc"
24
+
25
+ # Ellipsis frame sequence: four states cycle through "., "..", "...", and "" (empty).
26
+ ELLIPSIS_FRAMES = [".", "..", "...", ""].freeze
27
+
28
+ # Number of frames in the animation cycle before the indicator pattern repeats.
29
+ FRAME_COUNT = 10
30
+
31
+ # FNV-1a variant constants used by stable_hash for reproducible character selection per position.
32
+ FNV_OFFSET = 2_166_136_261
33
+ FNV_PRIME = 16_777_619
34
+ FNV_MASK = 0xffffffff
35
+
36
+ attr_reader :width, :label, :index, :seed, :chars, :gradient, :label_style
37
+
38
+ # Initializes a new ActivityIndicator with configurable visual parameters.
39
+ # width — Display width of the gradient bar in characters (minimum 1). Default: 10.
40
+ # label — Optional text label shown adjacent to the indicator.
41
+ # index — Initial frame index for the ellipsis/frame animations. Default: 0.
42
+ # seed — Hash seed that determines which characters appear at each position.
43
+ # chars — Character pool to draw from (default is DEFAULT_CHARS).
44
+ # gradient — Two-element array of hex color strings ["#rrggbb", "#rrggbb"] for interpolation.
45
+ # label_style — A Style object to use for rendering the label text; falls back to a gray foreground.
46
+ def initialize(width: 10, label: nil, index: 0, seed: 0, chars: DEFAULT_CHARS,
47
+ gradient: DEFAULT_GRADIENT, label_style: nil)
48
+ super()
49
+ raise ArgumentError, "chars cannot be empty" if chars.empty?
50
+
51
+ @width = [width.to_i, 1].max
52
+ @label = label
53
+ @index = index.to_i
54
+ @seed = seed
55
+ @chars = chars.map(&:to_s)
56
+ @gradient = gradient
57
+ @label_style = label_style
58
+ end
59
+
60
+ # Advances the frame counter forward by +count+ steps, allowing the displayed pattern to change.
61
+ # Accepts an integer count (converted via +to_i+). Returns self for chaining.
62
+ def tick(count = 1)
63
+ @index += count.to_i
64
+ self
65
+ end
66
+
67
+ # Renders the activity indicator as a styled string. If a label was provided,
68
+ # produces "bar ellipsis" alongside it; otherwise produces only the gradient bar.
69
+ # Returns a formatted string suitable for terminal rendering.
70
+ def render
71
+ return indicator unless label
72
+
73
+ "#{indicator} #{styled_label}#{styled_ellipsis}"
74
+ end
75
+
76
+ private
77
+
78
+ # Renders the full gradient bar as an array of styled characters joined into a single string.
79
+ # Each character at +position+ is selected by hashing together seed, frame, and position —
80
+ # making the pattern stable across renders — then styled with the interpolated gradient color
81
+ # at that position.
82
+ def indicator
83
+ Array.new(width) { |position| styled_char(position) }.join
84
+ end
85
+
86
+ # Selects a character for the bar at the given +position+, styles it with the gradient color
87
+ # interpolated for that position, and returns the result as a formatted string via +render+.
88
+ def styled_char(position)
89
+ style.foreground(color_at(position)).render(char_at(position))
90
+ end
91
+
92
+ # Chooses a character from self.chars by hashing seed:frame:position together with a stable
93
+ # FNV-1a hash. The resulting index is modulated against the character pool length, ensuring
94
+ # reproducible output across renders.
95
+ def char_at(position)
96
+ chars.fetch(stable_hash("#{seed}:#{frame}:#{position}") % chars.length)
97
+ end
98
+
99
+ # Renders the label text in its own style (or fallback gray color) via a Style renderer call.
100
+ def styled_label
101
+ label_style_or_default.render(label.to_s)
102
+ end
103
+
104
+ # Renders an ellipsis frame (".", "..", "...", or empty) based on (index / 4) mod 4, styled with the label style.
105
+ def styled_ellipsis
106
+ label_style_or_default.render(ellipsis_frame)
107
+ end
108
+
109
+ # Returns the current ellipsis frame string: one of ".", "..", "...", "". Cycles through four frames per tick.
110
+ def ellipsis_frame
111
+ ELLIPSIS_FRAMES.fetch((index / 4) % ELLIPSIS_FRAMES.length)
112
+ end
113
+
114
+ # Returns the label style if set, otherwise produces a gray foreground style for fallback rendering.
115
+ def label_style_or_default
116
+ label_style || style.foreground(DEFAULT_LABEL_COLOR)
117
+ end
118
+
119
+ # Interpolates between gradient[0] and gradient[1] at the fractional +position+ (0.0 to 1.0).
120
+ # Returns the first gradient color if width is 1; otherwise returns a blended hex string based on position.
121
+ def color_at(position)
122
+ return gradient.first unless width > 1
123
+
124
+ blend(gradient.first, gradient.last, position / (width - 1).to_f)
125
+ end
126
+
127
+ # Blends two hex colors by interpolating their red/green/blue components at fractional +amount+.
128
+ # Accepts strings like "#ff0000" and produces a new "#rrggbb" string.
129
+ def blend(start_hex, end_hex, amount)
130
+ start_rgb = rgb(start_hex)
131
+ end_rgb = rgb(end_hex)
132
+ mixed = start_rgb.zip(end_rgb).map { |from, to| (from + ((to - from) * amount)).round }
133
+ "#%02x%02x%02x" % mixed
134
+ end
135
+
136
+ # Decomposes a hex color string ("#rrggbb") into an array of three integers [r, g, b].
137
+ def rgb(hex)
138
+ value = hex.to_s.delete_prefix("#")
139
+ raise ArgumentError, "gradient colors must be #rrggbb" unless value.match?(/\A[0-9a-fA-F]{6}\z/)
140
+
141
+ [value[0..1], value[2..3], value[4..5]].map { |part| part.to_i(16) }
142
+ end
143
+
144
+ # Advances the animation frame counter, wrapping around after +FRAME_COUNT+ (10) steps.
145
+ def frame
146
+ index % FRAME_COUNT
147
+ end
148
+
149
+ # Produces a deterministic integer hash from the input string using FNV-1a hashing, ensuring the same
150
+ # characters appear at the same positions across multiple renderings of this indicator.
151
+ def stable_hash(value)
152
+ value.bytes.reduce(FNV_OFFSET) do |hash, byte|
153
+ ((hash ^ byte) * FNV_PRIME) & FNV_MASK
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # CommandPalette renders a fuzzy-searchable command picker UI. It wraps a TextInput for search
6
+ # input and a List for result display, dispatching key events between them. Users type to filter
7
+ # the registered commands by label match, navigate with up/down/home/end keys (delegated to List),
8
+ # confirm a selection with Enter (returns [:selected, command]), or cancel with Escape (returns :cancelled).
9
+ # State is serializable as a hash of value/cursor/selected_index for session persistence.
10
+ class CommandPalette < Component
11
+ Command = Data.define(:label, :value)
12
+
13
+ # A single command palette entry: a human-readable +label+ and a callable or
14
+ # method symbol +value+ that gets executed when the user selects it.
15
+ attr_reader :commands, :input
16
+
17
+ # Initializes the dropdown widget with a list of Command entries and search
18
+ # parameters for building the underlying TextInput (placeholder text, cursor
19
+ # position, value) and List (display height, initial selection). Returns void;
20
+ # the state is later serializable via +state+ for session persistence.
21
+ def initialize(commands:, placeholder: "Search commands", height: nil, value: "", cursor: nil, selected_index: 0, theme: nil)
22
+ super(theme: theme)
23
+ @commands = commands
24
+ @height = height
25
+ @input = TextInput.new(value: value, placeholder: placeholder, cursor: cursor)
26
+ @list = build_list(selected_index: selected_index)
27
+ end
28
+
29
+ # Returns the currently displayed Command entry in the List at the time of calling.
30
+ # Returns nil if no entry is highlighted (i.e., user has opened the palette but not
31
+ # moved the selection). Useful for retrieving the result after key handling.
32
+ def selected_command
33
+ list.selected_item
34
+ end
35
+
36
+ # Collects the current state of the TextInput and List into a serializable hash
37
+ # suitable for round-trip storage in session. Returns {value:, cursor:, selected_index:}.
38
+ def state
39
+ {
40
+ value: input.value,
41
+ cursor: input.cursor,
42
+ selected_index: list.selected_index
43
+ }
44
+ end
45
+
46
+ # Handles key events by routing them to the appropriate sub-component: Escape kills the
47
+ # palette returning :cancelled; up/down/home/end keys go to the List selection handler
48
+ # via handle_list_key; all other keys (including typed characters) are passed to the TextInput
49
+ # which manages cursor position and input filtering. If a list key match fails, falls through
50
+ # to the TextInput handler. Returns nil/nil if no handler consumed the event, or :cancelled when
51
+ # Escape is pressed.
52
+ def handle_key(event)
53
+ key = Charming.key_of(event)
54
+ return :cancelled if key == :escape
55
+
56
+ return handle_list_key(event) if list_key?(key)
57
+
58
+ handle_input_key(event)
59
+ end
60
+
61
+ # Renders the command palette as a vertically-stacked text representation: the search TextInput
62
+ # row on line 1, and then the filtered List results (or "No commands found") on subsequent lines.
63
+ # Returns a multiline string suitable for terminal rendering.
64
+ def render
65
+ [input.render, render_results].join("\n")
66
+ end
67
+
68
+ private
69
+
70
+ attr_reader :height, :list
71
+
72
+ # Delegates key handling entirely to the internal List widget, which manages up/down/home/end selection.
73
+ # Returns whatever the List's handle_key returns (typically nil or the symbol from the subclass).
74
+ def handle_list_key(event)
75
+ list.handle_key(event)
76
+ end
77
+
78
+ # Passes the key event to the TextInput for cursor position and search text management.
79
+ # If the input returns :handled, rebuilds the List so that filtering is re-evaluated against
80
+ # the new input value. Returns nil/nil if no handler consumed the event.
81
+ def handle_input_key(event)
82
+ result = input.handle_key(event)
83
+ @list = build_list if result == :handled
84
+ result
85
+ end
86
+
87
+ # Checks whether the given key is a List-navigation key (up/down/home/end). Returns true for those keys
88
+ # so they can be dispatched via +handle_list_key+ rather than falling through to TextInput.
89
+ def list_key?(key)
90
+ %i[up down home end enter].include?(key)
91
+ end
92
+
93
+ # Renders the filtered results section below the search input. If no commands match the current filter text,
94
+ # returns "No commands found"; otherwise renders the List widget's styled display string. Returns a single-line string.
95
+ def render_results
96
+ return "No commands found" if filtered_commands.empty?
97
+
98
+ list.render
99
+ end
100
+
101
+ # Builds a new List from the currently filtered commands at the given selected_index height and label extractor.
102
+ # The +selected_index+ parameter defaults to the last known value in +list+ to preserve scroll position across rebuilds.
103
+ def build_list(selected_index: list&.selected_index || 0)
104
+ List.new(items: filtered_commands, selected_index: selected_index, height: height, label: :label.to_proc, theme: theme)
105
+ end
106
+
107
+ # Returns the full commands array when input value is empty; otherwise a subset whose labels match case-insensitively
108
+ # against the current TextInput value. Used to drive the fuzzy search behavior. Returns an Array of Command entries.
109
+ def filtered_commands
110
+ return commands if input.value.empty?
111
+
112
+ commands.select do |command|
113
+ command.label.downcase.include?(input.value.downcase)
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # KeyboardHandler is a mixin module that provides keyboard event dispatch by mapping symbolic key names
6
+ # to private method calls. Implementors must define a constant +KEY_ACTIONS+ as a hash where each key is
7
+ # a symbol (e.g., :up, :down, :enter) and each value is the target method name (e.g., :move_up). Call
8
+ # +handle_key(event)+ with any event object; it uses Charming.key_of to resolve the raw event to a symbol,
9
+ # looks up the corresponding action in KEY_ACTIONS, sends that method on self, and returns :handled if an
10
+ # action was found. Returns nil (via :handled being truthy or not) when no matching key exists.
11
+ module KeyboardHandler
12
+ def handle_key(event)
13
+ key = Charming.key_of(event)
14
+ action = self.class.const_get(:KEY_ACTIONS)[key]
15
+ return unless action
16
+
17
+ send(action)
18
+ :handled
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ class List < Component
6
+ include KeyboardHandler
7
+
8
+ # Maps navigation key symbols to instance methods consumed by the KeyboardHandler
9
+ # mixin: :up moves selection up, :down moves down, :home jumps to first item,
10
+ # :end jumps to last. See Viewport#KEY_ACTIONS and Table#KEY_ACTIONS for identical pattern.
11
+ KEY_ACTIONS = {
12
+ up: :move_up,
13
+ down: :move_down,
14
+ home: :move_home,
15
+ end: :move_end
16
+ }.freeze
17
+
18
+ attr_reader :items, :selected_index
19
+
20
+ def initialize(items:, selected_index: 0, height: nil, label: nil, theme: nil)
21
+ super(theme: theme)
22
+ @items = items
23
+ @selected_index = selected_index
24
+ @height = height
25
+ @label = label || :to_s.to_proc
26
+ clamp_position
27
+ end
28
+
29
+ def handle_key(event)
30
+ return [:selected, selected_item] if Charming.key_of(event) == :enter && selected_item
31
+
32
+ super
33
+ end
34
+
35
+ def handle_mouse(event)
36
+ return nil unless @height
37
+ return nil unless event.respond_to?(:click?) && event.click?
38
+
39
+ clicked = event.y
40
+ return nil if clicked.negative? || clicked >= visible_items.length
41
+
42
+ @selected_index = viewport_start + clicked
43
+ clamp_position
44
+ :handled
45
+ end
46
+
47
+ def selected_item
48
+ items[selected_index]
49
+ end
50
+
51
+ def render
52
+ visible_items.each_with_index.map do |item, index|
53
+ render_item(item, viewport_start + index)
54
+ end.join("\n")
55
+ end
56
+
57
+ private
58
+
59
+ def move_up
60
+ @selected_index -= 1 if selected_index.positive?
61
+ end
62
+
63
+ def move_down
64
+ @selected_index += 1 if selected_index < items.length - 1
65
+ end
66
+
67
+ def move_home
68
+ @selected_index = 0
69
+ end
70
+
71
+ def move_end
72
+ @selected_index = items.length - 1 unless items.empty?
73
+ end
74
+
75
+ def visible_items
76
+ items[viewport_start, viewport_height] || []
77
+ end
78
+
79
+ def viewport_start
80
+ return 0 unless @height
81
+
82
+ (selected_index - @height + 1).clamp(0, max_viewport_start)
83
+ end
84
+
85
+ def viewport_height
86
+ @height || items.length
87
+ end
88
+
89
+ def max_viewport_start
90
+ [items.length - @height, 0].max
91
+ end
92
+
93
+ def render_item(item, index)
94
+ prefix = (index == selected_index) ? "> " : " "
95
+ rendered = "#{prefix}#{@label.call(item)}"
96
+ (index == selected_index) ? theme.selected.render(rendered) : rendered
97
+ end
98
+
99
+ def clamp_position
100
+ @selected_index = 0 if items.empty?
101
+ @selected_index = selected_index.clamp(0, items.length - 1) unless items.empty?
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ class Modal < Component
6
+ def initialize(content:, title: nil, help: nil, width: 52, style: nil, theme: nil)
7
+ super(theme: theme)
8
+ @content = content
9
+ @title = title
10
+ @help = help
11
+ @width = width
12
+ @style = style
13
+ end
14
+
15
+ def render
16
+ box(column(*lines, gap: 1), style: modal_style)
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :content, :title, :help, :width
22
+
23
+ def lines
24
+ [title_line, help_line, render_content].compact
25
+ end
26
+
27
+ def title_line
28
+ text(title, style: theme.title.align(:center).width(title_width)) if title
29
+ end
30
+
31
+ def help_line
32
+ text(help, style: theme.muted) if help
33
+ end
34
+
35
+ def render_content
36
+ content.respond_to?(:render) ? render_component(content) : content.to_s
37
+ end
38
+
39
+ def modal_style
40
+ @style || theme.modal.width(width)
41
+ end
42
+
43
+ def title_width
44
+ [width - 8, 0].max
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ class Progressbar < Component
6
+ attr_accessor :total, :current, :label, :complete, :incomplete, :bar_format
7
+
8
+ def initialize(total:, complete: "=", incomplete: " ", bar_format: :classic, label: nil)
9
+ super()
10
+ @total = [total.to_i, 0].max
11
+ @complete = complete.to_s
12
+ @incomplete = incomplete.to_s
13
+ @bar_format = bar_format.to_sym
14
+ @label = label
15
+ @current = 0
16
+ end
17
+
18
+ def tick(count = 1)
19
+ update(@current + count)
20
+ self
21
+ end
22
+
23
+ def update(value)
24
+ @current = value.to_i.clamp(0, @total)
25
+ self
26
+ end
27
+
28
+ def complete!
29
+ @current = @total
30
+ self
31
+ end
32
+
33
+ def render
34
+ width = [@total, 1].max
35
+ completed = completed_width(width)
36
+ incomplete = width - completed
37
+ incomplete -= 1 if @current.zero?
38
+ bar = (@complete * completed) + (@incomplete * incomplete)
39
+ result = "[" + bar + "]"
40
+
41
+ return result unless @label
42
+
43
+ "#{result} #{@label}"
44
+ end
45
+
46
+ private
47
+
48
+ def completed_width(width)
49
+ return 0 unless @total.positive?
50
+
51
+ ((width * @current) / @total.to_f).round
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ class Spinner < Component
6
+ DEFAULT_FRAMES = ["-", "\\", "|", "/"].freeze
7
+
8
+ attr_reader :frames, :index, :label
9
+
10
+ def initialize(frames: DEFAULT_FRAMES, index: 0, label: nil)
11
+ super()
12
+ raise ArgumentError, "frames cannot be empty" if frames.empty?
13
+
14
+ @frames = frames
15
+ @index = index
16
+ @label = label
17
+ end
18
+
19
+ def tick
20
+ @index = (index + 1) % frames.length
21
+ self
22
+ end
23
+
24
+ def render
25
+ return frame unless label
26
+
27
+ "#{frame} #{label}"
28
+ end
29
+
30
+ private
31
+
32
+ def frame
33
+ frames.fetch(index % frames.length)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-table"
4
+
5
+ module Charming
6
+ module Components
7
+ class Table < Component
8
+ include KeyboardHandler
9
+
10
+ KEY_ACTIONS = {
11
+ up: :move_up,
12
+ down: :move_down,
13
+ home: :move_home,
14
+ end: :move_end
15
+ }.freeze
16
+
17
+ HEADER_HEIGHT = 2
18
+
19
+ attr_reader :header, :rows, :selected_index
20
+
21
+ def initialize(header:, rows: [], selected_index: 0)
22
+ super()
23
+ @header = Array(header).map(&:to_s)
24
+ @rows = Array(rows)
25
+ @selected_index = clamp_index(selected_index)
26
+ end
27
+
28
+ def handle_key(event)
29
+ return nil if rows.empty?
30
+
31
+ case Charming.key_of(event)
32
+ when :enter then [:selected, selected_row]
33
+ else super
34
+ end
35
+ end
36
+
37
+ def handle_mouse(event)
38
+ return nil if rows.empty?
39
+ return nil unless event.respond_to?(:click?) && event.click?
40
+
41
+ clicked = event.y - HEADER_HEIGHT
42
+ return nil if clicked.negative? || clicked >= rows.length
43
+
44
+ @selected_index = clicked
45
+ :handled
46
+ end
47
+
48
+ def selected_row
49
+ rows[selected_index]
50
+ end
51
+
52
+ def render
53
+ return "(empty table)" if header.empty? && rows.empty?
54
+
55
+ normalized = rows.map { |row| normalize_row(row) }
56
+ lines = TTY::Table.new(header: header, rows: normalized)
57
+ .render(:unicode)
58
+ .lines(chomp: true)
59
+
60
+ compact_layout(lines)
61
+ end
62
+
63
+ private
64
+
65
+ def normalize_row(row)
66
+ cells = case row
67
+ when Hash then row.values
68
+ when String then [row]
69
+ else Array(row)
70
+ end
71
+ return cells if header.length <= 1 || cells.length <= header.length
72
+
73
+ kept = cells.first(header.length - 1)
74
+ merged = cells[(header.length - 1)..].join(" ")
75
+ kept + [merged]
76
+ end
77
+
78
+ def compact_layout(lines)
79
+ return lines.join("\n") if lines.length < 4
80
+
81
+ top, header_line, _separator, *rest = lines
82
+ body = rest.first(rows.length)
83
+ bottom = rest[rows.length]
84
+
85
+ highlighted = body.each_with_index.map do |line, index|
86
+ (index == selected_index) ? "\e[7m#{line}\e[m" : line
87
+ end
88
+
89
+ [top, header_line, *highlighted, bottom].compact.join("\n")
90
+ end
91
+
92
+ def move_up
93
+ @selected_index -= 1 if selected_index.positive?
94
+ end
95
+
96
+ def move_down
97
+ @selected_index += 1 if selected_index < rows.length - 1
98
+ end
99
+
100
+ def move_home
101
+ @selected_index = 0
102
+ end
103
+
104
+ def move_end
105
+ @selected_index = rows.length - 1
106
+ end
107
+
108
+ def clamp_index(value)
109
+ return 0 if rows.empty?
110
+
111
+ value.to_i.clamp(0, rows.length - 1)
112
+ end
113
+ end
114
+ end
115
+ end