charming 0.2.0 → 0.2.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +144 -9
  4. data/lib/charming/audio/player.rb +104 -0
  5. data/lib/charming/audio/system.rb +69 -0
  6. data/lib/charming/cli.rb +63 -7
  7. data/lib/charming/controller/action_hooks.rb +124 -0
  8. data/lib/charming/controller/class_methods.rb +15 -1
  9. data/lib/charming/controller/dispatching.rb +31 -5
  10. data/lib/charming/controller/focus.rb +9 -0
  11. data/lib/charming/controller/focus_management.rb +0 -7
  12. data/lib/charming/controller/key_dispatch.rb +113 -0
  13. data/lib/charming/controller/session_state.rb +27 -1
  14. data/lib/charming/controller/sidebar_navigation.rb +63 -28
  15. data/lib/charming/controller/terminal.rb +33 -0
  16. data/lib/charming/controller.rb +43 -18
  17. data/lib/charming/database/commands.rb +123 -11
  18. data/lib/charming/escape.rb +81 -0
  19. data/lib/charming/events/focus_event.rb +12 -0
  20. data/lib/charming/events/mouse_event.rb +22 -9
  21. data/lib/charming/events/paste_event.rb +11 -0
  22. data/lib/charming/events/task_progress_event.rb +21 -0
  23. data/lib/charming/generators/app_generator.rb +41 -3
  24. data/lib/charming/generators/database_installer.rb +4 -15
  25. data/lib/charming/generators/migration_generator.rb +116 -0
  26. data/lib/charming/generators/migration_timestamp.rb +29 -0
  27. data/lib/charming/generators/model_generator.rb +4 -2
  28. data/lib/charming/generators/templates/app/Gemfile.template +6 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +1 -1
  30. data/lib/charming/generators/templates/app/database_config.template +3 -1
  31. data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
  32. data/lib/charming/generators/templates/app/layout.template +1 -1
  33. data/lib/charming/generators/templates/app/spec_helper.template +2 -1
  34. data/lib/charming/generators/templates/app/view.template +1 -1
  35. data/lib/charming/image/protocol/kitty.rb +126 -0
  36. data/lib/charming/image/protocol.rb +18 -0
  37. data/lib/charming/image/source.rb +85 -0
  38. data/lib/charming/image/terminal.rb +52 -0
  39. data/lib/charming/image/transmit.rb +11 -0
  40. data/lib/charming/image.rb +21 -0
  41. data/lib/charming/internal/event_loop.rb +155 -0
  42. data/lib/charming/internal/terminal/adapter.rb +14 -0
  43. data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
  44. data/lib/charming/internal/terminal/memory_backend.rb +27 -3
  45. data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
  46. data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
  47. data/lib/charming/internal/terminal/tty_backend.rb +143 -4
  48. data/lib/charming/presentation/component.rb +7 -0
  49. data/lib/charming/presentation/components/activity_indicator.rb +2 -19
  50. data/lib/charming/presentation/components/audio.rb +31 -0
  51. data/lib/charming/presentation/components/autocomplete.rb +108 -0
  52. data/lib/charming/presentation/components/badge.rb +31 -0
  53. data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
  54. data/lib/charming/presentation/components/chart.rb +80 -0
  55. data/lib/charming/presentation/components/command_palette.rb +8 -5
  56. data/lib/charming/presentation/components/error_screen.rb +72 -0
  57. data/lib/charming/presentation/components/filepicker.rb +101 -0
  58. data/lib/charming/presentation/components/form/builder.rb +5 -0
  59. data/lib/charming/presentation/components/form/multiselect.rb +105 -0
  60. data/lib/charming/presentation/components/form.rb +9 -0
  61. data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
  62. data/lib/charming/presentation/components/help_overlay.rb +65 -0
  63. data/lib/charming/presentation/components/image.rb +38 -0
  64. data/lib/charming/presentation/components/list.rb +22 -4
  65. data/lib/charming/presentation/components/markdown.rb +6 -2
  66. data/lib/charming/presentation/components/modal.rb +45 -5
  67. data/lib/charming/presentation/components/multi_select_list.rb +85 -0
  68. data/lib/charming/presentation/components/paginator.rb +54 -0
  69. data/lib/charming/presentation/components/progressbar.rb +26 -5
  70. data/lib/charming/presentation/components/sparkline.rb +38 -0
  71. data/lib/charming/presentation/components/spinner.rb +22 -3
  72. data/lib/charming/presentation/components/status_bar.rb +75 -0
  73. data/lib/charming/presentation/components/stopwatch.rb +55 -0
  74. data/lib/charming/presentation/components/tab_bar.rb +103 -0
  75. data/lib/charming/presentation/components/table.rb +82 -10
  76. data/lib/charming/presentation/components/text_area.rb +48 -12
  77. data/lib/charming/presentation/components/text_input.rb +85 -5
  78. data/lib/charming/presentation/components/time_display.rb +20 -0
  79. data/lib/charming/presentation/components/timer.rb +43 -0
  80. data/lib/charming/presentation/components/toast.rb +51 -0
  81. data/lib/charming/presentation/components/tree.rb +176 -0
  82. data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
  83. data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
  84. data/lib/charming/presentation/components/viewport/position.rb +67 -0
  85. data/lib/charming/presentation/components/viewport.rb +37 -122
  86. data/lib/charming/presentation/layout/builder.rb +4 -1
  87. data/lib/charming/presentation/layout/overlay.rb +6 -4
  88. data/lib/charming/presentation/layout/pane.rb +2 -1
  89. data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
  90. data/lib/charming/presentation/layout/screen_layout.rb +12 -3
  91. data/lib/charming/presentation/layout/split.rb +37 -3
  92. data/lib/charming/presentation/markdown/renderer.rb +99 -63
  93. data/lib/charming/presentation/markdown/style_config.rb +11 -5
  94. data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
  95. data/lib/charming/presentation/markdown/table_renderer.rb +59 -0
  96. data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
  97. data/lib/charming/presentation/templates/erb_handler.rb +35 -2
  98. data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
  99. data/lib/charming/presentation/ui/ansi_codes.rb +15 -1
  100. data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
  101. data/lib/charming/presentation/ui/background.rb +58 -0
  102. data/lib/charming/presentation/ui/border.rb +14 -1
  103. data/lib/charming/presentation/ui/border_painter.rb +23 -11
  104. data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
  105. data/lib/charming/presentation/ui/canvas.rb +1 -0
  106. data/lib/charming/presentation/ui/color_support.rb +129 -0
  107. data/lib/charming/presentation/ui/gradient.rb +47 -0
  108. data/lib/charming/presentation/ui/style.rb +119 -19
  109. data/lib/charming/presentation/ui/text_wrapper.rb +40 -0
  110. data/lib/charming/presentation/ui/theme.rb +7 -0
  111. data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
  112. data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
  113. data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
  114. data/lib/charming/presentation/ui/themes/nord.json +32 -0
  115. data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
  116. data/lib/charming/presentation/ui/truncate.rb +29 -0
  117. data/lib/charming/presentation/ui/width.rb +38 -2
  118. data/lib/charming/presentation/ui.rb +52 -11
  119. data/lib/charming/presentation/view.rb +8 -6
  120. data/lib/charming/response.rb +11 -6
  121. data/lib/charming/router.rb +1 -1
  122. data/lib/charming/runtime.rb +236 -63
  123. data/lib/charming/tasks/cancelled.rb +11 -0
  124. data/lib/charming/tasks/inline_executor.rb +10 -4
  125. data/lib/charming/tasks/progress.rb +30 -0
  126. data/lib/charming/tasks/task.rb +24 -4
  127. data/lib/charming/tasks/threaded_executor.rb +35 -11
  128. data/lib/charming/test_helper.rb +120 -0
  129. data/lib/charming/version.rb +1 -1
  130. data/lib/charming.rb +43 -1
  131. metadata +63 -50
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Chart plots a numeric *series* into a `width`×`height` box of character cells. `kind: :line`
6
+ # (default) draws a connected line on a {Charming::UI::BrailleCanvas} (subpixel resolution);
7
+ # `kind: :bar` draws vertical eighth-block bars. Pure text — works on every terminal — and
8
+ # composes with `row`/`column`/`box`. Pass an optional `style:` ({Charming::UI::Style}) to colour it.
9
+ class Chart < Component
10
+ # Vertical fill levels for bar mode, empty (0) to full (8 eighths).
11
+ VBARS = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"].freeze
12
+
13
+ # *series* is the numeric data. *width*/*height* size the chart in character cells. *kind* is
14
+ # `:line` or `:bar`. *style* optionally paints the result. *theme* is forwarded.
15
+ def initialize(series:, width:, height:, kind: :line, style: nil, theme: nil)
16
+ super(theme: theme)
17
+ @series = series
18
+ @width = width
19
+ @height = height
20
+ @kind = kind
21
+ @style = style
22
+ end
23
+
24
+ # Renders the chart (empty string for an empty series or a non-positive box).
25
+ def render
26
+ return "" if @series.empty? || @width < 1 || @height < 1
27
+
28
+ body = (@kind == :bar) ? bars : line_plot
29
+ @style ? @style.render(body) : body
30
+ end
31
+
32
+ private
33
+
34
+ # A connected line on a braille canvas sized to the cell box.
35
+ def line_plot
36
+ canvas = UI::BrailleCanvas.new(@width * 2, @height * 4)
37
+ points = scaled_points(@width * 2, @height * 4)
38
+ points.each { |x, y| canvas.set(x, y) }
39
+ points.each_cons(2) { |(x0, y0), (x1, y1)| canvas.line(x0, y0, x1, y1) }
40
+ canvas.to_s
41
+ end
42
+
43
+ # Maps the series to canvas pixels: x spread across the width, y inverted (0 at top) and scaled
44
+ # between the series' min and max.
45
+ def scaled_points(pixel_width, pixel_height)
46
+ min, max = @series.minmax
47
+ span = (max - min).to_f
48
+ span = 1.0 if span.zero?
49
+ last = @series.length - 1
50
+ @series.each_with_index.map do |value, index|
51
+ x = last.zero? ? 0 : (index * (pixel_width - 1) / last.to_f).round
52
+ y = ((1 - (value - min) / span) * (pixel_height - 1)).round
53
+ [x, y]
54
+ end
55
+ end
56
+
57
+ # Vertical eighth-block bars, one column per cell, sampled from the series and scaled from a
58
+ # baseline of min(0, series-min).
59
+ def bars
60
+ columns = sample(@series, @width)
61
+ base = [columns.min, 0].min
62
+ span = (columns.max - base).to_f
63
+ span = 1.0 if span.zero?
64
+ eighths = columns.map { |value| ((value - base) / span * @height * 8).round }
65
+ Array.new(@height) do |row|
66
+ from_bottom = @height - 1 - row
67
+ eighths.map { |total| VBARS[(total - from_bottom * 8).clamp(0, 8)] }.join
68
+ end.join("\n")
69
+ end
70
+
71
+ # Resamples *series* to exactly *count* values via nearest-neighbour (identity when sizes match).
72
+ def sample(series, count)
73
+ size = series.length
74
+ return series if size == count
75
+
76
+ Array.new(count) { |index| series[(index * size / count.to_f).floor.clamp(0, size - 1)] }
77
+ end
78
+ end
79
+ end
80
+ end
@@ -43,6 +43,11 @@ module Charming
43
43
  }
44
44
  end
45
45
 
46
+ # Free-typed characters belong to this component while it is focused.
47
+ def captures_text?
48
+ true
49
+ end
50
+
46
51
  # Handles key events by routing them to the appropriate sub-component: Escape kills the
47
52
  # palette returning :cancelled; up/down/home/end keys go to the List selection handler
48
53
  # via handle_list_key; all other keys (including typed characters) are passed to the TextInput
@@ -104,14 +109,12 @@ module Charming
104
109
  List.new(items: filtered_commands, selected_index: selected_index, height: height, label: :label.to_proc, theme: theme)
105
110
  end
106
111
 
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.
112
+ # Returns the full commands array when input value is empty; otherwise the commands
113
+ # fuzzy-matched against the typed value, best matches first (see FuzzyMatcher).
109
114
  def filtered_commands
110
115
  return commands if input.value.empty?
111
116
 
112
- commands.select do |command|
113
- command.label.downcase.include?(input.value.downcase)
114
- end
117
+ FuzzyMatcher.filter(input.value, commands) { |command| command.label.to_s }
115
118
  end
116
119
  end
117
120
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # ErrorScreen renders an unhandled exception as a styled, centered panel instead of
6
+ # letting the backtrace crash into the raw terminal. Shows the exception class, message,
7
+ # the most relevant backtrace lines, and a dismiss hint. The Runtime displays it when a
8
+ # dispatched action raises and no `rescue_from` handler claimed the exception.
9
+ class ErrorScreen < Component
10
+ DEFAULT_WIDTH = 64
11
+ BACKTRACE_LINES = 6
12
+
13
+ # *error* is the rescued exception. *width* is the panel's total width. *root* is the
14
+ # app root used to shorten backtrace paths (defaults to the working directory).
15
+ def initialize(error:, width: DEFAULT_WIDTH, root: Dir.pwd, theme: nil)
16
+ super(theme: theme)
17
+ @error = error
18
+ @width = width
19
+ @root = root
20
+ end
21
+
22
+ # Renders the bordered error panel.
23
+ def render
24
+ box(column(*sections, gap: 1), style: panel_style)
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :error, :width, :root
30
+
31
+ # The panel sections: class name, message, backtrace, dismiss hint.
32
+ def sections
33
+ [
34
+ text(error.class.name, style: theme.warn.bold),
35
+ text(wrapped_message),
36
+ backtrace_section,
37
+ text("press any key to continue · q to quit", style: theme.muted)
38
+ ].compact
39
+ end
40
+
41
+ # The exception message, wrapped to the panel's inner width.
42
+ def wrapped_message
43
+ Charming::UI::TextWrapper.new(width: inner_width).wrap(error.message.to_s)
44
+ end
45
+
46
+ # The first few backtrace lines with the app root stripped, styled muted.
47
+ def backtrace_section
48
+ lines = (error.backtrace || []).first(BACKTRACE_LINES)
49
+ return nil if lines.empty?
50
+
51
+ body = lines.map { |line| shorten(line) }.join("\n")
52
+ text(body, style: theme.muted)
53
+ end
54
+
55
+ # Strips the app root prefix and clips each backtrace line to the inner width.
56
+ def shorten(line)
57
+ cleaned = line.delete_prefix("#{root}/")
58
+ UI.visible_slice(cleaned, 0, inner_width)
59
+ end
60
+
61
+ # The panel's content width inside border and padding.
62
+ def inner_width
63
+ [width - 6, 10].max
64
+ end
65
+
66
+ # The bordered panel style: warn-colored rounded border with padding.
67
+ def panel_style
68
+ style.border(:rounded, foreground: :red).padding(1, 2).width(width)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Filepicker is a directory browser built on List. Enter descends into the
6
+ # highlighted directory or returns `[:selected, absolute_path]` for a file;
7
+ # Backspace (or the "../" entry) goes up, never above the configured root.
8
+ # Dotfiles are hidden until `toggle_hidden`.
9
+ class Filepicker < Component
10
+ PARENT_ENTRY = "../"
11
+
12
+ # The directory currently being browsed.
13
+ attr_reader :current_dir
14
+
15
+ # *root* is the starting directory and the upper boundary for navigation.
16
+ # *show_hidden* includes dotfiles from the start. *height* windows the
17
+ # listing like List's height.
18
+ def initialize(root: Dir.pwd, show_hidden: false, height: nil, theme: nil, keymap: :vim)
19
+ super(theme: theme)
20
+ @root = File.expand_path(root)
21
+ @current_dir = @root
22
+ @show_hidden = show_hidden
23
+ @height = height
24
+ @keymap = keymap
25
+ rebuild_list
26
+ end
27
+
28
+ # The display entries for the current directory: an optional parent entry,
29
+ # then directories ("name/") before files, alphabetically.
30
+ def entries
31
+ list.items
32
+ end
33
+
34
+ # Handles navigation: Enter descends/selects, Backspace ascends, and all
35
+ # other keys delegate to the underlying List.
36
+ def handle_key(event)
37
+ case Charming.key_of(event)
38
+ when :enter then activate(list.selected_item)
39
+ when :backspace then ascend
40
+ else list.handle_key(event)
41
+ end
42
+ end
43
+
44
+ # Shows or hides dotfiles, refreshing the listing. Returns self.
45
+ def toggle_hidden
46
+ @show_hidden = !@show_hidden
47
+ rebuild_list
48
+ self
49
+ end
50
+
51
+ # Renders the current directory's listing via the underlying List.
52
+ def render
53
+ list.render
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :list
59
+
60
+ # Descends into directories, returns files as a selection.
61
+ def activate(entry)
62
+ return nil unless entry
63
+ return ascend if entry == PARENT_ENTRY
64
+ return descend(entry.delete_suffix("/")) if entry.end_with?("/")
65
+
66
+ [:selected, File.join(current_dir, entry)]
67
+ end
68
+
69
+ # Enters *name* under the current directory.
70
+ def descend(name)
71
+ @current_dir = File.join(current_dir, name)
72
+ rebuild_list
73
+ :handled
74
+ end
75
+
76
+ # Moves to the parent directory, unless already at the root.
77
+ def ascend
78
+ return nil if current_dir == @root
79
+
80
+ @current_dir = File.dirname(current_dir)
81
+ rebuild_list
82
+ :handled
83
+ end
84
+
85
+ # Builds a fresh List over the current directory's entries.
86
+ def rebuild_list
87
+ @list = List.new(items: directory_entries, height: @height, theme: theme, keymap: @keymap)
88
+ end
89
+
90
+ # Reads the current directory: parent entry (when below root), then
91
+ # directories before files, each group alphabetical, dotfiles filtered.
92
+ def directory_entries
93
+ names = Dir.children(current_dir).sort
94
+ names = names.reject { |name| name.start_with?(".") } unless @show_hidden
95
+ directories, files = names.partition { |name| File.directory?(File.join(current_dir, name)) }
96
+ parent = (current_dir == @root) ? [] : [PARENT_ENTRY]
97
+ parent + directories.map { |name| "#{name}/" } + files
98
+ end
99
+ end
100
+ end
101
+ end
@@ -32,6 +32,11 @@ module Charming
32
32
  fields << Select.new(name, **field_options(options))
33
33
  end
34
34
 
35
+ # Appends a Multiselect field with the given *options* array.
36
+ def multiselect(name, **options)
37
+ fields << Multiselect.new(name, **field_options(options))
38
+ end
39
+
35
40
  # Appends a Confirm (boolean) field.
36
41
  def confirm(name, **options)
37
42
  fields << Confirm.new(name, **field_options(options))
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ class Form
6
+ # Multiselect is a multiple-choice Form field backed by a MultiSelectList.
7
+ # Space toggles the highlighted option's checkbox, navigation keys move the
8
+ # highlight, and the checked set (in option order) becomes the field's value.
9
+ # Enter is left to the Form so it advances/submits like every other field.
10
+ class Multiselect < Field
11
+ # *options* is the array of selectable values. *selected_indices* pre-checks
12
+ # options. *max_selections* caps how many can be checked (nil = unlimited).
13
+ # *option_label* extracts the display string (default: `to_s`). All other
14
+ # options are forwarded to Field.
15
+ def initialize(name, options:, selected_indices: [], max_selections: nil, option_label: :to_s.to_proc, **field_options)
16
+ super(name, **field_options)
17
+ @options = options
18
+ @initial_indices = selected_indices
19
+ @max_selections = max_selections
20
+ @option_label = option_label
21
+ end
22
+
23
+ # Binds the field, then ensures the persisted checked set is applied.
24
+ def bind(state)
25
+ super
26
+ ensure_selection
27
+ end
28
+
29
+ # Forwards key events to the underlying MultiSelectList, syncing the checked
30
+ # set and highlight cursor back into the field state. Returns :handled when
31
+ # consumed; Enter (the list's submit) is left unconsumed for the Form.
32
+ def handle_key(event)
33
+ widget = list
34
+ result = widget.handle_key(event)
35
+ return nil if result.is_a?(Array)
36
+ return nil unless result == :handled
37
+
38
+ save_selection(widget)
39
+ :handled
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :options
45
+
46
+ # The default value is the pre-checked options, in option order.
47
+ def default_value
48
+ checked_options(normalized_initial_indices)
49
+ end
50
+
51
+ # Renders the field as "Label: choice, choice".
52
+ def render_control
53
+ "#{label}: #{display_value}"
54
+ end
55
+
56
+ # The checked options' labels joined for compact display.
57
+ def display_value
58
+ Array(value).map { |item| @option_label.call(item) }.join(", ")
59
+ end
60
+
61
+ # Builds a fresh MultiSelectList from the persisted checked set and cursor.
62
+ def list
63
+ MultiSelectList.new(
64
+ items: options,
65
+ selected_indices: field_state[:selected_indices],
66
+ selected_index: field_state[:cursor],
67
+ max_selections: @max_selections,
68
+ label: @option_label,
69
+ theme: theme
70
+ )
71
+ end
72
+
73
+ # Applies the persisted checked set (or the initial one) and highlight cursor.
74
+ def ensure_selection
75
+ checked = field_state[:selected_indices] || normalized_initial_indices
76
+ field_state[:selected_indices] = checked.sort
77
+ field_state[:cursor] ||= 0
78
+ state[:values][name] = checked_options(checked)
79
+ end
80
+
81
+ # Persists the widget's checked set and cursor, and the options as the value.
82
+ def save_selection(widget)
83
+ field_state[:selected_indices] = widget.selected_indices.sort
84
+ field_state[:cursor] = widget.selected_index
85
+ state[:values][name] = widget.selected_items
86
+ end
87
+
88
+ # The options at *indices*, in option order.
89
+ def checked_options(indices)
90
+ indices.sort.map { |index| options[index] }
91
+ end
92
+
93
+ # The initial indices, de-duplicated and clamped to the option range.
94
+ def normalized_initial_indices
95
+ @initial_indices.to_a.map(&:to_i).uniq.select { |index| index.between?(0, options.length - 1) }
96
+ end
97
+
98
+ # The per-field state hash for this field.
99
+ def field_state
100
+ state[:fields][name]
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -7,6 +7,9 @@ module Charming
7
7
  # and bound to a per-form mutable state hash. Tab/Shift+Tab cycles focus through
8
8
  # focusable fields, Enter advances to the next field (or submits on the last), Escape
9
9
  # cancels, and Ctrl+S submits from any field.
10
+ #
11
+ # Exception: inside a Textarea field, Enter inserts a newline (it's a text editor) —
12
+ # leave it with Tab and submit with Ctrl+S, matching charm.sh's huh behavior.
10
13
  class Form < Component
11
14
  # The list of field objects and the mutable state hash the form is bound to.
12
15
  attr_reader :fields, :state
@@ -35,6 +38,12 @@ module Charming
35
38
  advance_or_submit if key == :enter
36
39
  end
37
40
 
41
+ # Forms accept free-typed text (their input/textarea fields do), so printable
42
+ # characters route here before global/content key bindings.
43
+ def captures_text?
44
+ true
45
+ end
46
+
38
47
  # Returns a hash of `{field_name => value}` for the current field values.
39
48
  def values
40
49
  state[:values]
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # FuzzyMatcher implements fzf-style subsequence matching with contiguity and
6
+ # word-boundary scoring. Used by CommandPalette (and available to any component
7
+ # that filters labels against typed input).
8
+ #
9
+ # FuzzyMatcher.score("opl", "Open palette") # => positive score
10
+ # FuzzyMatcher.score("xyz", "Open palette") # => nil (not a subsequence)
11
+ # FuzzyMatcher.filter("op", commands, &:label)
12
+ module FuzzyMatcher
13
+ # Score bonuses: base per matched char, consecutive-run bonus, word-start bonus.
14
+ # The run bonus outweighs the word-start bonus so a literal substring match
15
+ # ("pal" in "Open palette") beats the same letters scattered across word starts.
16
+ CHAR_SCORE = 1
17
+ CONSECUTIVE_BONUS = 4
18
+ WORD_START_BONUS = 3
19
+
20
+ module_function
21
+
22
+ # Returns a relevance score when every character of *query* appears in order
23
+ # within *candidate* (case-insensitive), nil otherwise. Higher is better:
24
+ # contiguous runs and matches at word starts score above scattered matches.
25
+ # All alignments are considered (memoized), so "pal" finds the contiguous run
26
+ # in "Open palette" rather than the scattered greedy match.
27
+ def score(query, candidate)
28
+ q = query.to_s.downcase
29
+ c = candidate.to_s.downcase
30
+ return 0 if q.empty?
31
+
32
+ best_alignment(q, 0, c, 0, false, {})
33
+ end
34
+
35
+ # Finds the best-scoring alignment of q[qi..] within c[ci..]. *consecutive_at_ci*
36
+ # is true when the previous query char matched at ci - 1 (enabling the run bonus
37
+ # for a match exactly at ci). Returns nil when no alignment exists.
38
+ def best_alignment(q, qi, c, ci, consecutive_at_ci, memo)
39
+ return 0 if qi == q.length
40
+
41
+ key = [qi, ci, consecutive_at_ci]
42
+ return memo[key] if memo.key?(key)
43
+
44
+ best = nil
45
+ index = ci
46
+ while (index = c.index(q[qi], index))
47
+ points = CHAR_SCORE
48
+ points += CONSECUTIVE_BONUS if consecutive_at_ci && index == ci
49
+ points += WORD_START_BONUS if word_start?(c, index)
50
+ rest = best_alignment(q, qi + 1, c, index + 1, true, memo)
51
+ if rest
52
+ total = points + rest
53
+ best = total if best.nil? || total > best
54
+ end
55
+ index += 1
56
+ end
57
+
58
+ memo[key] = best
59
+ end
60
+
61
+ # Filters *candidates* to those matching *query*, ordered best-score first
62
+ # (original order breaks ties). The optional block extracts the searchable
63
+ # label from each candidate (defaults to to_s).
64
+ def filter(query, candidates, &label)
65
+ scored = candidates.each_with_index.filter_map do |candidate, index|
66
+ text = label ? yield(candidate) : candidate.to_s
67
+ candidate_score = score(query, text)
68
+ [candidate_score, index, candidate] if candidate_score
69
+ end
70
+
71
+ scored.sort_by { |candidate_score, index, _| [-candidate_score, index] }.map(&:last)
72
+ end
73
+
74
+ # True when the character at *index* starts a word: position 0 or preceded by
75
+ # a separator (space, underscore, hyphen, slash, dot, colon).
76
+ def word_start?(text, index)
77
+ return true if index.zero?
78
+
79
+ text[index - 1].match?(%r{[\s_\-/.:]})
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # HelpOverlay renders a controller's key bindings as a two-column cheat-sheet inside
6
+ # a Modal — the classic `?` help screen. Build it straight from a controller class:
7
+ #
8
+ # HelpOverlay.for_controller(self.class, theme: theme)
9
+ #
10
+ # or with explicit entries:
11
+ #
12
+ # HelpOverlay.new(bindings: {"q" => "Quit", "ctrl+p" => "Command palette"})
13
+ #
14
+ # Any key dismisses it (`handle_key` returns :cancelled).
15
+ class HelpOverlay < Component
16
+ DEFAULT_TITLE = "Keyboard Shortcuts"
17
+ DEFAULT_WIDTH = 44
18
+
19
+ # Builds an overlay from a controller class's `key_bindings` (key → action name).
20
+ # Action names are humanized into descriptions ("open_command_palette" → "Open command palette").
21
+ def self.for_controller(controller_class, title: DEFAULT_TITLE, theme: nil)
22
+ bindings = controller_class.key_bindings.transform_values do |action|
23
+ action.to_s.tr("_", " ").capitalize
24
+ end
25
+ new(bindings: bindings, title: title, theme: theme)
26
+ end
27
+
28
+ # *bindings* maps key names (symbols or strings) to description strings.
29
+ def initialize(bindings:, title: DEFAULT_TITLE, width: DEFAULT_WIDTH, theme: nil)
30
+ super(theme: theme)
31
+ @bindings = bindings
32
+ @title = title
33
+ @width = width
34
+ end
35
+
36
+ # Free-typed characters belong to this component while it is focused.
37
+ def captures_text?
38
+ true
39
+ end
40
+
41
+ # Any key dismisses the overlay.
42
+ def handle_key(_event)
43
+ :cancelled
44
+ end
45
+
46
+ # Renders the bindings table inside a titled modal.
47
+ def render
48
+ render_component(Modal.new(content: table, title: @title, width: @width, theme: theme))
49
+ end
50
+
51
+ private
52
+
53
+ # The two-column key/description table, keys right-padded to align.
54
+ def table
55
+ return theme.muted.render("No key bindings") if @bindings.empty?
56
+
57
+ key_width = @bindings.keys.map { |key| key.to_s.length }.max
58
+ @bindings.map do |key, description|
59
+ padded = key.to_s.ljust(key_width)
60
+ "#{theme.title.render(padded)} #{description}"
61
+ end.join("\n")
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Image displays a {Charming::Image::Source} inline in the terminal, sized to *rows*×*cols*
6
+ # character cells. On a graphics-capable terminal (Phase 1: Ghostty/Kitty) it returns a block of
7
+ # Unicode placeholder cells and registers the image's one-time out-of-band transmission; on other
8
+ # terminals it returns *fallback* so layouts degrade gracefully.
9
+ #
10
+ # Like {Charming::Components::Audio}, the view itself is thin: the {Charming::Image::Source}
11
+ # (held in `session`) owns the bytes and transmit state. The placeholder block is a normal
12
+ # width-`cols` string that composes with `row`/`column`/`box` like any other view output.
13
+ class Image < Component
14
+ # *source* is the {Charming::Image::Source} to display. *rows*/*cols* size the image in
15
+ # character cells. *fallback* is shown when the terminal lacks graphics support. *theme* is
16
+ # forwarded to the view layer.
17
+ def initialize(source:, rows:, cols:, fallback: "", theme: nil)
18
+ super(theme: theme)
19
+ @source = source
20
+ @rows = rows
21
+ @cols = cols
22
+ @fallback = fallback
23
+ end
24
+
25
+ # Returns the placeholder block (registering the transmit once) on a graphics-capable terminal,
26
+ # otherwise the fallback string.
27
+ def render
28
+ return @fallback.to_s unless @source.supports_graphics?
29
+
30
+ unless @source.transmitted?
31
+ Charming::Escape.register(@source.transmit(rows: @rows, cols: @cols))
32
+ @source.mark_transmitted
33
+ end
34
+ @source.placement(rows: @rows, cols: @cols)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -19,20 +19,38 @@ module Charming
19
19
  end: :move_end
20
20
  }.freeze
21
21
 
22
- # The item array and the currently selected index within it.
23
- attr_reader :items, :selected_index
22
+ # The currently selected index (within the filtered view) and active filter query.
23
+ attr_reader :selected_index, :filter
24
24
 
25
25
  # *items* is the array of selectable objects. *selected_index* defaults to 0.
26
26
  # *height* optionally constrains the visible window; *label* is a callable that
27
27
  # extracts the display string from an item (defaults to `to_s`).
28
28
  # *keymap* selects the keybinding style (`:vim` enables h/j/k/l → left/down/up/right).
29
- def initialize(items:, selected_index: 0, height: nil, label: nil, theme: nil, keymap: :vim)
29
+ # *filter* optionally narrows items by fuzzy-matching the label (see FuzzyMatcher);
30
+ # navigation, rendering, and selection all operate on the filtered view.
31
+ def initialize(items:, selected_index: 0, height: nil, label: nil, theme: nil, keymap: :vim, filter: nil)
30
32
  super(theme: theme)
31
- @items = items
33
+ @source_items = items
32
34
  @selected_index = selected_index
33
35
  @height = height
34
36
  @label = label || :to_s.to_proc
35
37
  @keymap = keymap
38
+ @filter = filter
39
+ clamp_position
40
+ end
41
+
42
+ # The visible items: the source list narrowed by the active filter (best
43
+ # fuzzy match first), or the full source list when no filter is set.
44
+ def items
45
+ return @source_items if filter.nil? || filter.to_s.empty?
46
+
47
+ FuzzyMatcher.filter(filter, @source_items, &@label)
48
+ end
49
+
50
+ # Replaces the filter query (nil clears it) and reclamps the selection to
51
+ # the narrowed view.
52
+ def filter=(query)
53
+ @filter = query
36
54
  clamp_position
37
55
  end
38
56