railroad_diagrams 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.
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailroadDiagrams
4
+ class MultipleChoice < DiagramMultiContainer
5
+ def initialize(default, type, *items)
6
+ super('g', items)
7
+ raise ArgumentError, "default must be between 0 and #{items.length - 1}" unless (0...items.length).cover?(default)
8
+ raise ArgumentError, "type must be 'any' or 'all'" unless %w[any all].include?(type)
9
+
10
+ @default = default
11
+ @type = type
12
+ @needs_space = true
13
+ @inner_width = @items.map(&:width).max
14
+ @width = 30 + AR + @inner_width + AR + 20
15
+ @up = @items[0].up
16
+ @down = @items[-1].down
17
+ @height = @items[default].height
18
+
19
+ @items.each_with_index do |item, i|
20
+ minimum =
21
+ if [default - 1, default + 1].include?(i)
22
+ 10 + AR
23
+ else
24
+ AR
25
+ end
26
+
27
+ if i < default
28
+ @up += [minimum, item.height + item.down + VS + @items[i + 1].up].max
29
+ elsif i > default
30
+ @down += [minimum, item.up + VS + @items[i - 1].down + @items[i - 1].height].max
31
+ end
32
+ end
33
+
34
+ @down -= @items[default].height # already counted in @height
35
+ end
36
+
37
+ def to_s
38
+ items = @items.map(&:to_s).join(', ')
39
+ "MultipleChoice(#{@default}, #{@type}, #{items})"
40
+ end
41
+
42
+ def format(x, y, width)
43
+ left_gap, right_gap = determine_gaps(width, @width)
44
+
45
+ # Hook up the two sides if self is narrower than its stated width.
46
+ Path.new(x, y).h(left_gap).add(self)
47
+ Path.new(x + left_gap + @width, y + @height).h(right_gap).add(self)
48
+ x += left_gap
49
+
50
+ default = @items[@default]
51
+
52
+ # Do the elements that curve above
53
+ above = @items[0...@default].reverse
54
+ distance_from_y = 0
55
+ distance_from_y = [10 + AR, default.up + VS + above.first.down + above.first.height].max if above.any?
56
+
57
+ double_enumerate(above).each do |i, ni, item|
58
+ Path.new(x + 30, y).up(distance_from_y - AR).arc('wn').add(self)
59
+ item.format(x + 30 + AR, y - distance_from_y, @inner_width).add(self)
60
+ Path.new(x + 30 + AR + @inner_width, y - distance_from_y + item.height)
61
+ .arc('ne')
62
+ .down(distance_from_y - item.height + default.height - AR - 10)
63
+ .add(self)
64
+ distance_from_y += [AR, item.up + VS + above[i + 1].down + above[i + 1].height].max if ni < -1
65
+ end
66
+
67
+ # Do the straight-line path.
68
+ Path.new(x + 30, y).right(AR).add(self)
69
+ @items[@default].format(x + 30 + AR, y, @inner_width).add(self)
70
+ Path.new(x + 30 + AR + @inner_width, y + @height).right(AR).add(self)
71
+
72
+ # Do the elements that curve below
73
+ below = @items[(@default + 1)..] || []
74
+ distance_from_y = [10 + AR, default.height + default.down + VS + below.first.up].max if below.any?
75
+
76
+ below.each_with_index do |item, i|
77
+ Path.new(x + 30, y).down(distance_from_y - AR).arc('ws').add(self)
78
+ item.format(x + 30 + AR, y + distance_from_y, @inner_width).add(self)
79
+ Path.new(x + 30 + AR + @inner_width, y + distance_from_y + item.height)
80
+ .arc('se')
81
+ .up(distance_from_y - AR + item.height - default.height - 10)
82
+ .add(self)
83
+
84
+ distance_from_y += [AR, item.height + item.down + VS + (below[i + 1]&.up || 0)].max
85
+ end
86
+
87
+ text = DiagramItem.new('g', attrs: { 'class' => 'diagram-text' }).add(self)
88
+ DiagramItem.new(
89
+ 'title',
90
+ text: @type == 'any' ? 'take one or more branches, once each, in any order' : 'take all branches, once each, in any order'
91
+ ).add(text)
92
+
93
+ DiagramItem.new(
94
+ 'path',
95
+ attrs: {
96
+ 'd' => "M #{x + 30} #{y - 10} h -26 a 4 4 0 0 0 -4 4 v 12 a 4 4 0 0 0 4 4 h 26 z",
97
+ 'class' => 'diagram-text'
98
+ }
99
+ ).add(text)
100
+
101
+ DiagramItem.new(
102
+ 'text',
103
+ text: @type == 'any' ? '1+' : 'all',
104
+ attrs: { 'x' => x + 15, 'y' => y + 4, 'class' => 'diagram-text' }
105
+ ).add(text)
106
+
107
+ DiagramItem.new(
108
+ 'path',
109
+ attrs: {
110
+ 'd' => "M #{x + @width - 20} #{y - 10} h 16 a 4 4 0 0 1 4 4 v 12 a 4 4 0 0 1 -4 4 h -16 z",
111
+ 'class' => 'diagram-text'
112
+ }
113
+ ).add(text)
114
+
115
+ DiagramItem.new(
116
+ 'text',
117
+ text: '↺',
118
+ attrs: { 'x' => x + @width - 10, 'y' => y + 4, 'class' => 'diagram-arrow' }
119
+ ).add(text)
120
+
121
+ self
122
+ end
123
+
124
+ def text_diagram
125
+ multi_repeat = TextDiagram.get_parts(['multi_repeat']).first
126
+ any_all = TextDiagram.rect(@type == 'any' ? '1+' : 'all')
127
+ diagram_td = Choice.text_diagram(self)
128
+ repeat_td = TextDiagram.rect(multi_repeat)
129
+ diagram_td = any_all.append_right(diagram_td, '')
130
+ diagram_td.append_right(repeat_td, '')
131
+ end
132
+
133
+ private
134
+
135
+ def double_enumerate(seq)
136
+ length = seq.length
137
+ seq.each_with_index.map { |item, i| [i, i - length, item] }
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailroadDiagrams
4
+ class NonTerminal < DiagramItem
5
+ def initialize(text, href = nil, title = nil, cls: '')
6
+ super('g', attrs: { 'class' => "non-terminal #{cls}" })
7
+ @text = text
8
+ @href = href
9
+ @title = title
10
+ @cls = cls
11
+ @width = (text.length * CHAR_WIDTH) + 20
12
+ @up = 11
13
+ @down = 11
14
+ @needs_space = true
15
+ end
16
+
17
+ def to_s
18
+ "NonTerminal(#{@text}, href=#{@href}, title=#{@title}, cls=#{@cls})"
19
+ end
20
+
21
+ def format(x, y, width)
22
+ left_gap, right_gap = determine_gaps(width, @width)
23
+
24
+ # Hook up the two sides if self is narrower than its stated width.
25
+ Path.new(x, y).h(left_gap).add(self)
26
+ Path.new(x + left_gap + @width, y).h(right_gap).add(self)
27
+
28
+ DiagramItem.new(
29
+ 'rect',
30
+ attrs: {
31
+ 'x' => x + left_gap,
32
+ 'y' => y - 11,
33
+ 'width' => @width,
34
+ 'height' => @up + @down
35
+ }
36
+ ).add(self)
37
+
38
+ text = DiagramItem.new(
39
+ 'text',
40
+ attrs: {
41
+ 'x' => x + left_gap + (@width / 2),
42
+ 'y' => y + 4
43
+ },
44
+ text: @text
45
+ )
46
+ if @href
47
+ a = DiagramItem.new(
48
+ 'a',
49
+ attrs: {
50
+ 'xlink:href' => @href
51
+ },
52
+ text:
53
+ ).add(self)
54
+ text.add(a)
55
+ else
56
+ text.add(self)
57
+ end
58
+ DiagramItem.new('title', attrs: {}, text: @title).add(self) if @title
59
+ self
60
+ end
61
+
62
+ def text_diagram
63
+ # NOTE: href, title, and cls are ignored for text diagrams.
64
+ TextDiagram.rect(@text)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailroadDiagrams
4
+ class OneOrMore < DiagramItem
5
+ def initialize(item, repeat = nil)
6
+ super('g')
7
+ @item = wrap_string(item)
8
+ repeat ||= Skip.new
9
+ @rep = wrap_string(repeat)
10
+ @width = [@item.width, @rep.width].max + (AR * 2)
11
+ @height = @item.height
12
+ @up = @item.up
13
+ @down = [AR * 2, @item.down + VS + @rep.up + @rep.height + @rep.down].max
14
+ @needs_space = true
15
+ end
16
+
17
+ def to_s
18
+ "OneOrMore(#{@item}, repeat=#{@rep})"
19
+ end
20
+
21
+ def format(x, y, width)
22
+ left_gap, right_gap = determine_gaps(width, @width)
23
+
24
+ # Hook up the two sides if self is narrower than its stated width.
25
+ Path.new(x, y).h(left_gap).add(self)
26
+ Path.new(x + left_gap + @width, y + @height).h(right_gap).add(self)
27
+ x += left_gap
28
+
29
+ # Draw item
30
+ Path.new(x, y).right(AR).add(self)
31
+ @item.format(x + AR, y, @width - (AR * 2)).add(self)
32
+ Path.new(x + @width - AR, y + @height).right(AR).add(self)
33
+
34
+ # Draw repeat arc
35
+ distance_from_y = [AR * 2, @item.height + @item.down + VS + @rep.up].max
36
+ Path.new(x + AR, y).arc('nw').down(distance_from_y - (AR * 2)).arc('ws').add(self)
37
+ @rep.format(x + AR, y + distance_from_y, @width - (AR * 2)).add(self)
38
+ Path.new(x + @width - AR, y + distance_from_y + @rep.height)
39
+ .arc('se')
40
+ .up(distance_from_y - (AR * 2) + @rep.height - @item.height)
41
+ .arc('en')
42
+ .add(self)
43
+
44
+ self
45
+ end
46
+
47
+ def text_diagram
48
+ parts = TextDiagram.get_parts(
49
+ %w[
50
+ line repeat_top_left repeat_left repeat_bot_left repeat_top_right repeat_right repeat_bot_right
51
+ ]
52
+ )
53
+ line, repeat_top_left, repeat_left, repeat_bot_left, repeat_top_right, repeat_right, repeat_bot_right = parts
54
+
55
+ # Format the item and then format the repeat append it to the bottom, after a spacer.
56
+ item_td = @item.text_diagram
57
+ repeat_td = @rep.text_diagram
58
+ fir_width = TextDiagram._max_width(item_td, repeat_td)
59
+ repeat_td = repeat_td.expand(0, fir_width - repeat_td.width, 0, 0)
60
+ item_td = item_td.expand(0, fir_width - item_td.width, 0, 0)
61
+ item_and_repeat_td = item_td.append_below(repeat_td, [])
62
+
63
+ # Build the left side of the repeat line and append the combined item and repeat to its right.
64
+ left_lines = []
65
+ left_lines << (repeat_top_left + line)
66
+ left_lines += [repeat_left + ' '] * ((item_td.height - item_td.entry) + repeat_td.entry - 1)
67
+ left_lines << (repeat_bot_left + line)
68
+ left_td = TextDiagram.new(0, 0, left_lines)
69
+ left_td = left_td.append_right(item_and_repeat_td, '')
70
+
71
+ # Build the right side of the repeat line and append it to the combined left side, item, and repeat's right.
72
+ right_lines = []
73
+ right_lines << (line + repeat_top_right)
74
+ right_lines += [' ' + repeat_right] * ((item_td.height - item_td.exit) + repeat_td.exit - 1)
75
+ right_lines << (line + repeat_bot_right)
76
+ right_td = TextDiagram.new(0, 0, right_lines)
77
+ left_td.append_right(right_td, '')
78
+ end
79
+
80
+ def walk(callback)
81
+ callback.call(self)
82
+ @item.walk(callback)
83
+ @rep.walk(callback)
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailroadDiagrams
4
+ class Optional < DiagramMultiContainer
5
+ def self.new(item, skip = false)
6
+ Choice.new(skip ? 0 : 1, Skip.new, item)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailroadDiagrams
4
+ class OptionalSequence < DiagramMultiContainer
5
+ def self.new(*items)
6
+ return Sequence.new(*items) if items.size <= 1
7
+
8
+ super
9
+ end
10
+
11
+ def initialize(*items)
12
+ super('g', items)
13
+ @needs_space = false
14
+ @width = 0
15
+ @up = 0
16
+ @height = @items.sum(&:height)
17
+ @down = @items.first.down
18
+
19
+ height_so_far = 0.0
20
+
21
+ @items.each_with_index do |item, i|
22
+ @up = [@up, [AR * 2, item.up + VS].max - height_so_far].max
23
+ height_so_far += item.height
24
+
25
+ if i.positive?
26
+ @down = [
27
+ @height + @down,
28
+ height_so_far + [AR * 2, item.down + VS].max
29
+ ].max - @height
30
+ end
31
+
32
+ item_width = item.width + (item.needs_space ? 10 : 0)
33
+ @width += if i.zero?
34
+ AR + [item_width, AR].max
35
+ else
36
+ (AR * 2) + [item_width, AR].max + AR
37
+ end
38
+ end
39
+ end
40
+
41
+ def to_s
42
+ items = @items.map(&:to_s).join(', ')
43
+ "OptionalSequence(#{items})"
44
+ end
45
+
46
+ def format(x, y, width)
47
+ left_gap, right_gap = determine_gaps(width, @width)
48
+ Path.new(x, y).right(left_gap).add(self)
49
+ Path.new(x + left_gap + @width, y + @height).right(right_gap).add(self)
50
+ x += left_gap
51
+ upper_line_y = y - @up
52
+ last = @items.size - 1
53
+
54
+ @items.each_with_index do |item, i|
55
+ item_space = item.needs_space ? 10 : 0
56
+ item_width = item.width + item_space
57
+
58
+ if i.zero?
59
+ # Upper skip
60
+ Path.new(x, y)
61
+ .arc('se')
62
+ .up(y - upper_line_y - (AR * 2))
63
+ .arc('wn')
64
+ .right(item_width - AR)
65
+ .arc('ne')
66
+ .down(y + item.height - upper_line_y - (AR * 2))
67
+ .arc('ws')
68
+ .add(self)
69
+
70
+ # Straight line
71
+ Path.new(x, y).right(item_space + AR).add(self)
72
+ item.format(x + item_space + AR, y, item.width).add(self)
73
+ x += item_width + AR
74
+ y += item.height
75
+ elsif i < last
76
+ # Upper skip
77
+ Path.new(x, upper_line_y)
78
+ .right((AR * 2) + [item_width, AR].max + AR)
79
+ .arc('ne')
80
+ .down(y - upper_line_y + item.height - (AR * 2))
81
+ .arc('ws')
82
+ .add(self)
83
+
84
+ # Straight line
85
+ Path.new(x, y).right(AR * 2).add(self)
86
+ item.format(x + (AR * 2), y, item.width).add(self)
87
+ Path.new(x + item.width + (AR * 2), y + item.height)
88
+ .right(item_space + AR)
89
+ .add(self)
90
+
91
+ # Lower skip
92
+ Path.new(x, y)
93
+ .arc('ne')
94
+ .down(item.height + [item.down + VS, AR * 2].max - (AR * 2))
95
+ .arc('ws')
96
+ .right(item_width - AR)
97
+ .arc('se')
98
+ .up(item.down + VS - (AR * 2))
99
+ .arc('wn')
100
+ .add(self)
101
+
102
+ x += (AR * 2) + [item_width, AR].max + AR
103
+ y += item.height
104
+ else
105
+ # Straight line
106
+ Path.new(x, y).right(AR * 2).add(self)
107
+ item.format(x + (AR * 2), y, item.width).add(self)
108
+ Path.new(x + (AR * 2) + item.width, y + item.height)
109
+ .right(item_space + AR)
110
+ .add(self)
111
+
112
+ # Lower skip
113
+ Path.new(x, y)
114
+ .arc('ne')
115
+ .down(item.height + [item.down + VS, AR * 2].max - (AR * 2))
116
+ .arc('ws')
117
+ .right(item_width - AR)
118
+ .arc('se')
119
+ .up(item.down + VS - (AR * 2))
120
+ .arc('wn')
121
+ .add(self)
122
+ end
123
+ end
124
+ self
125
+ end
126
+
127
+ def text_diagram
128
+ line, line_vertical, roundcorner_bot_left, roundcorner_bot_right,
129
+ roundcorner_top_left, roundcorner_top_right = TextDiagram.get_parts(
130
+ %w[line line_vertical roundcorner_bot_left roundcorner_bot_right roundcorner_top_left roundcorner_top_right]
131
+ )
132
+
133
+ # Format all the child items, so we can know the maximum entry.
134
+ item_tds = @items.map(&:text_diagram)
135
+
136
+ # diagramEntry: distance from top to lowest entry, aka distance from top to diagram entry, aka final diagram entry and exit.
137
+ diagram_entry = item_tds.map(&:entry).max
138
+ # SOILHeight: distance from top to lowest entry before rightmost item, aka distance from skip-over-items line to rightmost entry, aka SOIL height.
139
+ soil_height = item_tds[0...-1].map(&:entry).max
140
+ # topToSOIL: distance from top to skip-over-items line.
141
+ top_to_soil = diagram_entry - soil_height
142
+
143
+ # The diagram starts with a line from its entry up to the skip-over-items line:
144
+ lines = [' '] * top_to_soil
145
+ lines += [roundcorner_top_left + line]
146
+ lines += ["#{line_vertical} "] * soil_height
147
+ lines += [roundcorner_bot_right + line]
148
+ diagram_td = TextDiagram.new(lines.size - 1, lines.size - 1, lines)
149
+
150
+ @items.each_with_index do |item_td, i|
151
+ if i.positive?
152
+ # All items except the leftmost start with a line from their entry down to their skip-under-item line,
153
+ # with a joining-line across at the skip-over-items line:
154
+ lines = ([' '] * top_to_soil) + [line * 2] +
155
+ ([' '] * (diagram_td.exit - top_to_soil - 1)) +
156
+ [line + roundcorner_top_right] +
157
+ ([" #{line_vertical}"] * (item_td.height - item_td.entry - 1)) +
158
+ [" #{roundcorner_bot_left}"]
159
+
160
+ skip_down_td = TextDiagram.new(diagram_td.exit, diagram_td.exit, lines)
161
+ diagram_td = diagram_td.append_right(skip_down_td, '')
162
+
163
+ # All items except the leftmost next have a line from skip-over-items line down to their entry,
164
+ # with joining-lines at their entry and at their skip-under-item line:
165
+ lines = ([' '] * top_to_soil) + [line + roundcorner_top_right +
166
+ # All such items except the rightmost also have a continuation of the skip-over-items line:
167
+ (i < item_tds.size - 1 ? line : ' ')] +
168
+ ([" #{line_vertical} "] * (diagram_td.exit - top_to_soil - 1)) +
169
+ [line + roundcorner_bot_left + line] +
170
+ ([' ' * 3] * (item_td.height - item_td.entry - 1)) +
171
+ [line * 3]
172
+
173
+ entry_td = TextDiagram.new(diagram_td.exit, diagram_td.exit, lines)
174
+ diagram_td = diagram_td.append_right(entry_td, '')
175
+ end
176
+
177
+ part_td = TextDiagram.new(0, 0, [])
178
+ if i < item_tds.size - 1
179
+ # All items except the rightmost have a segment of the skip-over-items line at the top,
180
+ # followed by enough blank lines to push their entry down to the previous item's exit:
181
+ lines = [line * item_td.width] + ([' ' * item_td.width] * (soil_height - item_td.entry))
182
+ soil_segment = TextDiagram.new(0, 0, lines)
183
+ part_td = part_td.append_below(soil_segment, [])
184
+ end
185
+
186
+ part_td = part_td.append_below(item_td, [], move_entry: true, move_exit: true)
187
+
188
+ if i.positive?
189
+ # All items except the leftmost have their skip-under-item line at the bottom.
190
+ soil_segment = TextDiagram.new(0, 0, [line * item_td.width])
191
+ part_td = part_td.append_below(soil_segment, [])
192
+ end
193
+
194
+ diagram_td = diagram_td.append_right(part_td, '')
195
+
196
+ next unless i.positive?
197
+
198
+ # All items except the leftmost have a line from their skip-under-item line to their exit:
199
+ lines = ([' '] * top_to_soil) +
200
+ # All such items except the rightmost also have a joining-line across at the skip-over-items line:
201
+ [(i < item_tds.size - 1 ? line * 2 : ' ')] +
202
+ ([' '] * (diagram_td.exit - top_to_soil - 1)) +
203
+ [line + roundcorner_top_left] +
204
+ ([" #{line_vertical}"] * (part_td.height - part_td.exit - 2)) +
205
+ [line + roundcorner_bot_right]
206
+
207
+ skip_up_td = TextDiagram.new(diagram_td.exit, diagram_td.exit, lines)
208
+ diagram_td = diagram_td.append_right(skip_up_td, '')
209
+ end
210
+
211
+ diagram_td
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailroadDiagrams
4
+ class Path
5
+ attr_reader :x, :y, :attrs
6
+
7
+ def initialize(x, y)
8
+ @x = x
9
+ @y = y
10
+ @attrs = { 'd' => "M#{x} #{y}" }
11
+ end
12
+
13
+ def m(x, y)
14
+ @attrs['d'] += "m#{x} #{y}"
15
+ self
16
+ end
17
+
18
+ def l(x, y)
19
+ @attrs['d'] += "l#{x} #{y}"
20
+ self
21
+ end
22
+
23
+ def h(val)
24
+ @attrs['d'] += "h#{val}"
25
+ self
26
+ end
27
+
28
+ def right(val)
29
+ h([0, val].max)
30
+ end
31
+
32
+ def left(val)
33
+ h(-[0, val].max)
34
+ end
35
+
36
+ def v(val)
37
+ @attrs['d'] += "v#{val}"
38
+ self
39
+ end
40
+
41
+ def down(val)
42
+ v([0, val].max)
43
+ end
44
+
45
+ def up(val)
46
+ v(-[0, val].max)
47
+ end
48
+
49
+ def arc_8(start, dir)
50
+ arc = AR
51
+ s2 = 1 / Math.sqrt(2) * arc
52
+ s2inv = arc - s2
53
+ sweep = dir == 'cw' ? '1' : '0'
54
+ path = "a #{arc} #{arc} 0 0 #{sweep} "
55
+
56
+ sd = start + dir
57
+ offset = case sd
58
+ when 'ncw' then [s2, s2inv]
59
+ when 'necw' then [s2inv, s2]
60
+ when 'ecw' then [-s2inv, s2]
61
+ when 'secw' then [-s2, s2inv]
62
+ when 'scw' then [-s2, -s2inv]
63
+ when 'swcw' then [-s2inv, -s2]
64
+ when 'wcw' then [s2inv, -s2]
65
+ when 'nwcw' then [s2, -s2inv]
66
+ when 'nccw' then [-s2, s2inv]
67
+ when 'nwccw' then [-s2inv, s2]
68
+ when 'wccw' then [s2inv, s2]
69
+ when 'swccw' then [s2, s2inv]
70
+ when 'sccw' then [s2, -s2inv]
71
+ when 'seccw' then [s2inv, -s2]
72
+ when 'eccw' then [-s2inv, -s2]
73
+ when 'neccw' then [-s2, -s2inv]
74
+ end
75
+
76
+ path += offset.map(&:to_s).join(' ')
77
+ @attrs['d'] += path
78
+ self
79
+ end
80
+
81
+ def arc(sweep)
82
+ x = AR
83
+ y = AR
84
+ x *= -1 if sweep[0] == 'e' || sweep[1] == 'w'
85
+ y *= -1 if sweep[0] == 's' || sweep[1] == 'n'
86
+ cw = %w[ne es sw wn].include?(sweep) ? 1 : 0
87
+ @attrs['d'] += "a#{AR} #{AR} 0 0 #{cw} #{x} #{y}"
88
+ self
89
+ end
90
+
91
+ def add(parent)
92
+ parent.children << self
93
+ self
94
+ end
95
+
96
+ def write_svg(write)
97
+ write.call('<path')
98
+ @attrs.sort.each do |name, value|
99
+ write.call(" #{name}=\"#{RailroadDiagrams.escape_attr(value)}\"")
100
+ end
101
+ write.call(' />')
102
+ end
103
+
104
+ def format
105
+ @attrs['d'] += 'h.5'
106
+ self
107
+ end
108
+
109
+ def text_diagram
110
+ TextDiagram.new(0, 0, [])
111
+ end
112
+
113
+ def to_s
114
+ "Path(#{@x.inspect}, #{@y.inspect})"
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailroadDiagrams
4
+ class Sequence < DiagramMultiContainer
5
+ def initialize(*items)
6
+ super('g', items)
7
+ @needs_space = false
8
+ @up = 0
9
+ @down = 0
10
+ @height = 0
11
+ @width = 0
12
+ @items.each do |item|
13
+ @width += item.width + (item.needs_space ? 20 : 0)
14
+ @up = [@up, item.up - @height].max
15
+ @height += item.height
16
+ @down = [@down - item.height, item.down].max
17
+ end
18
+ @width -= 10 if @items[0].needs_space
19
+ @width -= 10 if @items[-1].needs_space
20
+ end
21
+
22
+ def to_s
23
+ items = @items.map(&:to_s).join(', ')
24
+ "Sequence(#{items})"
25
+ end
26
+
27
+ def format(x, y, width)
28
+ left_gap, right_gap = determine_gaps(width, @width)
29
+ Path.new(x, y).h(left_gap).add(self)
30
+ Path.new(x + left_gap + @width, y + @height).h(right_gap).add(self)
31
+ x += left_gap
32
+ @items.each_with_index do |item, i|
33
+ if item.needs_space && i.positive?
34
+ Path.new(x, y).h(10).add(self)
35
+ x += 10
36
+ end
37
+ item.format(x, y, item.width).add(self)
38
+ x += item.width
39
+ y += item.height
40
+ if item.needs_space && i < @items.length - 1
41
+ Path.new(x, y).h(10).add(self)
42
+ x += 10
43
+ end
44
+ end
45
+ self
46
+ end
47
+
48
+ def text_diagram
49
+ separator, = TextDiagram.get_parts(['separator'])
50
+ diagram_td = TextDiagram.new(0, 0, [''])
51
+ @items.each do |item|
52
+ item_td = item.text_diagram
53
+ item_td = item_td.expand(1, 1, 0, 0) if item.needs_space
54
+ diagram_td = diagram_td.append_right(item_td, separator)
55
+ end
56
+ diagram_td
57
+ end
58
+ end
59
+ end