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.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +144 -9
  4. data/lib/charming/audio/player.rb +104 -0
  5. data/lib/charming/audio/system.rb +69 -0
  6. data/lib/charming/cli.rb +63 -7
  7. data/lib/charming/controller/action_hooks.rb +124 -0
  8. data/lib/charming/controller/class_methods.rb +15 -1
  9. data/lib/charming/controller/dispatching.rb +31 -5
  10. data/lib/charming/controller/focus.rb +9 -0
  11. data/lib/charming/controller/focus_management.rb +0 -7
  12. data/lib/charming/controller/key_dispatch.rb +113 -0
  13. data/lib/charming/controller/session_state.rb +27 -1
  14. data/lib/charming/controller/sidebar_navigation.rb +63 -28
  15. data/lib/charming/controller/terminal.rb +33 -0
  16. data/lib/charming/controller.rb +43 -18
  17. data/lib/charming/database/commands.rb +123 -11
  18. data/lib/charming/escape.rb +81 -0
  19. data/lib/charming/events/focus_event.rb +12 -0
  20. data/lib/charming/events/mouse_event.rb +22 -9
  21. data/lib/charming/events/paste_event.rb +11 -0
  22. data/lib/charming/events/task_progress_event.rb +21 -0
  23. data/lib/charming/generators/app_generator.rb +41 -3
  24. data/lib/charming/generators/database_installer.rb +4 -15
  25. data/lib/charming/generators/migration_generator.rb +116 -0
  26. data/lib/charming/generators/migration_timestamp.rb +29 -0
  27. data/lib/charming/generators/model_generator.rb +4 -2
  28. data/lib/charming/generators/templates/app/Gemfile.template +6 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +1 -1
  30. data/lib/charming/generators/templates/app/database_config.template +3 -1
  31. data/lib/charming/generators/templates/app/dot_rspec.template +2 -0
  32. data/lib/charming/generators/templates/app/layout.template +1 -1
  33. data/lib/charming/generators/templates/app/spec_helper.template +2 -1
  34. data/lib/charming/generators/templates/app/view.template +1 -1
  35. data/lib/charming/image/protocol/kitty.rb +126 -0
  36. data/lib/charming/image/protocol.rb +18 -0
  37. data/lib/charming/image/source.rb +85 -0
  38. data/lib/charming/image/terminal.rb +52 -0
  39. data/lib/charming/image/transmit.rb +11 -0
  40. data/lib/charming/image.rb +21 -0
  41. data/lib/charming/internal/event_loop.rb +155 -0
  42. data/lib/charming/internal/terminal/adapter.rb +14 -0
  43. data/lib/charming/internal/terminal/key_normalizer.rb +20 -0
  44. data/lib/charming/internal/terminal/memory_backend.rb +27 -3
  45. data/lib/charming/internal/terminal/modified_key_parser.rb +63 -0
  46. data/lib/charming/internal/terminal/mouse_parser.rb +32 -27
  47. data/lib/charming/internal/terminal/tty_backend.rb +143 -4
  48. data/lib/charming/presentation/component.rb +7 -0
  49. data/lib/charming/presentation/components/activity_indicator.rb +2 -19
  50. data/lib/charming/presentation/components/audio.rb +31 -0
  51. data/lib/charming/presentation/components/autocomplete.rb +108 -0
  52. data/lib/charming/presentation/components/badge.rb +31 -0
  53. data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
  54. data/lib/charming/presentation/components/chart.rb +80 -0
  55. data/lib/charming/presentation/components/command_palette.rb +8 -5
  56. data/lib/charming/presentation/components/error_screen.rb +72 -0
  57. data/lib/charming/presentation/components/filepicker.rb +101 -0
  58. data/lib/charming/presentation/components/form/builder.rb +5 -0
  59. data/lib/charming/presentation/components/form/multiselect.rb +105 -0
  60. data/lib/charming/presentation/components/form.rb +9 -0
  61. data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
  62. data/lib/charming/presentation/components/help_overlay.rb +65 -0
  63. data/lib/charming/presentation/components/image.rb +38 -0
  64. data/lib/charming/presentation/components/list.rb +22 -4
  65. data/lib/charming/presentation/components/markdown.rb +6 -2
  66. data/lib/charming/presentation/components/modal.rb +45 -5
  67. data/lib/charming/presentation/components/multi_select_list.rb +85 -0
  68. data/lib/charming/presentation/components/paginator.rb +54 -0
  69. data/lib/charming/presentation/components/progressbar.rb +26 -5
  70. data/lib/charming/presentation/components/sparkline.rb +38 -0
  71. data/lib/charming/presentation/components/spinner.rb +22 -3
  72. data/lib/charming/presentation/components/status_bar.rb +75 -0
  73. data/lib/charming/presentation/components/stopwatch.rb +55 -0
  74. data/lib/charming/presentation/components/tab_bar.rb +103 -0
  75. data/lib/charming/presentation/components/table.rb +82 -10
  76. data/lib/charming/presentation/components/text_area.rb +48 -12
  77. data/lib/charming/presentation/components/text_input.rb +85 -5
  78. data/lib/charming/presentation/components/time_display.rb +20 -0
  79. data/lib/charming/presentation/components/timer.rb +43 -0
  80. data/lib/charming/presentation/components/toast.rb +51 -0
  81. data/lib/charming/presentation/components/tree.rb +176 -0
  82. data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
  83. data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
  84. data/lib/charming/presentation/components/viewport/position.rb +67 -0
  85. data/lib/charming/presentation/components/viewport.rb +37 -122
  86. data/lib/charming/presentation/layout/builder.rb +4 -1
  87. data/lib/charming/presentation/layout/overlay.rb +6 -4
  88. data/lib/charming/presentation/layout/pane.rb +2 -1
  89. data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
  90. data/lib/charming/presentation/layout/screen_layout.rb +12 -3
  91. data/lib/charming/presentation/layout/split.rb +37 -3
  92. data/lib/charming/presentation/markdown/renderer.rb +99 -63
  93. data/lib/charming/presentation/markdown/style_config.rb +11 -5
  94. data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
  95. data/lib/charming/presentation/markdown/table_renderer.rb +59 -0
  96. data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
  97. data/lib/charming/presentation/templates/erb_handler.rb +35 -2
  98. data/lib/charming/presentation/ui/adaptive_color.rb +20 -0
  99. data/lib/charming/presentation/ui/ansi_codes.rb +15 -1
  100. data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
  101. data/lib/charming/presentation/ui/background.rb +58 -0
  102. data/lib/charming/presentation/ui/border.rb +14 -1
  103. data/lib/charming/presentation/ui/border_painter.rb +23 -11
  104. data/lib/charming/presentation/ui/braille_canvas.rb +80 -0
  105. data/lib/charming/presentation/ui/canvas.rb +1 -0
  106. data/lib/charming/presentation/ui/color_support.rb +129 -0
  107. data/lib/charming/presentation/ui/gradient.rb +47 -0
  108. data/lib/charming/presentation/ui/style.rb +119 -19
  109. data/lib/charming/presentation/ui/text_wrapper.rb +40 -0
  110. data/lib/charming/presentation/ui/theme.rb +7 -0
  111. data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
  112. data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
  113. data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
  114. data/lib/charming/presentation/ui/themes/nord.json +32 -0
  115. data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
  116. data/lib/charming/presentation/ui/truncate.rb +29 -0
  117. data/lib/charming/presentation/ui/width.rb +38 -2
  118. data/lib/charming/presentation/ui.rb +52 -11
  119. data/lib/charming/presentation/view.rb +8 -6
  120. data/lib/charming/response.rb +11 -6
  121. data/lib/charming/router.rb +1 -1
  122. data/lib/charming/runtime.rb +236 -63
  123. data/lib/charming/tasks/cancelled.rb +11 -0
  124. data/lib/charming/tasks/inline_executor.rb +10 -4
  125. data/lib/charming/tasks/progress.rb +30 -0
  126. data/lib/charming/tasks/task.rb +24 -4
  127. data/lib/charming/tasks/threaded_executor.rb +35 -11
  128. data/lib/charming/test_helper.rb +120 -0
  129. data/lib/charming/version.rb +1 -1
  130. data/lib/charming.rb +43 -1
  131. 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
- 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/)
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
- index = 0
31
- while index < line.length
32
- match = line.match(Width::ANSI_PATTERN, index)
33
- if match&.begin(0) == index
34
- yield match[0], true
35
- index = match.end(0)
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
- return unless char_end > start_column && char_start < end_column
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
- state[:output] << char
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}#{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
@@ -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