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
|
@@ -18,6 +18,7 @@ module Charming
|
|
|
18
18
|
@options = {
|
|
19
19
|
attributes: [],
|
|
20
20
|
padding: [0, 0, 0, 0],
|
|
21
|
+
margin: [0, 0, 0, 0],
|
|
21
22
|
align: :left
|
|
22
23
|
}.merge(options)
|
|
23
24
|
end
|
|
@@ -49,11 +50,31 @@ module Charming
|
|
|
49
50
|
with(padding: expand_box_values(values))
|
|
50
51
|
end
|
|
51
52
|
|
|
53
|
+
# Returns a new Style with the margin set — blank space applied outside the border and
|
|
54
|
+
# untouched by the style's colors. Accepts the same CSS-style shorthand as `padding`.
|
|
55
|
+
def margin(*values)
|
|
56
|
+
with(margin: expand_box_values(values))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Per-side setters: padding_top/right/bottom/left and margin_top/right/bottom/left,
|
|
60
|
+
# each returning a new Style with just that side changed.
|
|
61
|
+
%i[padding margin].each do |box|
|
|
62
|
+
%i[top right bottom left].each_with_index do |side, index|
|
|
63
|
+
define_method(:"#{box}_#{side}") do |value|
|
|
64
|
+
values = @options.fetch(box).dup
|
|
65
|
+
values[index] = value
|
|
66
|
+
with(box => values)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
52
71
|
# Returns a new Style with the border set. *style* is a border name (e.g., :normal,
|
|
53
|
-
# :rounded
|
|
54
|
-
#
|
|
55
|
-
|
|
56
|
-
|
|
72
|
+
# :rounded, :square, :hidden, :block) or a custom Border instance. *sides* optionally
|
|
73
|
+
# restricts the border to specific sides. *foreground* colors the border — a single
|
|
74
|
+
# color, or a per-side hash like `{top: :red, left: :green}`. *background* colors the
|
|
75
|
+
# border independently of the box background.
|
|
76
|
+
def border(style = :normal, sides: nil, foreground: nil, background: nil)
|
|
77
|
+
with(border: style, border_sides: sides, border_foreground: foreground, border_background: background)
|
|
57
78
|
end
|
|
58
79
|
|
|
59
80
|
# Returns a new Style that fixes the rendered width to *value* (in display columns).
|
|
@@ -66,19 +87,50 @@ module Charming
|
|
|
66
87
|
with(height: value)
|
|
67
88
|
end
|
|
68
89
|
|
|
90
|
+
# Returns a new Style that caps the rendered width to *value* display columns.
|
|
91
|
+
# Unlike `width`, content narrower than the cap is not padded out to it.
|
|
92
|
+
def max_width(value)
|
|
93
|
+
with(max_width: value)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Returns a new Style that caps the rendered height to *value* rows. Unlike
|
|
97
|
+
# `height`, content shorter than the cap is not filled with blank rows.
|
|
98
|
+
def max_height(value)
|
|
99
|
+
with(max_height: value)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Returns a new Style that word-wraps overflowing lines at the fixed width
|
|
103
|
+
# instead of clipping them.
|
|
104
|
+
def wrap
|
|
105
|
+
with(fit: :wrap)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Returns a new Style that marks lines clipped at the fixed width with a
|
|
109
|
+
# trailing *ellipsis* instead of cutting them silently.
|
|
110
|
+
def truncate(ellipsis: Truncate::ELLIPSIS)
|
|
111
|
+
with(fit: :truncate, ellipsis: ellipsis)
|
|
112
|
+
end
|
|
113
|
+
|
|
69
114
|
# Returns a new Style with horizontal alignment set (`:left`, `:right`, or `:center`).
|
|
70
115
|
def align(value)
|
|
71
116
|
with(align: value)
|
|
72
117
|
end
|
|
73
118
|
|
|
119
|
+
# Returns a new Style with vertical alignment within a fixed height set
|
|
120
|
+
# (`:top`, `:middle`, or `:bottom`).
|
|
121
|
+
def align_vertical(value)
|
|
122
|
+
with(align_vertical: value)
|
|
123
|
+
end
|
|
124
|
+
|
|
74
125
|
# Applies the configured style to *value* and returns the styled string. Steps:
|
|
75
|
-
# 1. wrap to `:width`, 2. align horizontally, 3. expand to `:height`,
|
|
76
|
-
# 5. paint border, 6. emit ANSI attribute/foreground/background
|
|
126
|
+
# 1. wrap to `:width`, 2. align horizontally and vertically, 3. expand to `:height`,
|
|
127
|
+
# 4. apply padding, 5. paint border, 6. emit ANSI attribute/foreground/background
|
|
128
|
+
# escapes, 7. surround with unstyled margin space.
|
|
77
129
|
def render(value)
|
|
78
130
|
lines = apply_dimensions(value.to_s.lines(chomp: true))
|
|
79
131
|
lines = apply_padding(lines)
|
|
80
132
|
lines = apply_border(lines)
|
|
81
|
-
apply_ansi(lines.join("\n"))
|
|
133
|
+
apply_margin(apply_ansi(lines.join("\n")))
|
|
82
134
|
end
|
|
83
135
|
|
|
84
136
|
private
|
|
@@ -91,40 +143,87 @@ module Charming
|
|
|
91
143
|
# Wraps each line to the target width and applies horizontal alignment, then expands
|
|
92
144
|
# to the target height.
|
|
93
145
|
def apply_dimensions(lines)
|
|
146
|
+
lines = wrap_lines(lines)
|
|
94
147
|
content_width = target_content_width(lines)
|
|
95
148
|
dimensioned = lines.map { |line| align_line(fit_line(line, content_width), content_width) }
|
|
96
|
-
apply_height(dimensioned, content_width)
|
|
149
|
+
apply_max_height(apply_height(dimensioned, content_width))
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Word-wraps lines at the fixed width when the style is in wrap mode.
|
|
153
|
+
def wrap_lines(lines)
|
|
154
|
+
wrap_width = @options[:width]
|
|
155
|
+
return lines unless wrap_width && @options[:fit] == :wrap
|
|
156
|
+
|
|
157
|
+
wrapper = TextWrapper.new(width: wrap_width)
|
|
158
|
+
lines.flat_map { |line| wrapper.wrap(line).lines(chomp: true) }
|
|
97
159
|
end
|
|
98
160
|
|
|
99
161
|
# Returns the target content width: the explicit :width if set, otherwise the natural
|
|
100
|
-
# max display width of the lines.
|
|
162
|
+
# max display width of the lines, capped to :max_width when configured.
|
|
101
163
|
def target_content_width(lines)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
164
|
+
width = @options[:width] || Width.widest(lines)
|
|
165
|
+
max = @options[:max_width]
|
|
166
|
+
max ? [width, max].min : width
|
|
105
167
|
end
|
|
106
168
|
|
|
107
|
-
#
|
|
169
|
+
# Fits *line* into *width* display columns: clipped by default, or marked with an
|
|
170
|
+
# ellipsis in truncate mode. Preserves ANSI styling active at the cut.
|
|
108
171
|
def fit_line(line, width)
|
|
109
172
|
return line if Width.measure(line) <= width
|
|
173
|
+
return Truncate.tail(line, width, ellipsis: @options.fetch(:ellipsis, Truncate::ELLIPSIS)) if @options[:fit] == :truncate
|
|
110
174
|
|
|
111
175
|
UI.visible_slice(line, 0, width)
|
|
112
176
|
end
|
|
113
177
|
|
|
114
|
-
# Truncates or pads the lines array to *height* rows,
|
|
178
|
+
# Truncates or pads the lines array to *height* rows, distributing blank fill
|
|
179
|
+
# rows according to :align_vertical (:top, :middle, or :bottom).
|
|
115
180
|
def apply_height(lines, width)
|
|
116
181
|
height = @options[:height]
|
|
117
182
|
return lines unless height
|
|
118
183
|
|
|
119
184
|
visible = lines.first(height)
|
|
120
|
-
visible
|
|
185
|
+
distribute_rows(visible, height - visible.length, width)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Places *missing* blank rows around *visible* per the vertical alignment.
|
|
189
|
+
def distribute_rows(visible, missing, width)
|
|
190
|
+
blank = ->(count) { Array.new([count, 0].max) { " " * width } }
|
|
191
|
+
case @options.fetch(:align_vertical, :top)
|
|
192
|
+
when :bottom
|
|
193
|
+
blank.call(missing) + visible
|
|
194
|
+
when :middle
|
|
195
|
+
top = missing / 2
|
|
196
|
+
blank.call(top) + visible + blank.call(missing - top)
|
|
197
|
+
else
|
|
198
|
+
visible + blank.call(missing)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Surrounds the styled block with unstyled margin space: blank rows above and
|
|
203
|
+
# below, plain-space columns left and right. Applied after ANSI styling so the
|
|
204
|
+
# margin never carries the style's colors.
|
|
205
|
+
def apply_margin(styled)
|
|
206
|
+
top, right, bottom, left = @options.fetch(:margin)
|
|
207
|
+
return styled if [top, right, bottom, left].all?(&:zero?)
|
|
208
|
+
|
|
209
|
+
lines = styled.lines(chomp: true)
|
|
210
|
+
width = Width.widest(lines)
|
|
211
|
+
body = lines.map { |line| (" " * left) + Width.pad_to(line, width) + (" " * right) }
|
|
212
|
+
blank = " " * (left + width + right)
|
|
213
|
+
(Array.new(top, blank) + body + Array.new(bottom, blank)).join("\n")
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Caps the lines array to :max_height rows without filling missing rows.
|
|
217
|
+
def apply_max_height(lines)
|
|
218
|
+
max = @options[:max_height]
|
|
219
|
+
max ? lines.first(max) : lines
|
|
121
220
|
end
|
|
122
221
|
|
|
123
222
|
# Applies padding by prepending/appending blank rows (vertical) and indenting each
|
|
124
223
|
# line (horizontal).
|
|
125
224
|
def apply_padding(lines)
|
|
126
225
|
top, right, bottom, left = @options.fetch(:padding)
|
|
127
|
-
inner_width =
|
|
226
|
+
inner_width = Width.widest(lines)
|
|
128
227
|
empty = " " * (left + inner_width + right)
|
|
129
228
|
padded = lines.map do |line|
|
|
130
229
|
pad_line(line, inner_width, left, right)
|
|
@@ -143,7 +242,7 @@ module Charming
|
|
|
143
242
|
|
|
144
243
|
# Pads a single line to *inner_width*, with *left* and *right* padding spaces.
|
|
145
244
|
def pad_line(line, inner_width, left, right)
|
|
146
|
-
(" " * left) + line + (" " *
|
|
245
|
+
(" " * left) + Width.pad_to(line, inner_width) + (" " * right)
|
|
147
246
|
end
|
|
148
247
|
|
|
149
248
|
# Builds a BorderPainter configured for the current border options.
|
|
@@ -152,13 +251,14 @@ module Charming
|
|
|
152
251
|
border: Border.fetch(border_name),
|
|
153
252
|
sides: @options[:border_sides],
|
|
154
253
|
foreground: @options[:border_foreground],
|
|
155
|
-
background: @options[:background]
|
|
254
|
+
background: @options[:border_background] || @options[:background],
|
|
255
|
+
background_explicit: !@options[:border_background].nil?
|
|
156
256
|
)
|
|
157
257
|
end
|
|
158
258
|
|
|
159
259
|
# Returns the natural display width of the longest line in *lines*.
|
|
160
260
|
def content_width(lines)
|
|
161
|
-
|
|
261
|
+
Width.widest(lines)
|
|
162
262
|
end
|
|
163
263
|
|
|
164
264
|
# Applies the active ANSI attribute/foreground/background codes to *value*.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module UI
|
|
5
|
+
# TextWrapper greedily word-wraps text blocks to a display width.
|
|
6
|
+
class TextWrapper
|
|
7
|
+
def initialize(width:)
|
|
8
|
+
@width = width
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def wrap(value)
|
|
12
|
+
return value unless width
|
|
13
|
+
|
|
14
|
+
value.to_s.lines(chomp: true).map { |line| wrap_line(line) }.join("\n")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
attr_reader :width
|
|
20
|
+
|
|
21
|
+
def wrap_line(line)
|
|
22
|
+
return line if Width.measure(line) <= width
|
|
23
|
+
|
|
24
|
+
wrap_words(line.split(/\s+/))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def wrap_words(words)
|
|
28
|
+
words.each_with_object([]) { |word, lines| append_word(lines, word) }.join("\n")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def append_word(lines, word)
|
|
32
|
+
current = lines.pop.to_s
|
|
33
|
+
candidate = current.empty? ? word : "#{current} #{word}"
|
|
34
|
+
return lines.push(candidate) if current.empty? || Width.measure(candidate) <= width
|
|
35
|
+
|
|
36
|
+
lines.push(current.rstrip, word)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -109,6 +109,13 @@ module Charming
|
|
|
109
109
|
end
|
|
110
110
|
alias_method :[], :style
|
|
111
111
|
|
|
112
|
+
# Returns a new Theme with *overrides* (token name → style spec hash) merged over
|
|
113
|
+
# this theme's tokens. Override colors are literal (hex strings, named symbols, or
|
|
114
|
+
# 256 indexes) — the parent's palette names are already resolved.
|
|
115
|
+
def merge(overrides, background: @background)
|
|
116
|
+
self.class.new(@tokens.merge(symbolize_keys(overrides.to_h)), background: background)
|
|
117
|
+
end
|
|
118
|
+
|
|
112
119
|
def method_missing(name, ...)
|
|
113
120
|
return style(name) if @tokens.key?(name)
|
|
114
121
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Catppuccin Latte",
|
|
3
|
+
"id": "catppuccin-latte",
|
|
4
|
+
"background": "background",
|
|
5
|
+
"palette": {
|
|
6
|
+
"text": "#4C4F69",
|
|
7
|
+
"subtext": "#6C6F85",
|
|
8
|
+
"overlay": "#9CA0B0",
|
|
9
|
+
"background": "#EFF1F5",
|
|
10
|
+
"surface": "#CCD0DA",
|
|
11
|
+
"mauve": "#8839EF",
|
|
12
|
+
"peach": "#FE640B",
|
|
13
|
+
"sky": "#04A5E5",
|
|
14
|
+
"lavender": "#7287FD",
|
|
15
|
+
"green": "#40A02B",
|
|
16
|
+
"yellow": "#DF8E1D",
|
|
17
|
+
"red": "#D20F39"
|
|
18
|
+
},
|
|
19
|
+
"styles": {
|
|
20
|
+
"text": {"foreground": "text", "background": "background"},
|
|
21
|
+
"muted": {"foreground": "subtext", "background": "background"},
|
|
22
|
+
"title": {"foreground": "mauve", "background": "background", "bold": true},
|
|
23
|
+
"selected": {"foreground": "text", "background": "surface", "bold": true},
|
|
24
|
+
"header": {"foreground": "text", "background": "background", "border": {"style": "normal", "sides": ["bottom"], "foreground": "overlay"}, "padding": [0, 1]},
|
|
25
|
+
"header_accent": {"foreground": "mauve", "background": "background", "bold": true},
|
|
26
|
+
"sidebar": {"background": "background", "border": {"style": "normal", "sides": ["right"], "foreground": "overlay"}, "padding": [1, 1]},
|
|
27
|
+
"main": {"foreground": "text", "background": "background", "padding": [1, 2]},
|
|
28
|
+
"footer": {"foreground": "overlay", "background": "background", "border": {"style": "normal", "sides": ["top"], "foreground": "overlay"}, "padding": [0, 1]},
|
|
29
|
+
"modal": {"foreground": "text", "background": "background", "border": {"style": "rounded", "foreground": "lavender"}, "padding": [1, 2]},
|
|
30
|
+
"palette_accent": {"foreground": "mauve", "background": "background", "bold": true},
|
|
31
|
+
"border": {"foreground": "overlay", "background": "background"},
|
|
32
|
+
"info": {"foreground": "sky", "background": "background"},
|
|
33
|
+
"warn": {"foreground": "peach", "background": "background"}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Catppuccin Mocha",
|
|
3
|
+
"id": "catppuccin-mocha",
|
|
4
|
+
"background": "background",
|
|
5
|
+
"palette": {
|
|
6
|
+
"text": "#CDD6F4",
|
|
7
|
+
"subtext": "#A6ADC8",
|
|
8
|
+
"overlay": "#6C7086",
|
|
9
|
+
"background": "#1E1E2E",
|
|
10
|
+
"surface": "#313244",
|
|
11
|
+
"mauve": "#CBA6F7",
|
|
12
|
+
"peach": "#FAB387",
|
|
13
|
+
"sky": "#89DCEB",
|
|
14
|
+
"lavender": "#B4BEFE",
|
|
15
|
+
"green": "#A6E3A1",
|
|
16
|
+
"yellow": "#F9E2AF",
|
|
17
|
+
"red": "#F38BA8"
|
|
18
|
+
},
|
|
19
|
+
"styles": {
|
|
20
|
+
"text": {"foreground": "text", "background": "background"},
|
|
21
|
+
"muted": {"foreground": "subtext", "background": "background"},
|
|
22
|
+
"title": {"foreground": "mauve", "background": "background", "bold": true},
|
|
23
|
+
"selected": {"foreground": "text", "background": "surface", "bold": true},
|
|
24
|
+
"header": {"foreground": "text", "background": "background", "border": {"style": "normal", "sides": ["bottom"], "foreground": "overlay"}, "padding": [0, 1]},
|
|
25
|
+
"header_accent": {"foreground": "mauve", "background": "background", "bold": true},
|
|
26
|
+
"sidebar": {"background": "background", "border": {"style": "normal", "sides": ["right"], "foreground": "overlay"}, "padding": [1, 1]},
|
|
27
|
+
"main": {"foreground": "text", "background": "background", "padding": [1, 2]},
|
|
28
|
+
"footer": {"foreground": "overlay", "background": "background", "border": {"style": "normal", "sides": ["top"], "foreground": "overlay"}, "padding": [0, 1]},
|
|
29
|
+
"modal": {"foreground": "text", "background": "background", "border": {"style": "rounded", "foreground": "lavender"}, "padding": [1, 2]},
|
|
30
|
+
"palette_accent": {"foreground": "mauve", "background": "background", "bold": true},
|
|
31
|
+
"border": {"foreground": "overlay", "background": "background"},
|
|
32
|
+
"info": {"foreground": "sky", "background": "background"},
|
|
33
|
+
"warn": {"foreground": "peach", "background": "background"}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Gruvbox Dark",
|
|
3
|
+
"id": "gruvbox-dark",
|
|
4
|
+
"background": "background",
|
|
5
|
+
"palette": {
|
|
6
|
+
"fg": "#EBDBB2",
|
|
7
|
+
"gray": "#928374",
|
|
8
|
+
"background": "#282828",
|
|
9
|
+
"surface": "#3C3836",
|
|
10
|
+
"yellow": "#FABD2F",
|
|
11
|
+
"orange": "#FE8019",
|
|
12
|
+
"aqua": "#8EC07C",
|
|
13
|
+
"blue": "#83A598",
|
|
14
|
+
"green": "#B8BB26",
|
|
15
|
+
"red": "#FB4934"
|
|
16
|
+
},
|
|
17
|
+
"styles": {
|
|
18
|
+
"text": {"foreground": "fg", "background": "background"},
|
|
19
|
+
"muted": {"foreground": "gray", "background": "background"},
|
|
20
|
+
"title": {"foreground": "yellow", "background": "background", "bold": true},
|
|
21
|
+
"selected": {"foreground": "fg", "background": "surface", "bold": true},
|
|
22
|
+
"header": {"foreground": "fg", "background": "background", "border": {"style": "normal", "sides": ["bottom"], "foreground": "gray"}, "padding": [0, 1]},
|
|
23
|
+
"header_accent": {"foreground": "orange", "background": "background", "bold": true},
|
|
24
|
+
"sidebar": {"background": "background", "border": {"style": "normal", "sides": ["right"], "foreground": "gray"}, "padding": [1, 1]},
|
|
25
|
+
"main": {"foreground": "fg", "background": "background", "padding": [1, 2]},
|
|
26
|
+
"footer": {"foreground": "gray", "background": "background", "border": {"style": "normal", "sides": ["top"], "foreground": "gray"}, "padding": [0, 1]},
|
|
27
|
+
"modal": {"foreground": "fg", "background": "background", "border": {"style": "rounded", "foreground": "yellow"}, "padding": [1, 2]},
|
|
28
|
+
"palette_accent": {"foreground": "orange", "background": "background", "bold": true},
|
|
29
|
+
"border": {"foreground": "gray", "background": "background"},
|
|
30
|
+
"info": {"foreground": "blue", "background": "background"},
|
|
31
|
+
"warn": {"foreground": "orange", "background": "background"}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Nord",
|
|
3
|
+
"id": "nord",
|
|
4
|
+
"background": "background",
|
|
5
|
+
"palette": {
|
|
6
|
+
"snow": "#D8DEE9",
|
|
7
|
+
"comment": "#616E88",
|
|
8
|
+
"background": "#2E3440",
|
|
9
|
+
"surface": "#3B4252",
|
|
10
|
+
"frost_blue": "#88C0D0",
|
|
11
|
+
"frost_deep": "#81A1C1",
|
|
12
|
+
"aurora_yellow": "#EBCB8B",
|
|
13
|
+
"aurora_green": "#A3BE8C",
|
|
14
|
+
"aurora_red": "#BF616A"
|
|
15
|
+
},
|
|
16
|
+
"styles": {
|
|
17
|
+
"text": {"foreground": "snow", "background": "background"},
|
|
18
|
+
"muted": {"foreground": "comment", "background": "background"},
|
|
19
|
+
"title": {"foreground": "frost_blue", "background": "background", "bold": true},
|
|
20
|
+
"selected": {"foreground": "snow", "background": "surface", "bold": true},
|
|
21
|
+
"header": {"foreground": "snow", "background": "background", "border": {"style": "normal", "sides": ["bottom"], "foreground": "comment"}, "padding": [0, 1]},
|
|
22
|
+
"header_accent": {"foreground": "frost_blue", "background": "background", "bold": true},
|
|
23
|
+
"sidebar": {"background": "background", "border": {"style": "normal", "sides": ["right"], "foreground": "comment"}, "padding": [1, 1]},
|
|
24
|
+
"main": {"foreground": "snow", "background": "background", "padding": [1, 2]},
|
|
25
|
+
"footer": {"foreground": "comment", "background": "background", "border": {"style": "normal", "sides": ["top"], "foreground": "comment"}, "padding": [0, 1]},
|
|
26
|
+
"modal": {"foreground": "snow", "background": "background", "border": {"style": "rounded", "foreground": "frost_blue"}, "padding": [1, 2]},
|
|
27
|
+
"palette_accent": {"foreground": "frost_blue", "background": "background", "bold": true},
|
|
28
|
+
"border": {"foreground": "comment", "background": "background"},
|
|
29
|
+
"info": {"foreground": "frost_deep", "background": "background"},
|
|
30
|
+
"warn": {"foreground": "aurora_yellow", "background": "background"}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Tokyo Night",
|
|
3
|
+
"id": "tokyonight",
|
|
4
|
+
"background": "background",
|
|
5
|
+
"palette": {
|
|
6
|
+
"fg": "#C0CAF5",
|
|
7
|
+
"comment": "#565F89",
|
|
8
|
+
"background": "#1A1B26",
|
|
9
|
+
"surface": "#292E42",
|
|
10
|
+
"blue": "#7AA2F7",
|
|
11
|
+
"cyan": "#7DCFFF",
|
|
12
|
+
"yellow": "#E0AF68",
|
|
13
|
+
"magenta": "#BB9AF7",
|
|
14
|
+
"green": "#9ECE6A",
|
|
15
|
+
"red": "#F7768E",
|
|
16
|
+
"orange": "#FF9E64"
|
|
17
|
+
},
|
|
18
|
+
"styles": {
|
|
19
|
+
"text": {"foreground": "fg", "background": "background"},
|
|
20
|
+
"muted": {"foreground": "comment", "background": "background"},
|
|
21
|
+
"title": {"foreground": "blue", "background": "background", "bold": true},
|
|
22
|
+
"selected": {"foreground": "fg", "background": "surface", "bold": true},
|
|
23
|
+
"header": {"foreground": "fg", "background": "background", "border": {"style": "normal", "sides": ["bottom"], "foreground": "comment"}, "padding": [0, 1]},
|
|
24
|
+
"header_accent": {"foreground": "magenta", "background": "background", "bold": true},
|
|
25
|
+
"sidebar": {"background": "background", "border": {"style": "normal", "sides": ["right"], "foreground": "comment"}, "padding": [1, 1]},
|
|
26
|
+
"main": {"foreground": "fg", "background": "background", "padding": [1, 2]},
|
|
27
|
+
"footer": {"foreground": "comment", "background": "background", "border": {"style": "normal", "sides": ["top"], "foreground": "comment"}, "padding": [0, 1]},
|
|
28
|
+
"modal": {"foreground": "fg", "background": "background", "border": {"style": "rounded", "foreground": "magenta"}, "padding": [1, 2]},
|
|
29
|
+
"palette_accent": {"foreground": "magenta", "background": "background", "bold": true},
|
|
30
|
+
"border": {"foreground": "comment", "background": "background"},
|
|
31
|
+
"info": {"foreground": "cyan", "background": "background"},
|
|
32
|
+
"warn": {"foreground": "yellow", "background": "background"}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module UI
|
|
5
|
+
# Truncate cuts ANSI-styled text down to a display width, marking the cut
|
|
6
|
+
# with a trailing ellipsis. Styling active at the cut is preserved and
|
|
7
|
+
# terminated, and multi-column glyphs are never split (matching ANSISlicer).
|
|
8
|
+
module Truncate
|
|
9
|
+
ELLIPSIS = "…"
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Truncates each line of *text* to *width* display columns, appending
|
|
14
|
+
# *ellipsis* to lines that overflow. Lines that fit are returned unchanged.
|
|
15
|
+
def tail(text, width, ellipsis: ELLIPSIS)
|
|
16
|
+
text.to_s.lines(chomp: true).map { |line| tail_line(line, width, ellipsis) }.join("\n")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def tail_line(line, width, ellipsis)
|
|
20
|
+
return line if Width.measure(line) <= width
|
|
21
|
+
|
|
22
|
+
ellipsis_width = Width.measure(ellipsis)
|
|
23
|
+
return ANSISlicer.slice(line, 0, width) if ellipsis_width >= width
|
|
24
|
+
|
|
25
|
+
ANSISlicer.slice(line, 0, width - ellipsis_width) + ellipsis
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -8,17 +8,53 @@ module Charming
|
|
|
8
8
|
# ANSI escape sequences. It delegates to `Unicode::DisplayWidth` while automatically stripping
|
|
9
9
|
# formatting codes so layout primitives can calculate exact character positions.
|
|
10
10
|
module Width
|
|
11
|
-
|
|
11
|
+
# Matches OSC sequences (e.g. OSC 8 hyperlinks, terminated by BEL or ST),
|
|
12
|
+
# CSI sequences (SGR colors/attributes, cursor movement), and single-character
|
|
13
|
+
# Fe escapes. The OSC branch must come first and the Fe class must exclude
|
|
14
|
+
# "[" and "]", or "\e]" would match as a bare Fe escape and leave the OSC
|
|
15
|
+
# payload counted as visible text.
|
|
16
|
+
ANSI_PATTERN = /\e(?:\][^\a]*?(?:\a|\e\\)|\[[0-9;?]*[@-~]|[@-Z\\^_])/
|
|
17
|
+
|
|
18
|
+
# A grapheme cluster containing either codepoint renders as a single emoji
|
|
19
|
+
# (double-width) cell in a terminal: U+200D ZWJ joins a multi-glyph emoji
|
|
20
|
+
# sequence, and U+FE0F (VS16) requests emoji presentation. The
|
|
21
|
+
# unicode-display_width tables disagree with terminals here — e.g. "⚔️"
|
|
22
|
+
# measures 1 and "🧙♂️" measures 3 — so we pin such clusters to 2.
|
|
23
|
+
EMOJI_PRESENTATION = /[\u200D\uFE0F]/
|
|
24
|
+
|
|
25
|
+
# Onig's \X matches one extended grapheme cluster, keeping multi-codepoint
|
|
26
|
+
# emoji together so each is measured (and later sliced) as one unit.
|
|
27
|
+
GRAPHEME = /\X/
|
|
12
28
|
|
|
13
29
|
module_function
|
|
14
30
|
|
|
15
31
|
def measure(value)
|
|
16
|
-
|
|
32
|
+
stripped = strip_ansi(value.to_s)
|
|
33
|
+
return Unicode::DisplayWidth.of(stripped) unless stripped.match?(EMOJI_PRESENTATION)
|
|
34
|
+
|
|
35
|
+
stripped.scan(GRAPHEME).sum { |cluster| cluster_width(cluster) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def cluster_width(cluster)
|
|
39
|
+
return 2 if cluster.match?(EMOJI_PRESENTATION)
|
|
40
|
+
|
|
41
|
+
Unicode::DisplayWidth.of(cluster)
|
|
17
42
|
end
|
|
18
43
|
|
|
19
44
|
def strip_ansi(value)
|
|
20
45
|
value.to_s.gsub(ANSI_PATTERN, "")
|
|
21
46
|
end
|
|
47
|
+
|
|
48
|
+
# Pads *line* with trailing spaces to *width* display columns. Lines already at
|
|
49
|
+
# or beyond the target are returned unchanged.
|
|
50
|
+
def pad_to(line, width)
|
|
51
|
+
line + (" " * [width - measure(line), 0].max)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The maximum display width across *lines*; 0 when there are none.
|
|
55
|
+
def widest(lines)
|
|
56
|
+
lines.map { |line| measure(line) }.max || 0
|
|
57
|
+
end
|
|
22
58
|
end
|
|
23
59
|
end
|
|
24
60
|
end
|
|
@@ -13,22 +13,38 @@ module Charming
|
|
|
13
13
|
Style.new
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Builds a color that resolves to *light* or *dark* at render time based on
|
|
17
|
+
# the terminal background. Usable anywhere a color is accepted.
|
|
18
|
+
def adaptive(light:, dark:)
|
|
19
|
+
AdaptiveColor.new(light: light, dark: dark)
|
|
20
|
+
end
|
|
21
|
+
|
|
16
22
|
# Horizontally concatenates *blocks* into a single multi-line string, padding each block's
|
|
17
23
|
# rows to match the widest row. A *gap* argument (in spaces) can separate adjacent columns.
|
|
18
|
-
|
|
24
|
+
# *align* positions shorter blocks along the cross axis: `:top` (default), `:center`,
|
|
25
|
+
# `:bottom`, or a fraction between 0.0 and 1.0.
|
|
26
|
+
def join_horizontal(*blocks, gap: 0, align: :top)
|
|
19
27
|
normalized = normalize_blocks(blocks)
|
|
28
|
+
height = block_height(normalized)
|
|
29
|
+
aligned = normalized.map { |lines| offset_rows(lines, height, align) }
|
|
20
30
|
widths = block_widths(normalized)
|
|
21
31
|
separator = " " * gap
|
|
22
32
|
|
|
23
|
-
Array.new(
|
|
24
|
-
horizontal_line(
|
|
33
|
+
Array.new(height) do |index|
|
|
34
|
+
horizontal_line(aligned, widths, index).join(separator)
|
|
25
35
|
end.join("\n")
|
|
26
36
|
end
|
|
27
37
|
|
|
28
|
-
# Stacks *blocks* vertically separated by one or more blank lines
|
|
29
|
-
#
|
|
30
|
-
|
|
31
|
-
|
|
38
|
+
# Stacks *blocks* vertically separated by one or more blank lines, padding narrower
|
|
39
|
+
# blocks' lines to the widest block. A *gap* of N inserts N extra newline characters
|
|
40
|
+
# between blocks. *align* positions narrower lines along the cross axis: `:left`
|
|
41
|
+
# (default), `:center`, `:right`, or a fraction between 0.0 and 1.0.
|
|
42
|
+
def join_vertical(*blocks, gap: 0, align: :left)
|
|
43
|
+
normalized = normalize_blocks(blocks)
|
|
44
|
+
width = block_widths(normalized).max || 0
|
|
45
|
+
|
|
46
|
+
normalized.map { |lines| lines.map { |line| align_to(line, width, align) }.join("\n") }
|
|
47
|
+
.join("\n" * (gap + 1))
|
|
32
48
|
end
|
|
33
49
|
|
|
34
50
|
# Places *block* onto a blank canvas of *width* × *height* at an offset determined by *top* (row)
|
|
@@ -63,13 +79,13 @@ module Charming
|
|
|
63
79
|
|
|
64
80
|
# Measures the displayed (visual) width of each normalised block, returning an array of integer widths.
|
|
65
81
|
def block_widths(blocks)
|
|
66
|
-
blocks.map { |lines|
|
|
82
|
+
blocks.map { |lines| Width.widest(lines) }
|
|
67
83
|
end
|
|
68
84
|
|
|
69
85
|
# Returns the maximum visual character width across all *lines*, accounting for multi-column characters
|
|
70
86
|
# (e.g., full-width CJK glyphs) and invisible ANSI escape sequences.
|
|
71
87
|
def block_width(lines)
|
|
72
|
-
|
|
88
|
+
Width.widest(lines)
|
|
73
89
|
end
|
|
74
90
|
|
|
75
91
|
# Returns the height in rows of each normalised block, taking the maximum across all blocks.
|
|
@@ -81,9 +97,34 @@ module Charming
|
|
|
81
97
|
# every segment to its corresponding *width* in spaces. Returns the assembled array of padded segments.
|
|
82
98
|
def horizontal_line(blocks, widths, index)
|
|
83
99
|
blocks.each_with_index.map do |lines, block_index|
|
|
84
|
-
|
|
85
|
-
line + (" " * (widths[block_index] - Width.measure(line)))
|
|
100
|
+
Width.pad_to(lines[index] || "", widths[block_index])
|
|
86
101
|
end
|
|
87
102
|
end
|
|
103
|
+
|
|
104
|
+
# Prepends blank rows to *lines* so the block sits at the cross-axis position
|
|
105
|
+
# given by *align* within *height* total rows.
|
|
106
|
+
def offset_rows(lines, height, align)
|
|
107
|
+
Array.new(cross_offset(align, height - lines.length), "") + lines
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Pads *line* to *width*, splitting the slack per the cross-axis *align* position.
|
|
111
|
+
def align_to(line, width, align)
|
|
112
|
+
slack = width - Width.measure(line)
|
|
113
|
+
return line if slack <= 0
|
|
114
|
+
|
|
115
|
+
leading = cross_offset(align, slack)
|
|
116
|
+
(" " * leading) + line + (" " * (slack - leading))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Resolves an alignment (`:top`/`:left` → 0.0, `:center` → 0.5, `:bottom`/`:right` → 1.0,
|
|
120
|
+
# or a fraction) into a whole-cell offset within *slack* spare cells.
|
|
121
|
+
CROSS_POSITIONS = {top: 0.0, left: 0.0, center: 0.5, middle: 0.5, bottom: 1.0, right: 1.0}.freeze
|
|
122
|
+
|
|
123
|
+
def cross_offset(align, slack)
|
|
124
|
+
return 0 if slack <= 0
|
|
125
|
+
|
|
126
|
+
position = CROSS_POSITIONS.fetch(align, align)
|
|
127
|
+
(slack * position.to_f).round.clamp(0, slack)
|
|
128
|
+
end
|
|
88
129
|
end
|
|
89
130
|
end
|