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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/charming/application.rb +48 -0
  4. data/lib/charming/controller/key_dispatch.rb +113 -0
  5. data/lib/charming/controller/session_state.rb +11 -0
  6. data/lib/charming/controller/terminal.rb +33 -0
  7. data/lib/charming/controller.rb +12 -39
  8. data/lib/charming/escape.rb +81 -0
  9. data/lib/charming/events/mouse_event.rb +22 -9
  10. data/lib/charming/generators/app_generator.rb +3 -2
  11. data/lib/charming/generators/templates/app/Gemfile.template +6 -0
  12. data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
  13. data/lib/charming/image/protocol/kitty.rb +126 -0
  14. data/lib/charming/image/protocol.rb +18 -0
  15. data/lib/charming/image/source.rb +85 -0
  16. data/lib/charming/image/terminal.rb +52 -0
  17. data/lib/charming/image/transmit.rb +11 -0
  18. data/lib/charming/image.rb +21 -0
  19. data/lib/charming/internal/event_loop.rb +155 -0
  20. data/lib/charming/internal/terminal/adapter.rb +14 -0
  21. data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
  22. data/lib/charming/internal/terminal/memory_backend.rb +21 -3
  23. data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
  24. data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
  25. data/lib/charming/internal/terminal/tty_backend.rb +79 -2
  26. data/lib/charming/presentation/components/activity_indicator.rb +2 -19
  27. data/lib/charming/presentation/components/chart.rb +80 -0
  28. data/lib/charming/presentation/components/error_screen.rb +1 -1
  29. data/lib/charming/presentation/components/filepicker.rb +101 -0
  30. data/lib/charming/presentation/components/form/builder.rb +5 -0
  31. data/lib/charming/presentation/components/form/multiselect.rb +105 -0
  32. data/lib/charming/presentation/components/image.rb +38 -0
  33. data/lib/charming/presentation/components/list.rb +22 -4
  34. data/lib/charming/presentation/components/paginator.rb +54 -0
  35. data/lib/charming/presentation/components/progressbar.rb +26 -4
  36. data/lib/charming/presentation/components/sparkline.rb +38 -0
  37. data/lib/charming/presentation/components/spinner.rb +22 -3
  38. data/lib/charming/presentation/components/stopwatch.rb +55 -0
  39. data/lib/charming/presentation/components/table.rb +42 -1
  40. data/lib/charming/presentation/components/text_area.rb +1 -2
  41. data/lib/charming/presentation/components/text_input.rb +9 -4
  42. data/lib/charming/presentation/components/time_display.rb +20 -0
  43. data/lib/charming/presentation/components/timer.rb +43 -0
  44. data/lib/charming/presentation/components/viewport/content_lines.rb +1 -1
  45. data/lib/charming/presentation/components/viewport/line_window.rb +1 -1
  46. data/lib/charming/presentation/markdown/renderer.rb +1 -1
  47. data/lib/charming/presentation/markdown/style_config.rb +1 -0
  48. data/lib/charming/presentation/markdown/table_renderer.rb +2 -3
  49. data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
  50. data/lib/charming/presentation/ui/ansi_codes.rb +5 -2
  51. data/lib/charming/presentation/ui/background.rb +58 -0
  52. data/lib/charming/presentation/ui/border.rb +14 -1
  53. data/lib/charming/presentation/ui/border_painter.rb +23 -11
  54. data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
  55. data/lib/charming/presentation/ui/canvas.rb +1 -0
  56. data/lib/charming/presentation/ui/gradient.rb +47 -0
  57. data/lib/charming/presentation/ui/style.rb +119 -19
  58. data/lib/charming/presentation/{markdown → ui}/text_wrapper.rb +4 -4
  59. data/lib/charming/presentation/ui/truncate.rb +29 -0
  60. data/lib/charming/presentation/ui/width.rb +11 -0
  61. data/lib/charming/presentation/ui.rb +52 -11
  62. data/lib/charming/presentation/view.rb +8 -6
  63. data/lib/charming/response.rb +11 -6
  64. data/lib/charming/runtime.rb +154 -88
  65. data/lib/charming/version.rb +1 -1
  66. 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
- # *frames* defaults to DEFAULT_FRAMES but may be replaced with any array of frame strings.
16
- # *index* is the starting frame index. *label* is an optional suffix shown after the frame.
17
- def initialize(frames: DEFAULT_FRAMES, index: 0, label: nil)
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: header, rows: normalized)
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
- clipped = UI.visible_slice(line, 0, width)
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, recalls history on up/down
54
- # (when enabled), otherwise dispatches via KEY_ACTIONS.
55
- # Returns :handled when the event was consumed, nil otherwise.
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
- return :handled if history_event(Charming.key_of(event))
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
@@ -18,7 +18,7 @@ module Charming
18
18
  end
19
19
 
20
20
  def display_width
21
- lines.map { |line| UI::Width.measure(line) }.max || 0
21
+ UI::Width.widest(lines)
22
22
  end
23
23
 
24
24
  private
@@ -59,7 +59,7 @@ module Charming
59
59
  end
60
60
 
61
61
  def pad_line(line, target_width)
62
- line + (" " * [target_width - UI::Width.measure(line), 0].max)
62
+ UI::Width.pad_to(line, target_width)
63
63
  end
64
64
 
65
65
  def ansi?(token)
@@ -45,7 +45,7 @@ module Charming
45
45
  end
46
46
 
47
47
  def wrap(value, width:)
48
- TextWrapper.new(width: width).wrap(value)
48
+ UI::TextWrapper.new(width: width).wrap(value)
49
49
  end
50
50
 
51
51
  def style_for(name, fallback:)
@@ -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
- value = row[index].to_s
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| UI::Width.measure(row[index].to_s) }.max || 0
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
- raise ArgumentError, "truecolor must be #rrggbb" unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
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}#{line}#{" " * (width - Width.measure(line))}#{right}"
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
- return value unless @foreground
56
+ def render_border(value, side)
57
+ foreground = side_foreground(side)
58
+ return value unless foreground || @background_explicit
51
59
 
52
- Style.new(foreground: @foreground, background: @background).render(value)
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
@@ -44,6 +44,7 @@ module Charming
44
44
 
45
45
  def self.offset(value, available, size)
46
46
  return [(available - size) / 2, 0].max if value == :center
47
+ return [((available - size) * value).round, 0].max if value.is_a?(Float)
47
48
 
48
49
  value
49
50
  end