charming 0.1.2 → 0.1.4

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/lib/charming/application.rb +22 -5
  3. data/lib/charming/cli.rb +3 -3
  4. data/lib/charming/controller/class_methods.rb +2 -2
  5. data/lib/charming/controller/command_palette.rb +2 -2
  6. data/lib/charming/controller/component_dispatching.rb +47 -3
  7. data/lib/charming/controller/focus.rb +123 -0
  8. data/lib/charming/controller/focus_management.rb +1 -1
  9. data/lib/charming/controller/rendering.rb +6 -17
  10. data/lib/charming/controller/session_state.rb +12 -1
  11. data/lib/charming/controller.rb +11 -2
  12. data/lib/charming/database/commands.rb +106 -0
  13. data/lib/charming/generators/component_generator.rb +1 -1
  14. data/lib/charming/generators/database_installer.rb +154 -0
  15. data/lib/charming/generators/model_generator.rb +2 -10
  16. data/lib/charming/generators/name.rb +1 -1
  17. data/lib/charming/generators/templates/app/application.template +1 -1
  18. data/lib/charming/generators/templates/app/layout.template +3 -6
  19. data/lib/charming/generators/templates/app/view.template +1 -1
  20. data/lib/charming/generators/templates/component/component.rb.template +1 -1
  21. data/lib/charming/generators/templates/screen/view.rb.template +1 -1
  22. data/lib/charming/generators/templates/view/view.rb.template +1 -1
  23. data/lib/charming/generators/view_generator.rb +1 -1
  24. data/lib/charming/internal/renderer/differential.rb +13 -5
  25. data/lib/charming/internal/terminal/tty_backend.rb +22 -2
  26. data/lib/charming/presentation/component.rb +3 -5
  27. data/lib/charming/presentation/components/activity_indicator.rb +173 -134
  28. data/lib/charming/presentation/components/command_palette.rb +94 -96
  29. data/lib/charming/presentation/components/command_palette_modal.rb +33 -0
  30. data/lib/charming/presentation/components/empty_state.rb +47 -49
  31. data/lib/charming/presentation/components/form/builder.rb +52 -54
  32. data/lib/charming/presentation/components/form/confirm.rb +49 -51
  33. data/lib/charming/presentation/components/form/field.rb +94 -96
  34. data/lib/charming/presentation/components/form/input.rb +53 -55
  35. data/lib/charming/presentation/components/form/note.rb +27 -29
  36. data/lib/charming/presentation/components/form/select.rb +84 -86
  37. data/lib/charming/presentation/components/form/textarea.rb +67 -69
  38. data/lib/charming/presentation/components/form.rb +120 -122
  39. data/lib/charming/presentation/components/keyboard_handler.rb +41 -43
  40. data/lib/charming/presentation/components/list.rb +123 -125
  41. data/lib/charming/presentation/components/markdown.rb +21 -23
  42. data/lib/charming/presentation/components/modal.rb +46 -48
  43. data/lib/charming/presentation/components/progressbar.rb +51 -53
  44. data/lib/charming/presentation/components/spinner.rb +40 -42
  45. data/lib/charming/presentation/components/table.rb +109 -111
  46. data/lib/charming/presentation/components/text_area.rb +219 -221
  47. data/lib/charming/presentation/components/text_input.rb +120 -122
  48. data/lib/charming/presentation/components/viewport.rb +218 -220
  49. data/lib/charming/presentation/layout/builder.rb +64 -66
  50. data/lib/charming/presentation/layout/overlay.rb +48 -50
  51. data/lib/charming/presentation/layout/pane.rb +129 -118
  52. data/lib/charming/presentation/layout/rect.rb +19 -16
  53. data/lib/charming/presentation/layout/screen_layout.rb +47 -42
  54. data/lib/charming/presentation/layout/split.rb +107 -102
  55. data/lib/charming/presentation/layout.rb +28 -30
  56. data/lib/charming/presentation/markdown/render_context.rb +30 -14
  57. data/lib/charming/presentation/markdown/renderer.rb +302 -79
  58. data/lib/charming/presentation/markdown/style_config.rb +215 -0
  59. data/lib/charming/presentation/markdown/syntax_highlighter.rb +58 -59
  60. data/lib/charming/presentation/markdown.rb +4 -6
  61. data/lib/charming/presentation/template_view.rb +22 -24
  62. data/lib/charming/presentation/templates/erb_handler.rb +4 -6
  63. data/lib/charming/presentation/templates.rb +47 -49
  64. data/lib/charming/presentation/ui/ansi_codes.rb +66 -68
  65. data/lib/charming/presentation/ui/ansi_slicer.rb +67 -69
  66. data/lib/charming/presentation/ui/border.rb +24 -26
  67. data/lib/charming/presentation/ui/border_painter.rb +37 -39
  68. data/lib/charming/presentation/ui/canvas.rb +59 -61
  69. data/lib/charming/presentation/ui/style.rb +173 -175
  70. data/lib/charming/presentation/ui/theme.rb +133 -135
  71. data/lib/charming/presentation/ui/width.rb +12 -14
  72. data/lib/charming/presentation/ui.rb +69 -71
  73. data/lib/charming/presentation/view.rb +110 -105
  74. data/lib/charming/router.rb +3 -8
  75. data/lib/charming/runtime.rb +25 -10
  76. data/lib/charming/version.rb +1 -1
  77. data/lib/charming.rb +5 -4
  78. metadata +43 -9
  79. data/lib/charming/database_commands.rb +0 -103
  80. data/lib/charming/database_installer.rb +0 -152
  81. data/lib/charming/focus.rb +0 -121
  82. data/lib/charming/presentation/markdown/block_renderers.rb +0 -120
  83. data/lib/charming/presentation/markdown/inline_renderers.rb +0 -68
@@ -1,30 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- module Presentation
5
- module Components
6
- # Markdown renders Markdown source as ANSI-styled terminal text. Parsing is delegated to
7
- # `Presentation::Markdown::Renderer`; set *syntax_highlighting* to false to disable
8
- # Rouge-backed code block highlighting.
9
- class Markdown < Component
10
- # *content* is the Markdown source string. *width* optionally sets the wrap width.
11
- # *syntax_highlighting* enables Rouge for code blocks (defaults to true).
12
- def initialize(content:, width: nil, theme: nil, syntax_highlighting: true)
13
- super(theme: theme)
14
- @content = content
15
- @width = width
16
- @syntax_highlighting = syntax_highlighting
17
- end
4
+ module Components
5
+ # Markdown renders CommonMark/GFM source as ANSI-styled terminal text.
6
+ class Markdown < Component
7
+ def initialize(content:, width: nil, theme: nil, syntax_highlighting: true, style: :dark, base_url: nil)
8
+ super(theme: theme)
9
+ @content = content
10
+ @width = width
11
+ @syntax_highlighting = syntax_highlighting
12
+ @style = style
13
+ @base_url = base_url
14
+ end
18
15
 
19
- # Renders the Markdown body to a styled, terminal-safe string.
20
- def render
21
- Charming::Presentation::Markdown::Renderer.new(
22
- content: @content,
23
- width: @width,
24
- theme: theme,
25
- syntax_highlighting: @syntax_highlighting
26
- ).render
27
- end
16
+ # Renders the Markdown body to a styled, terminal-safe string.
17
+ def render
18
+ Charming::Markdown::Renderer.new(
19
+ content: @content,
20
+ width: @width,
21
+ theme: theme,
22
+ syntax_highlighting: @syntax_highlighting,
23
+ style: @style,
24
+ base_url: @base_url
25
+ ).render
28
26
  end
29
27
  end
30
28
  end
@@ -1,63 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- module Presentation
5
- module Components
6
- # Modal is a centered, boxed overlay with an optional title, help line, and body content.
7
- # The body may be a string, View, or Component; when it responds to `render`, its output
8
- # is used. The result is wrapped in a UI::Style border with padding.
9
- class Modal < Component
10
- # *content* is the modal body. *title* (optional) is rendered centered at the top.
11
- # *help* (optional) is rendered as a muted footer line. *width* is the modal's total width.
12
- # *style* overrides the default `theme.modal` style.
13
- def initialize(content:, title: nil, help: nil, width: 52, style: nil, theme: nil)
14
- super(theme: theme)
15
- @content = content
16
- @title = title
17
- @help = help
18
- @width = width
19
- @style = style
20
- end
4
+ module Components
5
+ # Modal is a centered, boxed overlay with an optional title, help line, and body content.
6
+ # The body may be a string, View, or Component; when it responds to `render`, its output
7
+ # is used. The result is wrapped in a UI::Style border with padding.
8
+ class Modal < Component
9
+ # *content* is the modal body. *title* (optional) is rendered centered at the top.
10
+ # *help* (optional) is rendered as a muted footer line. *width* is the modal's total width.
11
+ # *style* overrides the default `theme.modal` style.
12
+ def initialize(content:, title: nil, help: nil, width: 52, style: nil, theme: nil)
13
+ super(theme: theme)
14
+ @content = content
15
+ @title = title
16
+ @help = help
17
+ @width = width
18
+ @style = style
19
+ end
21
20
 
22
- # Renders the modal as a bordered, padded string with the title and help lines stacked
23
- # above the content.
24
- def render
25
- box(column(*lines, gap: 1), style: modal_style)
26
- end
21
+ # Renders the modal as a bordered, padded string with the title and help lines stacked
22
+ # above the content.
23
+ def render
24
+ box(column(*lines, gap: 1), style: modal_style)
25
+ end
27
26
 
28
- private
27
+ private
29
28
 
30
- attr_reader :content, :title, :help, :width
29
+ attr_reader :content, :title, :help, :width
31
30
 
32
- # Returns the array of non-nil lines: title, help, content.
33
- def lines
34
- [title_line, help_line, render_content].compact
35
- end
31
+ # Returns the array of non-nil lines: title, help, content.
32
+ def lines
33
+ [title_line, help_line, render_content].compact
34
+ end
36
35
 
37
- # Returns the centered title line styled with the theme's title style, when a title was given.
38
- def title_line
39
- text(title, style: theme.title.align(:center).width(title_width)) if title
40
- end
36
+ # Returns the centered title line styled with the theme's title style, when a title was given.
37
+ def title_line
38
+ text(title, style: theme.title.align(:center).width(title_width)) if title
39
+ end
41
40
 
42
- # Returns the help line styled with the theme's muted style, when help was given.
43
- def help_line
44
- text(help, style: theme.muted) if help
45
- end
41
+ # Returns the help line styled with the theme's muted style, when help was given.
42
+ def help_line
43
+ text(help, style: theme.muted) if help
44
+ end
46
45
 
47
- # Returns the rendered content string, calling `render` on the body when applicable.
48
- def render_content
49
- content.respond_to?(:render) ? render_component(content) : content.to_s
50
- end
46
+ # Returns the rendered content string, calling `render` on the body when applicable.
47
+ def render_content
48
+ content.respond_to?(:render) ? render_component(content) : content.to_s
49
+ end
51
50
 
52
- # Returns the modal's outer style: the user-provided style or `theme.modal` at the given width.
53
- def modal_style
54
- @style || theme.modal.width(width)
55
- end
51
+ # Returns the modal's outer style: the user-provided style or `theme.modal` at the given width.
52
+ def modal_style
53
+ @style || theme.modal.width(width)
54
+ end
56
55
 
57
- # Returns the title's display width, accounting for the modal's horizontal padding/border.
58
- def title_width
59
- [width - 8, 0].max
60
- end
56
+ # Returns the title's display width, accounting for the modal's horizontal padding/border.
57
+ def title_width
58
+ [width - 8, 0].max
61
59
  end
62
60
  end
63
61
  end
@@ -1,69 +1,67 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- module Presentation
5
- module Components
6
- # Progressbar renders a fixed-width ASCII progress bar. The bar is sized to the configured
7
- # *total* (in arbitrary units) and fills proportionally to the current value. Optionally
8
- # appends a label after the bar.
9
- class Progressbar < Component
10
- # Public accessors: total units, current value, label text, completed and remaining
11
- # characters, and the bar format symbol.
12
- attr_accessor :total, :current, :label, :complete, :incomplete, :bar_format
4
+ module Components
5
+ # Progressbar renders a fixed-width ASCII progress bar. The bar is sized to the configured
6
+ # *total* (in arbitrary units) and fills proportionally to the current value. Optionally
7
+ # appends a label after the bar.
8
+ class Progressbar < Component
9
+ # Public accessors: total units, current value, label text, completed and remaining
10
+ # characters, and the bar format symbol.
11
+ attr_accessor :total, :current, :label, :complete, :incomplete, :bar_format
13
12
 
14
- # *total* is the maximum unit count. *complete* and *incomplete* are the characters used
15
- # for filled and unfilled positions (default "=" and " "). *bar_format* is reserved for
16
- # future format variants. *label* is an optional suffix shown after the bar.
17
- def initialize(total:, complete: "=", incomplete: " ", bar_format: :classic, label: nil)
18
- super()
19
- @total = [total.to_i, 0].max
20
- @complete = complete.to_s
21
- @incomplete = incomplete.to_s
22
- @bar_format = bar_format.to_sym
23
- @label = label
24
- @current = 0
25
- end
13
+ # *total* is the maximum unit count. *complete* and *incomplete* are the characters used
14
+ # for filled and unfilled positions (default "=" and " "). *bar_format* is reserved for
15
+ # future format variants. *label* is an optional suffix shown after the bar.
16
+ def initialize(total:, complete: "=", incomplete: " ", bar_format: :classic, label: nil)
17
+ super()
18
+ @total = [total.to_i, 0].max
19
+ @complete = complete.to_s
20
+ @incomplete = incomplete.to_s
21
+ @bar_format = bar_format.to_sym
22
+ @label = label
23
+ @current = 0
24
+ end
26
25
 
27
- # Advances the current value by *count* (default 1), clamping to `[0, total]`. Returns self.
28
- def tick(count = 1)
29
- update(@current + count)
30
- self
31
- end
26
+ # Advances the current value by *count* (default 1), clamping to `[0, total]`. Returns self.
27
+ def tick(count = 1)
28
+ update(@current + count)
29
+ self
30
+ end
32
31
 
33
- # Sets the current value, clamping to `[0, total]`. Returns self.
34
- def update(value)
35
- @current = value.to_i.clamp(0, @total)
36
- self
37
- end
32
+ # Sets the current value, clamping to `[0, total]`. Returns self.
33
+ def update(value)
34
+ @current = value.to_i.clamp(0, @total)
35
+ self
36
+ end
38
37
 
39
- # Jumps the bar directly to 100% completion. Returns self.
40
- def complete!
41
- @current = @total
42
- self
43
- end
38
+ # Jumps the bar directly to 100% completion. Returns self.
39
+ def complete!
40
+ @current = @total
41
+ self
42
+ end
44
43
 
45
- # Renders the bar as `[==== ]` (with the *label* appended when present).
46
- def render
47
- width = [@total, 1].max
48
- completed = completed_width(width)
49
- incomplete = width - completed
50
- incomplete -= 1 if @current.zero?
51
- bar = (@complete * completed) + (@incomplete * incomplete)
52
- result = "[" + bar + "]"
44
+ # Renders the bar as `[==== ]` (with the *label* appended when present).
45
+ def render
46
+ width = [@total, 1].max
47
+ completed = completed_width(width)
48
+ incomplete = width - completed
49
+ incomplete -= 1 if @current.zero?
50
+ bar = (@complete * completed) + (@incomplete * incomplete)
51
+ result = "[" + bar + "]"
53
52
 
54
- return result unless @label
53
+ return result unless @label
55
54
 
56
- "#{result} #{@label}"
57
- end
55
+ "#{result} #{@label}"
56
+ end
58
57
 
59
- private
58
+ private
60
59
 
61
- # Returns the number of `complete` characters to draw, rounded to the nearest integer.
62
- def completed_width(width)
63
- return 0 unless @total.positive?
60
+ # Returns the number of `complete` characters to draw, rounded to the nearest integer.
61
+ def completed_width(width)
62
+ return 0 unless @total.positive?
64
63
 
65
- ((width * @current) / @total.to_f).round
66
- end
64
+ ((width * @current) / @total.to_f).round
67
65
  end
68
66
  end
69
67
  end
@@ -1,48 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- module Presentation
5
- module Components
6
- # Spinner is a simple rotating-frame indicator. The component cycles through a list of
7
- # frames on each `tick`; pair it with a controller timer to drive animation. An optional
8
- # *label* is appended after the current frame on each render.
9
- class Spinner < Component
10
- # The default frame set: a 4-frame ASCII spinner.
11
- DEFAULT_FRAMES = ["-", "\\", "|", "/"].freeze
12
-
13
- # The current frame list, frame index, and optional label string.
14
- attr_reader :frames, :index, :label
15
-
16
- # *frames* defaults to DEFAULT_FRAMES but may be replaced with any array of frame strings.
17
- # *index* is the starting frame index. *label* is an optional suffix shown after the frame.
18
- def initialize(frames: DEFAULT_FRAMES, index: 0, label: nil)
19
- super()
20
- raise ArgumentError, "frames cannot be empty" if frames.empty?
21
-
22
- @frames = frames
23
- @index = index
24
- @label = label
25
- end
26
-
27
- # Advances the frame index by one position, wrapping around. Returns self for chaining.
28
- def tick
29
- @index = (index + 1) % frames.length
30
- self
31
- end
32
-
33
- # Renders the current frame, optionally followed by the label and a space.
34
- def render
35
- return frame unless label
36
-
37
- "#{frame} #{label}"
38
- end
39
-
40
- private
41
-
42
- # Returns the current frame string (with index modulo frame count to be safe).
43
- def frame
44
- frames.fetch(index % frames.length)
45
- end
4
+ module Components
5
+ # Spinner is a simple rotating-frame indicator. The component cycles through a list of
6
+ # frames on each `tick`; pair it with a controller timer to drive animation. An optional
7
+ # *label* is appended after the current frame on each render.
8
+ class Spinner < Component
9
+ # The default frame set: a 4-frame ASCII spinner.
10
+ DEFAULT_FRAMES = ["-", "\\", "|", "/"].freeze
11
+
12
+ # The current frame list, frame index, and optional label string.
13
+ attr_reader :frames, :index, :label
14
+
15
+ # *frames* defaults to DEFAULT_FRAMES but may be replaced with any array of frame strings.
16
+ # *index* is the starting frame index. *label* is an optional suffix shown after the frame.
17
+ def initialize(frames: DEFAULT_FRAMES, index: 0, label: nil)
18
+ super()
19
+ raise ArgumentError, "frames cannot be empty" if frames.empty?
20
+
21
+ @frames = frames
22
+ @index = index
23
+ @label = label
24
+ end
25
+
26
+ # Advances the frame index by one position, wrapping around. Returns self for chaining.
27
+ def tick
28
+ @index = (index + 1) % frames.length
29
+ self
30
+ end
31
+
32
+ # Renders the current frame, optionally followed by the label and a space.
33
+ def render
34
+ return frame unless label
35
+
36
+ "#{frame} #{label}"
37
+ end
38
+
39
+ private
40
+
41
+ # Returns the current frame string (with index modulo frame count to be safe).
42
+ def frame
43
+ frames.fetch(index % frames.length)
46
44
  end
47
45
  end
48
46
  end
@@ -3,140 +3,138 @@
3
3
  require "tty-table"
4
4
 
5
5
  module Charming
6
- module Presentation
7
- module Components
8
- # Table renders tabular data with a header row, a selected row highlight, and keyboard
9
- # navigation. Mouse clicks within the body area also select rows. The table is rendered
10
- # via tty-table and the selected row is overlaid with reverse-video ANSI styling.
11
- class Table < Component
12
- include KeyboardHandler
13
-
14
- # Maps navigation keys to the instance methods that move the selection. Shared with
15
- # List and Viewport via KeyboardHandler.
16
- KEY_ACTIONS = {
17
- up: :move_up,
18
- down: :move_down,
19
- home: :move_home,
20
- end: :move_end
21
- }.freeze
22
-
23
- # Number of terminal rows occupied by the table's top border and header line. Used by
24
- # the mouse handler to translate absolute row coordinates to body rows.
25
- HEADER_HEIGHT = 2
26
-
27
- # The header row, the body rows, and the currently selected row index, respectively.
28
- attr_reader :header, :rows, :selected_index
29
-
30
- # *header* is an array of column labels. *rows* is the array of body rows (each either a
31
- # String, an Array, or a Hash of column-value pairs). *selected_index* defaults to 0.
32
- # *keymap* selects the keybinding style (`:vim` enables h/j/k/l → left/down/up/right).
33
- def initialize(header:, rows: [], selected_index: 0, keymap: :vim)
34
- super()
35
- @header = Array(header).map(&:to_s)
36
- @rows = Array(rows)
37
- @selected_index = clamp_index(selected_index)
38
- @keymap = keymap
39
- end
6
+ module Components
7
+ # Table renders tabular data with a header row, a selected row highlight, and keyboard
8
+ # navigation. Mouse clicks within the body area also select rows. The table is rendered
9
+ # via tty-table and the selected row is overlaid with reverse-video ANSI styling.
10
+ class Table < Component
11
+ include KeyboardHandler
12
+
13
+ # Maps navigation keys to the instance methods that move the selection. Shared with
14
+ # List and Viewport via KeyboardHandler.
15
+ KEY_ACTIONS = {
16
+ up: :move_up,
17
+ down: :move_down,
18
+ home: :move_home,
19
+ end: :move_end
20
+ }.freeze
21
+
22
+ # Number of terminal rows occupied by the table's top border and header line. Used by
23
+ # the mouse handler to translate absolute row coordinates to body rows.
24
+ HEADER_HEIGHT = 2
25
+
26
+ # The header row, the body rows, and the currently selected row index, respectively.
27
+ attr_reader :header, :rows, :selected_index
28
+
29
+ # *header* is an array of column labels. *rows* is the array of body rows (each either a
30
+ # String, an Array, or a Hash of column-value pairs). *selected_index* defaults to 0.
31
+ # *keymap* selects the keybinding style (`:vim` enables h/j/k/l → left/down/up/right).
32
+ def initialize(header:, rows: [], selected_index: 0, keymap: :vim)
33
+ super()
34
+ @header = Array(header).map(&:to_s)
35
+ @rows = Array(rows)
36
+ @selected_index = clamp_index(selected_index)
37
+ @keymap = keymap
38
+ end
40
39
 
41
- # Handles key events. Returns `[:selected, row]` on Enter; otherwise delegates to the
42
- # KeyboardHandler for navigation keys.
43
- def handle_key(event)
44
- return nil if rows.empty?
40
+ # Handles key events. Returns `[:selected, row]` on Enter; otherwise delegates to the
41
+ # KeyboardHandler for navigation keys.
42
+ def handle_key(event)
43
+ return nil if rows.empty?
45
44
 
46
- case Charming.key_of(event)
47
- when :enter then [:selected, selected_row]
48
- else super
49
- end
45
+ case Charming.key_of(event)
46
+ when :enter then [:selected, selected_row]
47
+ else super
50
48
  end
49
+ end
51
50
 
52
- # Handles mouse events: a click within the body area selects the clicked row.
53
- # Returns :handled on a successful click.
54
- def handle_mouse(event)
55
- return nil if rows.empty?
56
- return nil unless event.respond_to?(:click?) && event.click?
51
+ # Handles mouse events: a click within the body area selects the clicked row.
52
+ # Returns :handled on a successful click.
53
+ def handle_mouse(event)
54
+ return nil if rows.empty?
55
+ return nil unless event.respond_to?(:click?) && event.click?
57
56
 
58
- clicked = event.y - HEADER_HEIGHT
59
- return nil if clicked.negative? || clicked >= rows.length
57
+ clicked = event.y - HEADER_HEIGHT
58
+ return nil if clicked.negative? || clicked >= rows.length
60
59
 
61
- @selected_index = clicked
62
- :handled
63
- end
60
+ @selected_index = clicked
61
+ :handled
62
+ end
64
63
 
65
- # Returns the currently selected row, or nil when the table is empty.
66
- def selected_row
67
- rows[selected_index]
68
- end
64
+ # Returns the currently selected row, or nil when the table is empty.
65
+ def selected_row
66
+ rows[selected_index]
67
+ end
69
68
 
70
- # Renders the table to a string. Returns a placeholder when both header and rows are empty.
71
- def render
72
- return "(empty table)" if header.empty? && rows.empty?
69
+ # Renders the table to a string. Returns a placeholder when both header and rows are empty.
70
+ def render
71
+ return "(empty table)" if header.empty? && rows.empty?
73
72
 
74
- normalized = rows.map { |row| normalize_row(row) }
75
- lines = TTY::Table.new(header: header, rows: normalized)
76
- .render(:unicode)
77
- .lines(chomp: true)
73
+ normalized = rows.map { |row| normalize_row(row) }
74
+ lines = TTY::Table.new(header: header, rows: normalized)
75
+ .render(:unicode)
76
+ .lines(chomp: true)
78
77
 
79
- compact_layout(lines)
80
- end
78
+ compact_layout(lines)
79
+ end
81
80
 
82
- private
83
-
84
- # Coerces a *row* (Hash / String / Array) into a flat cell array matching the header.
85
- # Excess cells are merged into the last column with a space separator.
86
- def normalize_row(row)
87
- cells = case row
88
- when Hash then row.values
89
- when String then [row]
90
- else Array(row)
91
- end
92
- return cells if header.length <= 1 || cells.length <= header.length
93
-
94
- kept = cells.first(header.length - 1)
95
- merged = cells[(header.length - 1)..].join(" ")
96
- kept + [merged]
81
+ private
82
+
83
+ # Coerces a *row* (Hash / String / Array) into a flat cell array matching the header.
84
+ # Excess cells are merged into the last column with a space separator.
85
+ def normalize_row(row)
86
+ cells = case row
87
+ when Hash then row.values
88
+ when String then [row]
89
+ else Array(row)
97
90
  end
91
+ return cells if header.length <= 1 || cells.length <= header.length
98
92
 
99
- # Applies the selected-row highlight and trims unused body rows below the actual row count.
100
- def compact_layout(lines)
101
- return lines.join("\n") if lines.length < 4
93
+ kept = cells.first(header.length - 1)
94
+ merged = cells[(header.length - 1)..].join(" ")
95
+ kept + [merged]
96
+ end
102
97
 
103
- top, header_line, _separator, *rest = lines
104
- body = rest.first(rows.length)
105
- bottom = rest[rows.length]
98
+ # Applies the selected-row highlight and trims unused body rows below the actual row count.
99
+ def compact_layout(lines)
100
+ return lines.join("\n") if lines.length < 4
106
101
 
107
- highlighted = body.each_with_index.map do |line, index|
108
- (index == selected_index) ? "\e[7m#{line}\e[m" : line
109
- end
102
+ top, header_line, _separator, *rest = lines
103
+ body = rest.first(rows.length)
104
+ bottom = rest[rows.length]
110
105
 
111
- [top, header_line, *highlighted, bottom].compact.join("\n")
106
+ highlighted = body.each_with_index.map do |line, index|
107
+ (index == selected_index) ? "\e[7m#{line}\e[m" : line
112
108
  end
113
109
 
114
- # Moves the selection up one row.
115
- def move_up
116
- @selected_index -= 1 if selected_index.positive?
117
- end
110
+ [top, header_line, *highlighted, bottom].compact.join("\n")
111
+ end
118
112
 
119
- # Moves the selection down one row.
120
- def move_down
121
- @selected_index += 1 if selected_index < rows.length - 1
122
- end
113
+ # Moves the selection up one row.
114
+ def move_up
115
+ @selected_index -= 1 if selected_index.positive?
116
+ end
123
117
 
124
- # Moves the selection to the first row.
125
- def move_home
126
- @selected_index = 0
127
- end
118
+ # Moves the selection down one row.
119
+ def move_down
120
+ @selected_index += 1 if selected_index < rows.length - 1
121
+ end
128
122
 
129
- # Moves the selection to the last row.
130
- def move_end
131
- @selected_index = rows.length - 1
132
- end
123
+ # Moves the selection to the first row.
124
+ def move_home
125
+ @selected_index = 0
126
+ end
133
127
 
134
- # Clamps *value* to the valid row range, defaulting to 0 when the table is empty.
135
- def clamp_index(value)
136
- return 0 if rows.empty?
128
+ # Moves the selection to the last row.
129
+ def move_end
130
+ @selected_index = rows.length - 1
131
+ end
137
132
 
138
- value.to_i.clamp(0, rows.length - 1)
139
- end
133
+ # Clamps *value* to the valid row range, defaulting to 0 when the table is empty.
134
+ def clamp_index(value)
135
+ return 0 if rows.empty?
136
+
137
+ value.to_i.clamp(0, rows.length - 1)
140
138
  end
141
139
  end
142
140
  end