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
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
6
|
+
class Form
|
|
7
|
+
class Field < Component
|
|
8
|
+
attr_reader :name, :label, :help, :state
|
|
9
|
+
|
|
10
|
+
def initialize(name, label: nil, required: false, validate: nil, help: nil, theme: nil)
|
|
11
|
+
super(theme: theme)
|
|
12
|
+
@name = name.to_sym
|
|
13
|
+
@label = label || humanize(name)
|
|
14
|
+
@required = required
|
|
15
|
+
@validator = validate
|
|
16
|
+
@help = help
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def bind(state)
|
|
20
|
+
@state = state
|
|
21
|
+
state[:fields][name] ||= {}
|
|
22
|
+
state[:values][name] = default_value unless state[:values].key?(name)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def focusable?
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def handle_key(_event)
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def render(active: false)
|
|
34
|
+
line = "#{active ? ">" : " "} #{render_control}"
|
|
35
|
+
line = theme.selected.render(line) if active
|
|
36
|
+
[line, help_line, *error_lines].compact.join("\n")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validate
|
|
40
|
+
messages = []
|
|
41
|
+
messages << "is required" if required? && blank?(value)
|
|
42
|
+
messages.concat(validator_messages) if @validator
|
|
43
|
+
messages
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def value
|
|
47
|
+
state[:values][name]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def default_value
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def render_control
|
|
57
|
+
"#{label}: #{value}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def required?
|
|
61
|
+
@required
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def blank?(value)
|
|
65
|
+
return true if value.nil?
|
|
66
|
+
return value.strip.empty? if value.is_a?(String)
|
|
67
|
+
|
|
68
|
+
value.respond_to?(:empty?) && value.empty?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def validator_messages
|
|
72
|
+
result = @validator.call(value)
|
|
73
|
+
case result
|
|
74
|
+
when nil, true then []
|
|
75
|
+
when false then ["is invalid"]
|
|
76
|
+
when Array then result
|
|
77
|
+
else [result.to_s]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def help_line
|
|
82
|
+
" #{theme.muted.render(help)}" if help
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def error_lines
|
|
86
|
+
Array(state[:errors][name]).map { |message| " #{theme.warn.render(message)}" }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def humanize(value)
|
|
90
|
+
value.to_s.tr("_", " ").capitalize
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
6
|
+
class Form
|
|
7
|
+
class Input < Field
|
|
8
|
+
def initialize(name, value: "", placeholder: "", width: nil, **options)
|
|
9
|
+
super(name, **options)
|
|
10
|
+
@initial_value = value
|
|
11
|
+
@placeholder = placeholder
|
|
12
|
+
@width = width
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def bind(state)
|
|
16
|
+
super
|
|
17
|
+
state[:values][name] = @initial_value if state[:values][name].nil?
|
|
18
|
+
field_state[:cursor] = state[:values][name].to_s.length unless field_state.key?(:cursor)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def handle_key(event)
|
|
22
|
+
text_input = input
|
|
23
|
+
result = text_input.handle_key(event)
|
|
24
|
+
return nil unless result == :handled
|
|
25
|
+
|
|
26
|
+
state[:values][name] = text_input.value
|
|
27
|
+
field_state[:cursor] = text_input.cursor
|
|
28
|
+
:handled
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def default_value
|
|
34
|
+
@initial_value
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def render_control
|
|
38
|
+
"#{label}: #{input.render}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def input
|
|
42
|
+
TextInput.new(
|
|
43
|
+
value: value.to_s,
|
|
44
|
+
placeholder: @placeholder,
|
|
45
|
+
width: @width,
|
|
46
|
+
cursor: field_state[:cursor]
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def field_state
|
|
51
|
+
state[:fields][name]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
6
|
+
class Form
|
|
7
|
+
class Note < Field
|
|
8
|
+
def initialize(text, name: :note, theme: nil)
|
|
9
|
+
super(name, theme: theme)
|
|
10
|
+
@text = text
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def bind(state)
|
|
14
|
+
@state = state
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def focusable?
|
|
18
|
+
false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def validate
|
|
22
|
+
[]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def render(active: false)
|
|
26
|
+
@text.to_s
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
6
|
+
class Form
|
|
7
|
+
class Select < Field
|
|
8
|
+
def initialize(name, options:, selected_index: 0, option_label: :to_s.to_proc, **field_options)
|
|
9
|
+
super(name, **field_options)
|
|
10
|
+
@options = options
|
|
11
|
+
@initial_selected_index = selected_index
|
|
12
|
+
@option_label = option_label
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def bind(state)
|
|
16
|
+
super
|
|
17
|
+
ensure_selection
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def handle_key(event)
|
|
21
|
+
selection = list
|
|
22
|
+
result = selection.handle_key(event)
|
|
23
|
+
return nil unless result == :handled
|
|
24
|
+
|
|
25
|
+
save_selection(selection.selected_index)
|
|
26
|
+
:handled
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :options
|
|
32
|
+
|
|
33
|
+
def default_value
|
|
34
|
+
options[clamped_initial_index]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def render_control
|
|
38
|
+
"#{label}: #{display_value}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def display_value
|
|
42
|
+
value.nil? ? "" : @option_label.call(value)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def list
|
|
46
|
+
List.new(items: options, selected_index: selected_index, label: @option_label, theme: theme)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def ensure_selection
|
|
50
|
+
if field_state.key?(:selected_index)
|
|
51
|
+
save_selection(field_state[:selected_index])
|
|
52
|
+
elsif state[:values].key?(name)
|
|
53
|
+
save_selection(index_for(state[:values][name]) || clamped_initial_index)
|
|
54
|
+
else
|
|
55
|
+
save_selection(clamped_initial_index)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def save_selection(index)
|
|
60
|
+
field_state[:selected_index] = clamp_index(index)
|
|
61
|
+
state[:values][name] = options[field_state[:selected_index]]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def selected_index
|
|
65
|
+
field_state[:selected_index] || clamped_initial_index
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def clamped_initial_index
|
|
69
|
+
clamp_index(@initial_selected_index)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def clamp_index(index)
|
|
73
|
+
return 0 if options.empty?
|
|
74
|
+
|
|
75
|
+
index.to_i.clamp(0, options.length - 1)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def index_for(option)
|
|
79
|
+
options.index(option)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def field_state
|
|
83
|
+
state[:fields][name]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
6
|
+
class Form
|
|
7
|
+
class Textarea < Field
|
|
8
|
+
def initialize(name, value: "", placeholder: "", width: nil, height: nil, **options)
|
|
9
|
+
super(name, **options)
|
|
10
|
+
@initial_value = value
|
|
11
|
+
@placeholder = placeholder
|
|
12
|
+
@width = width
|
|
13
|
+
@height = height
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def bind(state)
|
|
17
|
+
super
|
|
18
|
+
state[:values][name] = @initial_value if state[:values][name].nil?
|
|
19
|
+
field_state[:cursor] = state[:values][name].to_s.length unless field_state.key?(:cursor)
|
|
20
|
+
field_state[:offset] ||= 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def handle_key(event)
|
|
24
|
+
area = text_area
|
|
25
|
+
result = area.handle_key(event)
|
|
26
|
+
return nil unless result == :handled
|
|
27
|
+
|
|
28
|
+
state[:values][name] = area.value
|
|
29
|
+
field_state[:cursor] = area.cursor
|
|
30
|
+
field_state[:offset] = area.offset
|
|
31
|
+
field_state[:preferred_column] = area.preferred_column
|
|
32
|
+
:handled
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def render(active: false)
|
|
36
|
+
label_line = "#{active ? ">" : " "} #{label}:"
|
|
37
|
+
label_line = theme.selected.render(label_line) if active
|
|
38
|
+
[label_line, *body_lines, help_line, *error_lines].compact.join("\n")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def default_value
|
|
44
|
+
@initial_value
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def body_lines
|
|
48
|
+
text_area.render.lines(chomp: true).map { |line| " #{line}" }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def text_area
|
|
52
|
+
TextArea.new(
|
|
53
|
+
value: value.to_s,
|
|
54
|
+
placeholder: @placeholder,
|
|
55
|
+
width: @width,
|
|
56
|
+
height: @height,
|
|
57
|
+
cursor: field_state[:cursor],
|
|
58
|
+
offset: field_state[:offset],
|
|
59
|
+
preferred_column: field_state[:preferred_column]
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def field_state
|
|
64
|
+
state[:fields][name]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
6
|
+
class Form < Component
|
|
7
|
+
attr_reader :fields, :state
|
|
8
|
+
|
|
9
|
+
def initialize(fields:, state: nil, theme: nil)
|
|
10
|
+
super(theme: theme)
|
|
11
|
+
@fields = fields
|
|
12
|
+
@state = normalize_state(state || {})
|
|
13
|
+
bind_fields
|
|
14
|
+
clamp_focus
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def handle_key(event)
|
|
18
|
+
key = Charming.key_of(event)
|
|
19
|
+
return :cancelled if key == :escape
|
|
20
|
+
return submit if submit_shortcut?(event)
|
|
21
|
+
return move_focus(tab_direction(event)) if key == :tab
|
|
22
|
+
|
|
23
|
+
result = handle_current_field(event)
|
|
24
|
+
return result if result
|
|
25
|
+
|
|
26
|
+
advance_or_submit if key == :enter
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def values
|
|
30
|
+
state[:values]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def render
|
|
34
|
+
fields.each_with_index.map do |field, index|
|
|
35
|
+
field.render(active: index == state[:focus_index])
|
|
36
|
+
end.join("\n")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def normalize_state(value)
|
|
42
|
+
value[:values] ||= {}
|
|
43
|
+
value[:fields] ||= {}
|
|
44
|
+
value[:errors] ||= {}
|
|
45
|
+
value[:focus_index] ||= first_focusable_index || 0
|
|
46
|
+
value
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def bind_fields
|
|
50
|
+
fields.each { |field| field.bind(state) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def handle_current_field(event)
|
|
54
|
+
current_field&.handle_key(event)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def tab_direction(event)
|
|
58
|
+
return -1 if event.respond_to?(:shift) && event.shift
|
|
59
|
+
|
|
60
|
+
+1
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def submit_shortcut?(event)
|
|
64
|
+
Charming.key_of(event) == :s && event.respond_to?(:ctrl) && event.ctrl
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def advance_or_submit
|
|
68
|
+
return submit if last_focusable?
|
|
69
|
+
|
|
70
|
+
move_focus(+1)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def submit
|
|
74
|
+
state[:errors] = validation_errors
|
|
75
|
+
focus_first_error unless state[:errors].empty?
|
|
76
|
+
return :handled unless state[:errors].empty?
|
|
77
|
+
|
|
78
|
+
[:submitted, values.dup]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validation_errors
|
|
82
|
+
fields.each_with_object({}) do |field, errors|
|
|
83
|
+
messages = field.validate
|
|
84
|
+
errors[field.name] = messages unless messages.empty?
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def focus_first_error
|
|
89
|
+
invalid = fields.index { |field| field.focusable? && state[:errors].key?(field.name) }
|
|
90
|
+
state[:focus_index] = invalid if invalid
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def current_field
|
|
94
|
+
fields[state[:focus_index]]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def move_focus(direction)
|
|
98
|
+
indices = focusable_indices
|
|
99
|
+
return nil if indices.empty?
|
|
100
|
+
|
|
101
|
+
current = indices.index(state[:focus_index]) || 0
|
|
102
|
+
state[:focus_index] = indices[(current + direction) % indices.length]
|
|
103
|
+
:handled
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def last_focusable?
|
|
107
|
+
focusable_indices.last == state[:focus_index]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def focusable_indices
|
|
111
|
+
@focusable_indices ||= fields.each_index.select { |index| fields[index].focusable? }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def first_focusable_index
|
|
115
|
+
fields.each_index.find { |index| fields[index].focusable? }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def clamp_focus
|
|
119
|
+
return if focusable_indices.empty?
|
|
120
|
+
return if focusable_indices.include?(state[:focus_index])
|
|
121
|
+
|
|
122
|
+
state[:focus_index] = focusable_indices.first
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
6
|
+
# KeyboardHandler is a mixin module that provides keyboard event dispatch by mapping symbolic key names
|
|
7
|
+
# to private method calls. Implementors must define a constant +KEY_ACTIONS+ as a hash where each key is
|
|
8
|
+
# a symbol (e.g., :up, :down, :enter) and each value is the target method name (e.g., :move_up). Call
|
|
9
|
+
# +handle_key(event)+ with any event object; it uses Charming.key_of to resolve the raw event to a symbol,
|
|
10
|
+
# looks up the corresponding action in KEY_ACTIONS, sends that method on self, and returns :handled if an
|
|
11
|
+
# action was found. Returns nil (via :handled being truthy or not) when no matching key exists.
|
|
12
|
+
module KeyboardHandler
|
|
13
|
+
VIM_KEYMAP = {
|
|
14
|
+
up: :k,
|
|
15
|
+
down: :j,
|
|
16
|
+
left: :h,
|
|
17
|
+
right: :l
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def handle_key(event)
|
|
21
|
+
key = Charming.key_of(event)
|
|
22
|
+
action = key_actions[key]
|
|
23
|
+
return unless action
|
|
24
|
+
|
|
25
|
+
send(action)
|
|
26
|
+
:handled
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def key_actions
|
|
32
|
+
base_key_actions.merge(normalized_keymap)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def base_key_actions
|
|
36
|
+
self.class.const_get(:KEY_ACTIONS)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def normalized_keymap
|
|
40
|
+
resolved_keymap.each_with_object({}) do |(action_key, keys), actions|
|
|
41
|
+
action = base_key_actions[action_key.to_sym]
|
|
42
|
+
next unless action
|
|
43
|
+
|
|
44
|
+
Array(keys).each { |key| actions[key.to_sym] = action }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def resolved_keymap
|
|
49
|
+
case @keymap
|
|
50
|
+
when :vim then VIM_KEYMAP
|
|
51
|
+
when nil then {}
|
|
52
|
+
else @keymap
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
6
|
+
class List < Component
|
|
7
|
+
include KeyboardHandler
|
|
8
|
+
|
|
9
|
+
# Maps navigation key symbols to instance methods consumed by the KeyboardHandler
|
|
10
|
+
# mixin: :up moves selection up, :down moves down, :home jumps to first item,
|
|
11
|
+
# :end jumps to last. See Viewport#KEY_ACTIONS and Table#KEY_ACTIONS for identical pattern.
|
|
12
|
+
KEY_ACTIONS = {
|
|
13
|
+
up: :move_up,
|
|
14
|
+
down: :move_down,
|
|
15
|
+
home: :move_home,
|
|
16
|
+
end: :move_end
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :items, :selected_index
|
|
20
|
+
|
|
21
|
+
def initialize(items:, selected_index: 0, height: nil, label: nil, theme: nil, keymap: :vim)
|
|
22
|
+
super(theme: theme)
|
|
23
|
+
@items = items
|
|
24
|
+
@selected_index = selected_index
|
|
25
|
+
@height = height
|
|
26
|
+
@label = label || :to_s.to_proc
|
|
27
|
+
@keymap = keymap
|
|
28
|
+
clamp_position
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def handle_key(event)
|
|
32
|
+
return [:selected, selected_item] if Charming.key_of(event) == :enter && selected_item
|
|
33
|
+
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def handle_mouse(event)
|
|
38
|
+
return nil unless @height
|
|
39
|
+
return nil unless event.respond_to?(:click?) && event.click?
|
|
40
|
+
|
|
41
|
+
clicked = event.y
|
|
42
|
+
return nil if clicked.negative? || clicked >= visible_items.length
|
|
43
|
+
|
|
44
|
+
@selected_index = viewport_start + clicked
|
|
45
|
+
clamp_position
|
|
46
|
+
:handled
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def selected_item
|
|
50
|
+
items[selected_index]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def render
|
|
54
|
+
visible_items.each_with_index.map do |item, index|
|
|
55
|
+
render_item(item, viewport_start + index)
|
|
56
|
+
end.join("\n")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def move_up
|
|
62
|
+
@selected_index -= 1 if selected_index.positive?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def move_down
|
|
66
|
+
@selected_index += 1 if selected_index < items.length - 1
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def move_home
|
|
70
|
+
@selected_index = 0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def move_end
|
|
74
|
+
@selected_index = items.length - 1 unless items.empty?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def visible_items
|
|
78
|
+
items[viewport_start, viewport_height] || []
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def viewport_start
|
|
82
|
+
return 0 unless @height
|
|
83
|
+
|
|
84
|
+
Layout.selected_window_start(selected_index: selected_index, item_count: items.length, window_size: @height)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def viewport_height
|
|
88
|
+
@height || items.length
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def render_item(item, index)
|
|
92
|
+
prefix = (index == selected_index) ? "> " : " "
|
|
93
|
+
rendered = "#{prefix}#{@label.call(item)}"
|
|
94
|
+
(index == selected_index) ? theme.selected.render(rendered) : rendered
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def clamp_position
|
|
98
|
+
@selected_index = 0 if items.empty?
|
|
99
|
+
@selected_index = selected_index.clamp(0, items.length - 1) unless items.empty?
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
6
|
+
class Markdown < Component
|
|
7
|
+
def initialize(content:, width: nil, theme: nil, syntax_highlighting: true)
|
|
8
|
+
super(theme: theme)
|
|
9
|
+
@content = content
|
|
10
|
+
@width = width
|
|
11
|
+
@syntax_highlighting = syntax_highlighting
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def render
|
|
15
|
+
Charming::Presentation::Markdown::Renderer.new(
|
|
16
|
+
content: @content,
|
|
17
|
+
width: @width,
|
|
18
|
+
theme: theme,
|
|
19
|
+
syntax_highlighting: @syntax_highlighting
|
|
20
|
+
).render
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
6
|
+
class Modal < Component
|
|
7
|
+
def initialize(content:, title: nil, help: nil, width: 52, style: nil, theme: nil)
|
|
8
|
+
super(theme: theme)
|
|
9
|
+
@content = content
|
|
10
|
+
@title = title
|
|
11
|
+
@help = help
|
|
12
|
+
@width = width
|
|
13
|
+
@style = style
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def render
|
|
17
|
+
box(column(*lines, gap: 1), style: modal_style)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
attr_reader :content, :title, :help, :width
|
|
23
|
+
|
|
24
|
+
def lines
|
|
25
|
+
[title_line, help_line, render_content].compact
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def title_line
|
|
29
|
+
text(title, style: theme.title.align(:center).width(title_width)) if title
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def help_line
|
|
33
|
+
text(help, style: theme.muted) if help
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def render_content
|
|
37
|
+
content.respond_to?(:render) ? render_component(content) : content.to_s
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def modal_style
|
|
41
|
+
@style || theme.modal.width(width)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def title_width
|
|
45
|
+
[width - 8, 0].max
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|