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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/charming/application.rb +144 -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/key_dispatch.rb +113 -0
- data/lib/charming/controller/session_state.rb +27 -1
- data/lib/charming/controller/sidebar_navigation.rb +63 -28
- data/lib/charming/controller/terminal.rb +33 -0
- data/lib/charming/controller.rb +43 -18
- data/lib/charming/database/commands.rb +123 -11
- data/lib/charming/escape.rb +81 -0
- data/lib/charming/events/focus_event.rb +12 -0
- data/lib/charming/events/mouse_event.rb +22 -9
- 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 +41 -3
- 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/Gemfile.template +6 -0
- 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/dot_rspec.template +2 -0
- 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/image/protocol/kitty.rb +126 -0
- data/lib/charming/image/protocol.rb +18 -0
- data/lib/charming/image/source.rb +85 -0
- data/lib/charming/image/terminal.rb +52 -0
- data/lib/charming/image/transmit.rb +11 -0
- data/lib/charming/image.rb +21 -0
- data/lib/charming/internal/event_loop.rb +155 -0
- data/lib/charming/internal/terminal/adapter.rb +14 -0
- data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
- data/lib/charming/internal/terminal/memory_backend.rb +27 -3
- data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
- data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
- data/lib/charming/internal/terminal/tty_backend.rb +143 -4
- data/lib/charming/presentation/component.rb +7 -0
- data/lib/charming/presentation/components/activity_indicator.rb +2 -19
- 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/chart.rb +80 -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/filepicker.rb +101 -0
- data/lib/charming/presentation/components/form/builder.rb +5 -0
- data/lib/charming/presentation/components/form/multiselect.rb +105 -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/image.rb +38 -0
- data/lib/charming/presentation/components/list.rb +22 -4
- 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/paginator.rb +54 -0
- data/lib/charming/presentation/components/progressbar.rb +26 -5
- data/lib/charming/presentation/components/sparkline.rb +38 -0
- data/lib/charming/presentation/components/spinner.rb +22 -3
- data/lib/charming/presentation/components/status_bar.rb +75 -0
- data/lib/charming/presentation/components/stopwatch.rb +55 -0
- data/lib/charming/presentation/components/tab_bar.rb +103 -0
- data/lib/charming/presentation/components/table.rb +82 -10
- data/lib/charming/presentation/components/text_area.rb +48 -12
- data/lib/charming/presentation/components/text_input.rb +85 -5
- data/lib/charming/presentation/components/time_display.rb +20 -0
- data/lib/charming/presentation/components/timer.rb +43 -0
- 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 +11 -5
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
- data/lib/charming/presentation/markdown/table_renderer.rb +59 -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/adaptive_color.rb +20 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +15 -1
- data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
- data/lib/charming/presentation/ui/background.rb +58 -0
- data/lib/charming/presentation/ui/border.rb +14 -1
- data/lib/charming/presentation/ui/border_painter.rb +23 -11
- data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
- data/lib/charming/presentation/ui/canvas.rb +1 -0
- data/lib/charming/presentation/ui/color_support.rb +129 -0
- data/lib/charming/presentation/ui/gradient.rb +47 -0
- data/lib/charming/presentation/ui/style.rb +119 -19
- data/lib/charming/presentation/ui/text_wrapper.rb +40 -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/truncate.rb +29 -0
- data/lib/charming/presentation/ui/width.rb +38 -2
- data/lib/charming/presentation/ui.rb +52 -11
- data/lib/charming/presentation/view.rb +8 -6
- data/lib/charming/response.rb +11 -6
- data/lib/charming/router.rb +1 -1
- data/lib/charming/runtime.rb +236 -63
- 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 +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
|
|
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::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
|
|
23
|
-
attr_reader :
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
|