merminal 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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +85 -0
  5. data/docs/adr/000-template.md +24 -0
  6. data/docs/adr/001-runtime.md +19 -0
  7. data/docs/adr/002-scene.md +18 -0
  8. data/docs/adr/003-layout.md +20 -0
  9. data/docs/adr/004-parser.md +19 -0
  10. data/docs/adr/005-public-api.md +23 -0
  11. data/docs/adr/README.md +10 -0
  12. data/docs/syntax/additional.md +58 -0
  13. data/docs/syntax/class.md +3 -0
  14. data/docs/syntax/er.md +3 -0
  15. data/docs/syntax/flowchart.md +16 -0
  16. data/docs/syntax/gantt.md +3 -0
  17. data/docs/syntax/mindmap.md +3 -0
  18. data/docs/syntax/pie.md +3 -0
  19. data/docs/syntax/sequence.md +14 -0
  20. data/docs/syntax/state.md +3 -0
  21. data/docs/syntax/timeline.md +3 -0
  22. data/docs/syntax/xychart.md +3 -0
  23. data/exe/merminal +7 -0
  24. data/lib/merminal/cli.rb +94 -0
  25. data/lib/merminal/diagrams/additional.rb +257 -0
  26. data/lib/merminal/diagrams/additional_layouts.rb +1274 -0
  27. data/lib/merminal/diagrams/base.rb +65 -0
  28. data/lib/merminal/diagrams/gantt.rb +109 -0
  29. data/lib/merminal/diagrams/mindmap.rb +94 -0
  30. data/lib/merminal/diagrams/pie.rb +64 -0
  31. data/lib/merminal/diagrams/sequence.rb +186 -0
  32. data/lib/merminal/diagrams/structure.rb +316 -0
  33. data/lib/merminal/diagrams/timeline.rb +60 -0
  34. data/lib/merminal/diagrams/xychart.rb +152 -0
  35. data/lib/merminal/flowchart/layout.rb +734 -0
  36. data/lib/merminal/flowchart.rb +279 -0
  37. data/lib/merminal/markdown.rb +37 -0
  38. data/lib/merminal/output.rb +93 -0
  39. data/lib/merminal/raster/box_drawing_table.rb +6 -0
  40. data/lib/merminal/raster.rb +175 -0
  41. data/lib/merminal/scene.rb +37 -0
  42. data/lib/merminal/shareable.rb +9 -0
  43. data/lib/merminal/source.rb +78 -0
  44. data/lib/merminal/text/east_asian_width_table.rb +8 -0
  45. data/lib/merminal/text.rb +110 -0
  46. data/lib/merminal/version.rb +5 -0
  47. data/lib/merminal.rb +122 -0
  48. data/sig/merminal.rbs +59 -0
  49. metadata +88 -0
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+ require "cgi"
5
+
6
+ module Merminal
7
+ # Flowchart grammar and Scene layout.
8
+ module Flowchart
9
+ Node = Data.define(:id, :label, :shape, :classes, :source_pos)
10
+ Edge = Data.define(:id, :from, :to, :label, :stroke, :start_marker, :end_marker, :minlen, :source_pos)
11
+ Subgraph = Data.define(:id, :label, :node_ids, :parent)
12
+ Diagram = Data.define(:direction, :nodes, :edges, :subgraphs, :styles)
13
+
14
+ SHAPES = [
15
+ ["(((", ")))", :double_circle], ["((", "))", :circle], ["([", "])", :stadium],
16
+ ["[(", ")]", :database], ["[[", "]]", :subroutine], ["{{", "}}", :hexagon],
17
+ ["(", ")", :rounded], ["{", "}", :decision], ["[", "]", :rectangle], [">", "]", :flag]
18
+ ].freeze
19
+ EDGE_PATTERN = /(?:<|o|x)?(?:-{2,}|-\.+-|={2,}|~{3,})(?:>|o|x)?/
20
+ DIRECTIONS = %w[TB TD BT LR RL].freeze
21
+
22
+ def self.diagram_type = :flowchart
23
+ def self.keywords = %w[flowchart flowchart-v2 flowchart-elk graph]
24
+
25
+ def self.parse(source)
26
+ Parser.new(source).parse
27
+ end
28
+
29
+ def self.layout(ast, **options)
30
+ Layout.new(ast, **options).scene
31
+ end
32
+
33
+ # Statement parser; scanner offsets are used in diagnostics.
34
+ class Parser
35
+ def initialize(source)
36
+ @source = source
37
+ @nodes = {}
38
+ @edges = []
39
+ @subgraphs = []
40
+ @styles = {}
41
+ @diagnostics = []
42
+ @stack = []
43
+ @direction = :TB
44
+ end
45
+
46
+ def parse
47
+ header, *rest = @source.lines
48
+ header_parts = header.to_s.split(";", 2)
49
+ words = header_parts[0].to_s.split
50
+ @direction = words[1].to_sym if DIRECTIONS.include?(words[1])
51
+ split_statements(header_parts[1].to_s).each { |statement| parse_statement(statement.strip, @source.line_map.first || 1) } if header_parts[1]
52
+ rest.each_with_index do |line, index|
53
+ split_statements(line.to_s).each { |statement| parse_statement(statement.strip, @source.line_map[index + 1] || 1) }
54
+ end
55
+ group_ids = @subgraphs.map(&:id)
56
+ @edges.each do |edge|
57
+ from = group_ids.include?(edge.from) ? edge.from : nil
58
+ to = group_ids.include?(edge.to) ? edge.to : nil
59
+ @styles["group_edge:#{edge.id}"] = [from, to] if from || to
60
+ end
61
+ @subgraphs.each do |group|
62
+ representative = group.node_ids.first
63
+ next unless representative
64
+
65
+ @edges.map! do |edge|
66
+ edge.with(from: edge.from == group.id ? representative : edge.from,
67
+ to: edge.to == group.id ? representative : edge.to)
68
+ end
69
+ @nodes.delete(group.id) unless group.node_ids.include?(group.id)
70
+ end
71
+ @diagnostics << Diagnostic.new(severity: :error, message: "unclosed subgraph", line: @source.line_map.last || 1, column: 1, length: 1) unless @stack.empty?
72
+ [Diagram.new(direction: @direction, nodes: @nodes.values.freeze, edges: @edges.freeze,
73
+ subgraphs: @subgraphs.freeze, styles: @styles.freeze), @diagnostics.freeze]
74
+ end
75
+
76
+ def split_statements(line)
77
+ statements = []
78
+ current = +""
79
+ quote = nil
80
+ depth = 0
81
+ line.each_char do |char|
82
+ if quote
83
+ quote = nil if char == quote
84
+ elsif char == '"' || char == "'"
85
+ quote = char
86
+ elsif ["(", "[", "{"].include?(char)
87
+ depth += 1
88
+ elsif [")", "]", "}"].include?(char)
89
+ depth -= 1 if depth.positive?
90
+ elsif char == ";" && depth.zero?
91
+ statements << current
92
+ current = +""
93
+ next
94
+ end
95
+ current << char
96
+ end
97
+ statements << current
98
+ end
99
+
100
+ def parse_statement(statement, line)
101
+ return if statement.empty?
102
+
103
+ case statement
104
+ when /\Adirection\s+(TB|TD|BT|LR|RL)\z/
105
+ if @stack.any?
106
+ info("subgraph direction ignored", line)
107
+ else
108
+ @direction = Regexp.last_match(1).to_sym
109
+ end
110
+ when /\Asubgraph\s+(.+)\z/
111
+ name = Regexp.last_match(1).strip
112
+ id, label = name =~ /\A([^\[]+)\[(.*)\]\z/ ? [Regexp.last_match(1).strip, Regexp.last_match(2)] : [name, name]
113
+ @subgraphs << Subgraph.new(id: id, label: label, node_ids: [], parent: @stack.last)
114
+ @stack << id
115
+ when "end"
116
+ @stack.pop || error("unexpected end", line)
117
+ when /\Astyle\s+(\S+)\s+(.+)\z/
118
+ @styles[Regexp.last_match(1)] = Regexp.last_match(2)
119
+ when /\AclassDef\s+(\S+)\s+(.+)\z/
120
+ @styles["class:#{Regexp.last_match(1)}"] = Regexp.last_match(2)
121
+ when /\Aclass\s+([\w,.-]+)\s+(\S+)\z/
122
+ ids, name = Regexp.last_match.captures
123
+ ids.split(",").each do |id|
124
+ previous = @nodes[id]
125
+ @nodes[id] = previous.with(classes: (previous.classes + [name]).uniq.freeze) if previous
126
+ end
127
+ when /\AlinkStyle\s+(\S+)\s+(.+)\z/
128
+ @styles["link:#{Regexp.last_match(1)}"] = Regexp.last_match(2)
129
+ when /\Aclick\b/
130
+ info("click ignored", line)
131
+ else
132
+ parse_chain(statement, line)
133
+ end
134
+ end
135
+
136
+ def parse_chain(statement, line)
137
+ scanner = StringScanner.new(statement)
138
+ previous = parse_group(scanner, line)
139
+ return unless previous
140
+
141
+ until scanner.eos?
142
+ scanner.skip(/\s*/)
143
+ operator = scanner.scan(EDGE_PATTERN)
144
+ unless operator
145
+ error("unexpected input", line, scanner.pos + 1)
146
+ break
147
+ end
148
+ label = nil
149
+ scanner.skip(/\s*/)
150
+ if scanner.scan(/\|/)
151
+ label = scanner.scan_until(/\|/)&.chop
152
+ error("unclosed edge label", line, scanner.pos + 1) unless label
153
+ elsif operator == "--" && scanner.scan(/(.+?)\s+-->/)
154
+ label = scanner[1]
155
+ operator = "-->"
156
+ end
157
+ following = parse_group(scanner, line)
158
+ break unless following
159
+
160
+ previous.product(following).each do |from, to|
161
+ @edges << Edge.new(id: @edges.length, from: from, to: to, label: label,
162
+ stroke: operator.include?("~") ? :invisible : operator.include?("=") ? :heavy : operator.include?(".") ? :dotted : :light,
163
+ start_marker: marker(operator[0]), end_marker: marker(operator[-1]),
164
+ minlen: [operator.count("-=.") - 1, 1].max, source_pos: [line, scanner.pos])
165
+ end
166
+ previous = following
167
+ end
168
+ end
169
+
170
+ def parse_group(scanner, line)
171
+ ids = []
172
+ loop do
173
+ scanner.skip(/\s*/)
174
+ id = scanner.scan(/[[:alnum:]_](?:[[:alnum:]_.]|-(?![-.=>~]))*/)
175
+ unless id
176
+ error("expected node", line, scanner.pos + 1)
177
+ return nil
178
+ end
179
+ shape, label = parse_shape(scanner, line)
180
+ previous = @nodes[id]
181
+ classes = []
182
+ while scanner.scan(/:::/)
183
+ name = scanner.scan(/[[:alnum:]_-]+/)
184
+ classes << name if name
185
+ end
186
+ label ||= previous&.label || id
187
+ label = CGI.unescapeHTML(label.gsub(/<br\s*\/?\s*>/i, "\n").gsub(/<[^>]+>/, ""))
188
+ if previous && shape
189
+ info("node #{id} redefined", line)
190
+ end
191
+ @nodes[id] = Node.new(id: id, label: shape ? label : previous&.label || label,
192
+ shape: shape || previous&.shape || :rectangle,
193
+ classes: ((previous&.classes || []) + classes).uniq.freeze, source_pos: [line, scanner.pos])
194
+ @subgraphs.each { |group| group.node_ids << id if @stack.include?(group.id) } if @stack.any?
195
+ ids << id
196
+ scanner.skip(/\s*/)
197
+ break unless scanner.scan(/&/)
198
+ end
199
+ ids
200
+ end
201
+
202
+ def parse_shape(scanner, line)
203
+ if scanner.scan(/@\{/)
204
+ value = scanner.scan_until(/\}/)
205
+ unless value
206
+ error("unclosed shape object", line, scanner.pos + 1)
207
+ return [:rectangle, ""]
208
+ end
209
+ name = value[/shape\s*:\s*([\w-]+)/, 1]
210
+ shape = { "rect" => :rectangle, "rounded" => :rounded, "diamond" => :decision,
211
+ "hex" => :hexagon, "circle" => :circle, "stadium" => :stadium,
212
+ "cyl" => :database, "subproc" => :subroutine }[name]
213
+ info("unknown shape #{name}", line) unless shape
214
+ return [shape || :rectangle, nil]
215
+ end
216
+ if scanner.peek(2) == "[/" || scanner.peek(2) == "[\\"
217
+ value = scan_shape_content(scanner, "]", escape_closer: false)
218
+ unless value
219
+ error("unclosed node shape", line, scanner.pos + 1)
220
+ return [:parallelogram, ""]
221
+ end
222
+ first = value[1]
223
+ last = value[-1]
224
+ shape = first == last ? :parallelogram : :trapezoid
225
+ return [shape, value[2...-1].delete_prefix('"').delete_suffix('"')]
226
+ end
227
+ opener, closer, shape = SHAPES.find { |start, _, _| scanner.peek(start.length) == start }
228
+ return [nil, nil] unless opener
229
+
230
+ scanner.pos += opener.length
231
+ value = scan_shape_content(scanner, closer)
232
+ unless value
233
+ error("unclosed node shape", line, scanner.pos + 1)
234
+ return [shape, ""]
235
+ end
236
+ [shape, value.delete_prefix('"').delete_suffix('"')]
237
+ end
238
+
239
+ def scan_shape_content(scanner, closer, escape_closer: true)
240
+ result = +""
241
+ quote = nil
242
+ escaped = false
243
+ until scanner.eos?
244
+ if !quote && (!escaped || !escape_closer) && scanner.peek(closer.bytesize) == closer
245
+ scanner.pos += closer.bytesize
246
+ return result
247
+ end
248
+
249
+ char = scanner.getch
250
+ if escaped
251
+ escaped = false
252
+ elsif char == "\\"
253
+ escaped = true
254
+ elsif quote == char
255
+ quote = nil
256
+ elsif !quote && (char == '"' || char == "'")
257
+ quote = char
258
+ end
259
+ result << char
260
+ end
261
+ nil
262
+ end
263
+
264
+ def marker(char)
265
+ { ">" => :arrow, "<" => :arrow, "o" => :circle, "x" => :cross }[char]
266
+ end
267
+
268
+ def error(message, line, column = 1)
269
+ @diagnostics << Diagnostic.new(severity: :error, message: message, line: line, column: column, length: 1)
270
+ end
271
+
272
+ def info(message, line)
273
+ @diagnostics << Diagnostic.new(severity: :info, message: message, line: line, column: 1, length: 1)
274
+ end
275
+ end
276
+ end
277
+ end
278
+
279
+ require_relative "flowchart/layout"
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Merminal
4
+ # One Mermaid fence extracted from a Markdown document.
5
+ MarkdownBlock = Data.define(:source, :line) do
6
+ def render(**options)
7
+ Merminal.render(source, **options)
8
+ end
9
+ end
10
+
11
+ module Markdown
12
+ module_function
13
+
14
+ def blocks(markdown)
15
+ result = []
16
+ fence = nil
17
+ start = nil
18
+ content = []
19
+ markdown.to_s.each_line.with_index(1) do |line, number|
20
+ if fence
21
+ if line.match?(/\A\s*#{Regexp.escape(fence[0])}{#{fence.length},}\s*\z/)
22
+ result << MarkdownBlock.new(source: content.join, line: start) if start
23
+ fence = nil
24
+ start = nil
25
+ content = []
26
+ else
27
+ content << line if start
28
+ end
29
+ elsif line =~ /\A\s*(`{3,}|~{3,})([^`~]*)\z/
30
+ fence = Regexp.last_match(1)
31
+ start = number + 1 if Regexp.last_match(2).strip == "mermaid"
32
+ end
33
+ end
34
+ result.freeze
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shareable"
4
+
5
+ module Merminal
6
+ # Plain and ANSI terminal output.
7
+ module Output
8
+ THEMES = Shareable.make({
9
+ default: { node_border: 39, node_text: 255, edge: 110, edge_label: 222, marker: 203,
10
+ container_border: 103, container_title: 222, axis: 244, axis_label: 250,
11
+ series_1: 39, series_2: 203, emphasis: 229, muted: 244 },
12
+ mono: {},
13
+ high_contrast: { node_border: 15, node_text: 15, edge: 15, edge_label: 11, marker: 9,
14
+ axis: 15, axis_label: 15, series_1: 11, series_2: 13, emphasis: 15 },
15
+ solarized: { node_border: 37, node_text: 230, edge: 66, edge_label: 136, marker: 160,
16
+ axis: 244, axis_label: 187, series_1: 37, series_2: 166, emphasis: 230 }
17
+ })
18
+ BASIC = Shareable.make([[0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0],
19
+ [0, 0, 128], [128, 0, 128], [0, 128, 128], [192, 192, 192],
20
+ [128, 128, 128], [255, 0, 0], [0, 255, 0], [255, 255, 0],
21
+ [0, 0, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255]])
22
+
23
+ module_function
24
+
25
+ def render(grid, charset: :unicode, color: false, theme: :default)
26
+ raise ArgumentError, "unknown theme: #{theme}" unless THEMES.key?(theme.to_sym)
27
+
28
+ colored = color == true || color == :always || (color == :auto && $stdout.tty?)
29
+ colored = false if ENV.key?("NO_COLOR") && !ENV.key?("FORCE_COLOR")
30
+ colored = true if ENV.key?("FORCE_COLOR") && color != false && color != :never
31
+ grid.lines(charset: charset).map do |cells|
32
+ last = cells.rindex { |char, _| char != " " }
33
+ cells = last ? cells.take(last + 1) : []
34
+ next "" if cells.empty?
35
+
36
+ colored ? ansi_line(cells, THEMES.fetch(theme.to_sym), color_depth) : cells.map(&:first).join
37
+ end.drop_while(&:empty?).reverse.drop_while(&:empty?).reverse.join("\n")
38
+ end
39
+
40
+ def ansi_line(cells, theme, depth)
41
+ current = nil
42
+ result = +""
43
+ cells.each do |char, role, background|
44
+ next_style = [role.to_s.start_with?("fg:") ? role.to_s.delete_prefix("fg:") : theme[role], background]
45
+ if next_style != current
46
+ result << "\e[0m" if current&.any?
47
+ codes = []
48
+ codes << color_code(next_style[0], depth) if next_style[0]
49
+ codes << color_code(next_style[1], depth, foreground: false) if next_style[1]
50
+ result << "\e[#{codes.join(';')}m" unless codes.empty?
51
+ current = next_style
52
+ end
53
+ result << char
54
+ end
55
+ result << "\e[0m" if current&.any?
56
+ result
57
+ end
58
+
59
+ def color_depth
60
+ return :truecolor if %w[truecolor 24bit].include?(ENV["COLORTERM"])
61
+ return :color256 if ENV["TERM"].to_s.include?("256color")
62
+
63
+ :color16
64
+ end
65
+
66
+ def color_code(value, depth, foreground: true)
67
+ prefix = foreground ? 38 : 48
68
+ rgb = value.is_a?(Integer) ? rgb256(value) : hex_rgb(value)
69
+ return "#{prefix};2;#{rgb.join(';')}" if depth == :truecolor
70
+ if depth == :color256
71
+ index = value.is_a?(Integer) ? value : (16..255).min_by { |candidate| rgb256(candidate).zip(rgb).sum { |a, b| (a - b)**2 } }
72
+ return "#{prefix};5;#{index}"
73
+ end
74
+
75
+ nearest = BASIC.each_with_index.min_by { |candidate, _| candidate.zip(rgb).sum { |a, b| (a - b)**2 } }.last
76
+ (nearest < 8 ? (foreground ? 30 : 40) + nearest : (foreground ? 90 : 100) + nearest - 8).to_s
77
+ end
78
+
79
+ def hex_rgb(value)
80
+ hex = value.to_s.delete_prefix("#")
81
+ hex = hex.chars.map { |char| char * 2 }.join if hex.length == 3
82
+ [0, 2, 4].map { |offset| hex[offset, 2].to_i(16) }
83
+ end
84
+
85
+ def rgb256(index)
86
+ return BASIC[index] if index < 16
87
+ return Array.new(3, 8 + (index - 232) * 10) if index >= 232
88
+
89
+ cube = index - 16
90
+ [cube / 36, cube / 6 % 6, cube % 6].map { |value| value.zero? ? 0 : 55 + value * 40 }
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+ # Unicode 17.0.0; generated from UnicodeData.txt.
3
+
4
+ module Merminal::Raster
5
+ BOX_DRAWING = Merminal::Shareable.make({"0001" => "╴", "0002" => "╸", "0010" => "╷", "0011" => "┐", "0012" => "┑", "0013" => "╕", "0020" => "╻", "0021" => "┒", "0022" => "┓", "0031" => "╖", "0033" => "╗", "0100" => "╶", "0101" => "─", "0102" => "╾", "0110" => "┌", "0111" => "┬", "0112" => "┭", "0120" => "┎", "0121" => "┰", "0122" => "┱", "0130" => "╓", "0131" => "╥", "0200" => "╺", "0201" => "╼", "0202" => "━", "0210" => "┍", "0211" => "┮", "0212" => "┯", "0220" => "┏", "0221" => "┲", "0222" => "┳", "0303" => "═", "0310" => "╒", "0313" => "╤", "0330" => "╔", "0333" => "╦", "1000" => "╵", "1001" => "┘", "1002" => "┙", "1003" => "╛", "1010" => "│", "1011" => "┤", "1012" => "┥", "1013" => "╡", "1020" => "╽", "1021" => "┧", "1022" => "┪", "1100" => "└", "1101" => "┴", "1102" => "┵", "1110" => "├", "1111" => "┼", "1112" => "┽", "1120" => "┟", "1121" => "╁", "1122" => "╅", "1200" => "┕", "1201" => "┶", "1202" => "┷", "1210" => "┝", "1211" => "┾", "1212" => "┿", "1220" => "┢", "1221" => "╆", "1222" => "╈", "1300" => "╘", "1303" => "╧", "1310" => "╞", "1313" => "╪", "2000" => "╹", "2001" => "┚", "2002" => "┛", "2010" => "╿", "2011" => "┦", "2012" => "┩", "2020" => "┃", "2021" => "┨", "2022" => "┫", "2100" => "┖", "2101" => "┸", "2102" => "┹", "2110" => "┞", "2111" => "╀", "2112" => "╃", "2120" => "┠", "2121" => "╂", "2122" => "╉", "2200" => "┗", "2201" => "┺", "2202" => "┻", "2210" => "┡", "2211" => "╄", "2212" => "╇", "2220" => "┣", "2221" => "╊", "2222" => "╋", "3001" => "╜", "3003" => "╝", "3030" => "║", "3031" => "╢", "3033" => "╣", "3100" => "╙", "3101" => "╨", "3130" => "╟", "3131" => "╫", "3300" => "╚", "3303" => "╩", "3330" => "╠", "3333" => "╬"})
6
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shareable"
4
+
5
+ module Merminal
6
+ # Converts Scene primitives to terminal cells.
7
+ module Raster
8
+ require_relative "raster/box_drawing_table"
9
+
10
+ Cell = Struct.new(:char, :arms, :weights, :owners, :role, :background, :protected, :continuation, :weight, :pattern, :rounded, keyword_init: true)
11
+ ARM = { n: 1, e: 2, s: 4, w: 8 }.freeze
12
+ INDEX = { n: 0, e: 1, s: 2, w: 3 }.freeze
13
+ WEIGHT = { light: 1, heavy: 2, double: 3 }.freeze
14
+ OPPOSITE = { n: :s, e: :w, s: :n, w: :e }.freeze
15
+ ROUNDED = { 3 => "╰", 6 => "╭", 9 => "╯", 12 => "╮" }.freeze
16
+
17
+ # Mutable raster grid. Bounds errors signal a layout bug.
18
+ class Grid
19
+ attr_reader :width, :height, :rows
20
+
21
+ def initialize(width, height, crossings: :plain)
22
+ @width, @height = width, height
23
+ @crossings = crossings
24
+ @rows = Array.new(height) { Array.new(width) { Cell.new(char: " ", arms: 0, weights: [0, 0, 0, 0]) } }
25
+ end
26
+
27
+ def at(x, y)
28
+ raise RangeError, "drawing outside Scene: #{x},#{y}" unless x.between?(0, width - 1) && y.between?(0, height - 1)
29
+
30
+ rows[y][x]
31
+ end
32
+
33
+ def line(points, stroke, role, protect: false, rounded: false, owner: nil)
34
+ points.each_cons(2) do |(x1, y1), (x2, y2)|
35
+ raise ArgumentError, "diagonal line" unless x1 == x2 || y1 == y2
36
+
37
+ dx = x2 <=> x1
38
+ dy = y2 <=> y1
39
+ x, y = x1, y1
40
+ until x == x2 && y == y2
41
+ nx, ny = x + dx, y + dy
42
+ direction = dx.positive? ? :e : dx.negative? ? :w : dy.positive? ? :s : :n
43
+ add_arm(x, y, direction, stroke, role, protect, rounded, owner)
44
+ add_arm(nx, ny, OPPOSITE.fetch(direction), stroke, role, protect, rounded, owner)
45
+ x, y = nx, ny
46
+ end
47
+ end
48
+ end
49
+
50
+ def add_arm(x, y, direction, stroke, role, protect, rounded, owner)
51
+ cell = at(x, y)
52
+ return if cell.continuation
53
+
54
+ cell.arms |= ARM.fetch(direction)
55
+ if owner
56
+ cell.owners ||= {}
57
+ (cell.owners[direction] ||= []) << owner
58
+ end
59
+ index = INDEX.fetch(direction)
60
+ cell.weights[index] = [cell.weights[index], WEIGHT.fetch(stroke.weight)].max
61
+ cell.role = role if !cell.protected || protect
62
+ cell.protected ||= protect
63
+ cell.weight = stroke.weight if !cell.weight || cell.weight == :light
64
+ cell.pattern = stroke.pattern
65
+ cell.rounded ||= rounded
66
+ end
67
+
68
+ def put(x, y, char, role, protect: false, ambiguous_width: 1)
69
+ cells = Text.width(char, ambiguous_width: ambiguous_width)
70
+ return if cells.zero?
71
+
72
+ cell = at(x, y)
73
+ raise RangeError, "drawing across Scene edge" if cells == 2 && x == width - 1
74
+ return if cell.protected && !protect
75
+
76
+ cell.char = char
77
+ cell.arms = 0
78
+ cell.role = role
79
+ cell.protected ||= protect
80
+ if cells == 2
81
+ follower = at(x + 1, y)
82
+ follower.char = ""
83
+ follower.arms = 0
84
+ follower.continuation = true
85
+ follower.role = role
86
+ end
87
+ end
88
+
89
+ def fill(rect, background)
90
+ rect.y.upto(rect.y + rect.height - 1) do |y|
91
+ rect.x.upto(rect.x + rect.width - 1) { |x| at(x, y).background = background }
92
+ end
93
+ end
94
+
95
+ def lines(charset: :unicode)
96
+ rows.map do |row|
97
+ row.filter_map do |cell|
98
+ next if cell.continuation
99
+
100
+ [character(cell, charset), cell.role, cell.background]
101
+ end
102
+ end
103
+ end
104
+
105
+ def character(cell, charset)
106
+ return cell.char if cell.arms.zero? || cell.char != " " && cell.protected
107
+
108
+ arms = cell.arms
109
+ if @crossings == :bridge && arms == 15 && !cell.protected && cell.owners
110
+ horizontal = (cell.owners[:e] || []) + (cell.owners[:w] || [])
111
+ vertical = (cell.owners[:n] || []) + (cell.owners[:s] || [])
112
+ return charset == :ascii ? "|" : "│" if (horizontal & vertical).empty?
113
+ end
114
+ if charset == :ascii
115
+ return "+" unless [5, 10].include?(arms)
116
+ return arms == 5 ? "|" : cell.weight == :heavy ? "=" : cell.pattern == :dotted ? "." : "-"
117
+ end
118
+ return ROUNDED[arms] if cell.rounded && cell.weight == :light && ROUNDED.key?(arms)
119
+ return cell.weight == :heavy ? "┇" : "┆" if cell.pattern == :dotted && arms == 5
120
+ return cell.weight == :heavy ? "┅" : "┄" if cell.pattern == :dotted && arms == 10
121
+
122
+ key = cell.weights.join
123
+ BOX_DRAWING[key] || BOX_DRAWING[key.tr("3", "1")] || BOX_DRAWING[key.tr("32", "11")]
124
+ end
125
+ end
126
+
127
+ module_function
128
+
129
+ def rasterize(scene, charset: :unicode, rounded: true, ambiguous_width: 1, crossings: :plain)
130
+ grid = Grid.new(scene.width, scene.height, crossings: crossings)
131
+ scene.items.each_with_index.sort_by { |item, index| [Scene::LAYERS.fetch(item.layer, 3), index] }.each do |item, _|
132
+ case item
133
+ in Scene::Box
134
+ r = item.rect
135
+ grid.line([[r.x, r.y], [r.x + r.width - 1, r.y], [r.x + r.width - 1, r.y + r.height - 1],
136
+ [r.x, r.y + r.height - 1], [r.x, r.y]], item.stroke, item.role,
137
+ protect: true, rounded: rounded && item.corners == :rounded)
138
+ in Scene::Polyline
139
+ grid.line(item.points, item.stroke, item.role, owner: item.points.first)
140
+ in Scene::Text
141
+ x = item.x
142
+ Text.each_cell(item.string, ambiguous_width: ambiguous_width) do |char, cells|
143
+ if charset == :ascii && cells == 2
144
+ grid.put(x, item.y, "?", item.role)
145
+ grid.put(x + 1, item.y, "?", item.role)
146
+ else
147
+ grid.put(x, item.y, charset == :ascii ? ascii(char) : char, item.role, ambiguous_width: ambiguous_width)
148
+ end
149
+ x += cells
150
+ end
151
+ in Scene::Marker
152
+ char = marker(item.kind, item.direction, charset)
153
+ grid.put(item.x, item.y, char, item.role, protect: true)
154
+ in Scene::Glyph
155
+ grid.put(item.x, item.y, charset == :ascii ? ascii(item.char) : item.char, item.role, protect: true)
156
+ in Scene::Fill
157
+ grid.fill(item.rect, item.role.to_s.delete_prefix("bg:")) if item.role.to_s.start_with?("bg:")
158
+ end
159
+ end
160
+ grid
161
+ end
162
+
163
+ def marker(kind, direction, charset)
164
+ return { arrow: { n: "^", e: ">", s: "v", w: "<" }, circle: "o", cross: "x",
165
+ triangle: "^", diamond: "*", open_diamond: "o" }.fetch(kind).then { |v| v.is_a?(Hash) ? v.fetch(direction) : v } if charset == :ascii
166
+
167
+ { arrow: { n: "▲", e: "▶", s: "▼", w: "◀" }, circle: "○", cross: "×",
168
+ triangle: "△", diamond: "◆", open_diamond: "◇" }.fetch(kind).then { |v| v.is_a?(Hash) ? v.fetch(direction) : v }
169
+ end
170
+
171
+ def ascii(char)
172
+ char.ascii_only? && char.ord.between?(32, 126) ? char : "?"
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Merminal
4
+ # Immutable drawing instructions in terminal cell coordinates.
5
+ Scene = Data.define(:width, :height, :items)
6
+ class Scene
7
+ Rect = Data.define(:x, :y, :width, :height)
8
+ Stroke = Data.define(:weight, :pattern)
9
+ Box = Data.define(:rect, :stroke, :corners, :role, :layer)
10
+ Polyline = Data.define(:points, :stroke, :role, :layer) do
11
+ def initialize(points:, **options)
12
+ raise ArgumentError, "polyline must be orthogonal" unless points.each_cons(2).all? { |a, b| a[0] == b[0] || a[1] == b[1] }
13
+
14
+ super
15
+ end
16
+ end
17
+ Text = Data.define(:x, :y, :string, :role, :layer, :emphasis)
18
+ Marker = Data.define(:x, :y, :kind, :direction, :role, :layer)
19
+ Glyph = Data.define(:x, :y, :char, :role, :layer)
20
+ Fill = Data.define(:rect, :role, :layer)
21
+
22
+ LIGHT = Stroke.new(weight: :light, pattern: :solid)
23
+ LAYERS = { background: 0, container: 1, edge: 2, node: 3, marker: 4, label: 5 }.freeze
24
+
25
+ def self.translate(item, dx: 0, dy: 0)
26
+ case item
27
+ in Box | Fill
28
+ rect = item.rect
29
+ item.with(rect: rect.with(x: rect.x + dx, y: rect.y + dy))
30
+ in Polyline
31
+ item.with(points: item.points.map { |x, y| [x + dx, y + dy] })
32
+ in Text | Marker | Glyph
33
+ item.with(x: item.x + dx, y: item.y + dy)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Merminal
4
+ module Shareable
5
+ def self.make(value)
6
+ defined?(Ractor) ? Ractor.make_shareable(value) : value.freeze
7
+ end
8
+ end
9
+ end