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.
- checksums.yaml +4 -4
- data/lib/charming/application.rb +22 -5
- data/lib/charming/cli.rb +3 -3
- data/lib/charming/controller/class_methods.rb +2 -2
- data/lib/charming/controller/command_palette.rb +2 -2
- data/lib/charming/controller/component_dispatching.rb +47 -3
- data/lib/charming/controller/focus.rb +123 -0
- data/lib/charming/controller/focus_management.rb +1 -1
- data/lib/charming/controller/rendering.rb +6 -17
- data/lib/charming/controller/session_state.rb +12 -1
- data/lib/charming/controller.rb +11 -2
- data/lib/charming/database/commands.rb +106 -0
- data/lib/charming/generators/component_generator.rb +1 -1
- data/lib/charming/generators/database_installer.rb +154 -0
- data/lib/charming/generators/model_generator.rb +2 -10
- data/lib/charming/generators/name.rb +1 -1
- data/lib/charming/generators/templates/app/application.template +1 -1
- data/lib/charming/generators/templates/app/layout.template +3 -6
- data/lib/charming/generators/templates/app/view.template +1 -1
- data/lib/charming/generators/templates/component/component.rb.template +1 -1
- data/lib/charming/generators/templates/screen/view.rb.template +1 -1
- data/lib/charming/generators/templates/view/view.rb.template +1 -1
- data/lib/charming/generators/view_generator.rb +1 -1
- data/lib/charming/internal/renderer/differential.rb +13 -5
- data/lib/charming/internal/terminal/tty_backend.rb +22 -2
- data/lib/charming/presentation/component.rb +3 -5
- data/lib/charming/presentation/components/activity_indicator.rb +173 -134
- data/lib/charming/presentation/components/command_palette.rb +94 -96
- data/lib/charming/presentation/components/command_palette_modal.rb +33 -0
- data/lib/charming/presentation/components/empty_state.rb +47 -49
- data/lib/charming/presentation/components/form/builder.rb +52 -54
- data/lib/charming/presentation/components/form/confirm.rb +49 -51
- data/lib/charming/presentation/components/form/field.rb +94 -96
- data/lib/charming/presentation/components/form/input.rb +53 -55
- data/lib/charming/presentation/components/form/note.rb +27 -29
- data/lib/charming/presentation/components/form/select.rb +84 -86
- data/lib/charming/presentation/components/form/textarea.rb +67 -69
- data/lib/charming/presentation/components/form.rb +120 -122
- data/lib/charming/presentation/components/keyboard_handler.rb +41 -43
- data/lib/charming/presentation/components/list.rb +123 -125
- data/lib/charming/presentation/components/markdown.rb +21 -23
- data/lib/charming/presentation/components/modal.rb +46 -48
- data/lib/charming/presentation/components/progressbar.rb +51 -53
- data/lib/charming/presentation/components/spinner.rb +40 -42
- data/lib/charming/presentation/components/table.rb +109 -111
- data/lib/charming/presentation/components/text_area.rb +219 -221
- data/lib/charming/presentation/components/text_input.rb +120 -122
- data/lib/charming/presentation/components/viewport.rb +218 -220
- data/lib/charming/presentation/layout/builder.rb +64 -66
- data/lib/charming/presentation/layout/overlay.rb +48 -50
- data/lib/charming/presentation/layout/pane.rb +129 -118
- data/lib/charming/presentation/layout/rect.rb +19 -16
- data/lib/charming/presentation/layout/screen_layout.rb +47 -42
- data/lib/charming/presentation/layout/split.rb +107 -102
- data/lib/charming/presentation/layout.rb +28 -30
- data/lib/charming/presentation/markdown/render_context.rb +30 -14
- data/lib/charming/presentation/markdown/renderer.rb +302 -79
- data/lib/charming/presentation/markdown/style_config.rb +215 -0
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +58 -59
- data/lib/charming/presentation/markdown.rb +4 -6
- data/lib/charming/presentation/template_view.rb +22 -24
- data/lib/charming/presentation/templates/erb_handler.rb +4 -6
- data/lib/charming/presentation/templates.rb +47 -49
- data/lib/charming/presentation/ui/ansi_codes.rb +66 -68
- data/lib/charming/presentation/ui/ansi_slicer.rb +67 -69
- data/lib/charming/presentation/ui/border.rb +24 -26
- data/lib/charming/presentation/ui/border_painter.rb +37 -39
- data/lib/charming/presentation/ui/canvas.rb +59 -61
- data/lib/charming/presentation/ui/style.rb +173 -175
- data/lib/charming/presentation/ui/theme.rb +133 -135
- data/lib/charming/presentation/ui/width.rb +12 -14
- data/lib/charming/presentation/ui.rb +69 -71
- data/lib/charming/presentation/view.rb +110 -105
- data/lib/charming/router.rb +3 -8
- data/lib/charming/runtime.rb +25 -10
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +5 -4
- metadata +43 -9
- data/lib/charming/database_commands.rb +0 -103
- data/lib/charming/database_installer.rb +0 -152
- data/lib/charming/focus.rb +0 -121
- data/lib/charming/presentation/markdown/block_renderers.rb +0 -120
- 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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
27
|
+
private
|
|
29
28
|
|
|
30
|
-
|
|
29
|
+
attr_reader :content, :title, :help, :width
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
# Jumps the bar directly to 100% completion. Returns self.
|
|
39
|
+
def complete!
|
|
40
|
+
@current = @total
|
|
41
|
+
self
|
|
42
|
+
end
|
|
44
43
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
53
|
+
return result unless @label
|
|
55
54
|
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
"#{result} #{@label}"
|
|
56
|
+
end
|
|
58
57
|
|
|
59
|
-
|
|
58
|
+
private
|
|
60
59
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
57
|
+
clicked = event.y - HEADER_HEIGHT
|
|
58
|
+
return nil if clicked.negative? || clicked >= rows.length
|
|
60
59
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
@selected_index = clicked
|
|
61
|
+
:handled
|
|
62
|
+
end
|
|
64
63
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
78
|
+
compact_layout(lines)
|
|
79
|
+
end
|
|
81
80
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
93
|
+
kept = cells.first(header.length - 1)
|
|
94
|
+
merged = cells[(header.length - 1)..].join(" ")
|
|
95
|
+
kept + [merged]
|
|
96
|
+
end
|
|
102
97
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
102
|
+
top, header_line, _separator, *rest = lines
|
|
103
|
+
body = rest.first(rows.length)
|
|
104
|
+
bottom = rest[rows.length]
|
|
110
105
|
|
|
111
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
@selected_index -= 1 if selected_index.positive?
|
|
117
|
-
end
|
|
110
|
+
[top, header_line, *highlighted, bottom].compact.join("\n")
|
|
111
|
+
end
|
|
118
112
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
113
|
+
# Moves the selection up one row.
|
|
114
|
+
def move_up
|
|
115
|
+
@selected_index -= 1 if selected_index.positive?
|
|
116
|
+
end
|
|
123
117
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
123
|
+
# Moves the selection to the first row.
|
|
124
|
+
def move_home
|
|
125
|
+
@selected_index = 0
|
|
126
|
+
end
|
|
133
127
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
128
|
+
# Moves the selection to the last row.
|
|
129
|
+
def move_end
|
|
130
|
+
@selected_index = rows.length - 1
|
|
131
|
+
end
|
|
137
132
|
|
|
138
|
-
|
|
139
|
-
|
|
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
|