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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/charming/application.rb +96 -9
- data/lib/charming/audio/player.rb +104 -0
- data/lib/charming/audio/system.rb +69 -0
- data/lib/charming/cli.rb +63 -7
- data/lib/charming/controller/action_hooks.rb +124 -0
- data/lib/charming/controller/class_methods.rb +15 -1
- data/lib/charming/controller/dispatching.rb +31 -5
- data/lib/charming/controller/focus.rb +9 -0
- data/lib/charming/controller/focus_management.rb +0 -7
- data/lib/charming/controller/session_state.rb +16 -1
- data/lib/charming/controller/sidebar_navigation.rb +63 -28
- data/lib/charming/controller.rb +62 -10
- data/lib/charming/database/commands.rb +123 -11
- data/lib/charming/events/focus_event.rb +12 -0
- data/lib/charming/events/paste_event.rb +11 -0
- data/lib/charming/events/task_progress_event.rb +21 -0
- data/lib/charming/generators/app_generator.rb +38 -1
- data/lib/charming/generators/database_installer.rb +4 -15
- data/lib/charming/generators/migration_generator.rb +116 -0
- data/lib/charming/generators/migration_timestamp.rb +29 -0
- data/lib/charming/generators/model_generator.rb +4 -2
- data/lib/charming/generators/templates/app/application_controller.template +1 -1
- data/lib/charming/generators/templates/app/database_config.template +3 -1
- data/lib/charming/generators/templates/app/layout.template +1 -1
- data/lib/charming/generators/templates/app/spec_helper.template +2 -1
- data/lib/charming/generators/templates/app/view.template +1 -1
- data/lib/charming/internal/terminal/memory_backend.rb +6 -0
- data/lib/charming/internal/terminal/tty_backend.rb +64 -2
- data/lib/charming/presentation/component.rb +7 -0
- data/lib/charming/presentation/components/audio.rb +31 -0
- data/lib/charming/presentation/components/autocomplete.rb +108 -0
- data/lib/charming/presentation/components/badge.rb +31 -0
- data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
- data/lib/charming/presentation/components/command_palette.rb +8 -5
- data/lib/charming/presentation/components/error_screen.rb +72 -0
- data/lib/charming/presentation/components/form.rb +9 -0
- data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
- data/lib/charming/presentation/components/help_overlay.rb +65 -0
- data/lib/charming/presentation/components/markdown.rb +6 -2
- data/lib/charming/presentation/components/modal.rb +45 -5
- data/lib/charming/presentation/components/multi_select_list.rb +85 -0
- data/lib/charming/presentation/components/progressbar.rb +0 -1
- data/lib/charming/presentation/components/status_bar.rb +75 -0
- data/lib/charming/presentation/components/tab_bar.rb +103 -0
- data/lib/charming/presentation/components/table.rb +40 -9
- data/lib/charming/presentation/components/text_area.rb +47 -10
- data/lib/charming/presentation/components/text_input.rb +79 -4
- data/lib/charming/presentation/components/toast.rb +51 -0
- data/lib/charming/presentation/components/tree.rb +176 -0
- data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
- data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
- data/lib/charming/presentation/components/viewport/position.rb +67 -0
- data/lib/charming/presentation/components/viewport.rb +37 -122
- data/lib/charming/presentation/layout/builder.rb +4 -1
- data/lib/charming/presentation/layout/overlay.rb +6 -4
- data/lib/charming/presentation/layout/pane.rb +2 -1
- data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
- data/lib/charming/presentation/layout/screen_layout.rb +12 -3
- data/lib/charming/presentation/layout/split.rb +37 -3
- data/lib/charming/presentation/markdown/renderer.rb +99 -63
- data/lib/charming/presentation/markdown/style_config.rb +10 -5
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
- data/lib/charming/presentation/markdown/table_renderer.rb +60 -0
- data/lib/charming/presentation/markdown/text_wrapper.rb +40 -0
- data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
- data/lib/charming/presentation/templates/erb_handler.rb +35 -2
- data/lib/charming/presentation/ui/ansi_codes.rb +11 -0
- data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
- data/lib/charming/presentation/ui/color_support.rb +129 -0
- data/lib/charming/presentation/ui/theme.rb +7 -0
- data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
- data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
- data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
- data/lib/charming/presentation/ui/themes/nord.json +32 -0
- data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
- data/lib/charming/presentation/ui/width.rb +27 -2
- data/lib/charming/router.rb +1 -1
- data/lib/charming/runtime.rb +122 -15
- data/lib/charming/tasks/cancelled.rb +11 -0
- data/lib/charming/tasks/inline_executor.rb +10 -4
- data/lib/charming/tasks/progress.rb +30 -0
- data/lib/charming/tasks/task.rb +24 -4
- data/lib/charming/tasks/threaded_executor.rb +35 -11
- data/lib/charming/test_helper.rb +120 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +43 -1
- 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
|
-
#
|
|
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
|
-
|
|
118
|
+
MigrationTimestamp.next(File.join(destination, "db", "migrate"))
|
|
117
119
|
end
|
|
118
120
|
end
|
|
119
121
|
end
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
require "active_record"
|
|
4
4
|
require "fileutils"
|
|
5
5
|
|
|
6
|
-
|
|
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(
|
|
@@ -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.
|
|
41
|
-
# other input is normalized via KeyNormalizer.
|
|
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
|
|
108
|
-
# against the
|
|
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
|
-
|
|
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]
|