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,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Merminal
4
+ module Diagrams
5
+ Text = Merminal::Text
6
+ Scene = Merminal::Scene
7
+ Flowchart = Merminal::Flowchart
8
+
9
+ # Shared Scene assembly for diagram plugins.
10
+ class Builder
11
+ def initialize
12
+ @items = []
13
+ @width = 0
14
+ @height = 0
15
+ end
16
+
17
+ def text(x, y, string, role: :node_text)
18
+ string.to_s.split("\n", -1).each_with_index do |line, offset|
19
+ @items << Scene::Text.new(x: x, y: y + offset, string: line, role: role, layer: :label, emphasis: nil)
20
+ reach(x + Text.width(line), y + offset + 1)
21
+ end
22
+ end
23
+
24
+ def box(x, y, width, height, role: :node_border, rounded: false)
25
+ @items << Scene::Box.new(rect: Scene::Rect.new(x: x, y: y, width: width, height: height),
26
+ stroke: Scene::LIGHT, corners: rounded ? :rounded : :sharp, role: role,
27
+ layer: role == :container_border ? :container : :node)
28
+ reach(x + width, y + height)
29
+ end
30
+
31
+ def line(points, role: :edge, weight: :light, pattern: :solid)
32
+ @items << Scene::Polyline.new(points: points, stroke: Scene::Stroke.new(weight: weight, pattern: pattern),
33
+ role: role, layer: :edge)
34
+ points.each { |x, y| reach(x + 1, y + 1) }
35
+ end
36
+
37
+ def marker(x, y, kind: :arrow, direction: :e)
38
+ @items << Scene::Marker.new(x: x, y: y, kind: kind, direction: direction, role: :marker, layer: :marker)
39
+ reach(x + 1, y + 1)
40
+ end
41
+
42
+ def glyph(x, y, char, role: :series_1)
43
+ @items << Scene::Glyph.new(x: x, y: y, char: char, role: role, layer: :marker)
44
+ reach(x + 1, y + 1)
45
+ end
46
+
47
+ def scene
48
+ Scene.new(width: @width, height: @height, items: @items.freeze)
49
+ end
50
+
51
+ private
52
+
53
+ def reach(x, y)
54
+ @width = [@width, x].max
55
+ @height = [@height, y].max
56
+ end
57
+ end
58
+
59
+ module_function
60
+
61
+ def finding(message, source, index, severity: :error)
62
+ Diagnostic.new(severity: severity, message: message, line: source.line_map[index] || 1, column: 1, length: 1)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Merminal::Diagrams
6
+ # Date based Mermaid Gantt chart.
7
+ module Gantt
8
+ Task = Data.define(:name, :state, :start_date, :end_date, :section)
9
+ Diagram = Data.define(:title, :tasks)
10
+ def self.diagram_type = :gantt
11
+ def self.keywords = %w[gantt]
12
+
13
+ def self.parse(source)
14
+ title = source.title
15
+ format = "%Y-%m-%d"
16
+ section = nil
17
+ weekends = false
18
+ tasks = []
19
+ by_id = {}
20
+ findings = []
21
+ source.lines.drop(1).each_with_index do |line, index|
22
+ statement = line.strip
23
+ case statement
24
+ when /\Atitle\s+(.+)\z/
25
+ title = Regexp.last_match(1)
26
+ when /\AdateFormat\s+(.+)\z/
27
+ format = Regexp.last_match(1).gsub("YYYY", "%Y").gsub("MM", "%m").gsub("DD", "%d")
28
+ when /\Asection\s+(.+)\z/
29
+ section = Regexp.last_match(1)
30
+ when /\Aexcludes\s+weekends\z/
31
+ weekends = true
32
+ when /\A([^:]+)\s*:\s*(.+)\z/
33
+ name = Regexp.last_match(1).strip
34
+ pieces = Regexp.last_match(2).split(",").map(&:strip)
35
+ state = pieces.grep(/\A(?:done|active|crit|milestone)\z/).first&.to_sym
36
+ pieces.delete(state.to_s) if state
37
+ id = pieces.length > 2 ? pieces.shift : nil
38
+ start_token = pieces.shift
39
+ duration_token = pieces.shift
40
+ start_date = if start_token&.start_with?("after ")
41
+ by_id[start_token.delete_prefix("after ")]&.end_date
42
+ else
43
+ Date.strptime(start_token.to_s, format)
44
+ end
45
+ raise ArgumentError, "unknown start date" unless start_date
46
+
47
+ end_date = if duration_token =~ /\A(\d+)([dw])\z/
48
+ days = Regexp.last_match(1).to_i * (Regexp.last_match(2) == "w" ? 7 : 1)
49
+ advance(start_date, days, weekends)
50
+ else
51
+ Date.strptime(duration_token.to_s, format)
52
+ end
53
+ task = Task.new(name: name, state: state, start_date: start_date, end_date: end_date, section: section)
54
+ tasks << task
55
+ by_id[id] = task if id
56
+ when "", /\A(?:axisFormat|todayMarker|tickInterval)\b/
57
+ next
58
+ else
59
+ findings << Merminal::Diagrams.finding("unrecognized gantt statement", source, index + 1)
60
+ end
61
+ rescue ArgumentError
62
+ findings << Merminal::Diagrams.finding("invalid gantt task", source, index + 1)
63
+ end
64
+ [Diagram.new(title: title, tasks: tasks.freeze), findings]
65
+ end
66
+
67
+ def self.advance(start_date, days, weekends)
68
+ date = start_date
69
+ days.times do
70
+ date += 1
71
+ date += 1 while weekends && [0, 6].include?(date.wday)
72
+ end
73
+ date
74
+ end
75
+
76
+ def self.layout(ast, charset: :unicode, **)
77
+ builder = Builder.new
78
+ return builder.scene if ast.tasks.empty?
79
+
80
+ y = 0
81
+ if ast.title
82
+ builder.text(0, y, ast.title, role: :emphasis)
83
+ y += 2
84
+ end
85
+ first = ast.tasks.map(&:start_date).min
86
+ last = ast.tasks.map(&:end_date).max
87
+ span = [(last - first).to_i, 1].max
88
+ label_width = ast.tasks.map { |task| Text.width(task.name) }.max
89
+ bar_left = label_width + 3
90
+ builder.text(bar_left, y, "#{first} — #{last}", role: :axis_label)
91
+ y += 1
92
+ section = nil
93
+ ast.tasks.each do |task|
94
+ if task.section && task.section != section
95
+ section = task.section
96
+ builder.text(0, y, section, role: :container_title)
97
+ y += 1
98
+ end
99
+ builder.text(0, y, Text.pad(task.name, label_width))
100
+ offset = ((task.start_date - first).to_f / span * 40).round
101
+ size = [((task.end_date - task.start_date).to_f / span * 40).round, 1].max
102
+ char = task.state == :milestone ? (charset == :ascii ? "*" : "◆") : (charset == :ascii ? "#" : "█")
103
+ builder.text(bar_left + offset, y, char * (task.state == :milestone ? 1 : size), role: task.state == :done ? :muted : :series_1)
104
+ y += 1
105
+ end
106
+ builder.scene
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Merminal::Diagrams
4
+ # Indented Mermaid mindmap rendered as a tidy tree.
5
+ module Mindmap
6
+ Node = Data.define(:id, :label, :parent, :depth, :shape)
7
+ Diagram = Data.define(:nodes)
8
+ def self.diagram_type = :mindmap
9
+ def self.keywords = %w[mindmap]
10
+
11
+ def self.parse(source)
12
+ nodes = []
13
+ stack = []
14
+ findings = []
15
+ source.lines.drop(1).each_with_index do |line, index|
16
+ next if line.strip.empty?
17
+
18
+ indent = line[/\A\s*/].length
19
+ while stack.any? && stack.last[0] >= indent
20
+ stack.pop
21
+ end
22
+ if nodes.any? && stack.empty?
23
+ findings << Merminal::Diagrams.finding("mindmap node has no parent", source, index + 1)
24
+ next
25
+ end
26
+ raw = line.strip
27
+ shape, label = if raw =~ /\A(.+?)\)\)(.+)\(\(\z/
28
+ [:cloud, Regexp.last_match(2)]
29
+ elsif raw =~ /\A(.+?)\(\((.+)\)\)\z/
30
+ [:circle, Regexp.last_match(2)]
31
+ elsif raw =~ /\A(.+?)\[(.+)\]\z/
32
+ [:rectangle, Regexp.last_match(2)]
33
+ elsif raw =~ /\A(.+?)\((.+)\)\z/
34
+ [:rounded, Regexp.last_match(2)]
35
+ else
36
+ [:rectangle, raw]
37
+ end
38
+ id = nodes.length
39
+ nodes << Node.new(id: id, label: label, parent: stack.last&.last, depth: stack.length, shape: shape)
40
+ stack << [indent, id]
41
+ end
42
+ [Diagram.new(nodes: nodes.freeze), findings]
43
+ end
44
+
45
+ def self.layout(ast, **)
46
+ builder = Builder.new
47
+ return builder.scene if ast.nodes.empty?
48
+
49
+ children = ast.nodes.to_h { |node| [node.id, []] }
50
+ ast.nodes.each { |node| children[node.parent] << node.id if node.parent }
51
+ depth_widths = ast.nodes.group_by(&:depth).transform_values do |nodes|
52
+ nodes.map { |node| node_width(node) }.max
53
+ end
54
+ x_positions = {}
55
+ depth_widths.keys.sort.each { |depth| x_positions[depth] = depth.zero? ? 0 : x_positions[depth - 1] + depth_widths[depth - 1] + 5 }
56
+ y_positions = {}
57
+ next_leaf = 0
58
+ place = lambda do |id|
59
+ if children[id].empty?
60
+ y_positions[id] = next_leaf * 4
61
+ next_leaf += 1
62
+ else
63
+ children[id].each { |child| place.call(child) }
64
+ y_positions[id] = (y_positions[children[id].first] + y_positions[children[id].last]) / 2
65
+ end
66
+ end
67
+ place.call(ast.nodes.first.id)
68
+ ast.nodes.each do |node|
69
+ x, y = x_positions[node.depth], y_positions[node.id]
70
+ width = node_width(node)
71
+ builder.box(x, y, width, 3, rounded: node.shape != :rectangle)
72
+ builder.text(x + (width - Text.width(node.label)) / 2, y + 1, node.label)
73
+ if %i[circle cloud].include?(node.shape)
74
+ left, right = node.shape == :circle ? %w[( )] : %w[~ ~]
75
+ builder.glyph(x, y + 1, left, role: :node_border)
76
+ builder.glyph(x + width - 1, y + 1, right, role: :node_border)
77
+ end
78
+ next unless node.parent
79
+
80
+ parent = ast.nodes[node.parent]
81
+ px = x_positions[parent.depth] + node_width(parent) - 1
82
+ py = y_positions[parent.id] + 1
83
+ cy = y + 1
84
+ middle = (px + x) / 2
85
+ builder.line([[px, py], [middle, py], [middle, cy], [x, cy]])
86
+ end
87
+ builder.scene
88
+ end
89
+
90
+ def self.node_width(node)
91
+ Text.width(node.label) + (%i[circle cloud].include?(node.shape) ? 6 : 4)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Merminal::Diagrams
4
+ # Mermaid pie data as proportional horizontal bars.
5
+ module Pie
6
+ Diagram = Data.define(:title, :show_data, :entries)
7
+ def self.diagram_type = :pie
8
+ def self.keywords = %w[pie]
9
+
10
+ def self.parse(source)
11
+ title = source.title
12
+ show_data = source.lines.first.to_s.include?("showData")
13
+ entries = []
14
+ findings = []
15
+ source.lines.drop(1).each_with_index do |line, index|
16
+ case line.strip
17
+ when /\Atitle\s+(.+)\z/i
18
+ title = Regexp.last_match(1)
19
+ when /\AshowData\z/i
20
+ show_data = true
21
+ when /\A["'](.+?)["']\s*:\s*(-?\d+(?:\.\d+)?)\z/
22
+ value = Regexp.last_match(2).to_f
23
+ if value.negative?
24
+ findings << Merminal::Diagrams.finding("pie values must be nonnegative", source, index + 1)
25
+ else
26
+ entries << [Regexp.last_match(1), value]
27
+ end
28
+ when ""
29
+ next
30
+ else
31
+ findings << Merminal::Diagrams.finding("unrecognized pie statement", source, index + 1)
32
+ end
33
+ end
34
+ [Diagram.new(title: title, show_data: show_data, entries: entries.freeze), findings]
35
+ end
36
+
37
+ def self.layout(ast, charset: :unicode, **)
38
+ builder = Builder.new
39
+ y = 0
40
+ if ast.title
41
+ builder.text(0, y, ast.title, role: :emphasis)
42
+ y += 2
43
+ end
44
+ total = ast.entries.sum(&:last)
45
+ label_width = ast.entries.map { |label, _| Text.width(label) }.max.to_i
46
+ ast.entries.each do |label, value|
47
+ fraction = total.zero? ? 0 : value / total
48
+ eighths = (fraction * 30 * 8).round
49
+ bar = if charset == :ascii
50
+ "#" * (eighths / 8) + (eighths % 8 > 0 ? "+" : "")
51
+ else
52
+ "█" * (eighths / 8) + (eighths % 8 > 0 ? %w[▏ ▎ ▍ ▌ ▋ ▊ ▉][eighths % 8 - 1] : "")
53
+ end
54
+ builder.text(0, y, Text.pad(label, label_width))
55
+ builder.text(label_width + 2, y, bar, role: :series_1)
56
+ suffix = format("%5.1f%%", fraction * 100)
57
+ suffix += " (#{format('%g', value)})" if ast.show_data
58
+ builder.text(label_width + 34, y, suffix, role: :axis_label)
59
+ y += 1
60
+ end
61
+ builder.scene
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Merminal::Diagrams
4
+ # Participants and chronological events of a Mermaid sequence diagram.
5
+ module Sequence
6
+ Participant = Data.define(:id, :label, :actor)
7
+ Event = Data.define(:kind, :from, :to, :text, :extra)
8
+ Diagram = Data.define(:participants, :events)
9
+ MESSAGE = /\A([\w.]+(?:-(?![-=>x])[\w.]+)*)\s*(-->>|->>|-->|->|--x|-x|--\)|-\))\s*([+-]?)([\w.-]+)\s*:\s*(.*)\z/
10
+ def self.diagram_type = :sequence
11
+ def self.keywords = %w[sequenceDiagram]
12
+
13
+ def self.parse(source)
14
+ participants = {}
15
+ events = []
16
+ findings = []
17
+ blocks = []
18
+ source.lines.drop(1).each_with_index do |line, index|
19
+ statement = line.strip
20
+ case statement
21
+ when /\A(participant|actor)\s+(\S+)(?:\s+as\s+(.+))?\z/
22
+ participants[Regexp.last_match(2)] = Participant.new(id: Regexp.last_match(2),
23
+ label: Regexp.last_match(3) || Regexp.last_match(2),
24
+ actor: Regexp.last_match(1) == "actor")
25
+ when MESSAGE
26
+ from, operator, activation, to, text = Regexp.last_match.captures
27
+ [from, to].each { |id| participants[id] ||= Participant.new(id: id, label: id, actor: false) }
28
+ events << Event.new(kind: :message, from: from, to: to, text: text, extra: operator)
29
+ events << Event.new(kind: activation == "+" ? :activate : :deactivate,
30
+ from: activation == "+" ? to : from, to: nil, text: nil, extra: nil) unless activation.empty?
31
+ when /\ANote\s+(left of|right of|over)\s+([^:]+):\s*(.*)\z/i
32
+ side, ids, text = Regexp.last_match.captures
33
+ ids.split(",").each { |id| participants[id.strip] ||= Participant.new(id: id.strip, label: id.strip, actor: false) }
34
+ events << Event.new(kind: :note, from: ids.split(",").first.strip, to: ids.split(",").last.strip,
35
+ text: text.gsub(/<br\s*\/?\s*>/i, "\n"), extra: side.downcase)
36
+ when /\A(loop|alt|opt|par|critical|break|rect)\b\s*(.*)\z/i
37
+ blocks << Regexp.last_match(1)
38
+ events << Event.new(kind: :block_start, from: nil, to: nil, text: Regexp.last_match(2), extra: Regexp.last_match(1))
39
+ when /\A(else|and)\b\s*(.*)\z/i
40
+ if blocks.empty?
41
+ findings << Merminal::Diagrams.finding("unexpected sequence branch", source, index + 1)
42
+ else
43
+ events << Event.new(kind: :block_else, from: nil, to: nil, text: Regexp.last_match(2), extra: Regexp.last_match(1))
44
+ end
45
+ when "end"
46
+ if blocks.empty?
47
+ findings << Merminal::Diagrams.finding("unexpected end", source, index + 1)
48
+ else
49
+ blocks.pop
50
+ events << Event.new(kind: :block_end, from: nil, to: nil, text: nil, extra: nil)
51
+ end
52
+ when /\A(activate|deactivate)\s+(\S+)\z/i
53
+ id = Regexp.last_match(2)
54
+ participants[id] ||= Participant.new(id: id, label: id, actor: false)
55
+ events << Event.new(kind: Regexp.last_match(1).downcase.to_sym, from: id, to: nil, text: nil, extra: nil)
56
+ when /\Aautonumber\b/
57
+ events << Event.new(kind: :autonumber, from: nil, to: nil, text: nil, extra: nil)
58
+ when ""
59
+ next
60
+ else
61
+ findings << Merminal::Diagrams.finding("unrecognized sequence statement", source, index + 1)
62
+ end
63
+ end
64
+ findings << Merminal::Diagrams.finding("unclosed sequence block", source, source.lines.length - 1) unless blocks.empty?
65
+ [Diagram.new(participants: participants.values.freeze, events: events.freeze), findings]
66
+ end
67
+
68
+ def self.layout(ast, repeat_participants: false, **)
69
+ builder = Builder.new
70
+ return builder.scene if ast.participants.empty?
71
+
72
+ ids = ast.participants.map(&:id)
73
+ gaps = ast.participants.each_cons(2).map do |left, right|
74
+ [14, (Text.width(left.label) + 5) / 2 + (Text.width(right.label) + 5) / 2 + 4].max
75
+ end
76
+ numbered = false
77
+ ast.events.each do |event|
78
+ numbered = true if event.kind == :autonumber
79
+ next unless event.kind == :message && event.from != event.to
80
+
81
+ a, b = [ids.index(event.from), ids.index(event.to)].sort
82
+ needed = Text.width(event.text) + 4 + (numbered ? 6 : 0)
83
+ current = gaps[a...b].sum
84
+ gaps[b - 1] += needed - current if current < needed
85
+ end
86
+ left_note = ast.events.select { |event| event.kind == :note && event.extra == "left of" && event.from == ids.first }
87
+ left_margin = left_note.map { |event| Text.width(event.text) + 4 }.max.to_i
88
+ first_width = Text.width(ast.participants.first.label) + 4
89
+ centers = [[5 + left_margin, first_width / 2 + 1].max]
90
+ gaps.each { |gap| centers << centers[-1] + gap }
91
+ columns = ids.zip(centers).to_h
92
+ rows = []
93
+ y = 4
94
+ autonumber = false
95
+ number = 0
96
+ ast.events.each do |event|
97
+ next autonumber = true if event.kind == :autonumber
98
+
99
+ label = event.text
100
+ if event.kind == :message && autonumber
101
+ number += 1
102
+ label = "#{number}. #{label}"
103
+ end
104
+ rows << [event, y, label]
105
+ y += case event.kind
106
+ when :message then event.from == event.to ? 4 : 3
107
+ when :note then label.count("\n") + 4
108
+ when :block_start, :block_else then 2
109
+ else 1
110
+ end
111
+ end
112
+ bottom = y + 2
113
+ ast.participants.each do |participant|
114
+ center = columns.fetch(participant.id)
115
+ width = Text.width(participant.label) + 4
116
+ x = center - width / 2
117
+ builder.box(x, 0, width, 3, rounded: participant.actor)
118
+ builder.text(x + 2, 1, participant.label)
119
+ builder.line([[center, 3], [center, bottom]], role: :muted, pattern: :dotted)
120
+ end
121
+ active = Hash.new { |hash, key| hash[key] = [] }
122
+ blocks = []
123
+ rows.each do |event, row, label|
124
+ case event.kind
125
+ when :message
126
+ from = columns.fetch(event.from)
127
+ to = columns.fetch(event.to)
128
+ if from == to
129
+ builder.text(from + 2, row, label, role: :edge_label)
130
+ builder.line([[from, row + 1], [from + 5, row + 1], [from + 5, row + 3], [from, row + 3]],
131
+ pattern: event.extra.start_with?("--") ? :dotted : :solid)
132
+ builder.marker(from + 1, row + 3, direction: :w, kind: marker_kind(event.extra))
133
+ else
134
+ left = [from, to].min
135
+ builder.text(left + 1, row, label, role: :edge_label)
136
+ builder.line([[from, row + 1], [to, row + 1]], pattern: event.extra.start_with?("--") ? :dotted : :solid)
137
+ builder.marker(to + (to > from ? -1 : 1), row + 1, direction: to > from ? :e : :w,
138
+ kind: marker_kind(event.extra))
139
+ end
140
+ when :note
141
+ from = columns.fetch(event.from)
142
+ to = columns.fetch(event.to)
143
+ width = [label.split("\n").map { |part| Text.width(part) }.max.to_i + 4,
144
+ event.extra == "over" ? (to - from).abs + 5 : 0].max
145
+ x = event.extra == "left of" ? from - width - 2 : event.extra == "right of" ? to + 2 : (from + to - width) / 2
146
+ x = [x, 0].max
147
+ builder.box(x, row, width, label.count("\n") + 3, role: :container_border)
148
+ builder.text(x + 2, row + 1, label)
149
+ when :block_start
150
+ blocks << [row, event.extra, label, blocks.length]
151
+ when :block_else
152
+ inset = blocks.length - 1
153
+ builder.line([[inset, row], [centers.last + 6 - inset, row]], role: :container_border, pattern: :dotted)
154
+ builder.text(inset + 2, row + 1, "#{event.extra} #{label}", role: :container_title)
155
+ when :block_end
156
+ start, kind, title, inset = blocks.pop
157
+ next unless start
158
+
159
+ builder.box(inset, start, centers.last + 7 - inset * 2, row - start + 1, role: :container_border)
160
+ builder.text(inset + 2, start, "#{kind} #{title}", role: :container_title)
161
+ when :activate
162
+ active[event.from] << [row, active[event.from].length]
163
+ when :deactivate
164
+ start, depth = active[event.from].pop
165
+ builder.box(columns.fetch(event.from) - 1 + depth * 2, start, 3, row - start + 1) if start
166
+ end
167
+ end
168
+ active.each do |id, starts|
169
+ starts.each { |start, depth| builder.box(columns.fetch(id) - 1 + depth * 2, start, 3, bottom - start + 1) }
170
+ end
171
+ if repeat_participants
172
+ ast.participants.each do |participant|
173
+ center = columns.fetch(participant.id)
174
+ width = Text.width(participant.label) + 4
175
+ builder.box(center - width / 2, bottom + 1, width, 3, rounded: participant.actor)
176
+ builder.text(center - width / 2 + 2, bottom + 2, participant.label)
177
+ end
178
+ end
179
+ builder.scene
180
+ end
181
+
182
+ def self.marker_kind(operator)
183
+ operator.end_with?("x") ? :cross : operator.end_with?(")") ? :circle : :arrow
184
+ end
185
+ end
186
+ end