charming 0.2.0 → 0.2.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +96 -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/session_state.rb +16 -1
  13. data/lib/charming/controller/sidebar_navigation.rb +63 -28
  14. data/lib/charming/controller.rb +62 -10
  15. data/lib/charming/database/commands.rb +123 -11
  16. data/lib/charming/events/focus_event.rb +12 -0
  17. data/lib/charming/events/paste_event.rb +11 -0
  18. data/lib/charming/events/task_progress_event.rb +21 -0
  19. data/lib/charming/generators/app_generator.rb +38 -1
  20. data/lib/charming/generators/database_installer.rb +4 -15
  21. data/lib/charming/generators/migration_generator.rb +116 -0
  22. data/lib/charming/generators/migration_timestamp.rb +29 -0
  23. data/lib/charming/generators/model_generator.rb +4 -2
  24. data/lib/charming/generators/templates/app/application_controller.template +1 -1
  25. data/lib/charming/generators/templates/app/database_config.template +3 -1
  26. data/lib/charming/generators/templates/app/layout.template +1 -1
  27. data/lib/charming/generators/templates/app/spec_helper.template +2 -1
  28. data/lib/charming/generators/templates/app/view.template +1 -1
  29. data/lib/charming/internal/terminal/memory_backend.rb +6 -0
  30. data/lib/charming/internal/terminal/tty_backend.rb +64 -2
  31. data/lib/charming/presentation/component.rb +7 -0
  32. data/lib/charming/presentation/components/audio.rb +31 -0
  33. data/lib/charming/presentation/components/autocomplete.rb +108 -0
  34. data/lib/charming/presentation/components/badge.rb +31 -0
  35. data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
  36. data/lib/charming/presentation/components/command_palette.rb +8 -5
  37. data/lib/charming/presentation/components/error_screen.rb +72 -0
  38. data/lib/charming/presentation/components/form.rb +9 -0
  39. data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
  40. data/lib/charming/presentation/components/help_overlay.rb +65 -0
  41. data/lib/charming/presentation/components/markdown.rb +6 -2
  42. data/lib/charming/presentation/components/modal.rb +45 -5
  43. data/lib/charming/presentation/components/multi_select_list.rb +85 -0
  44. data/lib/charming/presentation/components/progressbar.rb +0 -1
  45. data/lib/charming/presentation/components/status_bar.rb +75 -0
  46. data/lib/charming/presentation/components/tab_bar.rb +103 -0
  47. data/lib/charming/presentation/components/table.rb +40 -9
  48. data/lib/charming/presentation/components/text_area.rb +47 -10
  49. data/lib/charming/presentation/components/text_input.rb +79 -4
  50. data/lib/charming/presentation/components/toast.rb +51 -0
  51. data/lib/charming/presentation/components/tree.rb +176 -0
  52. data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
  53. data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
  54. data/lib/charming/presentation/components/viewport/position.rb +67 -0
  55. data/lib/charming/presentation/components/viewport.rb +37 -122
  56. data/lib/charming/presentation/layout/builder.rb +4 -1
  57. data/lib/charming/presentation/layout/overlay.rb +6 -4
  58. data/lib/charming/presentation/layout/pane.rb +2 -1
  59. data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
  60. data/lib/charming/presentation/layout/screen_layout.rb +12 -3
  61. data/lib/charming/presentation/layout/split.rb +37 -3
  62. data/lib/charming/presentation/markdown/renderer.rb +99 -63
  63. data/lib/charming/presentation/markdown/style_config.rb +10 -5
  64. data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
  65. data/lib/charming/presentation/markdown/table_renderer.rb +60 -0
  66. data/lib/charming/presentation/markdown/text_wrapper.rb +40 -0
  67. data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
  68. data/lib/charming/presentation/templates/erb_handler.rb +35 -2
  69. data/lib/charming/presentation/ui/ansi_codes.rb +11 -0
  70. data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
  71. data/lib/charming/presentation/ui/color_support.rb +129 -0
  72. data/lib/charming/presentation/ui/theme.rb +7 -0
  73. data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
  74. data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
  75. data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
  76. data/lib/charming/presentation/ui/themes/nord.json +32 -0
  77. data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
  78. data/lib/charming/presentation/ui/width.rb +27 -2
  79. data/lib/charming/router.rb +1 -1
  80. data/lib/charming/runtime.rb +122 -15
  81. data/lib/charming/tasks/cancelled.rb +11 -0
  82. data/lib/charming/tasks/inline_executor.rb +10 -4
  83. data/lib/charming/tasks/progress.rb +30 -0
  84. data/lib/charming/tasks/task.rb +24 -4
  85. data/lib/charming/tasks/threaded_executor.rb +35 -11
  86. data/lib/charming/test_helper.rb +120 -0
  87. data/lib/charming/version.rb +1 -1
  88. data/lib/charming.rb +43 -1
  89. metadata +36 -49
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Generators
5
+ # MigrationGenerator implements `charming generate migration NAME [field:type ...]`.
6
+ # Follows Rails naming conventions:
7
+ # - `create_<table>` generates a create_table migration (fields become columns)
8
+ # - `add_<x>_to_<table>` generates add_column lines for the supplied fields
9
+ # - `remove_<x>_from_<table>` generates remove_column lines
10
+ # - anything else generates an empty `change` method to fill in
11
+ class MigrationGenerator < AppFileGenerator
12
+ # A single migration field: column *name* and ActiveRecord *type*.
13
+ Field = Data.define(:name, :type)
14
+
15
+ # The set of ActiveRecord column types accepted on the command line.
16
+ VALID_TYPES = ModelGenerator::VALID_TYPES
17
+
18
+ def initialize(name, args, out:, destination:, force: false)
19
+ super
20
+ @fields = args.map { |arg| parse_field(arg) }
21
+ end
22
+
23
+ # Validates database support, then writes the timestamped migration file.
24
+ def generate
25
+ raise Error, "Database support is not configured. Run `charming db:install sqlite3` first." unless database_configured?
26
+
27
+ create_file(migration_path, migration)
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :fields
33
+
34
+ # No file-name suffix; MigrationGenerator writes to an explicit path.
35
+ def suffix
36
+ nil
37
+ end
38
+
39
+ # Path to the generated `db/migrate/<timestamp>_<name>.rb` file.
40
+ def migration_path
41
+ File.join("db", "migrate", "#{timestamp}_#{name.snake_name}.rb")
42
+ end
43
+
44
+ # The ActiveRecord migration API version stamped into generated migrations. Matches
45
+ # the version used by the model generator's migration template.
46
+ MIGRATION_VERSION = "8.1"
47
+
48
+ # The full source of the generated migration, dispatching on the name convention.
49
+ def migration
50
+ <<~RUBY
51
+ # frozen_string_literal: true
52
+
53
+ class #{migration_class_name} < ActiveRecord::Migration[#{MIGRATION_VERSION}]
54
+ def change
55
+ #{change_body.chomp}
56
+ end
57
+ end
58
+ RUBY
59
+ end
60
+
61
+ # The CamelCase migration class name derived from the snake_case migration name.
62
+ def migration_class_name
63
+ ActiveSupport::Inflector.camelize(name.snake_name)
64
+ end
65
+
66
+ # Builds the `change` method body based on the migration name convention.
67
+ def change_body
68
+ case name.snake_name
69
+ when /\Acreate_(.+)\z/
70
+ create_table_body(Regexp.last_match(1))
71
+ when /\Aadd_.+_to_(.+)\z/
72
+ column_lines(Regexp.last_match(1), :add_column)
73
+ when /\Aremove_.+_from_(.+)\z/
74
+ column_lines(Regexp.last_match(1), :remove_column)
75
+ else
76
+ " # Add your migration steps here.\n"
77
+ end
78
+ end
79
+
80
+ # Generates a `create_table` block with one column line per field plus timestamps.
81
+ def create_table_body(table)
82
+ field_lines = fields.map { |field| " t.#{field.type} :#{field.name}\n" }.join
83
+ " create_table :#{table} do |t|\n#{field_lines} t.timestamps\n end\n"
84
+ end
85
+
86
+ # Generates one add_column/remove_column line per field for the given table.
87
+ def column_lines(table, method)
88
+ return " # No fields given — add #{method} lines here.\n" if fields.empty?
89
+
90
+ fields.map { |field| " #{method} :#{table}, :#{field.name}, :#{field.type}\n" }.join
91
+ end
92
+
93
+ # Parses a single `name:type` argument. Raises Error on invalid names or unsupported types.
94
+ def parse_field(value)
95
+ field_name, type = value.split(":", 2)
96
+ raise Error, "Invalid field: #{value.inspect}" unless field_name && type
97
+ raise Error, "Invalid field name: #{field_name.inspect}" unless Name::VALID_NAME.match?(field_name)
98
+ raise Error, "Unsupported field type: #{type.inspect}" unless VALID_TYPES.include?(type)
99
+
100
+ Field.new(name: field_name, type: type)
101
+ end
102
+
103
+ # True when `config/database.rb` exists in the app.
104
+ def database_configured?
105
+ File.exist?(File.join(destination, "config", "database.rb"))
106
+ end
107
+
108
+ # A migration timestamp in ActiveRecord's filename format, bumped past any
109
+ # existing migration's version so two generators run in the same second
110
+ # can't produce colliding versions.
111
+ def timestamp
112
+ MigrationTimestamp.next(File.join(destination, "db", "migrate"))
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Generators
5
+ # MigrationTimestamp produces ActiveRecord-format migration version numbers
6
+ # (YYYYMMDDHHMMSS) that are guaranteed unique within a `db/migrate` directory:
7
+ # when generators run within the same second, the version is bumped one second
8
+ # past the highest existing migration version.
9
+ module MigrationTimestamp
10
+ module_function
11
+
12
+ # Returns the next available version string for *migrate_dir*.
13
+ def next(migrate_dir)
14
+ now = Time.now.utc.strftime("%Y%m%d%H%M%S")
15
+ highest = highest_existing(migrate_dir)
16
+ return now unless highest && highest >= now
17
+
18
+ (highest.to_i + 1).to_s
19
+ end
20
+
21
+ # The highest version prefix among existing migration files, or nil.
22
+ def highest_existing(migrate_dir)
23
+ Dir.glob(File.join(migrate_dir, "*.rb"))
24
+ .filter_map { |path| File.basename(path)[/\A\d{14}/] }
25
+ .max
26
+ end
27
+ end
28
+ end
29
+ end
@@ -111,9 +111,11 @@ module Charming
111
111
  ActiveSupport::Inflector.camelize(table_name)
112
112
  end
113
113
 
114
- # The current UTC timestamp in the format ActiveRecord uses for migration filenames.
114
+ # A migration timestamp in ActiveRecord's filename format, bumped past any
115
+ # existing migration's version so two generators run in the same second
116
+ # can't produce colliding versions.
115
117
  def timestamp
116
- Time.now.utc.strftime("%Y%m%d%H%M%S")
118
+ MigrationTimestamp.next(File.join(destination, "db", "migrate"))
117
119
  end
118
120
  end
119
121
  end
@@ -5,7 +5,7 @@ module __APP_CLASS__
5
5
  layout Layouts::ApplicationLayout
6
6
  focus_ring :sidebar, :content
7
7
 
8
- key "p", :open_command_palette, scope: :global
8
+ key "ctrl+p", :open_command_palette, scope: :global
9
9
  key "q", :quit, scope: :global
10
10
 
11
11
  command "Home" do
@@ -3,7 +3,9 @@
3
3
  require "active_record"
4
4
  require "fileutils"
5
5
 
6
- database_path = File.expand_path("../db/development.sqlite3", __dir__)
6
+ # The database file is selected by CHARMING_ENV (development, test, production).
7
+ environment = ENV["CHARMING_ENV"] || "development"
8
+ database_path = File.expand_path("../db/#{environment}.sqlite3", __dir__)
7
9
  FileUtils.mkdir_p(File.dirname(database_path))
8
10
 
9
11
  ActiveRecord::Base.establish_connection(
@@ -68,7 +68,7 @@ module __APP_CLASS__
68
68
  end
69
69
 
70
70
  def shortcuts
71
- text "tab focus\np commands\nq quit", style: theme.muted
71
+ text "tab focus\nctrl+p commands\nq quit", style: theme.muted
72
72
  end
73
73
 
74
74
  def sidebar_style
@@ -1,3 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "__APP_SNAKE__"
3
+ __ENV_SETUP__require "__APP_SNAKE__"
4
+ __DATABASE_SPEC_SETUP__
@@ -14,7 +14,7 @@ module __APP_CLASS__
14
14
  end
15
15
 
16
16
  def help_line
17
- text "Press p for commands, q to quit.", style: theme.muted
17
+ text "Press ctrl+p for commands, q to quit.", style: theme.muted
18
18
  end
19
19
  end
20
20
  end
@@ -33,6 +33,12 @@ module Charming
33
33
  @events.shift
34
34
  end
35
35
 
36
+ # True when every pre-seeded event has been consumed. The Runtime stops its loop
37
+ # on an exhausted backend so tests can't hang waiting for input that never comes.
38
+ def exhausted?
39
+ @events.empty?
40
+ end
41
+
36
42
  # Stores *frame* as the current frame and appends it to `frames`.
37
43
  def write_frame(frame)
38
44
  @current_frame = frame
@@ -22,6 +22,19 @@ module Charming
22
22
  AUTO_WRAP_OFF = "\e[?7l"
23
23
  AUTO_WRAP_ON = "\e[?7h"
24
24
 
25
+ # Escape sequences for enabling/disabling bracketed-paste mode, and the markers
26
+ # the terminal wraps around pasted text.
27
+ BRACKETED_PASTE_ON = "\e[?2004h"
28
+ BRACKETED_PASTE_OFF = "\e[?2004l"
29
+ PASTE_START = "\e[200~"
30
+ PASTE_END = "\e[201~"
31
+
32
+ # Escape sequences for terminal focus reporting and the focus-in/out markers.
33
+ FOCUS_REPORTING_ON = "\e[?1004h"
34
+ FOCUS_REPORTING_OFF = "\e[?1004l"
35
+ FOCUS_IN = "\e[I"
36
+ FOCUS_OUT = "\e[O"
37
+
25
38
  # *input* and *output* default to `$stdin`/`$stdout` for normal terminal use;
26
39
  # tests can inject IO objects. *reader* is a TTY::Reader instance (created from
27
40
  # *input*/*output* when nil). *cursor* is the TTY::Cursor class used for cursor control.
@@ -37,13 +50,17 @@ module Charming
37
50
  end
38
51
 
39
52
  # Reads the next event. If a SIGWINCH was received, returns a ResizeEvent with the
40
- # current terminal dimensions. Mouse escape sequences are parsed by MouseParser;
41
- # other input is normalized via KeyNormalizer. Returns nil on timeout.
53
+ # current terminal dimensions. Bracketed pastes return a PasteEvent; mouse escape
54
+ # sequences are parsed by MouseParser; other input is normalized via KeyNormalizer.
55
+ # Returns nil on timeout.
42
56
  def read_event(timeout: nil)
43
57
  return resize_event if resized?
44
58
 
45
59
  raw = @reader.read_keypress(echo: false, raw: true, nonblock: timeout)
46
60
  return nil unless raw
61
+ return Events::FocusEvent.new(focused: true) if raw == FOCUS_IN
62
+ return Events::FocusEvent.new(focused: false) if raw == FOCUS_OUT
63
+ return paste_event(raw) if raw.start_with?(PASTE_START)
47
64
  return MouseParser.parse(raw) if MouseParser.sequence?(raw)
48
65
 
49
66
  @key_normalizer.normalize(raw)
@@ -51,6 +68,38 @@ module Charming
51
68
  nil
52
69
  end
53
70
 
71
+ # Emits the ANSI sequence enabling terminal focus reporting. Idempotent.
72
+ def enable_focus_reporting
73
+ return if @focus_reporting
74
+
75
+ write_control(FOCUS_REPORTING_ON)
76
+ @focus_reporting = true
77
+ end
78
+
79
+ # Emits the ANSI sequence disabling terminal focus reporting. Idempotent.
80
+ def disable_focus_reporting
81
+ return unless @focus_reporting
82
+
83
+ write_control(FOCUS_REPORTING_OFF)
84
+ @focus_reporting = false
85
+ end
86
+
87
+ # Emits the ANSI sequence enabling bracketed-paste mode. Idempotent.
88
+ def enable_bracketed_paste
89
+ return if @bracketed_paste
90
+
91
+ write_control(BRACKETED_PASTE_ON)
92
+ @bracketed_paste = true
93
+ end
94
+
95
+ # Emits the ANSI sequence disabling bracketed-paste mode. Idempotent.
96
+ def disable_bracketed_paste
97
+ return unless @bracketed_paste
98
+
99
+ write_control(BRACKETED_PASTE_OFF)
100
+ @bracketed_paste = false
101
+ end
102
+
54
103
  # Keeps terminal input in raw/no-echo mode for the duration of a TUI run. Reading a
55
104
  # single keypress in raw mode is not enough: keys pressed while rendering or dispatching
56
105
  # events can otherwise be echoed into the alternate screen before the next read.
@@ -185,6 +234,19 @@ module Charming
185
234
  Events::ResizeEvent.new(width: width, height: height)
186
235
  end
187
236
 
237
+ # Builds a PasteEvent from a bracketed-paste chunk, reading further keypresses
238
+ # until the paste-end marker arrives (large pastes can span multiple reads).
239
+ def paste_event(raw)
240
+ buffer = raw.delete_prefix(PASTE_START)
241
+ until buffer.include?(PASTE_END)
242
+ chunk = @reader.read_keypress(echo: false, raw: true, nonblock: 0.2)
243
+ break unless chunk
244
+
245
+ buffer << chunk
246
+ end
247
+ Events::PasteEvent.new(text: buffer.sub(/#{Regexp.escape(PASTE_END)}.*\z/mo, ""))
248
+ end
249
+
188
250
  # Writes a raw escape *sequence* to the output stream and flushes.
189
251
  def write_control(sequence)
190
252
  @output.write(sequence)
@@ -4,5 +4,12 @@ module Charming
4
4
  # Component is the base class for all reusable terminal widgets. It inherits from View to gain assigns,
5
5
  # helper methods (text, box, row, column, etc.), and rendering via render.
6
6
  class Component < View
7
+ # True for components that accept free-typed text (TextInput, TextArea, Form, …).
8
+ # While such a component is focused, the controller routes printable characters to
9
+ # it BEFORE global/content key bindings — so typing "q" or "?" into a field inserts
10
+ # the character instead of triggering an app shortcut.
11
+ def captures_text?
12
+ false
13
+ end
7
14
  end
8
15
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Audio is a one-line playback-status indicator for a {Charming::Audio::Player}. It
6
+ # reads the player's `playing?` state and renders a play/stop glyph with an optional
7
+ # *label*; pair it with a controller timer (or `on_task` re-render) to keep it live.
8
+ #
9
+ # Note: this view component (`Charming::Components::Audio`) is distinct from the
10
+ # playback engine namespace (`Charming::Audio`) — the component only displays state,
11
+ # the engine spawns the sound.
12
+ class Audio < Component
13
+ # *player* is the {Charming::Audio::Player} whose state is shown. *label* is an
14
+ # optional suffix (e.g. the track name) appended after the glyph. *theme* is the
15
+ # active theme, forwarded to the view layer.
16
+ def initialize(player:, label: nil, theme: nil)
17
+ super(theme: theme)
18
+ @player = player
19
+ @label = label
20
+ end
21
+
22
+ # Renders `▶`/`■` for playing/stopped, followed by the label when present.
23
+ def render
24
+ glyph = @player.playing? ? "▶" : "■"
25
+ return glyph unless @label
26
+
27
+ "#{glyph} #{@label}"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Autocomplete is a combobox: a TextInput with a suggestion list beneath it,
6
+ # filtered live against the typed value. Up/down move through suggestions,
7
+ # Enter submits the highlighted suggestion (or the free text when nothing
8
+ # matches), Escape cancels.
9
+ #
10
+ # Autocomplete.new(suggestions: ["ruby", "rails", "rspec"], value: "r")
11
+ #
12
+ # `handle_key` returns `[:submitted, value]` on Enter, `:cancelled` on Escape,
13
+ # `:handled` for consumed keys, nil otherwise.
14
+ class Autocomplete < Component
15
+ DEFAULT_MAX_SUGGESTIONS = 6
16
+
17
+ # The current typed value and the highlighted suggestion index.
18
+ attr_reader :selected_index
19
+
20
+ # *suggestions* is the full candidate list. *value*/*cursor* seed the inner
21
+ # TextInput. *max_suggestions* caps the visible dropdown rows.
22
+ def initialize(suggestions:, value: "", cursor: nil, placeholder: "", selected_index: 0,
23
+ max_suggestions: DEFAULT_MAX_SUGGESTIONS, theme: nil)
24
+ super(theme: theme)
25
+ @suggestions = Array(suggestions).map(&:to_s)
26
+ @input = TextInput.new(value: value, cursor: cursor, placeholder: placeholder)
27
+ @selected_index = selected_index
28
+ @max_suggestions = max_suggestions
29
+ clamp_selection
30
+ end
31
+
32
+ # The typed text.
33
+ def value
34
+ @input.value
35
+ end
36
+
37
+ # The inner input's cursor offset.
38
+ def cursor
39
+ @input.cursor
40
+ end
41
+
42
+ # The suggestions matching the current value (case-insensitive substring),
43
+ # capped at max_suggestions. All suggestions when the value is empty.
44
+ def filtered_suggestions
45
+ query = value.downcase
46
+ matches = query.empty? ? @suggestions : @suggestions.select { |s| s.downcase.include?(query) }
47
+ matches.first(@max_suggestions)
48
+ end
49
+
50
+ # Free-typed characters belong to this component while it is focused.
51
+ def captures_text?
52
+ true
53
+ end
54
+
55
+ # Enter submits, Escape cancels, up/down move the highlight, everything else
56
+ # edits the text (resetting the highlight).
57
+ def handle_key(event)
58
+ case Charming.key_of(event)
59
+ when :escape then :cancelled
60
+ when :enter then [:submitted, submission_value]
61
+ when :up then move_selection(-1)
62
+ when :down then move_selection(+1)
63
+ else
64
+ result = @input.handle_key(event)
65
+ clamp_selection if result
66
+ result
67
+ end
68
+ end
69
+
70
+ # Renders the input row followed by the suggestion dropdown.
71
+ def render
72
+ [input_line, *suggestion_lines].join("\n")
73
+ end
74
+
75
+ private
76
+
77
+ # The highlighted suggestion, or the raw text when none match.
78
+ def submission_value
79
+ filtered_suggestions[selected_index] || value
80
+ end
81
+
82
+ def input_line
83
+ @input.render
84
+ end
85
+
86
+ # One row per suggestion; the highlighted row in the selected style.
87
+ def suggestion_lines
88
+ filtered_suggestions.each_with_index.map do |suggestion, index|
89
+ line = " #{suggestion}"
90
+ (index == selected_index) ? theme.selected.render(line) : theme.muted.render(line)
91
+ end
92
+ end
93
+
94
+ def move_selection(delta)
95
+ count = filtered_suggestions.length
96
+ return :handled if count.zero?
97
+
98
+ @selected_index = (selected_index + delta).clamp(0, count - 1)
99
+ :handled
100
+ end
101
+
102
+ def clamp_selection
103
+ max = [filtered_suggestions.length - 1, 0].max
104
+ @selected_index = selected_index.clamp(0, max)
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Badge is a small inline label rendered as a styled pill — useful for versions,
6
+ # counts, and statuses inside status bars, lists, and headers.
7
+ #
8
+ # Badge.new("v1.2").render # themed default
9
+ # Badge.new("3 errors", style: theme.warn).render
10
+ class Badge < Component
11
+ # *label* is the badge text. *style* overrides the default themed style.
12
+ def initialize(label, style: nil, theme: nil)
13
+ super(theme: theme)
14
+ @label = label.to_s
15
+ @badge_style = style
16
+ end
17
+
18
+ # Renders the pill: a space-padded label with the badge style applied.
19
+ def render
20
+ resolved_style.render(" #{@label} ")
21
+ end
22
+
23
+ private
24
+
25
+ # The user style or the theme's selected style (which guarantees contrast).
26
+ def resolved_style
27
+ @badge_style || theme.selected
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Components
5
+ # Breadcrumbs renders a navigation trail like `Home › Projects › My App`, with the
6
+ # final (current) item highlighted and ancestors muted.
7
+ class Breadcrumbs < Component
8
+ DEFAULT_SEPARATOR = " › "
9
+
10
+ # *items* is the trail (strings or anything responding to to_s), first-to-last.
11
+ # *separator* joins the items.
12
+ def initialize(items:, separator: DEFAULT_SEPARATOR, theme: nil)
13
+ super(theme: theme)
14
+ @items = Array(items).map(&:to_s)
15
+ @separator = separator
16
+ end
17
+
18
+ # Renders the trail; ancestors muted, current item in the title style.
19
+ def render
20
+ return "" if @items.empty?
21
+
22
+ *ancestors, current = @items
23
+ parts = ancestors.map { |item| theme.muted.render(item) }
24
+ parts << theme.title.render(current)
25
+ parts.join(theme.muted.render(@separator))
26
+ end
27
+ end
28
+ end
29
+ 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::Markdown::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
@@ -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]