charming 0.2.1 → 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 +1 -1
- data/lib/charming/application.rb +48 -0
- 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 +12 -39
- data/lib/charming/escape.rb +81 -0
- data/lib/charming/events/mouse_event.rb +22 -9
- data/lib/charming/generators/app_generator.rb +3 -2
- data/lib/charming/generators/templates/app/Gemfile.template +6 -0
- data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
- 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 +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/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/multiselect.rb +105 -0
- data/lib/charming/presentation/components/image.rb +38 -0
- data/lib/charming/presentation/components/list.rb +22 -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 +1 -2
- data/lib/charming/presentation/components/text_input.rb +9 -4
- data/lib/charming/presentation/components/time_display.rb +20 -0
- data/lib/charming/presentation/components/timer.rb +43 -0
- 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/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 +154 -88
- data/lib/charming/version.rb +1 -1
- metadata +29 -3
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Components
|
|
5
|
+
# Sparkline renders a series of numbers as a compact one-line bar graph using the eighth-block
|
|
6
|
+
# glyphs `▁▂▃▄▅▆▇█` — one cell per value, scaled between the series' min and max. Pure text, so it
|
|
7
|
+
# works on every terminal. Pass an optional `style:` ({Charming::UI::Style}) to colour it.
|
|
8
|
+
class Sparkline < Component
|
|
9
|
+
# The eight bar heights, shortest to tallest.
|
|
10
|
+
BARS = %w[▁ ▂ ▃ ▄ ▅ ▆ ▇ █].freeze
|
|
11
|
+
|
|
12
|
+
# *values* is the numeric series. *style* optionally paints the result. *theme* is forwarded.
|
|
13
|
+
def initialize(values:, style: nil, theme: nil)
|
|
14
|
+
super(theme: theme)
|
|
15
|
+
@values = values
|
|
16
|
+
@style = style
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Renders one bar glyph per value (empty string for an empty series).
|
|
20
|
+
def render
|
|
21
|
+
return "" if @values.empty?
|
|
22
|
+
|
|
23
|
+
glyphs = @values.map { |value| BARS[level(value)] }.join
|
|
24
|
+
@style ? @style.render(glyphs) : glyphs
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# The 0..7 bar index for *value*, scaled across the series range (flat series → lowest bar).
|
|
30
|
+
def level(value)
|
|
31
|
+
min, max = @values.minmax
|
|
32
|
+
return 0 if max == min
|
|
33
|
+
|
|
34
|
+
((value - min).to_f / (max - min) * (BARS.length - 1)).round
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -9,13 +9,32 @@ module Charming
|
|
|
9
9
|
# The default frame set: a 4-frame ASCII spinner.
|
|
10
10
|
DEFAULT_FRAMES = ["-", "\\", "|", "/"].freeze
|
|
11
11
|
|
|
12
|
+
# Named frame presets, mirroring the roster popularized by charm.sh's bubbles.
|
|
13
|
+
STYLES = {
|
|
14
|
+
line: DEFAULT_FRAMES,
|
|
15
|
+
dots: %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze,
|
|
16
|
+
mini_dot: %w[⠁ ⠂ ⠄ ⡀ ⢀ ⠠ ⠐ ⠈].freeze,
|
|
17
|
+
jump: %w[⢄ ⢂ ⢁ ⡁ ⡈ ⡐ ⡠].freeze,
|
|
18
|
+
pulse: %w[█ ▓ ▒ ░].freeze,
|
|
19
|
+
points: ["∙∙∙", "●∙∙", "∙●∙", "∙∙●"].freeze,
|
|
20
|
+
globe: %w[🌍 🌎 🌏].freeze,
|
|
21
|
+
moon: %w[🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘].freeze,
|
|
22
|
+
meter: %w[▱▱▱ ▰▱▱ ▰▰▱ ▰▰▰ ▰▰▱ ▰▱▱].freeze,
|
|
23
|
+
hamburger: %w[☱ ☲ ☴ ☲].freeze,
|
|
24
|
+
ellipsis: [" ", ". ", ".. ", "..."].freeze
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
12
27
|
# The current frame list, frame index, and optional label string.
|
|
13
28
|
attr_reader :frames, :index, :label
|
|
14
29
|
|
|
15
|
-
# *
|
|
16
|
-
#
|
|
17
|
-
|
|
30
|
+
# *style* picks a named preset from STYLES (default :line). *frames* overrides the
|
|
31
|
+
# preset with any array of frame strings. *index* is the starting frame index.
|
|
32
|
+
# *label* is an optional suffix shown after the frame.
|
|
33
|
+
def initialize(style: :line, frames: nil, index: 0, label: nil)
|
|
18
34
|
super()
|
|
35
|
+
frames ||= STYLES.fetch(style.to_sym) do
|
|
36
|
+
raise ArgumentError, "unknown spinner style: #{style.inspect} (available: #{STYLES.keys.join(", ")})"
|
|
37
|
+
end
|
|
19
38
|
raise ArgumentError, "frames cannot be empty" if frames.empty?
|
|
20
39
|
|
|
21
40
|
@frames = frames
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Components
|
|
5
|
+
# Stopwatch is a count-up display. Drive it from a controller timer: call
|
|
6
|
+
# `tick` per interval; elapsed time accumulates only while running.
|
|
7
|
+
class Stopwatch < Component
|
|
8
|
+
attr_reader :elapsed, :label
|
|
9
|
+
|
|
10
|
+
# *label* is an optional suffix shown after the time.
|
|
11
|
+
def initialize(label: nil, theme: nil)
|
|
12
|
+
super(theme: theme)
|
|
13
|
+
@elapsed = 0
|
|
14
|
+
@running = false
|
|
15
|
+
@label = label
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Starts accumulating time. Returns self.
|
|
19
|
+
def start
|
|
20
|
+
@running = true
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Pauses accumulation. Returns self.
|
|
25
|
+
def stop
|
|
26
|
+
@running = false
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# True while the stopwatch is accumulating time.
|
|
31
|
+
def running?
|
|
32
|
+
@running
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Adds *seconds* (default 1) when running; a no-op otherwise. Returns self.
|
|
36
|
+
def tick(seconds = 1)
|
|
37
|
+
@elapsed += seconds.to_i if running?
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Stops and zeroes the elapsed time. Returns self.
|
|
42
|
+
def reset
|
|
43
|
+
@elapsed = 0
|
|
44
|
+
@running = false
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Renders the elapsed time (with the label appended when present).
|
|
49
|
+
def render
|
|
50
|
+
clock = TimeDisplay.clock(elapsed)
|
|
51
|
+
label ? "#{clock} #{label}" : clock
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -72,12 +72,30 @@ module Charming
|
|
|
72
72
|
rows[selected_index]
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
+
# Sorts the body rows by *column* (a header label or 0-based index).
|
|
76
|
+
# Numeric-looking cells compare numerically; everything else as strings.
|
|
77
|
+
# The sorted column is marked ▲/▼ in the rendered header. Returns self.
|
|
78
|
+
def sort_by!(column, direction: :asc)
|
|
79
|
+
@sort_column = column_index(column)
|
|
80
|
+
@sort_direction = direction
|
|
81
|
+
sorted = @rows.sort_by { |row| sort_key(row) }
|
|
82
|
+
@rows = (direction == :desc) ? sorted.reverse : sorted
|
|
83
|
+
self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Sorts by *column*, flipping the direction on repeated calls for the same
|
|
87
|
+
# column (ascending first). Returns self.
|
|
88
|
+
def toggle_sort(column)
|
|
89
|
+
flipping = @sort_column == column_index(column) && @sort_direction == :asc
|
|
90
|
+
sort_by!(column, direction: flipping ? :desc : :asc)
|
|
91
|
+
end
|
|
92
|
+
|
|
75
93
|
# Renders the table to a string. Returns a placeholder when both header and rows are empty.
|
|
76
94
|
def render
|
|
77
95
|
return "(empty table)" if header.empty? && rows.empty?
|
|
78
96
|
|
|
79
97
|
normalized = rows.map { |row| normalize_row(row) }
|
|
80
|
-
lines = TTY::Table.new(header:
|
|
98
|
+
lines = TTY::Table.new(header: sort_marked_header, rows: normalized)
|
|
81
99
|
.render(:unicode)
|
|
82
100
|
.lines(chomp: true)
|
|
83
101
|
|
|
@@ -161,6 +179,29 @@ module Charming
|
|
|
161
179
|
@selected_index = rows.length - 1
|
|
162
180
|
end
|
|
163
181
|
|
|
182
|
+
# Resolves *column* (label or 0-based index) to a column index.
|
|
183
|
+
def column_index(column)
|
|
184
|
+
return column if column.is_a?(Integer) && column.between?(0, header.length - 1)
|
|
185
|
+
|
|
186
|
+
header.index(column.to_s) ||
|
|
187
|
+
raise(ArgumentError, "unknown column: #{column.inspect} (columns: #{header.join(", ")})")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# The comparable key for *row* at the active sort column: [0, number] for
|
|
191
|
+
# numeric-looking cells, [1, string] otherwise, so mixed columns still sort.
|
|
192
|
+
def sort_key(row)
|
|
193
|
+
cell = normalize_row(row)[@sort_column].to_s
|
|
194
|
+
cell.match?(/\A-?\d+(\.\d+)?\z/) ? [0, cell.to_f] : [1, cell]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# The header with the sorted column marked ▲ (ascending) or ▼ (descending).
|
|
198
|
+
def sort_marked_header
|
|
199
|
+
return header unless @sort_column
|
|
200
|
+
|
|
201
|
+
marker = (@sort_direction == :desc) ? "▼" : "▲"
|
|
202
|
+
header.each_with_index.map { |label, index| (index == @sort_column) ? "#{label} #{marker}" : label }
|
|
203
|
+
end
|
|
204
|
+
|
|
164
205
|
# Clamps *value* to the valid row range, defaulting to 0 when the table is empty.
|
|
165
206
|
def clamp_index(value)
|
|
166
207
|
return 0 if rows.empty?
|
|
@@ -259,8 +259,7 @@ module Charming
|
|
|
259
259
|
def render_line(line)
|
|
260
260
|
return line unless width
|
|
261
261
|
|
|
262
|
-
|
|
263
|
-
clipped + (" " * [width - UI::Width.measure(clipped), 0].max)
|
|
262
|
+
UI::Width.pad_to(UI.visible_slice(line, 0, width), width)
|
|
264
263
|
end
|
|
265
264
|
|
|
266
265
|
# Adjusts the top-visible offset so the cursor row is in view. Scrolling is performed
|
|
@@ -50,12 +50,17 @@ module Charming
|
|
|
50
50
|
true
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
# Handles key events. Inserts printable characters,
|
|
54
|
-
# (
|
|
55
|
-
#
|
|
53
|
+
# Handles key events. Inserts printable characters, submits on Enter
|
|
54
|
+
# (returning `[:submitted, value]` so a focused slot dispatches
|
|
55
|
+
# `<slot>_submitted(value)`), recalls history on up/down (when enabled),
|
|
56
|
+
# otherwise dispatches via KEY_ACTIONS.
|
|
57
|
+
# Returns :handled or `[:submitted, value]` when the event was consumed, nil otherwise.
|
|
56
58
|
def handle_key(event)
|
|
57
59
|
return :handled if character_event?(event) && insert(event.char)
|
|
58
|
-
|
|
60
|
+
|
|
61
|
+
key = Charming.key_of(event)
|
|
62
|
+
return [:submitted, value] if key == :enter
|
|
63
|
+
return :handled if history_event(key)
|
|
59
64
|
|
|
60
65
|
super
|
|
61
66
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Components
|
|
5
|
+
# TimeDisplay formats whole-second durations as clock strings: "mm:ss", or
|
|
6
|
+
# "h:mm:ss" once an hour is reached. Shared by Timer and Stopwatch.
|
|
7
|
+
module TimeDisplay
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def clock(total_seconds)
|
|
11
|
+
seconds = [total_seconds.to_i, 0].max
|
|
12
|
+
hours, remainder = seconds.divmod(3600)
|
|
13
|
+
minutes, secs = remainder.divmod(60)
|
|
14
|
+
return format("%d:%02d:%02d", hours, minutes, secs) if hours.positive?
|
|
15
|
+
|
|
16
|
+
format("%02d:%02d", minutes, secs)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Components
|
|
5
|
+
# Timer is a countdown display. Drive it from a controller timer: call
|
|
6
|
+
# `tick` per interval and check `expired?` to act when time runs out.
|
|
7
|
+
class Timer < Component
|
|
8
|
+
attr_reader :duration, :remaining, :label
|
|
9
|
+
|
|
10
|
+
# *duration* is the countdown length in seconds. *label* is an optional
|
|
11
|
+
# suffix shown after the time.
|
|
12
|
+
def initialize(duration:, label: nil, theme: nil)
|
|
13
|
+
super(theme: theme)
|
|
14
|
+
@duration = [duration.to_i, 0].max
|
|
15
|
+
@remaining = @duration
|
|
16
|
+
@label = label
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Counts down by *seconds* (default 1), clamping at zero. Returns self.
|
|
20
|
+
def tick(seconds = 1)
|
|
21
|
+
@remaining = [@remaining - seconds.to_i, 0].max
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# True once the countdown has reached zero.
|
|
26
|
+
def expired?
|
|
27
|
+
remaining.zero?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Restores the full duration. Returns self.
|
|
31
|
+
def reset
|
|
32
|
+
@remaining = duration
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Renders the remaining time (with the label appended when present).
|
|
37
|
+
def render
|
|
38
|
+
clock = TimeDisplay.clock(remaining)
|
|
39
|
+
label ? "#{clock} #{label}" : clock
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -185,6 +185,7 @@ module Charming
|
|
|
185
185
|
|
|
186
186
|
def self.builtin(name)
|
|
187
187
|
key = name.to_s.tr("-", "_").to_sym
|
|
188
|
+
key = UI::Background.dark? ? :dark : :light if key == :auto
|
|
188
189
|
raise ArgumentError, "unknown markdown style: #{name.inspect}" unless BUILT_INS.key?(key)
|
|
189
190
|
|
|
190
191
|
from_hash(BUILT_INS.fetch(key))
|
|
@@ -30,8 +30,7 @@ module Charming
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def table_cell(row, width, index)
|
|
33
|
-
|
|
34
|
-
" #{value}#{" " * [width - UI::Width.measure(value), 0].max} "
|
|
33
|
+
" #{UI::Width.pad_to(row[index].to_s, width)} "
|
|
35
34
|
end
|
|
36
35
|
|
|
37
36
|
def table_separator
|
|
@@ -40,7 +39,7 @@ module Charming
|
|
|
40
39
|
|
|
41
40
|
def widths
|
|
42
41
|
@widths ||= Array.new(column_count) do |index|
|
|
43
|
-
rows.map { |row|
|
|
42
|
+
UI::Width.widest(rows.map { |row| row[index].to_s })
|
|
44
43
|
end
|
|
45
44
|
end
|
|
46
45
|
|
|
@@ -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
|