charming 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +38 -378
- data/lib/charming/application.rb +3 -3
- data/lib/charming/{application_model.rb → application_state.rb} +3 -3
- data/lib/charming/cli.rb +39 -3
- data/lib/charming/controller.rb +146 -24
- data/lib/charming/database_commands.rb +87 -0
- data/lib/charming/database_installer.rb +125 -0
- data/lib/charming/events/key_event.rb +15 -0
- data/lib/charming/events/mouse_event.rb +42 -0
- data/lib/charming/events/resize_event.rb +9 -0
- data/lib/charming/events/task_event.rb +19 -0
- data/lib/charming/events/timer_event.rb +9 -0
- data/lib/charming/generators/app_generator/app_spec_templates.rb +12 -8
- data/lib/charming/generators/app_generator/basic_templates.rb +14 -2
- data/lib/charming/generators/app_generator/component_templates.rb +1 -1
- data/lib/charming/generators/app_generator/controller_template.rb +3 -12
- data/lib/charming/generators/app_generator/database_templates.rb +45 -0
- data/lib/charming/generators/app_generator/layout_template.rb +51 -145
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +7 -8
- data/lib/charming/generators/app_generator/{model_templates.rb → state_templates.rb} +5 -5
- data/lib/charming/generators/app_generator/view_template.rb +12 -18
- data/lib/charming/generators/app_generator.rb +37 -11
- data/lib/charming/generators/component_generator.rb +1 -1
- data/lib/charming/generators/controller_generator.rb +1 -4
- data/lib/charming/generators/model_generator.rb +119 -0
- data/lib/charming/generators/name.rb +0 -4
- data/lib/charming/generators/screen_generator.rb +14 -28
- data/lib/charming/generators/view_generator.rb +11 -14
- data/lib/charming/internal/renderer/differential.rb +2 -3
- data/lib/charming/internal/terminal/tty_backend.rb +25 -8
- data/lib/charming/presentation/component.rb +10 -0
- data/lib/charming/presentation/components/activity_indicator.rb +160 -0
- data/lib/charming/presentation/components/command_palette.rb +120 -0
- data/lib/charming/presentation/components/empty_state.rb +43 -0
- data/lib/charming/presentation/components/form/builder.rb +48 -0
- data/lib/charming/presentation/components/form/confirm.rb +56 -0
- data/lib/charming/presentation/components/form/field.rb +96 -0
- data/lib/charming/presentation/components/form/input.rb +57 -0
- data/lib/charming/presentation/components/form/note.rb +32 -0
- data/lib/charming/presentation/components/form/select.rb +89 -0
- data/lib/charming/presentation/components/form/textarea.rb +70 -0
- data/lib/charming/presentation/components/form.rb +127 -0
- data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
- data/lib/charming/presentation/components/list.rb +104 -0
- data/lib/charming/presentation/components/markdown.rb +25 -0
- data/lib/charming/presentation/components/modal.rb +50 -0
- data/lib/charming/presentation/components/progressbar.rb +57 -0
- data/lib/charming/presentation/components/spinner.rb +39 -0
- data/lib/charming/presentation/components/table.rb +118 -0
- data/lib/charming/presentation/components/text_area.rb +219 -0
- data/lib/charming/presentation/components/text_input.rb +105 -0
- data/lib/charming/presentation/components/viewport.rb +220 -0
- data/lib/charming/presentation/layout.rb +43 -0
- data/lib/charming/presentation/markdown/renderer.rb +203 -0
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +63 -0
- data/lib/charming/presentation/markdown.rb +8 -0
- data/lib/charming/presentation/template_view.rb +27 -0
- data/lib/charming/presentation/templates/erb_handler.rb +15 -0
- data/lib/charming/presentation/templates.rb +51 -0
- data/lib/charming/presentation/ui/border.rb +35 -0
- data/lib/charming/presentation/ui/style.rb +246 -0
- data/lib/charming/presentation/ui/theme.rb +180 -0
- data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
- data/lib/charming/presentation/ui/width.rb +26 -0
- data/lib/charming/presentation/ui.rb +232 -0
- data/lib/charming/presentation/view.rb +118 -0
- data/lib/charming/runtime.rb +7 -7
- data/lib/charming/screen.rb +5 -1
- data/lib/charming/tasks/inline_executor.rb +28 -0
- data/lib/charming/tasks/task.rb +9 -0
- data/lib/charming/{task_executor.rb → tasks/threaded_executor.rb} +4 -27
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +4 -0
- metadata +114 -29
- data/lib/charming/component.rb +0 -8
- data/lib/charming/components/activity_indicator.rb +0 -158
- data/lib/charming/components/command_palette.rb +0 -118
- data/lib/charming/components/keyboard_handler.rb +0 -22
- data/lib/charming/components/list.rb +0 -105
- data/lib/charming/components/modal.rb +0 -48
- data/lib/charming/components/progressbar.rb +0 -55
- data/lib/charming/components/spinner.rb +0 -37
- data/lib/charming/components/table.rb +0 -115
- data/lib/charming/components/text_input.rb +0 -103
- data/lib/charming/components/viewport.rb +0 -191
- data/lib/charming/key_event.rb +0 -13
- data/lib/charming/mouse_event.rb +0 -40
- data/lib/charming/resize_event.rb +0 -7
- data/lib/charming/task.rb +0 -7
- data/lib/charming/task_event.rb +0 -17
- data/lib/charming/timer_event.rb +0 -7
- data/lib/charming/ui/border.rb +0 -33
- data/lib/charming/ui/style.rb +0 -244
- data/lib/charming/ui/theme.rb +0 -178
- data/lib/charming/ui/width.rb +0 -24
- data/lib/charming/ui.rb +0 -230
- data/lib/charming/view.rb +0 -116
- /data/lib/charming/{generators.rb → generators/error.rb} +0 -0
|
@@ -1,118 +0,0 @@
|
|
|
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
|
|
@@ -1,22 +0,0 @@
|
|
|
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
|
|
@@ -1,105 +0,0 @@
|
|
|
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
|
|
@@ -1,48 +0,0 @@
|
|
|
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
|
|
@@ -1,55 +0,0 @@
|
|
|
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
|
|
@@ -1,37 +0,0 @@
|
|
|
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
|
|
@@ -1,115 +0,0 @@
|
|
|
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
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Charming
|
|
4
|
-
module Components
|
|
5
|
-
class TextInput < Component
|
|
6
|
-
include KeyboardHandler
|
|
7
|
-
|
|
8
|
-
# Maps editing keys (left/right/home/end/backspace/delete) to the instance
|
|
9
|
-
# methods they dispatch via KeyboardHandler. Each symbol key (e.g., :left)
|
|
10
|
-
# maps to a method (e.g., :move_left) that adjusts cursor position or text content.
|
|
11
|
-
KEY_ACTIONS = {
|
|
12
|
-
left: :move_left,
|
|
13
|
-
right: :move_right,
|
|
14
|
-
home: :move_home,
|
|
15
|
-
end: :move_end,
|
|
16
|
-
backspace: :delete_before_cursor,
|
|
17
|
-
delete: :delete_at_cursor
|
|
18
|
-
}.freeze
|
|
19
|
-
|
|
20
|
-
attr_reader :value, :cursor
|
|
21
|
-
|
|
22
|
-
def initialize(value: "", placeholder: "", width: nil, cursor: nil)
|
|
23
|
-
super()
|
|
24
|
-
@value = value.dup
|
|
25
|
-
@placeholder = placeholder
|
|
26
|
-
@width = width
|
|
27
|
-
@cursor = cursor || @value.length
|
|
28
|
-
clamp_position
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def handle_key(event)
|
|
32
|
-
return :handled if character_event?(event) && insert(event.char)
|
|
33
|
-
|
|
34
|
-
super
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def render
|
|
38
|
-
rendered = render_value
|
|
39
|
-
@width ? style.width(@width).render(rendered) : rendered
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
private
|
|
43
|
-
|
|
44
|
-
attr_reader :placeholder
|
|
45
|
-
|
|
46
|
-
def character_event?(event)
|
|
47
|
-
event.respond_to?(:char) && event.char && event.char.length == 1 && printable?(event.char)
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def printable?(char)
|
|
51
|
-
!char.match?(/[[:cntrl:]]/)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def insert(char)
|
|
55
|
-
@value = value[0...cursor] + char + value[cursor..]
|
|
56
|
-
@cursor += char.length
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def move_left
|
|
60
|
-
@cursor -= 1 if cursor.positive?
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def move_right
|
|
64
|
-
@cursor += 1 if cursor < value.length
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def move_home
|
|
68
|
-
@cursor = 0
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def move_end
|
|
72
|
-
@cursor = value.length
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def delete_before_cursor
|
|
76
|
-
return if cursor.zero?
|
|
77
|
-
|
|
78
|
-
@value = value[0...(cursor - 1)] + value[cursor..]
|
|
79
|
-
@cursor -= 1
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def delete_at_cursor
|
|
83
|
-
return if cursor >= value.length
|
|
84
|
-
|
|
85
|
-
@value = value[0...cursor] + value[(cursor + 1)..]
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def render_value
|
|
89
|
-
return cursor_marker + placeholder if value.empty?
|
|
90
|
-
|
|
91
|
-
value[0...cursor] + cursor_marker + value[cursor..]
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def cursor_marker
|
|
95
|
-
"|"
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def clamp_position
|
|
99
|
-
@cursor = cursor.clamp(0, value.length)
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
end
|