charming 0.2.1 → 0.2.3
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 +16 -3
- data/lib/charming/application.rb +48 -0
- data/lib/charming/cli.rb +16 -4
- data/lib/charming/controller/key_dispatch.rb +113 -0
- data/lib/charming/controller/session_state.rb +11 -0
- data/lib/charming/controller/terminal.rb +33 -0
- data/lib/charming/controller.rb +13 -40
- data/lib/charming/escape.rb +81 -0
- data/lib/charming/events/mouse_event.rb +22 -9
- data/lib/charming/generators/app_file_generator.rb +29 -0
- data/lib/charming/generators/app_generator.rb +13 -25
- data/lib/charming/generators/base.rb +5 -0
- data/lib/charming/generators/layout_generator.rb +155 -0
- data/lib/charming/generators/screen_generator.rb +0 -29
- data/lib/charming/generators/templates/app/Gemfile.template +6 -0
- data/lib/charming/generators/templates/app/README.md.template +15 -1
- data/lib/charming/generators/templates/app/application.template +0 -6
- data/lib/charming/generators/templates/app/application_controller.template +0 -10
- data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
- data/lib/charming/generators/templates/app/layout.template +4 -93
- data/lib/charming/generators/templates/app/routes.template +3 -1
- data/lib/charming/generators/templates/layout/sidebar/application_layout.rb.template +110 -0
- data/lib/charming/image/protocol/kitty.rb +133 -0
- data/lib/charming/image/protocol.rb +18 -0
- data/lib/charming/image/source.rb +95 -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 +21 -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 +79 -2
- data/lib/charming/presentation/components/activity_indicator.rb +2 -19
- data/lib/charming/presentation/components/autocomplete.rb +7 -0
- data/lib/charming/presentation/components/chart.rb +80 -0
- data/lib/charming/presentation/components/error_screen.rb +1 -1
- 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/field.rb +5 -0
- data/lib/charming/presentation/components/form/input.rb +14 -4
- data/lib/charming/presentation/components/form/multiselect.rb +105 -0
- data/lib/charming/presentation/components/form/textarea.rb +18 -8
- data/lib/charming/presentation/components/form.rb +6 -0
- data/lib/charming/presentation/components/image.rb +38 -0
- data/lib/charming/presentation/components/list.rb +22 -4
- data/lib/charming/presentation/components/modal.rb +4 -4
- data/lib/charming/presentation/components/paginator.rb +54 -0
- data/lib/charming/presentation/components/progressbar.rb +26 -4
- data/lib/charming/presentation/components/sparkline.rb +38 -0
- data/lib/charming/presentation/components/spinner.rb +22 -3
- data/lib/charming/presentation/components/stopwatch.rb +55 -0
- data/lib/charming/presentation/components/table.rb +42 -1
- data/lib/charming/presentation/components/text_area.rb +3 -4
- data/lib/charming/presentation/components/text_input.rb +11 -6
- 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 +4 -3
- data/lib/charming/presentation/components/viewport/content_lines.rb +1 -1
- data/lib/charming/presentation/components/viewport/line_window.rb +1 -1
- data/lib/charming/presentation/components/viewport.rb +11 -1
- data/lib/charming/presentation/markdown/renderer.rb +1 -1
- data/lib/charming/presentation/markdown/style_config.rb +1 -0
- data/lib/charming/presentation/markdown/table_renderer.rb +2 -3
- data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +5 -2
- 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/gradient.rb +47 -0
- data/lib/charming/presentation/ui/style.rb +119 -19
- data/lib/charming/presentation/{markdown → ui}/text_wrapper.rb +4 -4
- data/lib/charming/presentation/ui/truncate.rb +29 -0
- data/lib/charming/presentation/ui/width.rb +11 -0
- 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/runtime.rb +167 -90
- data/lib/charming/version.rb +1 -1
- data/lib/charming/welcome/controller.rb +23 -0
- data/lib/charming/welcome/show_view.rb +113 -0
- data/lib/charming/welcome.rb +13 -0
- metadata +34 -9
- data/lib/charming/generators/templates/app/home_controller.template +0 -6
- data/lib/charming/generators/templates/app/home_state.template +0 -7
- data/lib/charming/generators/templates/app/spec_controller.template +0 -17
- data/lib/charming/generators/templates/app/spec_state.template +0 -17
- data/lib/charming/generators/templates/app/spec_view.template +0 -16
- data/lib/charming/generators/templates/app/view.template +0 -21
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module UI
|
|
5
|
+
# AdaptiveColor is a color value that resolves to its light or dark variant
|
|
6
|
+
# at render time, based on the detected terminal background. Build one with
|
|
7
|
+
# `UI.adaptive(light:, dark:)` and use it anywhere a color is accepted.
|
|
8
|
+
class AdaptiveColor
|
|
9
|
+
def initialize(light:, dark:)
|
|
10
|
+
@light = light
|
|
11
|
+
@dark = dark
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# The variant readable on the current terminal background.
|
|
15
|
+
def resolve
|
|
16
|
+
Background.dark? ? @dark : @light
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -57,8 +57,10 @@ module Charming
|
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
# Resolves *color* to SGR codes, downconverting to the terminal's capability
|
|
60
|
-
# (see UI::ColorSupport): truecolor → 256 → 16 → none.
|
|
60
|
+
# (see UI::ColorSupport): truecolor → 256 → 16 → none. Adaptive colors
|
|
61
|
+
# resolve against the terminal background first.
|
|
61
62
|
def color_codes(color, foreground:)
|
|
63
|
+
color = color.resolve if color.respond_to?(:resolve)
|
|
62
64
|
return [] unless color
|
|
63
65
|
return [] if ColorSupport.level == :none
|
|
64
66
|
return indexed_color_code(color, foreground: foreground) if color.is_a?(Integer)
|
|
@@ -82,7 +84,8 @@ module Charming
|
|
|
82
84
|
|
|
83
85
|
def truecolor_codes(color, foreground:)
|
|
84
86
|
hex = color.to_s.delete_prefix("#")
|
|
85
|
-
|
|
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/)
|
|
86
89
|
return [foreground ? 38 : 48, 5, ColorSupport.hex_to_256(hex)] if ColorSupport.level == :color256
|
|
87
90
|
return basic_color_code(ColorSupport.hex_to_16(hex), foreground: foreground) if ColorSupport.level == :color16
|
|
88
91
|
|
|
@@ -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,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
|
|
@@ -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*.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Charming
|
|
4
|
-
module
|
|
5
|
-
# TextWrapper wraps
|
|
4
|
+
module UI
|
|
5
|
+
# TextWrapper greedily word-wraps text blocks to a display width.
|
|
6
6
|
class TextWrapper
|
|
7
7
|
def initialize(width:)
|
|
8
8
|
@width = width
|
|
@@ -19,7 +19,7 @@ module Charming
|
|
|
19
19
|
attr_reader :width
|
|
20
20
|
|
|
21
21
|
def wrap_line(line)
|
|
22
|
-
return line if
|
|
22
|
+
return line if Width.measure(line) <= width
|
|
23
23
|
|
|
24
24
|
wrap_words(line.split(/\s+/))
|
|
25
25
|
end
|
|
@@ -31,7 +31,7 @@ module Charming
|
|
|
31
31
|
def append_word(lines, word)
|
|
32
32
|
current = lines.pop.to_s
|
|
33
33
|
candidate = current.empty? ? word : "#{current} #{word}"
|
|
34
|
-
return lines.push(candidate) if current.empty? ||
|
|
34
|
+
return lines.push(candidate) if current.empty? || Width.measure(candidate) <= width
|
|
35
35
|
|
|
36
36
|
lines.push(current.rstrip, word)
|
|
37
37
|
end
|
|
@@ -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
|
|
@@ -44,6 +44,17 @@ module Charming
|
|
|
44
44
|
def strip_ansi(value)
|
|
45
45
|
value.to_s.gsub(ANSI_PATTERN, "")
|
|
46
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
|
|
47
58
|
end
|
|
48
59
|
end
|
|
49
60
|
end
|