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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -378
  3. data/lib/charming/application.rb +3 -3
  4. data/lib/charming/{application_model.rb → application_state.rb} +3 -3
  5. data/lib/charming/cli.rb +39 -3
  6. data/lib/charming/controller.rb +146 -24
  7. data/lib/charming/database_commands.rb +87 -0
  8. data/lib/charming/database_installer.rb +125 -0
  9. data/lib/charming/events/key_event.rb +15 -0
  10. data/lib/charming/events/mouse_event.rb +42 -0
  11. data/lib/charming/events/resize_event.rb +9 -0
  12. data/lib/charming/events/task_event.rb +19 -0
  13. data/lib/charming/events/timer_event.rb +9 -0
  14. data/lib/charming/generators/app_generator/app_spec_templates.rb +12 -8
  15. data/lib/charming/generators/app_generator/basic_templates.rb +14 -2
  16. data/lib/charming/generators/app_generator/component_templates.rb +1 -1
  17. data/lib/charming/generators/app_generator/controller_template.rb +3 -12
  18. data/lib/charming/generators/app_generator/database_templates.rb +45 -0
  19. data/lib/charming/generators/app_generator/layout_template.rb +51 -145
  20. data/lib/charming/generators/app_generator/screen_spec_templates.rb +7 -8
  21. data/lib/charming/generators/app_generator/{model_templates.rb → state_templates.rb} +5 -5
  22. data/lib/charming/generators/app_generator/view_template.rb +12 -18
  23. data/lib/charming/generators/app_generator.rb +37 -11
  24. data/lib/charming/generators/component_generator.rb +1 -1
  25. data/lib/charming/generators/controller_generator.rb +1 -4
  26. data/lib/charming/generators/model_generator.rb +119 -0
  27. data/lib/charming/generators/name.rb +0 -4
  28. data/lib/charming/generators/screen_generator.rb +14 -28
  29. data/lib/charming/generators/view_generator.rb +11 -14
  30. data/lib/charming/internal/renderer/differential.rb +2 -3
  31. data/lib/charming/internal/terminal/tty_backend.rb +25 -8
  32. data/lib/charming/presentation/component.rb +10 -0
  33. data/lib/charming/presentation/components/activity_indicator.rb +160 -0
  34. data/lib/charming/presentation/components/command_palette.rb +120 -0
  35. data/lib/charming/presentation/components/empty_state.rb +43 -0
  36. data/lib/charming/presentation/components/form/builder.rb +48 -0
  37. data/lib/charming/presentation/components/form/confirm.rb +56 -0
  38. data/lib/charming/presentation/components/form/field.rb +96 -0
  39. data/lib/charming/presentation/components/form/input.rb +57 -0
  40. data/lib/charming/presentation/components/form/note.rb +32 -0
  41. data/lib/charming/presentation/components/form/select.rb +89 -0
  42. data/lib/charming/presentation/components/form/textarea.rb +70 -0
  43. data/lib/charming/presentation/components/form.rb +127 -0
  44. data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
  45. data/lib/charming/presentation/components/list.rb +104 -0
  46. data/lib/charming/presentation/components/markdown.rb +25 -0
  47. data/lib/charming/presentation/components/modal.rb +50 -0
  48. data/lib/charming/presentation/components/progressbar.rb +57 -0
  49. data/lib/charming/presentation/components/spinner.rb +39 -0
  50. data/lib/charming/presentation/components/table.rb +118 -0
  51. data/lib/charming/presentation/components/text_area.rb +219 -0
  52. data/lib/charming/presentation/components/text_input.rb +105 -0
  53. data/lib/charming/presentation/components/viewport.rb +220 -0
  54. data/lib/charming/presentation/layout.rb +43 -0
  55. data/lib/charming/presentation/markdown/renderer.rb +203 -0
  56. data/lib/charming/presentation/markdown/syntax_highlighter.rb +63 -0
  57. data/lib/charming/presentation/markdown.rb +8 -0
  58. data/lib/charming/presentation/template_view.rb +27 -0
  59. data/lib/charming/presentation/templates/erb_handler.rb +15 -0
  60. data/lib/charming/presentation/templates.rb +51 -0
  61. data/lib/charming/presentation/ui/border.rb +35 -0
  62. data/lib/charming/presentation/ui/style.rb +246 -0
  63. data/lib/charming/presentation/ui/theme.rb +180 -0
  64. data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
  65. data/lib/charming/presentation/ui/width.rb +26 -0
  66. data/lib/charming/presentation/ui.rb +232 -0
  67. data/lib/charming/presentation/view.rb +118 -0
  68. data/lib/charming/runtime.rb +7 -7
  69. data/lib/charming/screen.rb +5 -1
  70. data/lib/charming/tasks/inline_executor.rb +28 -0
  71. data/lib/charming/tasks/task.rb +9 -0
  72. data/lib/charming/{task_executor.rb → tasks/threaded_executor.rb} +4 -27
  73. data/lib/charming/version.rb +1 -1
  74. data/lib/charming.rb +4 -0
  75. metadata +114 -29
  76. data/lib/charming/component.rb +0 -8
  77. data/lib/charming/components/activity_indicator.rb +0 -158
  78. data/lib/charming/components/command_palette.rb +0 -118
  79. data/lib/charming/components/keyboard_handler.rb +0 -22
  80. data/lib/charming/components/list.rb +0 -105
  81. data/lib/charming/components/modal.rb +0 -48
  82. data/lib/charming/components/progressbar.rb +0 -55
  83. data/lib/charming/components/spinner.rb +0 -37
  84. data/lib/charming/components/table.rb +0 -115
  85. data/lib/charming/components/text_input.rb +0 -103
  86. data/lib/charming/components/viewport.rb +0 -191
  87. data/lib/charming/key_event.rb +0 -13
  88. data/lib/charming/mouse_event.rb +0 -40
  89. data/lib/charming/resize_event.rb +0 -7
  90. data/lib/charming/task.rb +0 -7
  91. data/lib/charming/task_event.rb +0 -17
  92. data/lib/charming/timer_event.rb +0 -7
  93. data/lib/charming/ui/border.rb +0 -33
  94. data/lib/charming/ui/style.rb +0 -244
  95. data/lib/charming/ui/theme.rb +0 -178
  96. data/lib/charming/ui/width.rb +0 -24
  97. data/lib/charming/ui.rb +0 -230
  98. data/lib/charming/view.rb +0 -116
  99. /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