charming 0.1.1 → 0.1.2

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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +11 -0
  4. data/lib/charming/cli.rb +23 -0
  5. data/lib/charming/controller/class_methods.rb +115 -0
  6. data/lib/charming/controller/command_palette.rb +135 -0
  7. data/lib/charming/controller/component_dispatching.rb +81 -0
  8. data/lib/charming/controller/dispatching.rb +60 -0
  9. data/lib/charming/controller/focus_management.rb +30 -0
  10. data/lib/charming/controller/rendering.rb +127 -0
  11. data/lib/charming/controller/session_state.rb +41 -0
  12. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  13. data/lib/charming/controller.rb +35 -559
  14. data/lib/charming/database_commands.rb +16 -0
  15. data/lib/charming/database_installer.rb +27 -0
  16. data/lib/charming/focus.rb +58 -2
  17. data/lib/charming/generators/app_file_generator.rb +13 -0
  18. data/lib/charming/generators/app_generator.rb +123 -47
  19. data/lib/charming/generators/base.rb +26 -0
  20. data/lib/charming/generators/component_generator.rb +10 -10
  21. data/lib/charming/generators/controller_generator.rb +22 -11
  22. data/lib/charming/generators/model_generator.rb +38 -29
  23. data/lib/charming/generators/name.rb +10 -0
  24. data/lib/charming/generators/screen_generator.rb +78 -32
  25. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  26. data/lib/charming/generators/templates/app/README.md.template +9 -0
  27. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  28. data/lib/charming/generators/templates/app/application.template +13 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  30. data/lib/charming/generators/templates/app/application_record.template +7 -0
  31. data/lib/charming/generators/templates/app/application_state.template +6 -0
  32. data/lib/charming/generators/templates/app/database_config.template +12 -0
  33. data/lib/charming/generators/templates/app/executable.template +7 -0
  34. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  35. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  36. data/lib/charming/generators/templates/app/home_state.template +7 -0
  37. data/lib/charming/generators/templates/app/keep.template +0 -0
  38. data/lib/charming/generators/templates/app/layout.template +113 -0
  39. data/lib/charming/generators/templates/app/root_file.template +20 -0
  40. data/lib/charming/generators/templates/app/routes.template +5 -0
  41. data/lib/charming/generators/templates/app/seeds.template +1 -0
  42. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  43. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  44. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  45. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  46. data/lib/charming/generators/templates/app/version.template +5 -0
  47. data/lib/charming/generators/templates/app/view.template +21 -0
  48. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  49. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  50. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  51. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  52. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  53. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  54. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  55. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  56. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  57. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  58. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  59. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  60. data/lib/charming/generators/view_generator.rb +19 -3
  61. data/lib/charming/internal/renderer/differential.rb +15 -0
  62. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  63. data/lib/charming/internal/terminal/adapter.rb +29 -3
  64. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  65. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  66. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  67. data/lib/charming/internal/terminal/tty_backend.rb +43 -113
  68. data/lib/charming/presentation/components/empty_state.rb +13 -0
  69. data/lib/charming/presentation/components/form/builder.rb +14 -0
  70. data/lib/charming/presentation/components/form/confirm.rb +13 -0
  71. data/lib/charming/presentation/components/form/field.rb +25 -0
  72. data/lib/charming/presentation/components/form/input.rb +14 -0
  73. data/lib/charming/presentation/components/form/note.rb +9 -0
  74. data/lib/charming/presentation/components/form/select.rb +23 -0
  75. data/lib/charming/presentation/components/form/textarea.rb +16 -0
  76. data/lib/charming/presentation/components/form.rb +29 -0
  77. data/lib/charming/presentation/components/list.rb +28 -0
  78. data/lib/charming/presentation/components/markdown.rb +6 -0
  79. data/lib/charming/presentation/components/modal.rb +14 -0
  80. data/lib/charming/presentation/components/progressbar.rb +13 -0
  81. data/lib/charming/presentation/components/spinner.rb +10 -0
  82. data/lib/charming/presentation/components/table.rb +25 -0
  83. data/lib/charming/presentation/components/text_area.rb +48 -0
  84. data/lib/charming/presentation/components/text_input.rb +24 -0
  85. data/lib/charming/presentation/components/viewport.rb +52 -0
  86. data/lib/charming/presentation/layout/builder.rb +86 -0
  87. data/lib/charming/presentation/layout/overlay.rb +57 -0
  88. data/lib/charming/presentation/layout/pane.rb +145 -0
  89. data/lib/charming/presentation/layout/rect.rb +23 -0
  90. data/lib/charming/presentation/layout/screen_layout.rb +60 -0
  91. data/lib/charming/presentation/layout/split.rb +134 -0
  92. data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
  93. data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
  94. data/lib/charming/presentation/markdown/render_context.rb +22 -0
  95. data/lib/charming/presentation/markdown/renderer.rb +45 -135
  96. data/lib/charming/presentation/markdown/syntax_highlighter.rb +16 -0
  97. data/lib/charming/presentation/markdown.rb +3 -0
  98. data/lib/charming/presentation/template_view.rb +7 -0
  99. data/lib/charming/presentation/templates.rb +17 -0
  100. data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
  101. data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
  102. data/lib/charming/presentation/ui/border_painter.rb +58 -0
  103. data/lib/charming/presentation/ui/canvas.rb +82 -0
  104. data/lib/charming/presentation/ui/style.rb +62 -95
  105. data/lib/charming/presentation/ui.rb +15 -156
  106. data/lib/charming/presentation/view.rb +17 -0
  107. data/lib/charming/runtime.rb +2 -0
  108. data/lib/charming/tasks/inline_executor.rb +9 -0
  109. data/lib/charming/tasks/task.rb +3 -0
  110. data/lib/charming/tasks/threaded_executor.rb +12 -0
  111. data/lib/charming/version.rb +1 -1
  112. data/lib/charming.rb +13 -0
  113. metadata +59 -10
  114. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
  115. data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
  116. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  117. data/lib/charming/generators/app_generator/controller_template.rb +0 -60
  118. data/lib/charming/generators/app_generator/database_templates.rb +0 -45
  119. data/lib/charming/generators/app_generator/layout_template.rb +0 -66
  120. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
  121. data/lib/charming/generators/app_generator/state_templates.rb +0 -30
  122. data/lib/charming/generators/app_generator/view_template.rb +0 -84
@@ -4,9 +4,17 @@ module Charming
4
4
  module Presentation
5
5
  module Components
6
6
  class Form
7
+ # Field is the abstract base class for Form fields. Subclasses define `default_value`
8
+ # and `render_control` (or override `render`); the base class supplies validation,
9
+ # help-line rendering, error-line rendering, and value lookup against the form state.
7
10
  class Field < Component
11
+ # The field's name symbol, human-readable label, optional help text, and bound state hash.
8
12
  attr_reader :name, :label, :help, :state
9
13
 
14
+ # *name* is the value key (a Symbol). *label* defaults to a humanized version of *name*.
15
+ # *required* enables a "is required" validator. *validate* is an optional callable
16
+ # (returning nil/true → ok, false → "is invalid", Array → messages, anything else → stringified).
17
+ # *help* is an optional muted helper line rendered under the field.
10
18
  def initialize(name, label: nil, required: false, validate: nil, help: nil, theme: nil)
11
19
  super(theme: theme)
12
20
  @name = name.to_sym
@@ -16,26 +24,34 @@ module Charming
16
24
  @help = help
17
25
  end
18
26
 
27
+ # Binds the field to the form's *state* hash, ensuring the field's per-field state
28
+ # and a default value are present.
19
29
  def bind(state)
20
30
  @state = state
21
31
  state[:fields][name] ||= {}
22
32
  state[:values][name] = default_value unless state[:values].key?(name)
23
33
  end
24
34
 
35
+ # Subclasses that participate in Tab/Enter navigation return true. Default is true.
25
36
  def focusable?
26
37
  true
27
38
  end
28
39
 
40
+ # Default key handler returns nil (no key handling). Subclasses override.
29
41
  def handle_key(_event)
30
42
  nil
31
43
  end
32
44
 
45
+ # Renders the field as a control line prefixed with ">" (active) or " " (inactive),
46
+ # optionally followed by the help line and any error lines.
33
47
  def render(active: false)
34
48
  line = "#{active ? ">" : " "} #{render_control}"
35
49
  line = theme.selected.render(line) if active
36
50
  [line, help_line, *error_lines].compact.join("\n")
37
51
  end
38
52
 
53
+ # Returns an array of validation error messages. Includes "is required" when
54
+ # the field is required and blank, plus any messages produced by the user validator.
39
55
  def validate
40
56
  messages = []
41
57
  messages << "is required" if required? && blank?(value)
@@ -43,24 +59,29 @@ module Charming
43
59
  messages
44
60
  end
45
61
 
62
+ # The current value of this field from the bound state.
46
63
  def value
47
64
  state[:values][name]
48
65
  end
49
66
 
50
67
  private
51
68
 
69
+ # The default value assigned to a freshly-bound field. Subclasses override.
52
70
  def default_value
53
71
  nil
54
72
  end
55
73
 
74
+ # Renders the control portion (label + value). Default: "Label: <value>".
56
75
  def render_control
57
76
  "#{label}: #{value}"
58
77
  end
59
78
 
79
+ # True when the field was declared with `required: true`.
60
80
  def required?
61
81
  @required
62
82
  end
63
83
 
84
+ # True when *value* is nil, an empty string, or responds to `empty?` with true.
64
85
  def blank?(value)
65
86
  return true if value.nil?
66
87
  return value.strip.empty? if value.is_a?(String)
@@ -68,6 +89,7 @@ module Charming
68
89
  value.respond_to?(:empty?) && value.empty?
69
90
  end
70
91
 
92
+ # Normalizes the user validator's return value into an array of error message strings.
71
93
  def validator_messages
72
94
  result = @validator.call(value)
73
95
  case result
@@ -78,14 +100,17 @@ module Charming
78
100
  end
79
101
  end
80
102
 
103
+ # The muted help line (with two-space indent) when help text was given.
81
104
  def help_line
82
105
  " #{theme.muted.render(help)}" if help
83
106
  end
84
107
 
108
+ # The list of error lines (with two-space indent) for any errors stored against this field.
85
109
  def error_lines
86
110
  Array(state[:errors][name]).map { |message| " #{theme.warn.render(message)}" }
87
111
  end
88
112
 
113
+ # Converts a snake_case symbol/string to a humanized "Capitalized" string.
89
114
  def humanize(value)
90
115
  value.to_s.tr("_", " ").capitalize
91
116
  end
@@ -4,7 +4,12 @@ module Charming
4
4
  module Presentation
5
5
  module Components
6
6
  class Form
7
+ # Input is a single-line Form field backed by a TextInput widget. The cursor position
8
+ # is persisted in the form's per-field state so the field can be refocused mid-edit.
7
9
  class Input < Field
10
+ # *value* is the initial text. *placeholder* is shown when the value is empty.
11
+ # *width* optionally constrains the rendered width. All other options are forwarded
12
+ # to Field (label, required, validate, help, theme).
8
13
  def initialize(name, value: "", placeholder: "", width: nil, **options)
9
14
  super(name, **options)
10
15
  @initial_value = value
@@ -12,12 +17,16 @@ module Charming
12
17
  @width = width
13
18
  end
14
19
 
20
+ # Binds the field to the form state, sets the initial value if absent, and initializes
21
+ # the per-field cursor offset to the end of the value.
15
22
  def bind(state)
16
23
  super
17
24
  state[:values][name] = @initial_value if state[:values][name].nil?
18
25
  field_state[:cursor] = state[:values][name].to_s.length unless field_state.key?(:cursor)
19
26
  end
20
27
 
28
+ # Forwards key events to the underlying TextInput, syncing the value and cursor
29
+ # back into the form state. Returns :handled when the event was consumed.
21
30
  def handle_key(event)
22
31
  text_input = input
23
32
  result = text_input.handle_key(event)
@@ -30,14 +39,18 @@ module Charming
30
39
 
31
40
  private
32
41
 
42
+ # The default value for a freshly-bound field is the *value* passed at construction.
33
43
  def default_value
34
44
  @initial_value
35
45
  end
36
46
 
47
+ # Renders the field as "Label: <text input>".
37
48
  def render_control
38
49
  "#{label}: #{input.render}"
39
50
  end
40
51
 
52
+ # Builds a fresh TextInput each render, seeded from the current form-state value
53
+ # and the persisted cursor offset.
41
54
  def input
42
55
  TextInput.new(
43
56
  value: value.to_s,
@@ -47,6 +60,7 @@ module Charming
47
60
  )
48
61
  end
49
62
 
63
+ # Returns the per-field state hash for this field.
50
64
  def field_state
51
65
  state[:fields][name]
52
66
  end
@@ -4,24 +4,33 @@ module Charming
4
4
  module Presentation
5
5
  module Components
6
6
  class Form
7
+ # Note is a non-interactive Form field that renders a static string of text. Notes
8
+ # never receive focus, never validate, and store no value — they are presentational
9
+ # only, useful for headings, dividers, or instructional text inside a form.
7
10
  class Note < Field
11
+ # *text* is the literal string to render. *name* is unused (defaults to :note) and
12
+ # exists only because the Field base class requires a name.
8
13
  def initialize(text, name: :note, theme: nil)
9
14
  super(name, theme: theme)
10
15
  @text = text
11
16
  end
12
17
 
18
+ # Binds the field to the form state but does not create any per-field storage.
13
19
  def bind(state)
14
20
  @state = state
15
21
  end
16
22
 
23
+ # Notes are never focusable and therefore excluded from Tab/Enter traversal.
17
24
  def focusable?
18
25
  false
19
26
  end
20
27
 
28
+ # Notes never produce validation errors.
21
29
  def validate
22
30
  []
23
31
  end
24
32
 
33
+ # Returns the literal text, ignoring the *active:* flag (notes have no focus state).
25
34
  def render(active: false)
26
35
  @text.to_s
27
36
  end
@@ -4,7 +4,13 @@ module Charming
4
4
  module Presentation
5
5
  module Components
6
6
  class Form
7
+ # Select is a single-choice Form field backed by a List widget. The selected option
8
+ # becomes the field's value; navigation keys (up/down/home/end) cycle through options
9
+ # and Enter has no effect (selection is applied immediately on key release).
7
10
  class Select < Field
11
+ # *options* is the array of selectable values. *selected_index* defaults to 0.
12
+ # *option_label* is a callable used to extract the display string (default: `to_s`).
13
+ # All other options are forwarded to Field.
8
14
  def initialize(name, options:, selected_index: 0, option_label: :to_s.to_proc, **field_options)
9
15
  super(name, **field_options)
10
16
  @options = options
@@ -12,11 +18,14 @@ module Charming
12
18
  @option_label = option_label
13
19
  end
14
20
 
21
+ # Binds the field, then ensures the persisted selection (or initial/derived one) is applied.
15
22
  def bind(state)
16
23
  super
17
24
  ensure_selection
18
25
  end
19
26
 
27
+ # Forwards key events to the underlying List, syncing the chosen option index back
28
+ # into the field state. Returns :handled when consumed.
20
29
  def handle_key(event)
21
30
  selection = list
22
31
  result = selection.handle_key(event)
@@ -28,24 +37,32 @@ module Charming
28
37
 
29
38
  private
30
39
 
40
+ # The options array (used as the source of truth for default value and clamp).
31
41
  attr_reader :options
32
42
 
43
+ # The default value is the option at the clamped initial selected index.
33
44
  def default_value
34
45
  options[clamped_initial_index]
35
46
  end
36
47
 
48
+ # Renders the field as "Label: <display value>".
37
49
  def render_control
38
50
  "#{label}: #{display_value}"
39
51
  end
40
52
 
53
+ # Returns the stringified value via the configured option label callable.
41
54
  def display_value
42
55
  value.nil? ? "" : @option_label.call(value)
43
56
  end
44
57
 
58
+ # Builds a fresh List each render with the current options, selected index, label
59
+ # callable, and theme.
45
60
  def list
46
61
  List.new(items: options, selected_index: selected_index, label: @option_label, theme: theme)
47
62
  end
48
63
 
64
+ # Ensures the persisted selection is set, falling back to the field's initial index
65
+ # or the current stored value.
49
66
  def ensure_selection
50
67
  if field_state.key?(:selected_index)
51
68
  save_selection(field_state[:selected_index])
@@ -56,29 +73,35 @@ module Charming
56
73
  end
57
74
  end
58
75
 
76
+ # Persists the chosen *index* and the corresponding option as the field's value.
59
77
  def save_selection(index)
60
78
  field_state[:selected_index] = clamp_index(index)
61
79
  state[:values][name] = options[field_state[:selected_index]]
62
80
  end
63
81
 
82
+ # The currently persisted selected index (or the initial index when unset).
64
83
  def selected_index
65
84
  field_state[:selected_index] || clamped_initial_index
66
85
  end
67
86
 
87
+ # Clamps the initial selected index to the valid range.
68
88
  def clamped_initial_index
69
89
  clamp_index(@initial_selected_index)
70
90
  end
71
91
 
92
+ # Clamps *index* to the valid range. Returns 0 when there are no options.
72
93
  def clamp_index(index)
73
94
  return 0 if options.empty?
74
95
 
75
96
  index.to_i.clamp(0, options.length - 1)
76
97
  end
77
98
 
99
+ # Returns the index of *option* in the options array, or nil when absent.
78
100
  def index_for(option)
79
101
  options.index(option)
80
102
  end
81
103
 
104
+ # Returns the per-field state hash for this field.
82
105
  def field_state
83
106
  state[:fields][name]
84
107
  end
@@ -4,7 +4,13 @@ module Charming
4
4
  module Presentation
5
5
  module Components
6
6
  class Form
7
+ # Textarea is a multi-line Form field backed by a TextArea widget. The cursor offset,
8
+ # top-visible row, and preferred vertical column are all persisted in the form's
9
+ # per-field state so the field behaves consistently when refocused mid-edit.
7
10
  class Textarea < Field
11
+ # *value* is the initial text. *placeholder* is shown when the value is empty.
12
+ # *width* and *height* constrain the rendered area. All other options are forwarded
13
+ # to Field (label, required, validate, help, theme).
8
14
  def initialize(name, value: "", placeholder: "", width: nil, height: nil, **options)
9
15
  super(name, **options)
10
16
  @initial_value = value
@@ -13,6 +19,7 @@ module Charming
13
19
  @height = height
14
20
  end
15
21
 
22
+ # Binds the field, seeds the initial value, and initializes the cursor/offset state.
16
23
  def bind(state)
17
24
  super
18
25
  state[:values][name] = @initial_value if state[:values][name].nil?
@@ -20,6 +27,8 @@ module Charming
20
27
  field_state[:offset] ||= 0
21
28
  end
22
29
 
30
+ # Forwards key events to the underlying TextArea, syncing the value, cursor, offset,
31
+ # and preferred column back into the form state. Returns :handled when consumed.
23
32
  def handle_key(event)
24
33
  area = text_area
25
34
  result = area.handle_key(event)
@@ -32,6 +41,8 @@ module Charming
32
41
  :handled
33
42
  end
34
43
 
44
+ # Renders the field with its label on the first line, body lines indented, and
45
+ # optional help/error lines below.
35
46
  def render(active: false)
36
47
  label_line = "#{active ? ">" : " "} #{label}:"
37
48
  label_line = theme.selected.render(label_line) if active
@@ -40,14 +51,18 @@ module Charming
40
51
 
41
52
  private
42
53
 
54
+ # The default value for a freshly-bound field is the *value* passed at construction.
43
55
  def default_value
44
56
  @initial_value
45
57
  end
46
58
 
59
+ # Renders the multi-line body, indenting each line by two spaces.
47
60
  def body_lines
48
61
  text_area.render.lines(chomp: true).map { |line| " #{line}" }
49
62
  end
50
63
 
64
+ # Builds a fresh TextArea each render, seeded from the current form-state value and
65
+ # the persisted cursor/offset/preferred_column.
51
66
  def text_area
52
67
  TextArea.new(
53
68
  value: value.to_s,
@@ -60,6 +75,7 @@ module Charming
60
75
  )
61
76
  end
62
77
 
78
+ # Returns the per-field state hash for this field.
63
79
  def field_state
64
80
  state[:fields][name]
65
81
  end
@@ -3,9 +3,17 @@
3
3
  module Charming
4
4
  module Presentation
5
5
  module Components
6
+ # Form is a multi-field form component with built-in focus traversal, validation, and
7
+ # submit/cancel handling. Fields are produced by `Form::Builder` (see `controller.form`)
8
+ # and bound to a per-form mutable state hash. Tab/Shift+Tab cycles focus through
9
+ # focusable fields, Enter advances to the next field (or submits on the last), Escape
10
+ # cancels, and Ctrl+S submits from any field.
6
11
  class Form < Component
12
+ # The list of field objects and the mutable state hash the form is bound to.
7
13
  attr_reader :fields, :state
8
14
 
15
+ # *fields* is the array of form field objects. *state* is a hash for storing field
16
+ # values/errors and the current focus index; usually `session[:forms][form_name]`.
9
17
  def initialize(fields:, state: nil, theme: nil)
10
18
  super(theme: theme)
11
19
  @fields = fields
@@ -14,6 +22,8 @@ module Charming
14
22
  clamp_focus
15
23
  end
16
24
 
25
+ # Handles key events: Escape cancels, Ctrl+S submits, Tab cycles focus, Enter advances
26
+ # or submits, and unhandled keys are passed to the focused field.
17
27
  def handle_key(event)
18
28
  key = Charming.key_of(event)
19
29
  return :cancelled if key == :escape
@@ -26,10 +36,12 @@ module Charming
26
36
  advance_or_submit if key == :enter
27
37
  end
28
38
 
39
+ # Returns a hash of `{field_name => value}` for the current field values.
29
40
  def values
30
41
  state[:values]
31
42
  end
32
43
 
44
+ # Renders each field on its own line, marking the active field with `active: true`.
33
45
  def render
34
46
  fields.each_with_index.map do |field, index|
35
47
  field.render(active: index == state[:focus_index])
@@ -38,6 +50,8 @@ module Charming
38
50
 
39
51
  private
40
52
 
53
+ # Ensures the state hash has all the required sub-keys: :values, :fields, :errors, and
54
+ # a sensible :focus_index default.
41
55
  def normalize_state(value)
42
56
  value[:values] ||= {}
43
57
  value[:fields] ||= {}
@@ -46,30 +60,37 @@ module Charming
46
60
  value
47
61
  end
48
62
 
63
+ # Binds each field to the state hash so field updates write back into `state[:values]`.
49
64
  def bind_fields
50
65
  fields.each { |field| field.bind(state) }
51
66
  end
52
67
 
68
+ # Forwards *event* to the currently focused field and returns its result.
53
69
  def handle_current_field(event)
54
70
  current_field&.handle_key(event)
55
71
  end
56
72
 
73
+ # Returns -1 for Shift+Tab (backward), +1 for plain Tab (forward).
57
74
  def tab_direction(event)
58
75
  return -1 if event.respond_to?(:shift) && event.shift
59
76
 
60
77
  +1
61
78
  end
62
79
 
80
+ # True when the event is the submit shortcut (Ctrl+S).
63
81
  def submit_shortcut?(event)
64
82
  Charming.key_of(event) == :s && event.respond_to?(:ctrl) && event.ctrl
65
83
  end
66
84
 
85
+ # On Enter: submit when the last focusable field is active, otherwise advance focus.
67
86
  def advance_or_submit
68
87
  return submit if last_focusable?
69
88
 
70
89
  move_focus(+1)
71
90
  end
72
91
 
92
+ # Validates all fields, focuses the first invalid one, and returns [:submitted, values]
93
+ # when there are no errors.
73
94
  def submit
74
95
  state[:errors] = validation_errors
75
96
  focus_first_error unless state[:errors].empty?
@@ -78,6 +99,7 @@ module Charming
78
99
  [:submitted, values.dup]
79
100
  end
80
101
 
102
+ # Runs each field's validator and collects per-field error messages.
81
103
  def validation_errors
82
104
  fields.each_with_object({}) do |field, errors|
83
105
  messages = field.validate
@@ -85,15 +107,18 @@ module Charming
85
107
  end
86
108
  end
87
109
 
110
+ # Moves focus to the first focusable field with errors, when any.
88
111
  def focus_first_error
89
112
  invalid = fields.index { |field| field.focusable? && state[:errors].key?(field.name) }
90
113
  state[:focus_index] = invalid if invalid
91
114
  end
92
115
 
116
+ # Returns the field at the current focus index, or nil when out of range.
93
117
  def current_field
94
118
  fields[state[:focus_index]]
95
119
  end
96
120
 
121
+ # Moves focus by *direction* (forward or backward) through the focusable fields.
97
122
  def move_focus(direction)
98
123
  indices = focusable_indices
99
124
  return nil if indices.empty?
@@ -103,18 +128,22 @@ module Charming
103
128
  :handled
104
129
  end
105
130
 
131
+ # True when the current focus index is the last focusable field.
106
132
  def last_focusable?
107
133
  focusable_indices.last == state[:focus_index]
108
134
  end
109
135
 
136
+ # Indices of focusable fields, memoized.
110
137
  def focusable_indices
111
138
  @focusable_indices ||= fields.each_index.select { |index| fields[index].focusable? }
112
139
  end
113
140
 
141
+ # The first index of a focusable field, or nil when no fields are focusable.
114
142
  def first_focusable_index
115
143
  fields.each_index.find { |index| fields[index].focusable? }
116
144
  end
117
145
 
146
+ # On initialization, ensures :focus_index points at a focusable field.
118
147
  def clamp_focus
119
148
  return if focusable_indices.empty?
120
149
  return if focusable_indices.include?(state[:focus_index])
@@ -3,6 +3,10 @@
3
3
  module Charming
4
4
  module Presentation
5
5
  module Components
6
+ # List is a vertically-scrollable selectable list. Supports keyboard navigation
7
+ # (up/down/home/end, Enter to activate) and mouse click selection. When a *height* is
8
+ # given, the list renders a fixed-height window over its items with auto-scroll
9
+ # keeping the selected item in view.
6
10
  class List < Component
7
11
  include KeyboardHandler
8
12
 
@@ -16,8 +20,13 @@ module Charming
16
20
  end: :move_end
17
21
  }.freeze
18
22
 
23
+ # The item array and the currently selected index within it.
19
24
  attr_reader :items, :selected_index
20
25
 
26
+ # *items* is the array of selectable objects. *selected_index* defaults to 0.
27
+ # *height* optionally constrains the visible window; *label* is a callable that
28
+ # extracts the display string from an item (defaults to `to_s`).
29
+ # *keymap* selects the keybinding style (`:vim` enables h/j/k/l → left/down/up/right).
21
30
  def initialize(items:, selected_index: 0, height: nil, label: nil, theme: nil, keymap: :vim)
22
31
  super(theme: theme)
23
32
  @items = items
@@ -28,12 +37,16 @@ module Charming
28
37
  clamp_position
29
38
  end
30
39
 
40
+ # Handles key events. Returns `[:selected, item]` on Enter when an item is selected;
41
+ # otherwise delegates to the KeyboardHandler for navigation keys.
31
42
  def handle_key(event)
32
43
  return [:selected, selected_item] if Charming.key_of(event) == :enter && selected_item
33
44
 
34
45
  super
35
46
  end
36
47
 
48
+ # Handles mouse events: a click within the visible window selects the clicked row.
49
+ # Returns :handled on a successful click, nil otherwise.
37
50
  def handle_mouse(event)
38
51
  return nil unless @height
39
52
  return nil unless event.respond_to?(:click?) && event.click?
@@ -46,10 +59,13 @@ module Charming
46
59
  :handled
47
60
  end
48
61
 
62
+ # Returns the currently selected item, or nil when the list is empty.
49
63
  def selected_item
50
64
  items[selected_index]
51
65
  end
52
66
 
67
+ # Renders the visible window of items, prefixing each with "> " (and applying the
68
+ # selected style) or " ".
53
69
  def render
54
70
  visible_items.each_with_index.map do |item, index|
55
71
  render_item(item, viewport_start + index)
@@ -58,42 +74,54 @@ module Charming
58
74
 
59
75
  private
60
76
 
77
+ # Moves the selection up one position.
61
78
  def move_up
62
79
  @selected_index -= 1 if selected_index.positive?
63
80
  end
64
81
 
82
+ # Moves the selection down one position.
65
83
  def move_down
66
84
  @selected_index += 1 if selected_index < items.length - 1
67
85
  end
68
86
 
87
+ # Moves the selection to the first item.
69
88
  def move_home
70
89
  @selected_index = 0
71
90
  end
72
91
 
92
+ # Moves the selection to the last item (no-op when the list is empty).
73
93
  def move_end
74
94
  @selected_index = items.length - 1 unless items.empty?
75
95
  end
76
96
 
97
+ # Returns the slice of items currently in the visible window.
77
98
  def visible_items
78
99
  items[viewport_start, viewport_height] || []
79
100
  end
80
101
 
102
+ # Returns the index of the topmost visible item, computed so the selected item stays
103
+ # in view when the list is taller than the visible window.
81
104
  def viewport_start
82
105
  return 0 unless @height
83
106
 
84
107
  Layout.selected_window_start(selected_index: selected_index, item_count: items.length, window_size: @height)
85
108
  end
86
109
 
110
+ # Returns the number of items visible in the window (the configured *height* or the
111
+ # total item count when no height was set).
87
112
  def viewport_height
88
113
  @height || items.length
89
114
  end
90
115
 
116
+ # Renders a single item: prefix with "> " (selected) or " " (unselected), then apply
117
+ # the theme's selected style to the selected item's row.
91
118
  def render_item(item, index)
92
119
  prefix = (index == selected_index) ? "> " : " "
93
120
  rendered = "#{prefix}#{@label.call(item)}"
94
121
  (index == selected_index) ? theme.selected.render(rendered) : rendered
95
122
  end
96
123
 
124
+ # Resets the selection when the list is empty, otherwise clamps it to the valid range.
97
125
  def clamp_position
98
126
  @selected_index = 0 if items.empty?
99
127
  @selected_index = selected_index.clamp(0, items.length - 1) unless items.empty?
@@ -3,7 +3,12 @@
3
3
  module Charming
4
4
  module Presentation
5
5
  module Components
6
+ # Markdown renders Markdown source as ANSI-styled terminal text. Parsing is delegated to
7
+ # `Presentation::Markdown::Renderer`; set *syntax_highlighting* to false to disable
8
+ # Rouge-backed code block highlighting.
6
9
  class Markdown < Component
10
+ # *content* is the Markdown source string. *width* optionally sets the wrap width.
11
+ # *syntax_highlighting* enables Rouge for code blocks (defaults to true).
7
12
  def initialize(content:, width: nil, theme: nil, syntax_highlighting: true)
8
13
  super(theme: theme)
9
14
  @content = content
@@ -11,6 +16,7 @@ module Charming
11
16
  @syntax_highlighting = syntax_highlighting
12
17
  end
13
18
 
19
+ # Renders the Markdown body to a styled, terminal-safe string.
14
20
  def render
15
21
  Charming::Presentation::Markdown::Renderer.new(
16
22
  content: @content,
@@ -3,7 +3,13 @@
3
3
  module Charming
4
4
  module Presentation
5
5
  module Components
6
+ # Modal is a centered, boxed overlay with an optional title, help line, and body content.
7
+ # The body may be a string, View, or Component; when it responds to `render`, its output
8
+ # is used. The result is wrapped in a UI::Style border with padding.
6
9
  class Modal < Component
10
+ # *content* is the modal body. *title* (optional) is rendered centered at the top.
11
+ # *help* (optional) is rendered as a muted footer line. *width* is the modal's total width.
12
+ # *style* overrides the default `theme.modal` style.
7
13
  def initialize(content:, title: nil, help: nil, width: 52, style: nil, theme: nil)
8
14
  super(theme: theme)
9
15
  @content = content
@@ -13,6 +19,8 @@ module Charming
13
19
  @style = style
14
20
  end
15
21
 
22
+ # Renders the modal as a bordered, padded string with the title and help lines stacked
23
+ # above the content.
16
24
  def render
17
25
  box(column(*lines, gap: 1), style: modal_style)
18
26
  end
@@ -21,26 +29,32 @@ module Charming
21
29
 
22
30
  attr_reader :content, :title, :help, :width
23
31
 
32
+ # Returns the array of non-nil lines: title, help, content.
24
33
  def lines
25
34
  [title_line, help_line, render_content].compact
26
35
  end
27
36
 
37
+ # Returns the centered title line styled with the theme's title style, when a title was given.
28
38
  def title_line
29
39
  text(title, style: theme.title.align(:center).width(title_width)) if title
30
40
  end
31
41
 
42
+ # Returns the help line styled with the theme's muted style, when help was given.
32
43
  def help_line
33
44
  text(help, style: theme.muted) if help
34
45
  end
35
46
 
47
+ # Returns the rendered content string, calling `render` on the body when applicable.
36
48
  def render_content
37
49
  content.respond_to?(:render) ? render_component(content) : content.to_s
38
50
  end
39
51
 
52
+ # Returns the modal's outer style: the user-provided style or `theme.modal` at the given width.
40
53
  def modal_style
41
54
  @style || theme.modal.width(width)
42
55
  end
43
56
 
57
+ # Returns the title's display width, accounting for the modal's horizontal padding/border.
44
58
  def title_width
45
59
  [width - 8, 0].max
46
60
  end