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
|
@@ -56,8 +56,13 @@ module Charming
|
|
|
56
56
|
@attributes.map { |attribute| ATTRIBUTES.fetch(attribute) }
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
# Resolves *color* to SGR codes, downconverting to the terminal's capability
|
|
60
|
+
# (see UI::ColorSupport): truecolor → 256 → 16 → none. Adaptive colors
|
|
61
|
+
# resolve against the terminal background first.
|
|
59
62
|
def color_codes(color, foreground:)
|
|
63
|
+
color = color.resolve if color.respond_to?(:resolve)
|
|
60
64
|
return [] unless color
|
|
65
|
+
return [] if ColorSupport.level == :none
|
|
61
66
|
return indexed_color_code(color, foreground: foreground) if color.is_a?(Integer)
|
|
62
67
|
return named_color_code(color, foreground: foreground) if COLORS.key?(color.to_sym)
|
|
63
68
|
return truecolor_codes(color, foreground: foreground) if color.to_s.start_with?("#")
|
|
@@ -72,16 +77,25 @@ module Charming
|
|
|
72
77
|
|
|
73
78
|
def indexed_color_code(color, foreground:)
|
|
74
79
|
raise ArgumentError, "indexed color must be between 0 and 255" unless color.between?(0, 255)
|
|
80
|
+
return basic_color_code(ColorSupport.index_to_16(color), foreground: foreground) unless ColorSupport.at_least?(:color256)
|
|
75
81
|
|
|
76
82
|
[foreground ? 38 : 48, 5, color]
|
|
77
83
|
end
|
|
78
84
|
|
|
79
85
|
def truecolor_codes(color, foreground:)
|
|
80
86
|
hex = color.to_s.delete_prefix("#")
|
|
81
|
-
|
|
87
|
+
hex = hex.chars.map { |digit| digit * 2 }.join if hex.match?(/\A[0-9a-fA-F]{3}\z/)
|
|
88
|
+
raise ArgumentError, "truecolor must be #rgb or #rrggbb" unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
|
|
89
|
+
return [foreground ? 38 : 48, 5, ColorSupport.hex_to_256(hex)] if ColorSupport.level == :color256
|
|
90
|
+
return basic_color_code(ColorSupport.hex_to_16(hex), foreground: foreground) if ColorSupport.level == :color16
|
|
82
91
|
|
|
83
92
|
[foreground ? 38 : 48, 2, hex[0..1].to_i(16), hex[2..3].to_i(16), hex[4..5].to_i(16)]
|
|
84
93
|
end
|
|
94
|
+
|
|
95
|
+
# Wraps a basic SGR foreground code (30-37/90-97) for the requested plane.
|
|
96
|
+
def basic_color_code(code, foreground:)
|
|
97
|
+
[foreground ? code : code + 10]
|
|
98
|
+
end
|
|
85
99
|
end
|
|
86
100
|
end
|
|
87
101
|
end
|
|
@@ -6,6 +6,10 @@ module Charming
|
|
|
6
6
|
# escape sequences, preserving the styling that is active at the start of
|
|
7
7
|
# the slice and emitting a trailing reset if any styled content was copied.
|
|
8
8
|
class ANSISlicer
|
|
9
|
+
# One ANSI escape sequence or one grapheme cluster (`\X`). The ANSI branch
|
|
10
|
+
# comes first so a valid escape is consumed whole rather than as graphemes.
|
|
11
|
+
TOKEN_PATTERN = /#{Width::ANSI_PATTERN}|\X/
|
|
12
|
+
|
|
9
13
|
def self.slice(line, start_column, width)
|
|
10
14
|
return "" unless width.positive?
|
|
11
15
|
|
|
@@ -27,16 +31,12 @@ module Charming
|
|
|
27
31
|
end
|
|
28
32
|
|
|
29
33
|
def self.each_ansi_or_char(line)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
else
|
|
37
|
-
yield line[index], false
|
|
38
|
-
index += 1
|
|
39
|
-
end
|
|
34
|
+
# Iterate one ANSI escape or one *grapheme cluster* (`\X`) at a time. A
|
|
35
|
+
# single emoji may be several codepoints (ZWJ sequences, skin-tone and
|
|
36
|
+
# variation selectors, e.g. "🧙♂️"); treating it as one unit keeps its full
|
|
37
|
+
# display width together so a slice never splits it mid-glyph.
|
|
38
|
+
line.scan(TOKEN_PATTERN) do |token|
|
|
39
|
+
yield token, token.start_with?("\e")
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
|
|
@@ -57,10 +57,17 @@ module Charming
|
|
|
57
57
|
char_start = state[:column]
|
|
58
58
|
char_end = char_start + char_width
|
|
59
59
|
state[:column] = char_end
|
|
60
|
-
|
|
60
|
+
|
|
61
|
+
visible = [char_end, end_column].min - [char_start, start_column].max
|
|
62
|
+
return unless visible.positive?
|
|
61
63
|
|
|
62
64
|
start_slice(state)
|
|
63
|
-
|
|
65
|
+
# A multi-column glyph cut by a slice boundary cannot be partially drawn,
|
|
66
|
+
# so render the in-range columns as spaces (standard terminal behavior).
|
|
67
|
+
# This keeps the slice exactly *width* columns wide regardless of where
|
|
68
|
+
# the boundaries fall relative to wide glyphs.
|
|
69
|
+
fits = char_start >= start_column && char_end <= end_column
|
|
70
|
+
state[:output] << (fits ? char : " " * visible)
|
|
64
71
|
end
|
|
65
72
|
|
|
66
73
|
def self.start_slice(state)
|
|
@@ -81,7 +88,7 @@ module Charming
|
|
|
81
88
|
if token.include?("[0m")
|
|
82
89
|
active.clear
|
|
83
90
|
else
|
|
84
|
-
active << token
|
|
91
|
+
active << token unless active.include?(token)
|
|
85
92
|
end
|
|
86
93
|
end
|
|
87
94
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module UI
|
|
5
|
+
# Background tracks whether the terminal has a dark or light background so
|
|
6
|
+
# adaptive colors and the markdown :auto style can pick the readable variant.
|
|
7
|
+
#
|
|
8
|
+
# The runtime feeds a definitive answer from an OSC 11 query when the
|
|
9
|
+
# terminal replies to one (see TTYBackend#query_background_color); otherwise
|
|
10
|
+
# detection falls back to the COLORFGBG convention and finally assumes dark —
|
|
11
|
+
# the overwhelmingly common terminal default. `Background.assume = :light`
|
|
12
|
+
# overrides everything (useful in tests and for user preference).
|
|
13
|
+
module Background
|
|
14
|
+
MODES = %i[dark light].freeze
|
|
15
|
+
|
|
16
|
+
# ITU-R BT.601 luma threshold on 8-bit channels: below this is "dark".
|
|
17
|
+
LUMA_THRESHOLD = 128
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# True when the terminal background is dark (the assumed mode, or detection).
|
|
22
|
+
def dark?
|
|
23
|
+
(@assumed || detect(ENV)) == :dark
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Overrides detection with :dark or :light; nil returns to auto-detection.
|
|
27
|
+
def assume=(value)
|
|
28
|
+
raise ArgumentError, "unknown background: #{value.inspect}" if value && !MODES.include?(value)
|
|
29
|
+
|
|
30
|
+
@assumed = value
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Detects the background from an environment hash via the COLORFGBG
|
|
34
|
+
# convention ("<fg>;<bg>", where bg 7 or 15 means a light background).
|
|
35
|
+
# Defaults to :dark when the environment says nothing.
|
|
36
|
+
def detect(env)
|
|
37
|
+
bg_index = env["COLORFGBG"].to_s.split(";").last
|
|
38
|
+
%w[7 15].include?(bg_index) ? :light : :dark
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Classifies an 8-bit RGB triple as :dark or :light by luma.
|
|
42
|
+
def classify(red, green, blue)
|
|
43
|
+
luma = (0.299 * red) + (0.587 * green) + (0.114 * blue)
|
|
44
|
+
(luma < LUMA_THRESHOLD) ? :dark : :light
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Parses an OSC 11 reply ("\e]11;rgb:RRRR/GGGG/BBBB" + BEL or ST) and
|
|
48
|
+
# classifies the reported color. Returns nil for anything unparseable.
|
|
49
|
+
def parse_osc11(reply)
|
|
50
|
+
match = reply.to_s.match(%r{\e\]11;rgb:(\h{2,4})/(\h{2,4})/(\h{2,4})})
|
|
51
|
+
return unless match
|
|
52
|
+
|
|
53
|
+
red, green, blue = match.captures.map { |channel| channel[0, 2].to_i(16) }
|
|
54
|
+
classify(red, green, blue)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -10,7 +10,11 @@ module Charming
|
|
|
10
10
|
@horizontal, @vertical = edges
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
+
# Resolves *name* to a Border: a Border instance passes through (custom
|
|
14
|
+
# borders), anything else is looked up in the built-in STYLES.
|
|
13
15
|
def self.fetch(name)
|
|
16
|
+
return name if name.is_a?(Border)
|
|
17
|
+
|
|
14
18
|
STYLES.fetch(name.to_sym)
|
|
15
19
|
end
|
|
16
20
|
end
|
|
@@ -27,7 +31,16 @@ module Charming
|
|
|
27
31
|
),
|
|
28
32
|
double: Border.new(
|
|
29
33
|
corners: ["╔", "╗", "╚", "╝"], edges: ["═", "║"]
|
|
34
|
+
),
|
|
35
|
+
square: Border.new(
|
|
36
|
+
corners: ["┌", "┐", "└", "┘"], edges: ["─", "│"]
|
|
37
|
+
),
|
|
38
|
+
hidden: Border.new(
|
|
39
|
+
corners: [" ", " ", " ", " "], edges: [" ", " "]
|
|
40
|
+
),
|
|
41
|
+
block: Border.new(
|
|
42
|
+
corners: ["█", "█", "█", "█"], edges: ["█", "█"]
|
|
30
43
|
)
|
|
31
|
-
}.freeze
|
|
44
|
+
}.tap { |styles| styles[:ascii] = styles[:normal] }.freeze
|
|
32
45
|
end
|
|
33
46
|
end
|
|
@@ -2,14 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
4
|
module UI
|
|
5
|
+
# BorderPainter draws a Border's glyphs around content lines, optionally
|
|
6
|
+
# coloring the border. *foreground* is a single color or a per-side hash
|
|
7
|
+
# (`{top:, right:, bottom:, left:}`); corners take the top/bottom row's
|
|
8
|
+
# color. *background* colors border cells; when it merely inherits the box
|
|
9
|
+
# background (background_explicit: false) it only paints alongside a
|
|
10
|
+
# foreground, preserving unstyled borders on unstyled boxes.
|
|
5
11
|
class BorderPainter
|
|
6
12
|
DEFAULT_SIDES = %i[top right bottom left].freeze
|
|
7
13
|
|
|
8
|
-
def initialize(border:, sides: nil, foreground: nil, background: nil)
|
|
14
|
+
def initialize(border:, sides: nil, foreground: nil, background: nil, background_explicit: false)
|
|
9
15
|
@border = border
|
|
10
16
|
@sides = Array(sides || DEFAULT_SIDES).map(&:to_sym)
|
|
11
17
|
@foreground = foreground
|
|
12
18
|
@background = background
|
|
19
|
+
@background_explicit = background_explicit
|
|
13
20
|
end
|
|
14
21
|
|
|
15
22
|
def paint(lines, inner_width)
|
|
@@ -22,34 +29,39 @@ module Charming
|
|
|
22
29
|
private
|
|
23
30
|
|
|
24
31
|
def border_line(line, width)
|
|
25
|
-
left = @sides.include?(:left) ? render_border(@border.vertical) : ""
|
|
26
|
-
right = @sides.include?(:right) ? render_border(@border.vertical) : ""
|
|
32
|
+
left = @sides.include?(:left) ? render_border(@border.vertical, :left) : ""
|
|
33
|
+
right = @sides.include?(:right) ? render_border(@border.vertical, :right) : ""
|
|
27
34
|
|
|
28
|
-
"#{left}#{
|
|
35
|
+
"#{left}#{Width.pad_to(line, width)}#{right}"
|
|
29
36
|
end
|
|
30
37
|
|
|
31
38
|
def top_border(horizontal)
|
|
32
39
|
return unless @sides.include?(:top)
|
|
33
|
-
return render_border(horizontal) unless full_horizontal?
|
|
40
|
+
return render_border(horizontal, :top) unless full_horizontal?
|
|
34
41
|
|
|
35
|
-
render_border("#{@border.top_left}#{horizontal}#{@border.top_right}")
|
|
42
|
+
render_border("#{@border.top_left}#{horizontal}#{@border.top_right}", :top)
|
|
36
43
|
end
|
|
37
44
|
|
|
38
45
|
def bottom_border(horizontal)
|
|
39
46
|
return unless @sides.include?(:bottom)
|
|
40
|
-
return render_border(horizontal) unless full_horizontal?
|
|
47
|
+
return render_border(horizontal, :bottom) unless full_horizontal?
|
|
41
48
|
|
|
42
|
-
render_border("#{@border.bottom_left}#{horizontal}#{@border.bottom_right}")
|
|
49
|
+
render_border("#{@border.bottom_left}#{horizontal}#{@border.bottom_right}", :bottom)
|
|
43
50
|
end
|
|
44
51
|
|
|
45
52
|
def full_horizontal?
|
|
46
53
|
@sides.include?(:left) && @sides.include?(:right)
|
|
47
54
|
end
|
|
48
55
|
|
|
49
|
-
def render_border(value)
|
|
50
|
-
|
|
56
|
+
def render_border(value, side)
|
|
57
|
+
foreground = side_foreground(side)
|
|
58
|
+
return value unless foreground || @background_explicit
|
|
51
59
|
|
|
52
|
-
Style.new(foreground:
|
|
60
|
+
Style.new(foreground: foreground, background: @background).render(value)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def side_foreground(side)
|
|
64
|
+
@foreground.is_a?(Hash) ? @foreground[side] : @foreground
|
|
53
65
|
end
|
|
54
66
|
end
|
|
55
67
|
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module UI
|
|
5
|
+
# BrailleCanvas is a monochrome subpixel drawing surface backed by Unicode braille glyphs
|
|
6
|
+
# (U+2800–U+28FF). Each character cell packs a 2×4 grid of dots, so the canvas addresses
|
|
7
|
+
# `width`×`height` *pixels* while rendering to `cols`×`rows` *cells* — 8× the vertical and 2× the
|
|
8
|
+
# horizontal resolution of plain text. It's pure text (works on every terminal) and composes via
|
|
9
|
+
# `row`/`column`/`Canvas` like any other block. Used by {Charming::Components::Chart}.
|
|
10
|
+
class BrailleCanvas
|
|
11
|
+
# The first braille code point; OR-ing dot bits onto it yields the glyph for a cell.
|
|
12
|
+
BASE = 0x2800
|
|
13
|
+
|
|
14
|
+
# Dot bit for each (x%2, y%4) position within a cell, indexed `DOTS[y % 4][x % 2]`.
|
|
15
|
+
DOTS = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]].freeze
|
|
16
|
+
|
|
17
|
+
# *width* and *height* are the drawable area in pixels (dots).
|
|
18
|
+
def initialize(width, height)
|
|
19
|
+
@width = width
|
|
20
|
+
@height = height
|
|
21
|
+
@cols = (width + 1) / 2
|
|
22
|
+
@rows = (height + 3) / 4
|
|
23
|
+
@cells = Array.new(@rows) { Array.new(@cols, 0) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_reader :width, :height, :cols, :rows
|
|
27
|
+
|
|
28
|
+
# Turns the dot at pixel (*x*, *y*) on (or off when `on: false`). Out-of-range points are
|
|
29
|
+
# ignored. Returns self for chaining.
|
|
30
|
+
def set(x, y, on: true)
|
|
31
|
+
return self unless x.between?(0, @width - 1) && y.between?(0, @height - 1)
|
|
32
|
+
|
|
33
|
+
bit = DOTS[y % 4][x % 2]
|
|
34
|
+
if on
|
|
35
|
+
@cells[y / 4][x / 2] |= bit
|
|
36
|
+
else
|
|
37
|
+
@cells[y / 4][x / 2] &= ~bit
|
|
38
|
+
end
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Turns the dot at pixel (*x*, *y*) off. Returns self.
|
|
43
|
+
def unset(x, y)
|
|
44
|
+
set(x, y, on: false)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Draws a straight line between (*x0*, *y0*) and (*x1*, *y1*) with Bresenham's algorithm.
|
|
48
|
+
# Returns self.
|
|
49
|
+
def line(x0, y0, x1, y1)
|
|
50
|
+
dx = (x1 - x0).abs
|
|
51
|
+
dy = -(y1 - y0).abs
|
|
52
|
+
sx = (x0 < x1) ? 1 : -1
|
|
53
|
+
sy = (y0 < y1) ? 1 : -1
|
|
54
|
+
err = dx + dy
|
|
55
|
+
x = x0
|
|
56
|
+
y = y0
|
|
57
|
+
loop do
|
|
58
|
+
set(x, y)
|
|
59
|
+
break if x == x1 && y == y1
|
|
60
|
+
|
|
61
|
+
e2 = 2 * err
|
|
62
|
+
if e2 >= dy
|
|
63
|
+
err += dy
|
|
64
|
+
x += sx
|
|
65
|
+
end
|
|
66
|
+
if e2 <= dx
|
|
67
|
+
err += dx
|
|
68
|
+
y += sy
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Renders the canvas as `rows` lines of `cols` braille glyphs.
|
|
75
|
+
def to_s
|
|
76
|
+
@cells.map { |row| row.map { |bits| (BASE + bits).chr(Encoding::UTF_8) }.join }.join("\n")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module UI
|
|
5
|
+
# ColorSupport detects the terminal's color capability and downconverts colors so
|
|
6
|
+
# themes written in truecolor degrade gracefully on less-capable terminals.
|
|
7
|
+
#
|
|
8
|
+
# Levels (best to worst): :truecolor, :color256, :color16, :none.
|
|
9
|
+
#
|
|
10
|
+
# Detection honors NO_COLOR, then COLORTERM (truecolor/24bit), then TERM.
|
|
11
|
+
# `UI::ColorSupport.level = :color256` overrides detection (useful in tests and
|
|
12
|
+
# for user preference).
|
|
13
|
+
module ColorSupport
|
|
14
|
+
LEVELS = %i[none color16 color256 truecolor].freeze
|
|
15
|
+
|
|
16
|
+
# The 6-level RGB ramp used by the xterm 256-color cube (indices 16-231).
|
|
17
|
+
CUBE_LEVELS = [0, 95, 135, 175, 215, 255].freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# The active color level: the explicit override or the detected level (memoized).
|
|
22
|
+
def level
|
|
23
|
+
@level ||= detect(ENV)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Overrides the detected level (nil resets to auto-detection on next access).
|
|
27
|
+
def level=(value)
|
|
28
|
+
raise ArgumentError, "unknown color level: #{value.inspect}" if value && !LEVELS.include?(value)
|
|
29
|
+
|
|
30
|
+
@level = value
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Detects the color capability from an environment hash.
|
|
34
|
+
def detect(env)
|
|
35
|
+
return :none if env["NO_COLOR"] && !env["NO_COLOR"].empty?
|
|
36
|
+
return :truecolor if %w[truecolor 24bit].include?(env["COLORTERM"])
|
|
37
|
+
|
|
38
|
+
term = env["TERM"].to_s
|
|
39
|
+
return :none if term.empty? || term == "dumb"
|
|
40
|
+
return :truecolor if term.include?("direct")
|
|
41
|
+
return :color256 if term.include?("256color")
|
|
42
|
+
|
|
43
|
+
:color16
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# True when the active level is at least *required* (e.g., `at_least?(:color256)`).
|
|
47
|
+
def at_least?(required)
|
|
48
|
+
LEVELS.index(level) >= LEVELS.index(required)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Converts "#rrggbb" to the nearest xterm 256-color index (cube or grayscale ramp).
|
|
52
|
+
def hex_to_256(hex)
|
|
53
|
+
r, g, b = hex_components(hex)
|
|
54
|
+
cube = cube_index(r, g, b)
|
|
55
|
+
gray = gray_index(r, g, b)
|
|
56
|
+
(color_distance([r, g, b], index_to_rgb(cube)) <= color_distance([r, g, b], index_to_rgb(gray))) ? cube : gray
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Converts "#rrggbb" to the nearest of the 16 basic ANSI colors, returned as the
|
|
60
|
+
# SGR foreground code (30-37 or 90-97).
|
|
61
|
+
def hex_to_16(hex)
|
|
62
|
+
rgb = hex_components(hex)
|
|
63
|
+
best = basic_palette.min_by { |_code, basic_rgb| color_distance(rgb, basic_rgb) }
|
|
64
|
+
best.first
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Converts a 256-color index to the nearest basic ANSI SGR foreground code.
|
|
68
|
+
def index_to_16(index)
|
|
69
|
+
rgb = index_to_rgb(index)
|
|
70
|
+
best = basic_palette.min_by { |_code, basic_rgb| color_distance(rgb, basic_rgb) }
|
|
71
|
+
best.first
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# -- conversion internals ----------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def hex_components(hex)
|
|
77
|
+
digits = hex.to_s.delete_prefix("#")
|
|
78
|
+
[digits[0..1].to_i(16), digits[2..3].to_i(16), digits[4..5].to_i(16)]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Index in the 6x6x6 cube (16-231) closest to the RGB triple.
|
|
82
|
+
def cube_index(r, g, b)
|
|
83
|
+
16 + (36 * nearest_cube_level(r)) + (6 * nearest_cube_level(g)) + nearest_cube_level(b)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Index in the grayscale ramp (232-255) closest to the RGB triple's luminance.
|
|
87
|
+
def gray_index(r, g, b)
|
|
88
|
+
gray = ((r + g + b) / 3.0).round
|
|
89
|
+
step = ((gray - 8) / 10.0).round.clamp(0, 23)
|
|
90
|
+
232 + step
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def nearest_cube_level(component)
|
|
94
|
+
CUBE_LEVELS.each_index.min_by { |index| (CUBE_LEVELS[index] - component).abs }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The RGB triple a 256-color *index* renders as.
|
|
98
|
+
def index_to_rgb(index)
|
|
99
|
+
if index >= 232
|
|
100
|
+
gray = 8 + (index - 232) * 10
|
|
101
|
+
[gray, gray, gray]
|
|
102
|
+
elsif index >= 16
|
|
103
|
+
offset = index - 16
|
|
104
|
+
[CUBE_LEVELS[offset / 36], CUBE_LEVELS[(offset / 6) % 6], CUBE_LEVELS[offset % 6]]
|
|
105
|
+
else
|
|
106
|
+
basic_palette.find { |code, _| basic_index_for_code(code) == index }&.last || [0, 0, 0]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def color_distance(a, b)
|
|
111
|
+
(a[0] - b[0])**2 + (a[1] - b[1])**2 + (a[2] - b[2])**2
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The 16 basic ANSI colors as [SGR foreground code, RGB] pairs.
|
|
115
|
+
def basic_palette
|
|
116
|
+
@basic_palette ||= {
|
|
117
|
+
30 => [0, 0, 0], 31 => [205, 49, 49], 32 => [13, 188, 121], 33 => [229, 229, 16],
|
|
118
|
+
34 => [36, 114, 200], 35 => [188, 63, 188], 36 => [17, 168, 205], 37 => [229, 229, 229],
|
|
119
|
+
90 => [102, 102, 102], 91 => [241, 76, 76], 92 => [35, 209, 139], 93 => [245, 245, 67],
|
|
120
|
+
94 => [59, 142, 234], 95 => [214, 112, 214], 96 => [41, 184, 219], 97 => [255, 255, 255]
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def basic_index_for_code(code)
|
|
125
|
+
(code < 90) ? code - 30 : code - 90 + 8
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module UI
|
|
5
|
+
# Gradient interpolates between two hex colors: blend a single point, build
|
|
6
|
+
# an evenly spaced ramp, or paint text with a per-character color sweep.
|
|
7
|
+
module Gradient
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Blends *start_hex* and *end_hex* ("#rrggbb") at fractional *amount*
|
|
11
|
+
# (0.0 → start, 1.0 → end), returning a "#rrggbb" string.
|
|
12
|
+
def blend(start_hex, end_hex, amount)
|
|
13
|
+
mixed = rgb(start_hex).zip(rgb(end_hex)).map do |from, to|
|
|
14
|
+
(from + ((to - from) * amount)).round
|
|
15
|
+
end
|
|
16
|
+
format("#%02x%02x%02x", *mixed)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# An evenly spaced ramp of *count* colors from *start_hex* to *end_hex*,
|
|
20
|
+
# endpoints included.
|
|
21
|
+
def steps(start_hex, end_hex, count)
|
|
22
|
+
return [blend(start_hex, end_hex, 0.0)] if count <= 1
|
|
23
|
+
|
|
24
|
+
Array.new(count) { |index| blend(start_hex, end_hex, index.to_f / (count - 1)) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Paints each grapheme cluster of plain-text *text* with a foreground color
|
|
28
|
+
# swept from *from* to *to* across its visible characters.
|
|
29
|
+
def colorize(text, from:, to:)
|
|
30
|
+
clusters = text.to_s.scan(Width::GRAPHEME)
|
|
31
|
+
span = [clusters.length - 1, 1].max
|
|
32
|
+
|
|
33
|
+
clusters.each_with_index.map do |cluster, index|
|
|
34
|
+
Style.new(foreground: blend(from, to, index.to_f / span)).render(cluster)
|
|
35
|
+
end.join
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Decomposes "#rrggbb" into [r, g, b] integers.
|
|
39
|
+
def rgb(hex)
|
|
40
|
+
value = hex.to_s.delete_prefix("#")
|
|
41
|
+
raise ArgumentError, "gradient colors must be #rrggbb" unless value.match?(/\A[0-9a-fA-F]{6}\z/)
|
|
42
|
+
|
|
43
|
+
[value[0..1], value[2..3], value[4..5]].map { |part| part.to_i(16) }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|