chamomile-flourish 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5d26d2a333d6f0a962ffb7bf18af8b80424fdeac18262392e534fe4a2d65336f
4
+ data.tar.gz: 617a4eca7cbf2034c8921a293d44707295553400a368e306bfad3de228394635
5
+ SHA512:
6
+ metadata.gz: 83b89f48310d72d22c5a22681f3c261af4213ca1fc6709bf6abc6ec6c03e3a64cc9cb0f8e7311002634e14968d2b72bc9133fd7fb390f7b73df10cfcfcd8ade7
7
+ data.tar.gz: f42c0446e12058d4565ff09efef8094ea45b8c15882bf1e90d610ae5a260c084814e725c3e3bbdc98c0bf284de8d2abf0417f004f9beb9b8abd81ef607093974
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flourish
4
+ module Align
5
+ class << self
6
+ def horizontal(lines, width, position)
7
+ lines.map do |line|
8
+ line_width = ANSI.printable_width(line)
9
+ gap = width - line_width
10
+ next line if gap <= 0
11
+
12
+ left_pad = (gap * position).round
13
+ right_pad = gap - left_pad
14
+ "#{" " * left_pad}#{line}#{" " * right_pad}"
15
+ end
16
+ end
17
+
18
+ def vertical(lines, height, position)
19
+ gap = height - lines.length
20
+ return lines if gap <= 0
21
+
22
+ top_pad = (gap * position).round
23
+ bottom_pad = gap - top_pad
24
+
25
+ result = []
26
+ top_pad.times { result << "" }
27
+ result.concat(lines)
28
+ bottom_pad.times { result << "" }
29
+ result
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flourish
4
+ module ANSI
5
+ # Matches CSI sequences, OSC sequences, ESC charset/other sequences
6
+ ESCAPE_RE = /\e\[[0-9;]*[A-Za-z]|\e\][^\a\e]*(?:\a|\e\\)|\e[()][AB012]|\e./
7
+
8
+ class << self
9
+ def strip(str)
10
+ str.gsub(ESCAPE_RE, "")
11
+ end
12
+
13
+ def printable_width(str)
14
+ stripped = strip(str)
15
+ width = 0
16
+ stripped.each_char do |ch|
17
+ width += char_width(ch)
18
+ end
19
+ width
20
+ end
21
+
22
+ def height(str)
23
+ str.count("\n") + 1
24
+ end
25
+
26
+ def size(str)
27
+ return [0, 1] if str.empty?
28
+
29
+ lines = str.split("\n", -1)
30
+ w = lines.map { |line| printable_width(line) }.max || 0
31
+ [w, lines.length]
32
+ end
33
+
34
+ def extract_escape(chars, start)
35
+ return nil unless chars[start] == "\e"
36
+
37
+ i = start + 1
38
+ return nil if i >= chars.length
39
+
40
+ if chars[i] == "["
41
+ seq = +"\e["
42
+ i += 1
43
+ while i < chars.length && chars[i].match?(/[0-9;]/)
44
+ seq << chars[i]
45
+ i += 1
46
+ end
47
+ if i < chars.length && chars[i].match?(/[A-Za-z]/)
48
+ seq << chars[i]
49
+ return seq
50
+ end
51
+ end
52
+
53
+ nil
54
+ end
55
+
56
+ def track_sgr(active_sgr, seq)
57
+ if ["\e[0m", "\e[m"].include?(seq)
58
+ active_sgr.clear
59
+ elsif seq.match?(/\A\e\[\d/)
60
+ if active_sgr.empty?
61
+ active_sgr.replace(seq)
62
+ else
63
+ active_sgr.replace("#{active_sgr.delete_suffix("\e[0m")}#{seq}")
64
+ end
65
+ end
66
+ end
67
+
68
+ def sgr_open_after?(was_open, seq)
69
+ return false if ["\e[0m", "\e[m"].include?(seq)
70
+ return true if seq.match?(/\A\e\[\d/)
71
+
72
+ was_open
73
+ end
74
+
75
+ private
76
+
77
+ def char_width(ch)
78
+ cp = ch.ord
79
+ return 2 if cjk?(cp)
80
+ return 0 if cp < 32 || (cp >= 0x7F && cp < 0xA0)
81
+
82
+ 1
83
+ end
84
+
85
+ def cjk?(cp)
86
+ cp.between?(0x1100, 0x115F) || # Hangul Jamo
87
+ cp == 0x2329 || cp == 0x232A || # Angle brackets
88
+ cp.between?(0x2E80, 0x303E) || # CJK Radicals..CJK Symbols
89
+ cp.between?(0x3040, 0x33BF) || # Hiragana..CJK Compatibility
90
+ cp.between?(0x3400, 0x4DBF) || # CJK Unified Ext A
91
+ cp.between?(0x4E00, 0xA4CF) || # CJK Unified..Yi Radicals
92
+ cp.between?(0xAC00, 0xD7A3) || # Hangul Syllables
93
+ cp.between?(0xF900, 0xFAFF) || # CJK Compatibility Ideographs
94
+ cp.between?(0xFE10, 0xFE6F) || # Vertical forms..Small forms
95
+ cp.between?(0xFF01, 0xFF60) || # Fullwidth forms
96
+ cp.between?(0xFFE0, 0xFFE6) || # Fullwidth signs
97
+ cp.between?(0x1F300, 0x1F9FF) || # Misc Symbols/Emoji
98
+ cp.between?(0x20000, 0x2FFFD) || # CJK Ext B..
99
+ cp.between?(0x30000, 0x3FFFD) # CJK Ext G..
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flourish
4
+ BorderDef = Data.define(
5
+ :top, :bottom, :left, :right,
6
+ :top_left, :top_right, :bottom_left, :bottom_right,
7
+ :middle_left, :middle_right, :middle, :middle_top, :middle_bottom
8
+ )
9
+
10
+ module Border
11
+ NORMAL = BorderDef.new(
12
+ top: "─", bottom: "─", left: "│", right: "│",
13
+ top_left: "┌", top_right: "┐", bottom_left: "└", bottom_right: "┘",
14
+ middle_left: "├", middle_right: "┤", middle: "┼", middle_top: "┬", middle_bottom: "┴"
15
+ ).freeze
16
+
17
+ ROUNDED = BorderDef.new(
18
+ top: "─", bottom: "─", left: "│", right: "│",
19
+ top_left: "╭", top_right: "╮", bottom_left: "╰", bottom_right: "╯",
20
+ middle_left: "├", middle_right: "┤", middle: "┼", middle_top: "┬", middle_bottom: "┴"
21
+ ).freeze
22
+
23
+ THICK = BorderDef.new(
24
+ top: "━", bottom: "━", left: "┃", right: "┃",
25
+ top_left: "┏", top_right: "┓", bottom_left: "┗", bottom_right: "┛",
26
+ middle_left: "┣", middle_right: "┫", middle: "╋", middle_top: "┳", middle_bottom: "┻"
27
+ ).freeze
28
+
29
+ DOUBLE = BorderDef.new(
30
+ top: "═", bottom: "═", left: "║", right: "║",
31
+ top_left: "╔", top_right: "╗", bottom_left: "╚", bottom_right: "╝",
32
+ middle_left: "╠", middle_right: "╣", middle: "╬", middle_top: "╦", middle_bottom: "╩"
33
+ ).freeze
34
+
35
+ BLOCK = BorderDef.new(
36
+ top: "█", bottom: "█", left: "█", right: "█",
37
+ top_left: "█", top_right: "█", bottom_left: "█", bottom_right: "█",
38
+ middle_left: "█", middle_right: "█", middle: "█", middle_top: "█", middle_bottom: "█"
39
+ ).freeze
40
+
41
+ OUTER_HALF_BLOCK = BorderDef.new(
42
+ top: "▀", bottom: "▄", left: "▌", right: "▐",
43
+ top_left: "▛", top_right: "▜", bottom_left: "▙", bottom_right: "▟",
44
+ middle_left: "▌", middle_right: "▐", middle: "┼", middle_top: "▀", middle_bottom: "▄"
45
+ ).freeze
46
+
47
+ INNER_HALF_BLOCK = BorderDef.new(
48
+ top: "▄", bottom: "▀", left: "▐", right: "▌",
49
+ top_left: "▗", top_right: "▖", bottom_left: "▝", bottom_right: "▘",
50
+ middle_left: "▐", middle_right: "▌", middle: "┼", middle_top: "▄", middle_bottom: "▀"
51
+ ).freeze
52
+
53
+ HIDDEN = BorderDef.new(
54
+ top: " ", bottom: " ", left: " ", right: " ",
55
+ top_left: " ", top_right: " ", bottom_left: " ", bottom_right: " ",
56
+ middle_left: " ", middle_right: " ", middle: " ", middle_top: " ", middle_bottom: " "
57
+ ).freeze
58
+
59
+ ASCII = BorderDef.new(
60
+ top: "-", bottom: "-", left: "|", right: "|",
61
+ top_left: "+", top_right: "+", bottom_left: "+", bottom_right: "+",
62
+ middle_left: "+", middle_right: "+", middle: "+", middle_top: "+", middle_bottom: "+"
63
+ ).freeze
64
+
65
+ MARKDOWN = ASCII
66
+ end
67
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flourish
4
+ module Color
5
+ def self.parse(value)
6
+ return NoColor.new if value.nil? || value == ""
7
+
8
+ return parse_hex(value) if value.is_a?(String) && value.start_with?("#")
9
+
10
+ code = value.to_i
11
+ return ANSIColor.new(code: code) if code.between?(0, 15)
12
+ return ANSI256Color.new(code: code) if code.between?(16, 255)
13
+
14
+ NoColor.new
15
+ end
16
+
17
+ def self.parse_hex(hex)
18
+ hex = hex.delete_prefix("#")
19
+ case hex.length
20
+ when 3
21
+ r = (hex[0] * 2).to_i(16)
22
+ g = (hex[1] * 2).to_i(16)
23
+ b = (hex[2] * 2).to_i(16)
24
+ TrueColor.new(r: r, g: g, b: b)
25
+ when 6
26
+ r = hex[0..1].to_i(16)
27
+ g = hex[2..3].to_i(16)
28
+ b = hex[4..5].to_i(16)
29
+ TrueColor.new(r: r, g: g, b: b)
30
+ else
31
+ NoColor.new
32
+ end
33
+ end
34
+
35
+ private_class_method :parse_hex
36
+
37
+ NoColor = Data.define do
38
+ def fg_sequence = nil
39
+ def bg_sequence = nil
40
+ def no_color? = true
41
+ end
42
+
43
+ ANSIColor = Data.define(:code) do
44
+ def fg_sequence
45
+ if code < 8
46
+ (30 + code).to_s
47
+ else
48
+ (90 + code - 8).to_s
49
+ end
50
+ end
51
+
52
+ def bg_sequence
53
+ if code < 8
54
+ (40 + code).to_s
55
+ else
56
+ (100 + code - 8).to_s
57
+ end
58
+ end
59
+
60
+ def no_color? = false
61
+ end
62
+
63
+ ANSI256Color = Data.define(:code) do
64
+ def fg_sequence
65
+ "38;5;#{code}"
66
+ end
67
+
68
+ def bg_sequence
69
+ "48;5;#{code}"
70
+ end
71
+
72
+ def no_color? = false
73
+ end
74
+
75
+ TrueColor = Data.define(:r, :g, :b) do
76
+ def fg_sequence
77
+ "38;2;#{r};#{g};#{b}"
78
+ end
79
+
80
+ def bg_sequence
81
+ "48;2;#{r};#{g};#{b}"
82
+ end
83
+
84
+ def no_color? = false
85
+ end
86
+
87
+ # Named ANSI color constants (0-15)
88
+ BLACK = ANSIColor.new(code: 0)
89
+ RED = ANSIColor.new(code: 1)
90
+ GREEN = ANSIColor.new(code: 2)
91
+ YELLOW = ANSIColor.new(code: 3)
92
+ BLUE = ANSIColor.new(code: 4)
93
+ MAGENTA = ANSIColor.new(code: 5)
94
+ CYAN = ANSIColor.new(code: 6)
95
+ WHITE = ANSIColor.new(code: 7)
96
+ BRIGHT_BLACK = ANSIColor.new(code: 8)
97
+ BRIGHT_RED = ANSIColor.new(code: 9)
98
+ BRIGHT_GREEN = ANSIColor.new(code: 10)
99
+ BRIGHT_YELLOW = ANSIColor.new(code: 11)
100
+ BRIGHT_BLUE = ANSIColor.new(code: 12)
101
+ BRIGHT_MAGENTA = ANSIColor.new(code: 13)
102
+ BRIGHT_CYAN = ANSIColor.new(code: 14)
103
+ BRIGHT_WHITE = ANSIColor.new(code: 15)
104
+ end
105
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flourish
4
+ module ColorProfile
5
+ TRUE_COLOR = :true_color
6
+ ANSI256 = :ansi256
7
+ ANSI = :ansi
8
+ NO_COLOR = :no_color
9
+
10
+ # ANSI256 to ANSI16 lookup table
11
+ ANSI256_TO_ANSI = [
12
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, # 0-15: identity
13
+ 0, 4, 4, 4, 12, 12, # 16-21
14
+ 2, 6, 4, 4, 12, 12, # 22-27
15
+ 2, 2, 6, 4, 12, 12, # 28-33
16
+ 2, 2, 2, 6, 12, 12, # 34-39
17
+ 10, 10, 10, 10, 14, 12, # 40-45
18
+ 10, 10, 10, 10, 10, 14, # 46-51
19
+ 1, 5, 4, 4, 12, 12, # 52-57
20
+ 3, 8, 4, 4, 12, 12, # 58-63
21
+ 2, 2, 6, 4, 12, 12, # 64-69
22
+ 2, 2, 2, 6, 12, 12, # 70-75
23
+ 10, 10, 10, 10, 14, 12, # 76-81
24
+ 10, 10, 10, 10, 10, 14, # 82-87
25
+ 1, 5, 4, 4, 12, 12, # 88-93
26
+ 3, 3, 8, 4, 12, 12, # 94-99
27
+ 2, 2, 2, 6, 12, 12, # 100-105
28
+ 2, 2, 2, 2, 6, 12, # 106-111
29
+ 10, 10, 10, 10, 14, 12, # 112-117
30
+ 10, 10, 10, 10, 10, 14, # 118-123
31
+ 1, 5, 5, 4, 12, 12, # 124-129
32
+ 3, 3, 8, 4, 12, 12, # 130-135
33
+ 3, 3, 3, 8, 12, 12, # 136-141
34
+ 2, 2, 2, 2, 6, 12, # 142-147
35
+ 10, 10, 10, 10, 14, 12, # 148-153
36
+ 10, 10, 10, 10, 10, 14, # 154-159
37
+ 9, 5, 5, 5, 13, 12, # 160-165
38
+ 3, 3, 8, 8, 12, 12, # 166-171
39
+ 3, 3, 3, 8, 12, 12, # 172-177
40
+ 3, 3, 3, 3, 8, 12, # 178-183
41
+ 11, 11, 10, 10, 14, 12, # 184-189
42
+ 10, 10, 10, 10, 10, 14, # 190-195
43
+ 9, 9, 5, 5, 13, 12, # 196-201
44
+ 9, 9, 9, 13, 13, 12, # 202-207
45
+ 3, 3, 3, 8, 8, 12, # 208-213
46
+ 3, 3, 3, 3, 8, 14, # 214-219
47
+ 11, 11, 11, 11, 7, 12, # 220-225
48
+ 11, 11, 11, 11, 11, 15, # 226-231
49
+ 0, 0, 0, 0, 0, 0, # 232-237 (grayscale dark)
50
+ 8, 8, 8, 8, 8, 8, # 238-243
51
+ 7, 7, 7, 7, 7, 7, # 244-249
52
+ 15, 15, 15, 15, 15, 15, # 250-255 (grayscale light)
53
+ ].freeze
54
+
55
+ class << self
56
+ def detect
57
+ return NO_COLOR if ENV.key?("NO_COLOR")
58
+
59
+ colorterm = ENV.fetch("COLORTERM", "")
60
+ return TRUE_COLOR if %w[truecolor 24bit].include?(colorterm)
61
+
62
+ term = ENV.fetch("TERM", "")
63
+ return ANSI256 if term.include?("256color")
64
+ return ANSI if term.include?("color") || term.include?("ansi")
65
+
66
+ ANSI
67
+ end
68
+
69
+ def downsample(color, target_profile)
70
+ return Color::NoColor.new if target_profile == NO_COLOR
71
+
72
+ case color
73
+ when Color::TrueColor
74
+ case target_profile
75
+ when TRUE_COLOR then color
76
+ when ANSI256 then truecolor_to_256(color)
77
+ when ANSI then ansi256_to_ansi(truecolor_to_256(color))
78
+ end
79
+ when Color::ANSI256Color
80
+ case target_profile
81
+ when TRUE_COLOR, ANSI256 then color
82
+ when ANSI then ansi256_to_ansi(color)
83
+ end
84
+ else # ANSIColor, NoColor, etc.
85
+ color
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def truecolor_to_256(color)
92
+ # Check grayscale ramp first (232-255)
93
+ if color.r == color.g && color.g == color.b
94
+ return Color::ANSI256Color.new(code: 16) if color.r < 8
95
+ return Color::ANSI256Color.new(code: 231) if color.r > 248
96
+
97
+ gray_idx = ((color.r.to_f - 8) / 247 * 24).round
98
+ return Color::ANSI256Color.new(code: 232 + gray_idx)
99
+ end
100
+
101
+ # Map to 6x6x6 color cube (indices 16-231)
102
+ r_idx = (color.r.to_f / 255 * 5).round
103
+ g_idx = (color.g.to_f / 255 * 5).round
104
+ b_idx = (color.b.to_f / 255 * 5).round
105
+
106
+ cube_idx = 16 + (36 * r_idx) + (6 * g_idx) + b_idx
107
+
108
+ # Compare cube color distance vs nearest grayscale
109
+ cube_r = r_idx.positive? ? 55 + (r_idx * 40) : 0
110
+ cube_g = g_idx.positive? ? 55 + (g_idx * 40) : 0
111
+ cube_b = b_idx.positive? ? 55 + (b_idx * 40) : 0
112
+ cube_dist = color_distance(color.r, color.g, color.b, cube_r, cube_g, cube_b)
113
+
114
+ gray_avg = (color.r + color.g + color.b) / 3
115
+ gray_idx = ((gray_avg.to_f - 8) / 247 * 24).round.clamp(0, 23)
116
+ gray_val = 8 + (10 * gray_idx)
117
+ gray_dist = color_distance(color.r, color.g, color.b, gray_val, gray_val, gray_val)
118
+
119
+ if gray_dist < cube_dist
120
+ Color::ANSI256Color.new(code: 232 + gray_idx)
121
+ else
122
+ Color::ANSI256Color.new(code: cube_idx)
123
+ end
124
+ end
125
+
126
+ def ansi256_to_ansi(color)
127
+ idx = color.code.clamp(0, 255)
128
+ Color::ANSIColor.new(code: ANSI256_TO_ANSI[idx])
129
+ end
130
+
131
+ def color_distance(r1, g1, b1, r2, g2, b2)
132
+ ((r1 - r2)**2) + ((g1 - g2)**2) + ((b1 - b2)**2)
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flourish
4
+ module Join
5
+ class << self
6
+ def horizontal(position, *strs)
7
+ strs = strs.flatten
8
+ return "" if strs.empty?
9
+
10
+ blocks = strs.map { |s| s.split("\n", -1) }
11
+ max_height = blocks.map(&:length).max
12
+
13
+ # Equalize heights using position for vertical alignment
14
+ blocks = blocks.map do |lines|
15
+ if lines.length < max_height
16
+ Align.vertical(lines, max_height, position)
17
+ else
18
+ lines
19
+ end
20
+ end
21
+
22
+ # Find max width of each block
23
+ widths = blocks.map do |lines|
24
+ lines.map { |l| ANSI.printable_width(l) }.max || 0
25
+ end
26
+
27
+ # Join line by line
28
+ (0...max_height).map do |row|
29
+ blocks.each_with_index.map do |lines, idx|
30
+ line = lines[row] || ""
31
+ # Pad all blocks except the last to their max width
32
+ if idx < blocks.length - 1
33
+ line_width = ANSI.printable_width(line)
34
+ pad = widths[idx] - line_width
35
+ pad.positive? ? "#{line}#{" " * pad}" : line
36
+ else
37
+ line
38
+ end
39
+ end.join
40
+ end.join("\n")
41
+ end
42
+
43
+ def vertical(position, *strs)
44
+ strs = strs.flatten
45
+ return "" if strs.empty?
46
+
47
+ blocks = strs.map { |s| s.split("\n", -1) }
48
+
49
+ # Find max width across all blocks
50
+ max_width = blocks.flat_map { |lines| lines.map { |l| ANSI.printable_width(l) } }.max || 0
51
+
52
+ # Align each block's lines horizontally
53
+ all_lines = blocks.flat_map do |lines|
54
+ Align.horizontal(lines, max_width, position)
55
+ end
56
+
57
+ all_lines.join("\n")
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flourish
4
+ module Place
5
+ class << self
6
+ def place(width, height, h_pos, v_pos, str)
7
+ lines = str.split("\n", -1)
8
+
9
+ # Horizontal placement
10
+ lines = Align.horizontal(lines, width, h_pos)
11
+
12
+ # Vertical placement
13
+ lines = Align.vertical(lines, height, v_pos)
14
+
15
+ # Ensure all lines are padded to full width
16
+ lines.map do |line|
17
+ line_width = ANSI.printable_width(line)
18
+ pad = width - line_width
19
+ pad.positive? ? "#{line}#{" " * pad}" : line
20
+ end.join("\n")
21
+ end
22
+
23
+ def place_horizontal(width, pos, str)
24
+ lines = str.split("\n", -1)
25
+ Align.horizontal(lines, width, pos).join("\n")
26
+ end
27
+
28
+ def place_vertical(height, pos, str)
29
+ lines = str.split("\n", -1)
30
+ Align.vertical(lines, height, pos).join("\n")
31
+ end
32
+ end
33
+ end
34
+ end