├── .gitignore
├── LICENSE
├── README.md
├── elm.json
├── html
├── built.js
├── css
│ ├── dark-theme.css
│ └── layout.css
└── img
│ ├── arrow-left.svg
│ ├── bin.svg
│ ├── copy.svg
│ ├── energy-background.svg
│ ├── favicon.png
│ ├── logo.svg
│ └── tidy.svg
├── index.html
├── modd.conf
├── package.json
├── readme
└── browser-stack.png
└── src
├── AutoLayout.elm
├── Build.elm
├── IdMap.elm
├── LinearDict.elm
├── Main.elm
├── Model.elm
├── Parse.elm
├── Update.elm
├── Vec2.elm
└── View.elm
/.gitignore:
--------------------------------------------------------------------------------
1 | # elm-package generated files
2 | elm-stuff
3 |
4 | # elm-repl generated files
5 | repl-temp-*
6 |
7 | # intellij project files
8 | regex-nodes.iml
9 | .idea
10 |
11 | # file save watcher
12 | modd.exe
13 |
14 | package-lock.json
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Johannes Vollmer
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Regex Nodes
2 |
3 | [This node-based regular expression editor](https://johannesvollmer.github.io/regex-nodes/)
4 | helps you understand and edit regular expressions for use in your Javascript code.
5 |
6 | > If your regular expressions are complex enough to give this editor relevance,
7 | > you probably should consider not using regular expressions, haha.
8 |
9 | # Why Nodes?
10 |
11 | One of the problems with regular expressions is
12 | that they get quite messy very quickly. Operator
13 | precedence is not always obvious and can be misleading.
14 | Nodes, on the other hand, are a visual hierarchy. A text-based regex
15 | cannot simply be broken into several lines or indented,
16 | because that would alter the meaning of the expression.
17 |
18 | The other major benefit of nodes is that the editor will prevent you from
19 | producing invalid expressions. Other regex editors analyze the possibly incorrect
20 | regular expression that the user has come up with. The node editor will
21 | allow you to enter your intention and generate a correct regular expression.
22 |
23 | In addition, nodes offer various other advantages, such as
24 | reusing subexpressions, automatic character escaping, grouping and parameterizing expressions,
25 | and automatic optimizations.
26 |
27 |
28 | # Core Features
29 | - Construct regular expressions using a visual editor
30 | - Load existing regular expressions from your Javascript code into the editor and edit it utilizing nodes
31 | - Use the generated expression in Javascript
32 | - See effects of the regular expression live using a customizable example text
33 | - Coming Soon: Reuse common patterns to not spend time reinventing the regex wheel
34 |
35 |
36 | # How to use
37 |
38 | See [this blog post](https://johannesvollmer.github.io/2019/announcing-regex-nodes/).
39 | It explains how to handle the nodes and what the buttons do.
40 |
41 | # Build
42 |
43 | With elm installed on your system, run
44 | `elm make src/Main.elm --output=html/built.js`. Also, see
45 | [compiling elm with optimization enabled](https://elm-lang.org/0.19.0/optimize).
46 |
47 | Alternatively, use [modd](https://github.com/cortesi/modd) or `npm run watch`
48 | in this directory to compile on every file save.
49 |
50 |
51 | # Roadmap
52 | 1. As I have realized that node groups would not be worth development time
53 | right now, the editor should offer common regex patterns as hard-coded nodes.
54 | When parsing a regular expression, those patterns should be recognized.
55 | 2. To fully quality as an editor, the parser must support repetition ranges in curly
56 | braces and Unicode literals at all costs.
57 | 3. Simply connecting and rearranging properties of set nodes and sequence nodes.
58 |
59 | # Project Songs (archived here for future nostalgia)
60 | - Sorsari: Children of Gaia
61 | - Barnacle Boi: Downpour
62 | - Yedgar: Asura
63 |
64 | # Shoutout
65 |
66 | [](https://www.browserstack.com/)
67 |
68 | Thanks to BrowserStack, we can make sure this website runs on any browser, for free.
69 | BrowserStack loves Open Source, and Open Source loves BrowserStack.
70 |
71 |
72 | # To Do
73 | - [ ] Add automated tests
74 | - [x] Example text for instant feedback
75 | - [x] Implement all node types
76 | - [x] Automatic node width calculation
77 | - [x] Initial node setup for an easy start
78 | - [x] After improving parsing, add a more interesting start setup
79 | - [x] Build Scripts + Build to Github Pages
80 | - [x] Use optimized builds instead of debug builds for github pages
81 | - [x] Use node width and property count when layouting parsed nodes
82 | Or use iterative physics approach (force-directed layout)
83 | - [ ] While in "Add Nodes", press enter to pick the first option
84 | - [x] Parse regex code in "Add Nodes"
85 | - [x] Charset `[abc]`
86 | - [x] Char ranges `[a-bc-d][^a-b]`
87 | - [ ] Fix composed negation being ignored
88 | - [x] Alternation `(a|b)`
89 | - [x] Escaped Characters `\W`
90 | - [ ] Unicode literals? `\x01`
91 | - [x] Sequences `the( |_)`
92 | - [x] Look Ahead `a(?!b)`
93 | - [x] Quantifiers `a?b{0,3}`
94 | - [x] `a?b??c+?d*?`
95 | - [x] `b{0,3} c{1,} d{5}`
96 | - [x] Positioning `(^, $)`
97 | - [x] How to delete nodes
98 | - [ ] Option: Delete with children
99 | - [ ] Option: When deleting a node, try to retain connections
100 | (If the deleted node is a single property node,
101 | connect the otherwise now opened connections)
102 | - [x] Do not adjust example for every node move
103 | - [x] Middle mouse button view movement
104 | - [x] Blur text input on non-middle-mouse-clicking anywhere
105 | - [x] Simplify UX of changing order in "Set Node"s
106 | - [x] Prevent cyclic connections
107 | - [x] Tooltips
108 | - [ ] Custom, styled tooltips?
109 | - [ ] Live Explanations!!
110 | - [ ] Move node including all input nodes? (Next to duplicate and delete)
111 | - [x] Iterative Auto-layout using physics simulation?
112 | - [ ] UX: Animate while holding button down instead of once per click
113 | - [ ] Reconnect replaced connections
114 | when reverting connection prototype
115 | - [x] Instantiate Nodes centered to the screen
116 | - [ ] Using the real window size
117 | - [ ] Consider rewriting Css to Sass
118 | - [ ] On input focus select container node
119 | - [ ] Premade Reusable Node Patterns!
120 | - [ ] UX: A node, which by default is collapsed, and on click
121 | reveals all connected nodes which are otherwise hidden
122 | (Think of it more like a section in a wiki which is summarized by the header)
123 | - [ ] Unicode in the output regex
124 | - [ ] Unicode literal node?
125 | - [ ] Support mobile devices
126 | - [x] Enable sharing node graphs by url?
127 | - [ ] Do not always update URI, but only when user wants to share?
128 | - [ ] Reset view button when panned too far away
129 | - [x] Deduplicate parsed node graph
130 | - [ ] Turn this whole project into a NodeJS monster just for testing and minification of the generated javascript.... or wait until a better solution arrives.
131 |
--------------------------------------------------------------------------------
/elm.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "application",
3 | "source-directories": [
4 | "src"
5 | ],
6 | "elm-version": "0.19.1",
7 | "dependencies": {
8 | "direct": {
9 | "elm/browser": "1.0.2",
10 | "elm/core": "1.0.5",
11 | "elm/html": "1.0.0",
12 | "elm/json": "1.1.3",
13 | "elm/regex": "1.0.0",
14 | "elm/svg": "1.0.1",
15 | "elm/url": "1.0.0",
16 | "mpizenberg/elm-pointer-events": "4.0.2",
17 | "rtfeldman/elm-css": "16.1.1",
18 | "truqu/elm-base64": "2.0.4"
19 | },
20 | "indirect": {
21 | "elm/bytes": "1.0.8",
22 | "elm/file": "1.0.5",
23 | "elm/time": "1.0.0",
24 | "elm/virtual-dom": "1.0.3",
25 | "rtfeldman/elm-hex": "1.0.0"
26 | }
27 | },
28 | "test-dependencies": {
29 | "direct": {},
30 | "indirect": {}
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/html/css/dark-theme.css:
--------------------------------------------------------------------------------
1 |
2 | /* TODO remove confirm-deletion-alert */
3 |
4 | *::selection {
5 | background: teal;
6 | color: #fff;
7 | }
8 |
9 |
10 |
11 | /* FIXME will not work when removing parent classes! */
12 | .property, .graph-node, .connector, #search input, #match-limit, #match-limit input,
13 | .property .chars.input,.property .int.input, #edit-example-container,
14 | .selected.graph-node .menu
15 | {
16 | transition: background-color 250ms, opacity 100ms;
17 | }
18 |
19 | .property .chars.input, .property .int.input {
20 | transition: border-color 150ms;
21 | }
22 |
23 | .property, nav a, #example-text, .graph-node .menu {
24 | transition: color 150ms;
25 | }
26 |
27 | .graph-node .menu, .selected.graph-node .menu {
28 | transition: height 80ms, top 80ms;
29 | }
30 |
31 |
32 | code, #search #results code {
33 | font-family: 'Ubuntu Mono', source-code-pro, Consolas, monospace;
34 | }
35 |
36 | textarea:focus, input:focus {
37 | outline: none;
38 | }
39 |
40 | body {
41 | caret-color: teal;
42 | background-color: #222;
43 | color: #ddd;
44 | }
45 |
46 | header > h1 > a {
47 | color: inherit;
48 | text-decoration: inherit;
49 | }
50 |
51 | header > a {
52 | color: #666;
53 | text-decoration: none;
54 | }
55 | header > a:hover {
56 | color: #888;
57 | }
58 | nav {
59 | background-color: #333;
60 | }
61 |
62 | #search, #search input {
63 | text-align: center;
64 | }
65 | #search #results {
66 | background-color: #444;
67 | color: #bbb;
68 | }
69 | #search #results > *:hover {
70 | background-color: #555;
71 | color: #ddd;
72 | }
73 | #search #results .description {
74 | display: none;
75 | }
76 |
77 | #search #results > *:hover .description,
78 | #search #results:not(:hover) > *:first-child .description
79 | {
80 | display: block;
81 | }
82 |
83 | #search input, #match-limit input {
84 | background: #444;
85 | border: 2px solid #444;
86 | color: #fff;
87 | }
88 | #search input:focus, #match-limit input:focus {
89 | background: transparent;
90 | border: 2px solid #444;
91 | }
92 | #search #results code {
93 | color: #0bb;
94 | }
95 | #search #results .description {
96 | opacity: 0.3;
97 | }
98 |
99 | #expression-result {
100 | background: #1b1b1b;
101 | color: #ddd;
102 | }
103 | #expression-result #declaration {
104 | color: #666;
105 | font: inherit;
106 | }
107 | #expression-result #lock {
108 | fill: #666;
109 | stroke: #666;
110 | }
111 | #expression-result #lock:hover {
112 | background-color: #242424;
113 | }
114 | #expression-result #lock.checked {
115 | background-color: #111;
116 | fill: #ddd;
117 | stroke: #ddd;
118 | }
119 |
120 | #match-limit {
121 | color: #999;
122 | }
123 |
124 | #edit-example {
125 | color: #999;
126 | }
127 | #edit-example:hover {
128 | background-color: #393939;
129 | }
130 | .editing-example-text #edit-example {
131 | background-color: #222;
132 | color: #ddd;
133 | }
134 |
135 | #history .button {
136 | opacity: 0.7;
137 | }
138 | #history .button:hover {
139 | opacity: 0.9;
140 | }
141 | #history .disabled.button {
142 | opacity: 0.2;
143 | }
144 |
145 | #example-text {
146 | /*animation: enterEditableText 0.2s forwards reverse; FIXME would flash on page load */
147 | color: #444;
148 | opacity: 0.7; /* avoid too bright text highlighting */
149 | }
150 | #example-text .match {
151 | color: #777;
152 | }
153 | .editing-example-text #example-text {
154 | animation: enterEditableText 0.2s forwards;
155 |
156 | border: none; /* reset textarea styling */
157 | background: transparent;
158 | }
159 |
160 | /* on-spawn animation is started when example text area is swapped with div */
161 | @keyframes enterEditableText {
162 | from { color: #444; }
163 | to { color: #888; }
164 | }
165 |
166 | .alert {
167 | background-color: #00000066;
168 | }
169 | .alert .dialog-box p {
170 | background-color: #eee;
171 | font-weight: bold;
172 | border-top: 5px solid #804;
173 | color: #444;
174 | }
175 | .alert .button {
176 | background-color: #fff;
177 | color: #444;
178 | }
179 | .alert .confirm {
180 | background-color: #804;
181 | color: #eee;
182 | }
183 | .notification {
184 | background: #111;
185 | color: #da0;
186 | }
187 | .notification div {
188 | color: #dddddd77;
189 | }
190 | .show.notification {
191 | animation: wobble 0.2s forwards;
192 | }
193 | @keyframes wobble {
194 | 0% { transform: translate(0px, 0px) }
195 | 33% { transform: translate(-4px, 0px) }
196 | 66% { transform: translate(4px, 0px) }
197 | 100% { transform: translate(0px, 0px) }
198 | }
199 |
200 |
201 |
202 | .connection {
203 | stroke: teal;
204 | }
205 |
206 | .graph-node .properties {
207 | background-color: #eee;
208 | box-shadow: 2px 2px 0 0 #00000044;
209 | }
210 | .graph-node {
211 | cursor: pointer;
212 | }
213 |
214 | .output.graph-node .properties {
215 | color: #088;
216 | }
217 |
218 | .move-dragging * {
219 | cursor: grabbing;
220 | }
221 | .connect-dragging * {
222 | cursor: pointer;
223 | }
224 |
225 | .graph-node .menu {
226 | background-color: #00000088;
227 | }
228 | .graph-node .menu .button {
229 | opacity: 0.6;
230 | }
231 | .graph-node .menu .button:hover {
232 | background-color: #111;
233 | color: #ddd;
234 | opacity: 1;
235 | }
236 | .graph-node .menu .delete.button:hover {
237 | background-color: #804;
238 | }
239 |
240 | .property:hover {
241 | background-color: #fff;
242 | }
243 | .connect-dragging .property:hover {
244 | background-color: #eeeeee22;
245 | }
246 |
247 | .property .chars.input, .property .int.input, .property .char.input, .property .regex-preview {
248 | border: 1px solid transparent;
249 | background-color: #22222222;
250 | font-family: 'Ubuntu Mono', source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
251 | }
252 | .property .chars.input:focus, .property .int.input:focus, .property .char.input:focus {
253 | border: 1px solid #333;
254 | background-color: transparent;
255 | }
256 |
257 | .property .regex-preview {
258 | opacity: 0.5;
259 | }
260 |
261 | .connector {
262 | background-color: teal;
263 | }
264 | .inactive.connector {
265 | opacity: 0;
266 | }
267 |
268 | .graph-node .properties {
269 | color: #333;
270 | }
271 | .graph-node input {
272 | color: inherit;
273 | }
274 |
275 | .connect-dragging .graph-node:not(.connecting) .property.connectable-input {
276 | background-color: #ddd;
277 | }
278 | .connect-dragging .graph-node .properties {
279 | background-color: #444;
280 | }
281 |
282 | .connect-dragging .property:not(.connectable-input) {
283 | opacity: 0.25;
284 | color: #fff;
285 | }
286 | .connect-dragging .property:first-child:not(.connectable-input) {
287 | opacity: 0.6;
288 | }
289 |
290 | .connecting.graph-node .property:first-child {
291 | animation: flashToTealBackground 1s ease-out forwards;
292 | background-image: url("../img/energy-background.svg");
293 | background-repeat: no-repeat;
294 | background-position: 0% 0%;
295 | background-size: 100% 100%;
296 | opacity: 1;
297 | color: #fff;
298 | }
299 |
300 | .connect-dragging .connector {
301 | background-color: #555;
302 | }
303 | .connect-dragging .connectable-input.property .connector {
304 | background-color: transparent;
305 | }
306 | .connect-dragging .graph-node:not(.connecting) .connectable-input.property .left.connector {
307 | background-color: teal;
308 | }
309 |
310 | .connecting.graph-node .property:first-child .connector {
311 | opacity: 0;
312 | }
313 |
314 | .connect-dragging .connection:not(.prototype) {
315 | stroke: #555;
316 | }
317 | .connect-dragging .prototype.connection {
318 | animation: fromSolidToDash 2s ease-out forwards;
319 | }
320 | :not(.connect-dragging) .connection:not(.prototype) {
321 | animation: fromDashToSolid 2s ease-in-out forwards;
322 | }
323 |
324 | .may-drag-connect .property:hover {
325 | background-color: teal;
326 | animation: flashToTealBackground 0.3s ease-out forwards;
327 | color: #ffffff77;
328 | }
329 | .may-drag-connect .property:hover .connector {
330 | background-color: #ffffff44;
331 | }
332 |
333 |
334 |
335 |
336 | @keyframes flashToTealBackground {
337 | from {
338 | background-color: #4FC195;
339 | }
340 | to {
341 | background-color: teal;
342 | }
343 | }
344 |
345 | /*@keyframes fromDashToSolid {
346 | 0% {
347 | stroke-dasharray: 9;
348 | stroke: #4FC195;
349 | }
350 | 99.99% {
351 | stroke-dasharray: 3;
352 | }
353 | 100% {
354 | stroke-dasharray: 0;
355 | stroke: teal;
356 | }
357 | }*/
358 | @keyframes fromSolidToDash {
359 | 0% {
360 | /*stroke-dasharray: 0;*/
361 | stroke: #4FC195;
362 | }
363 | /*0.001% {
364 | stroke-dasharray: 3;
365 | }*/
366 | 100% {
367 | /*stroke-dasharray: 9;*/
368 | stroke: teal;
369 | }
370 | }
371 |
372 |
373 |
374 |
375 | /* LOCK */
376 |
377 | #lock #bracket {
378 | fill: none;
379 | stroke: inherit;
380 | stroke-width: 1;
381 |
382 | /*animation: closeBracket 0.6s forwards reverse; FIXME */
383 | }
384 |
385 | #lock #body {
386 | fill: inherit;
387 | }
388 |
389 | .checked#lock #bracket {
390 | animation: closeBracket 0.4s forwards;
391 | }
392 |
393 | @keyframes closeBracket {
394 | 0% { transform: translate(0,0) }
395 | 40% { transform: translate(0,-0.5px) }
396 | 70% { transform: translate(0,2.5px) }
397 | 100% { transform: translate(0,2px) }
398 | }
399 |
400 |
401 |
--------------------------------------------------------------------------------
/html/css/layout.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css?family=Ubuntu+Mono:700');
2 | @import url('https://fonts.googleapis.com/css?family=Roboto+Condensed:400,700');
3 |
4 | /* TODO remove confirm-deletion-alert */
5 |
6 | * {
7 | margin: 0;
8 | padding: 0;
9 | overflow: hidden;
10 |
11 | font-family: 'Roboto Condensed', "Roboto", "Ubuntu", "Cantarell", sans-serif;
12 | -webkit-font-smoothing: antialiased;
13 | -moz-osx-font-smoothing: grayscale;
14 | }
15 |
16 | body, #main, #overlay, #node-graph, #connection-graph, #example-text, #confirm-deletion-alert {
17 | position: fixed;
18 | top:0; left:0;
19 | width: 100vw;
20 | height: 100vh;
21 | overflow: hidden;
22 | }
23 |
24 | .button, #node-graph :not(input) {
25 | user-select: none; /* wont work for double click */
26 | }
27 | .button {
28 | cursor: pointer;
29 | }
30 |
31 | .button::selection, #node-graph :not(input)::selection {
32 | color: inherit;
33 | background: transparent; /* user-select:none wont work for double click */
34 | }
35 |
36 |
37 | .transform-wrapper {
38 | position: absolute;
39 | display: block;
40 |
41 | top: 0; left: 0;
42 | overflow: visible;
43 | }
44 |
45 | #example-text {
46 | font-size: 1.2em;
47 |
48 | overflow: hidden;
49 | box-sizing: border-box;
50 | padding: 70px 12px 12px 12px;
51 | text-align: justify;
52 | resize: none;
53 | white-space: pre-line; /* required for align:justify in a textarea */
54 |
55 | user-select: auto;
56 | pointer-events: all;
57 | }
58 |
59 | .editing-example-text #example-text {
60 | }
61 |
62 | .editing-example-text #connection-graph, .editing-example-text #node-graph {
63 | display: none;
64 | }
65 |
66 | #overlay {
67 | pointer-events: none;
68 | }
69 | #overlay > * {
70 | pointer-events: all;
71 | }
72 |
73 | #overlay {
74 | display: flex;
75 | flex-direction: column;
76 | justify-content: space-between;
77 | align-items: stretch;
78 | }
79 |
80 | nav {
81 | display: flex;
82 | flex-direction: row;
83 | justify-content: space-between;
84 | align-items: stretch;
85 | }
86 |
87 | header {
88 | display: flex;
89 | flex-direction: row;
90 | justify-content: space-between;
91 | align-items: baseline;
92 |
93 | padding: 8px;
94 | height: 42px;
95 | }
96 | header > h1 {
97 | font-size: 24px;
98 | font-weight: normal;
99 | display: inline;
100 | margin-left: 8px;
101 | margin-right: 8px;
102 | }
103 | header > a {
104 | margin-left: 12px;
105 | font-size: 18px;
106 | }
107 | header > a, header > h1 {
108 | transform: translate(0px, -10px);
109 | }
110 | header img {
111 | display: inline-block;
112 | height: 100%;
113 | transform: scale(0.9)
114 | }
115 |
116 | #example-options {
117 | display: flex;
118 | flex-direction: row;
119 | justify-content: flex-end;
120 | align-items: center;
121 | }
122 |
123 | #match-limit {
124 | opacity: 0;
125 | pointer-events: none;
126 | }
127 |
128 | #match-limit input {
129 | width: 70px;
130 | padding: 4px;
131 | margin: 0 20px 0 20px;
132 | }
133 |
134 | .editing-example-text #match-limit {
135 | opacity: 1;
136 | pointer-events: all;
137 | }
138 |
139 | #edit-example {
140 | padding: 20px;
141 | }
142 |
143 | #search {
144 | position: fixed;
145 | top: 8px;
146 | left: 33vw;
147 | width: 33vw;
148 |
149 | display: flex;
150 | flex-direction: column;
151 | justify-content: flex-start;
152 | align-items: stretch;
153 | }
154 | #history {
155 | position: fixed;
156 | top: 8px;
157 | left: 68vw;
158 | height: 44px;
159 |
160 | display: flex;
161 | flex-direction: row;
162 | justify-content: flex-start;
163 | align-items: center;
164 | }
165 | #history .button {
166 | padding: 8px;
167 | }
168 | #history .disabled.button {
169 | pointer-events: none;
170 | }
171 | #history .button:hover {
172 | background-color: #444;
173 | }
174 | #history img {
175 | height: 20px;
176 | }
177 | #history #redo img {
178 | transform: scale(-1, 1);
179 | }
180 |
181 | #search input {
182 | font-size: 18px;
183 | padding: 8px;
184 | box-sizing: border-box;
185 | }
186 |
187 | #search #results {
188 | max-height: 80vh;
189 | overflow-x: hidden;
190 | overflow-y: auto;
191 | }
192 |
193 | #search #results > * {
194 | font-size: 18px;
195 | padding: 6px;
196 | }
197 |
198 | #expression-result {
199 | display: flex;
200 | flex-direction: row;
201 | align-items: stretch;
202 | max-height: 40vh;
203 | overflow-y: auto;
204 |
205 | font-size: 28px;
206 | }
207 |
208 | .no#expression-result {
209 | visibility: hidden;
210 | }
211 |
212 | #expression-result code {
213 | overflow-y: auto;
214 | text-align: center;
215 | flex-grow: 1;
216 | padding: 12px;
217 | }
218 |
219 | #expression-result #lock {
220 | height: 100%;
221 | }
222 |
223 | #lock svg {
224 | padding: 0 32px;
225 | height: 100%;
226 | transform: scale(0.5);
227 | }
228 |
229 | .alert {
230 | position: absolute;
231 | top:0; left:0;
232 | width: 100vw; height: 100vh;
233 |
234 | display: flex;
235 | visibility: hidden;
236 | }
237 |
238 | .show.alert {
239 | visibility: visible;
240 | }
241 |
242 | .dialog-box {
243 | margin: 25vh auto auto auto;
244 | font-size: 1.4em;
245 |
246 | display: flex;
247 | flex-direction: column;
248 | align-items: stretch;
249 | text-align: center;
250 | }
251 | .dialog-box p {
252 | padding: 18px;
253 | }
254 | .dialog-box .options {
255 | display: flex;
256 | flex-direction: row;
257 | align-items: stretch;
258 | }
259 | .dialog-box .button {
260 | padding: 12px;
261 | width: 50%;
262 | }
263 | .notification {
264 | position: absolute;
265 | bottom: 80px;
266 | right: 40px;
267 | visibility: hidden;
268 | padding: 12px;
269 | }
270 | .show.notification {
271 | visibility: visible;
272 | }
273 |
274 | .graph-node {
275 | position: absolute;
276 | top:0; left:0;
277 | font-size: 14px;
278 |
279 | overflow: visible; /* display the menu */
280 | }
281 |
282 | .graph-node .properties {
283 | display: flex;
284 | flex-direction: column;
285 | align-items: stretch;
286 | align-content: stretch;
287 | }
288 |
289 | .graph-node .menu {
290 | position: absolute;
291 | right: 0; top: 0;
292 | height: 0;
293 |
294 | display: flex;
295 | flex-direction: row;
296 | }
297 | #main:not(.connect-dragging) .selected.graph-node .menu {
298 | top: -25px;
299 | height: 25px;
300 | }
301 | .graph-node .menu .button, .menu .button img {
302 | height: 100%;
303 | }
304 | .menu .button img {
305 | margin: 0; padding: 0;
306 | transform: scale(0.8);
307 | height: 100%;
308 | }
309 |
310 | .property {
311 | height: 25px;
312 | display: flex;
313 | flex-direction: row;
314 | align-items: center;
315 | align-content: stretch;
316 | justify-content: space-between;
317 | }
318 |
319 | /* align input items to the left by filling space with the name */
320 | .property > .title {
321 | text-align: left;
322 | margin-right: 6px;
323 | flex-grow: 1;
324 | text-overflow: ellipsis;
325 | white-space: nowrap;
326 | }
327 |
328 | /*.property.main*/ .property:first-child > .title {
329 | text-align: center;
330 | font-weight: 700;
331 | }
332 |
333 | /* override priority, maximise text input size */
334 | .property > .chars.input, .property > .int.input {
335 | flex-grow: 100;
336 | }
337 |
338 | /* override priority, maximise text input size */
339 | .property > .chars.input, .property > .int.input, .property > .char.input {
340 | text-align: center;
341 | }
342 |
343 | .property .regex-preview {
344 | font-size: 0.7em;
345 | padding: 0 2px;
346 | text-overflow: ellipsis;
347 | white-space: nowrap;
348 | flex-shrink: 100;
349 | border-radius: 1px;
350 | }
351 |
352 | .property input {
353 | padding: 2px;
354 | font-size: 0.7em;
355 | width: 10px;
356 | }
357 |
358 | .connector {
359 | width: 4px;
360 | height: 100%;
361 | flex-shrink: 0;
362 | }
363 |
364 | .left.connector {
365 | margin-right: 6px;
366 | }
367 |
368 | .right.connector {
369 | margin-left: 6px;
370 | }
371 |
372 | .inactive.connector {
373 | opacity: 0;
374 | }
375 |
376 | .connect-dragging .property:not(.connectable-input) {
377 | pointer-events: none;
378 | }
379 |
380 |
381 | .connection {
382 | stroke-linecap: round;
383 | stroke-width: 3;
384 | fill: none;
385 | }
386 |
--------------------------------------------------------------------------------
/html/img/arrow-left.svg:
--------------------------------------------------------------------------------
1 |
2 |
21 |
--------------------------------------------------------------------------------
/html/img/bin.svg:
--------------------------------------------------------------------------------
1 |
2 |
28 |
--------------------------------------------------------------------------------
/html/img/copy.svg:
--------------------------------------------------------------------------------
1 |
2 |
26 |
--------------------------------------------------------------------------------
/html/img/energy-background.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/html/img/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/johannesvollmer/regex-nodes/61121c8a189edffda56f31b3a3de55e69a716253/html/img/favicon.png
--------------------------------------------------------------------------------
/html/img/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
9 |
--------------------------------------------------------------------------------
/html/img/tidy.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Regex Nodes
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
30 |
31 |
--------------------------------------------------------------------------------
/modd.conf:
--------------------------------------------------------------------------------
1 | **/*.elm {
2 | prep: elm make src/Main.elm --optimize --output=html/built.js
3 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": { },
3 |
4 | "scripts": {
5 | "watch": "nodemon -x \"elm make src/Main.elm --optimize --output=html/built.js\""
6 | },
7 |
8 | "nodemonConfig": {
9 | "verbose": true,
10 | "ignore": [
11 | "elm-stuff"
12 | ],
13 | "ext": "elm"
14 | }
15 | }
--------------------------------------------------------------------------------
/readme/browser-stack.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/johannesvollmer/regex-nodes/61121c8a189edffda56f31b3a3de55e69a716253/readme/browser-stack.png
--------------------------------------------------------------------------------
/src/AutoLayout.elm:
--------------------------------------------------------------------------------
1 | module AutoLayout exposing (..)
2 |
3 | import Array
4 | import Dict
5 | import IdMap
6 | import Model exposing (..)
7 | import Set
8 | import Vec2 exposing (Vec2)
9 |
10 |
11 |
12 | layout: Bool -> NodeId -> Nodes -> Nodes
13 | layout hard nodeId nodes =
14 | let
15 | -- build simulation from real node graph
16 | current = buildBlockGraph nodeId nodes
17 |
18 | -- do a manual base layout as a starting point, if desired
19 | baseBlocks = if hard
20 | then baseLayout nodeId current
21 | else current
22 |
23 | -- do an iterative physical layout
24 | smoothedBlocks = forceBasedLayout baseBlocks
25 |
26 | finalBlocks = smoothedBlocks
27 |
28 | -- move all nodes such that the main node does not move
29 | delta = Maybe.map2 (\original new -> Vec2.sub original.position new.position)
30 | (IdMap.get nodeId nodes) (Dict.get nodeId finalBlocks)
31 | |> Maybe.withDefault Vec2.zero
32 |
33 | -- transfer simulation to real node graph
34 | updateNode id nodeView =
35 | case Dict.get id finalBlocks of
36 | Nothing -> nodeView
37 | Just simulatedBlock ->
38 | { nodeView | position = Vec2.add delta simulatedBlock.position }
39 |
40 | in IdMap.updateAll updateNode nodes
41 |
42 |
43 | type alias NodeBlock =
44 | { position: Vec2 -- top left corner
45 | , size: Vec2 -- rectangle width x height
46 | , inputs: List { property: Int, connected: NodeId }
47 | }
48 |
49 | type alias NodeBlocks = Dict.Dict NodeId NodeBlock
50 |
51 |
52 | buildBlockGraph: NodeId -> Nodes -> NodeBlocks
53 | buildBlockGraph nodeId nodes =
54 | case IdMap.get nodeId nodes of
55 | Nothing -> Dict.empty
56 | Just nodeView ->
57 | let
58 | properties = nodeProperties nodeView.node
59 |
60 | size = Vec2 (nodeWidth nodeView.node) (List.length properties |> toFloat |> (*) propertyHeight)
61 | positionedBlock = NodeBlock nodeView.position size
62 |
63 | getInputs property = case property.contents of
64 | ConnectingProperties _ props _ -> List.map Just (Array.toList props)
65 | ConnectingProperty prop _ -> [ prop ]
66 | _ -> [ Nothing ]
67 |
68 | inputs = properties |> List.map getInputs |> flattenList
69 | indexedInputs = inputs |> List.indexedMap
70 | (\index input -> Maybe.map (\i -> { property = index, connected = i }) input)
71 |
72 | filteredInputs = List.filterMap identity indexedInputs
73 | filteredRawInputs = List.filterMap identity inputs
74 |
75 | blocksOfInput input blocks = Dict.union blocks (buildBlockGraph input nodes)
76 | inputBlocks = List.foldr blocksOfInput Dict.empty filteredRawInputs
77 | block = positionedBlock filteredInputs
78 |
79 | in Dict.insert nodeId block inputBlocks
80 |
81 |
82 | flattenList list = List.foldr (++) [] list
83 |
84 | -- TODO if a node has multiple outputs, it should be placed in the lowest layer
85 | -- TODO layout every node once, which also avoids stack overflow on circular node graphs
86 |
87 | baseLayout : NodeId -> NodeBlocks -> NodeBlocks
88 | baseLayout nodeId blocks =
89 | let
90 | totalHeight = treeHeight blocks nodeId
91 | block = Dict.get nodeId blocks
92 | { x, y } = block |> Maybe.map .position |> Maybe.withDefault Vec2.zero
93 | size = block |> Maybe.map .size |> Maybe.withDefault Vec2.zero
94 |
95 | in baseLayoutToHeight nodeId blocks totalHeight (x + size.x) (y - 0.5*totalHeight + 0.5*size.y)
96 |
97 | baseHorizontalPadding = 2 * propertyHeight
98 | layerHeightFactor = 1
99 |
100 | baseLayoutToHeight : NodeId -> NodeBlocks -> Float -> Float -> Float -> NodeBlocks
101 | baseLayoutToHeight nodeId blocks height rightX topY =
102 | case Dict.get nodeId blocks of
103 | Nothing -> blocks
104 | Just block ->
105 | let
106 |
107 | -- increase spacing where many children stack up to a great height
108 | childrenRightX = rightX - block.size.x - baseHorizontalPadding
109 | - layerHeightFactor * propertyHeight * toFloat (List.length block.inputs)
110 |
111 | layoutSubBlock input (y, subblocks) =
112 | let
113 | subHeight = treeHeight blocks input
114 | in (y + subHeight, baseLayoutToHeight input subblocks subHeight childrenRightX y)
115 |
116 | (_, newBlocks) = List.map .connected block.inputs
117 | |> deduplicateInOrder
118 | |> List.foldl layoutSubBlock (topY, blocks)
119 |
120 | newBlock = { block | position = Vec2 (rightX - block.size.x) (topY + 0.5 * height - 0.5 * block.size.y) }
121 |
122 | in
123 | Dict.insert nodeId newBlock newBlocks
124 |
125 |
126 | treeHeight: NodeBlocks -> NodeId -> Float
127 | treeHeight blocks nodeId =
128 | case Dict.get nodeId blocks of
129 | Just block -> max (blockSelfHeight block) (blockChildrenHeight blocks block)
130 | Nothing -> 0
131 |
132 | blockChildrenHeight blocks block =
133 | block.inputs
134 | |> List.map .connected
135 | |> deduplicateRandomOrder
136 | |> List.map (treeHeight blocks)
137 | |> List.foldr (+) 0
138 |
139 | blockSelfHeight block =
140 | block.size.y + 2 * propertyHeight
141 |
142 |
143 |
144 | deduplicateRandomOrder: List comparable -> List comparable
145 | deduplicateRandomOrder = Set.fromList >> Set.toList
146 |
147 | deduplicateInOrder: List comparable -> List comparable
148 | deduplicateInOrder list =
149 | List.foldr buildDedupSet ([], Set.empty) list |> Tuple.first
150 |
151 | buildDedupSet element (resultList, resultSet) =
152 | if Set.member element resultSet then (resultList, resultSet)
153 | else (element :: resultList, Set.insert element resultSet)
154 |
155 |
156 |
157 |
158 | -- ITERATIVE FORCE LAYOUT
159 |
160 | -- force proportions:
161 | uncollide = 1
162 | horizontalUntwist = 0.6
163 | horizontalGroup = 0.001
164 | verticalConvergence = 0.0001
165 | groupAll = 0.000000001
166 |
167 | -- minimal distances:
168 | horizontalPadding = 1 * propertyHeight
169 | collisionPadding = 0.9 * propertyHeight
170 | keepDistanceToLargeLayers = 2 * propertyHeight
171 |
172 | -- automatic calculation of number of iterations
173 | forceBasedLayout blocks =
174 | let
175 | atLeast = max
176 | nodes = Dict.size blocks |> toFloat
177 | complexity = nodes * nodes
178 | budged = 2048 * 32
179 |
180 | desiredIterations = budged / complexity
181 | iterations = floor desiredIterations |> atLeast 1
182 |
183 | in repeat iterations iterateLayout blocks
184 |
185 |
186 | iterateLayout blocks = blocks |> Dict.map (iterateBlock blocks)
187 |
188 |
189 | hasInput input block =
190 | List.map .connected block.inputs |> List.member input
191 |
192 |
193 | -- simulating a single block
194 | iterateBlock: NodeBlocks -> NodeId -> NodeBlock -> NodeBlock
195 | iterateBlock blocks id block =
196 | let
197 | accumulateForceBetweenNodes otherId otherBlock force =
198 | if id == otherId then force else
199 | let
200 | center = Vec2.ray 0.5 block.size block.position
201 | otherCenter = Vec2.ray 0.5 otherBlock.size otherBlock.position
202 | minDistance = 0.6 * (Vec2.length block.size) + 0.6 * (Vec2.length otherBlock.size) + collisionPadding
203 | difference = Vec2.sub center otherCenter
204 | distance = Vec2.length difference
205 |
206 | distanceForce =
207 | if distance < minDistance then Vec2.scale (0.5 * uncollide / distance) difference -- push apart if colliding (normalizing the difference) (0.5 because every node only pushes itself)
208 | else Vec2.scale -(groupAll * distance) difference -- pull together slightly
209 |
210 | otherIsInput = hasInput otherId block
211 | otherIsOutput = hasInput id otherBlock
212 |
213 | smoothnessBetween leftBlock rightBlock =
214 | rightBlock.position.x - (leftBlock.position.x + leftBlock.size.x)
215 | - horizontalPadding -- - keepDistanceToLargeLayers * abs difference.y
216 | - keepDistanceToLargeLayers * (toFloat <| max (List.length leftBlock.inputs) (List.length rightBlock.inputs))
217 |
218 | smoothen left right =
219 | let smoothness = smoothnessBetween left right
220 | in (if smoothness < 0 then horizontalUntwist else horizontalGroup) * -smoothness
221 |
222 |
223 | horizontalConnectionForce =
224 | if otherIsInput then smoothen otherBlock block
225 | -- let smoothness = smoothnessBetween otherBlock block
226 | -- in (if smoothness < 0 then horizontalUntwist else horizontalGroup) * -smoothness
227 |
228 | else if otherIsOutput then -(smoothen block otherBlock)
229 | -- let smoothness = smoothnessBetween block otherBlock
230 | -- in -((if smoothness < 0 then horizontalUntwist else horizontalGroup) * -smoothness)
231 | else 0
232 |
233 | verticalConnectionForce =
234 | if otherIsInput || otherIsOutput then
235 | verticalConvergence * -difference.y
236 | else 0
237 |
238 |
239 | connectionForce = Vec2 (0.5 * horizontalConnectionForce) (0.5 * verticalConnectionForce) -- * 0.5 because every node processes every other
240 | in
241 | force |> Vec2.add distanceForce |> Vec2.add connectionForce
242 |
243 | collisionForce = blocks |> Dict.foldr accumulateForceBetweenNodes Vec2.zero
244 | totalForce = collisionForce
245 | in
246 | { block | position = Vec2.add block.position totalForce }
247 |
248 | repeat: Int -> (a -> a) -> a -> a
249 | repeat count action value =
250 | if count <= 0 then value
251 | else repeat (count - 1) action (action value) -- tail call
252 |
253 |
254 |
255 |
256 |
257 |
258 |
259 |
260 |
261 |
262 |
263 |
264 |
265 |
266 |
267 |
268 |
269 |
270 |
271 |
272 |
273 |
274 |
275 |
276 |
277 |
--------------------------------------------------------------------------------
/src/Build.elm:
--------------------------------------------------------------------------------
1 | module Build exposing (..)
2 |
3 | import Array
4 | import IdMap
5 | import Regex
6 |
7 | import Model exposing (..)
8 |
9 |
10 |
11 | cycles = "Nodes have cycles" -- TODO use enum
12 |
13 | {-
14 | The "cycle detection" works by having a cost attached to each node
15 | while generating the regular expression, and cycles being infinite
16 | will always exceed the maximum cost.
17 |
18 | This means that overly complex (non-cyclic) nodes also will be rejected,
19 | and the message text currently incorrectly says it's because of cycles in the node graph.
20 | This will be improved later but it has a low priority.
21 | -}
22 |
23 |
24 |
25 | escapeCharset = escapeChars "[^-.\\]"
26 | escapeLiteral = escapeChars "[]{}()|^.-+*?!$/\\"
27 | andMinimal min expression = if min
28 | then expression ++ "?" else expression
29 |
30 |
31 | set options = if not (List.isEmpty options)
32 | then String.join "|" options
33 | else "(nothing)"
34 |
35 | capture child = "(" ++ child ++ ")"
36 |
37 | sequence members = if not (List.isEmpty members)
38 | then String.concat members
39 | else "(nothing)"
40 |
41 | optional min expression = expression ++ "?" |> andMinimal min
42 | atLeastOne min expression = expression ++ "+" |> andMinimal min
43 | anyRepetition min expression = expression ++ "*" |> andMinimal min
44 |
45 | exactRepetition count expression = expression ++ "{" ++ String.fromInt count ++ "}"
46 | minimumRepetition min minimum expression = expression ++ "{" ++ String.fromInt minimum ++ ",}" |> andMinimal min
47 | maximumRepetition min maximum expression = expression ++ "{0," ++ String.fromInt maximum ++ "}" |> andMinimal min
48 | rangedRepetition min minimum maximum expression = expression
49 | ++ "{" ++ String.fromInt minimum
50 | ++ "," ++ String.fromInt maximum
51 | ++ "}" |> andMinimal min
52 |
53 | ifFollowedBy successor expression = expression ++ "(?=" ++ successor ++ ")"
54 | ifNotFollowedBy successor expression = expression ++ "(?!" ++ successor ++ ")"
55 |
56 | charset chars = "[" ++ escapeCharset chars ++ "]"
57 | notInCharset chars = "[^" ++ escapeCharset chars ++ "]"
58 | charRange start end = "[" ++ escapeCharset (String.fromChar start) ++ "-" ++ escapeCharset (String.fromChar end) ++ "]"
59 | notInCharRange start end = "[^" ++ escapeCharset (String.fromChar start) ++ "-" ++ escapeCharset (String.fromChar end) ++ "]"
60 | literal chars = escapeLiteral chars
61 |
62 |
63 |
64 |
65 |
66 | buildNodeExpression : Int -> Nodes -> Node -> BuildResult (Int, String)
67 | buildNodeExpression cost nodes node =
68 | let
69 | ownPrecedence = precedence node
70 | build childParens depth child = buildExpression childParens depth nodes ownPrecedence child
71 |
72 | buildSingleChild childParens map child = build childParens cost child |> Result.map (Tuple.mapSecond map)
73 | buildTwoChildren map childParens1 child1 childParens2 child2 =
74 | let
75 | first = build childParens1 cost child1
76 | buildSecond (firstCost, _) = build childParens2 firstCost child2
77 | merge (_, firstChild) (totalCost, secondChild) = (totalCost, map firstChild secondChild)
78 |
79 | in Result.map2 merge first (Result.andThen buildSecond first)
80 |
81 | buildMember : Bool -> NodeId -> Result String (Int, List String) -> Result String (Int, List String)
82 | buildMember childParens element lastResult =
83 | lastResult |> Result.andThen (\(currentCost, builtMembers) ->
84 | (build childParens currentCost (Just element)) |> Result.map (Tuple.mapSecond (\e -> e :: builtMembers))
85 | )
86 |
87 | buildMembers childParens join members = members |> Array.toList
88 | |> List.foldr (buildMember childParens) (Ok (cost, []))
89 | |> Result.map (Tuple.mapSecond join)
90 |
91 | string = case node of
92 | SymbolNode symbol -> Ok (cost, buildSymbol symbol)
93 | CharSetNode chars -> Ok (cost, charset chars)
94 | NotInCharSetNode chars -> Ok (cost, notInCharset chars)
95 | CharRangeNode start end -> Ok (cost, simplifyInCharRange start end)
96 | NotInCharRangeNode start end -> Ok (cost, simplifyNotInCharRange start end)
97 | LiteralNode chars -> Ok (cost, literal chars)
98 |
99 | SequenceNode members -> buildMembers True sequence members
100 | SetNode options -> buildMembers True set options
101 | CaptureNode child -> buildSingleChild False capture child
102 |
103 | FlagsNode { expression } -> build False cost expression -- we use flags directly at topmost level
104 |
105 | IfNotFollowedByNode { expression, successor } -> buildTwoChildren ifNotFollowedBy False successor True expression
106 | IfFollowedByNode { expression, successor } -> buildTwoChildren ifFollowedBy False successor True expression
107 |
108 | OptionalNode { expression, minimal } -> buildSingleChild True (optional minimal) expression
109 | AtLeastOneNode { expression, minimal } -> buildSingleChild True (atLeastOne minimal) expression
110 | AnyRepetitionNode { expression, minimal } -> buildSingleChild True (anyRepetition minimal) expression
111 | ExactRepetitionNode { expression, count } -> buildSingleChild True (exactRepetition count) expression
112 | MinimumRepetitionNode { expression, count, minimal } -> buildSingleChild True (minimumRepetition minimal count) expression
113 | MaximumRepetitionNode { expression, count, minimal } -> buildSingleChild True (maximumRepetition minimal count) expression
114 |
115 | RangedRepetitionNode { expression, minimum, maximum, minimal } ->
116 | expression |> simplifyRangedRepetition minimal minimum maximum (buildSingleChild True)
117 | -- buildSingleChild True (rangedRepetition minimal minimum maximum) expression
118 |
119 |
120 | in string
121 |
122 |
123 | -- TODO this is not parentheses aware
124 | simplifyRangedRepetition minimal minimum maximum buildSingleChild =
125 | if minimum == maximum then buildSingleChild (exactRepetition minimum)
126 | else buildSingleChild (rangedRepetition minimal minimum maximum)
127 |
128 | -- TODO not in range
129 | simplifyInCharRange start end =
130 | if start == '0' && end == '9'
131 | then buildSymbol DigitChar
132 | else charRange start end
133 |
134 | -- TODO not in range
135 | simplifyNotInCharRange start end =
136 | if start == '0' && end == '9'
137 | then buildSymbol NonDigitChar
138 | else notInCharRange start end
139 |
140 |
141 | {-| Dereferences a node and returns `buildExpression` of it, handling corner cases -}
142 | buildExpression : Bool -> Int -> Nodes -> Int -> Maybe NodeId -> BuildResult (Int, String)
143 | buildExpression childMayNeedParens cost nodes ownPrecedence nodeId =
144 | if cost < 0 then Err cycles
145 | else case nodeId of
146 | Nothing -> Ok (cost, "(?:nothing)")
147 |
148 | Just id ->
149 | let
150 | nodeResult = IdMap.get id nodes
151 | |> okOrErr "Internal Error: Invalid Node Id"
152 |
153 | parens node = if childMayNeedParens
154 | then parenthesesForPrecedence ownPrecedence (precedence node)
155 | else identity
156 |
157 | build: Node -> BuildResult (Int, String)
158 | build node = buildNodeExpression (cost - 1) nodes node |> Result.map (Tuple.mapSecond (parens node))
159 | built = nodeResult |> Result.andThen (.node >> build)
160 |
161 | in built
162 |
163 |
164 |
165 | buildRegex : Nodes -> NodeId -> BuildResult (RegexBuild)
166 | buildRegex nodes id =
167 | let
168 | maxCost = (IdMap.size nodes) * 6
169 | expression = buildExpression False maxCost nodes 0 (Just id)
170 | nodeView = IdMap.get id nodes
171 | options = case nodeView |> Maybe.map .node of
172 | Just (FlagsNode { flags }) -> flags
173 | _ -> defaultFlags
174 |
175 | in expression |> Result.map (\(_, string) -> RegexBuild string options)
176 |
177 |
178 | constructRegexLiteral : RegexBuild -> String
179 | constructRegexLiteral regex =
180 | "/" ++ regex.expression ++ "/"
181 | ++ (if regex.flags.multiple then "g" else "")
182 | ++ (if regex.flags.caseInsensitive then "i" else "")
183 | ++ (if regex.flags.multiline then "m" else "")
184 |
185 |
186 | compileRegex : RegexBuild -> Regex.Regex
187 | compileRegex build =
188 | let options = { caseInsensitive = build.flags.caseInsensitive, multiline = build.flags.multiline }
189 | in Regex.fromStringWith options build.expression |> Maybe.withDefault Regex.never
190 |
191 |
192 | parenthesesForPrecedence ownPrecedence childPrecedence child =
193 | if ownPrecedence > childPrecedence && childPrecedence < 5 -- atomic children don't need any parentheses
194 | then "(?:" ++ child ++ ")" else child
195 |
196 | precedence : Node -> Int
197 | precedence node = case node of
198 | FlagsNode _ -> 0
199 |
200 | SetNode _ -> 1
201 |
202 | LiteralNode text ->
203 | if String.length text == 1
204 | then 5
205 | else 2
206 |
207 | SequenceNode _ -> 2 -- TODO collapse if only one member
208 |
209 | -- IfAtEndNode _ -> 3
210 | -- IfAtStartNode _ -> 3 can if-at-start be a symbol?
211 | IfNotFollowedByNode _ -> 3
212 | IfFollowedByNode _ -> 3
213 |
214 | OptionalNode _ -> 4 -- at least one ...
215 | AtLeastOneNode _ -> 4
216 | MaximumRepetitionNode _ -> 4
217 | MinimumRepetitionNode _ -> 4
218 | ExactRepetitionNode _ -> 4
219 | RangedRepetitionNode _ -> 4
220 | AnyRepetitionNode _ -> 4
221 |
222 | CharSetNode _ -> 5
223 | NotInCharSetNode _ -> 5
224 | CharRangeNode _ _ -> 5
225 | NotInCharRangeNode _ _ -> 5
226 | CaptureNode _ -> 5 -- TODO will produce unnecessary parens if
227 | SymbolNode _ -> 5
228 |
229 |
230 | buildSymbol symbol = case symbol of
231 | WhitespaceChar -> "\\s"
232 | NonWhitespaceChar -> "\\S"
233 | DigitChar -> "\\d"
234 | NonDigitChar -> "\\D"
235 | WordChar -> "\\w"
236 | NonWordChar -> "\\W"
237 | WordBoundary -> "\\b"
238 | NonWordBoundary -> "\\B"
239 | LinebreakChar -> "\\n"
240 | NonLinebreakChar -> "."
241 | TabChar -> "\\t"
242 | Never -> "(?!)"
243 | Always -> "(.|\\n)"
244 | Start -> "^"
245 | End -> "$"
246 |
247 |
248 | escapeChars pattern chars = chars |> String.toList
249 | |> List.concatMap (escapeChar (String.toList pattern))
250 | |> String.fromList
251 |
252 | escapeChar pattern char =
253 | if List.member char pattern
254 | then [ '\\', char ]
255 | else [ char ]
256 |
257 | okOrErr : e -> Maybe k -> Result e k
258 | okOrErr error maybe = case maybe of
259 | Just value -> Ok value
260 | Nothing -> Err error
--------------------------------------------------------------------------------
/src/IdMap.elm:
--------------------------------------------------------------------------------
1 | module IdMap exposing (..)
2 | import Dict exposing (Dict)
3 |
4 | type alias Id = Int
5 |
6 | type alias IdMap v =
7 | { dict : Dict Id v
8 | , nextId : Id
9 | }
10 |
11 |
12 | empty = IdMap Dict.empty 0
13 |
14 | isEmpty : IdMap v -> Bool
15 | isEmpty idMap = idMap.dict |> Dict.isEmpty
16 |
17 | insertAnonymous : v -> IdMap v -> IdMap v
18 | insertAnonymous value idMap =
19 | { dict = idMap.dict |> Dict.insert idMap.nextId value
20 | , nextId = idMap.nextId + 1
21 | }
22 |
23 | insert : v -> IdMap v -> (Id, IdMap v)
24 | insert value idMap =
25 | ( idMap.nextId
26 | , insertAnonymous value idMap
27 | )
28 |
29 | remove : Id -> IdMap v -> IdMap v
30 | remove id idMap = { idMap | dict = idMap.dict |> Dict.remove id }
31 |
32 | updateAllValues : (v -> v) -> IdMap v -> IdMap v
33 | updateAllValues mapper idMap = updateAll (\_ value -> mapper value) idMap
34 |
35 | updateAll : (Id -> v -> v) -> IdMap v -> IdMap v
36 | updateAll mapper idMap = { idMap | dict = idMap.dict |> Dict.map (\id v -> mapper id v) }
37 |
38 | get : Id -> IdMap v -> Maybe v
39 | get id idMap = idMap.dict |> Dict.get id
40 |
41 | update : Id -> (v -> v) -> IdMap v -> IdMap v
42 | update id mapper idMap =
43 | let updateDictValue oldValue = oldValue |> Maybe.map mapper
44 | in { idMap | dict = idMap.dict |> Dict.update id updateDictValue }
45 |
46 |
47 | type alias Insert v = IdMap v -> (Id, IdMap v)
48 | insertListWith : List (Insert v) -> IdMap v -> (List Id, IdMap v)
49 | insertListWith values idMap =
50 | let insertWithInserter inserter (ids, result) = inserter result |> Tuple.mapFirst (\id -> id :: ids)
51 | in List.foldr insertWithInserter ([], idMap) values
52 |
53 |
54 | toList : IdMap v -> List (Id, v)
55 | toList idMap = idMap.dict |> Dict.toList
56 |
57 |
58 | size = Dict.size << .dict
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
--------------------------------------------------------------------------------
/src/LinearDict.elm:
--------------------------------------------------------------------------------
1 | module LinearDict exposing (..)
2 |
3 | {-
4 | This is just like a Dict,
5 | but allows for non-comparable keys
6 | -}
7 |
8 |
9 | type alias LinearDict k v = List (k, v)
10 |
11 | empty: LinearDict k v
12 | empty = []
13 |
14 | member: k -> LinearDict k v -> Bool
15 | member key dict =
16 | get key dict /= Nothing
17 |
18 | get : k -> LinearDict k v -> Maybe v
19 | get key dict = dict
20 | |> List.filter (Tuple.first >> (==) key)
21 | |> List.head |> Maybe.map Tuple.second
22 |
23 |
24 | insert: k -> v -> LinearDict k v -> LinearDict k v
25 | insert key value dict = -- TODO can be done in a recursive function with only one iteration instead of 2
26 | if member key dict
27 | then dict |> List.map (replaceValueForKey key value)
28 | else (key, value) :: dict
29 |
30 |
31 | replaceValueForKey: k -> v -> (k, v) -> (k, v)
32 | replaceValueForKey key newValue (currentKey, currentValue) =
33 | if key == currentKey then (currentKey, newValue)
34 | else (currentKey, currentValue)
35 |
36 |
37 |
--------------------------------------------------------------------------------
/src/Main.elm:
--------------------------------------------------------------------------------
1 | port module Main exposing (..)
2 |
3 | import Browser
4 | import Build
5 | import Model
6 | import Update
7 | import Url
8 | import Base64
9 | import View
10 |
11 | port url : String -> Cmd msg
12 |
13 |
14 | main = Browser.element
15 | { init = \flags -> (init flags, Cmd.none)
16 | , update = \message model -> update message model
17 | , subscriptions = always Sub.none
18 | , view = View.view
19 | }
20 |
21 |
22 | init rawUrl =
23 | let
24 | expression = case String.split "?expression=" rawUrl of
25 | _ :: [ query ] -> Just query
26 | _ -> Nothing
27 |
28 | escapedExpression = expression |> Maybe.andThen Url.percentDecode
29 | -- expression is base64 encoded because firefox will change backslashes to regular slashes
30 | |> Maybe.andThen (Base64.decode >> Result.toMaybe)
31 | |> Maybe.withDefault "/\\s(?:the|for)/g"
32 |
33 | in
34 | Model.initialValue |> Update.update -- cannot be done in Model due to circular imports
35 | (Update.SearchMessage <| Update.FinishSearch <| Update.ParseRegex escapedExpression)
36 |
37 | update message model =
38 | let
39 | newModel = Update.update message model
40 |
41 | -- expression is base64 encoded because firefox will change backslashes to regular slashes
42 | encode = Base64.encode >> Url.percentEncode
43 |
44 | regex = newModel.history.present.cachedRegex
45 | |> Maybe.map (Result.map (Build.constructRegexLiteral >> encode))
46 |
47 | in case regex of
48 | Just (Ok expression) -> (newModel, url ("?expression=" ++ expression))
49 | _ -> (newModel, url "?")
--------------------------------------------------------------------------------
/src/Model.elm:
--------------------------------------------------------------------------------
1 | module Model exposing (..)
2 |
3 | import Array exposing (Array)
4 | import Vec2 exposing (Vec2)
5 | import IdMap exposing (IdMap)
6 |
7 | -- MODEL
8 |
9 |
10 | -- TODO improve structure
11 |
12 | type alias Model =
13 | { history: History
14 |
15 | , view: View
16 | , search: Maybe String
17 | , dragMode: Maybe DragMode
18 | }
19 |
20 |
21 | type alias History =
22 | { past: List CoreModel
23 | , present: CoreModel
24 | , future: List CoreModel
25 | }
26 |
27 |
28 | type alias CoreModel =
29 | { nodes: Nodes
30 | , outputNode: OutputNode
31 | , selectedNode: Maybe NodeId
32 |
33 | , exampleText: ExampleText
34 | , cachedRegex: Maybe (BuildResult RegexBuild)
35 |
36 | , cyclesError: Bool
37 | }
38 |
39 |
40 | initialValue =
41 | { history = { past = [], present = initialHistoryValue, future = [] }
42 | , dragMode = Nothing
43 | , search = Nothing
44 | , view = View 0 Vec2.zero
45 | }
46 |
47 |
48 | initialHistoryValue : CoreModel
49 | initialHistoryValue =
50 | { nodes = IdMap.empty
51 | , outputNode = { id = Nothing, locked = False }
52 | , selectedNode = Nothing
53 | , cachedRegex = Nothing
54 | , cyclesError = False
55 | , exampleText =
56 | { contents = String.repeat 12 "Leverage agile frameworks to provide a robust synopsis for high level overviews. Iterative approaches to corporate strategy foster collaborative thinking to further the overall value proposition. With markdown, you can write inline html: `
and scripts `. But what would that feel like? Organically grow the holistic world view of disruptive innovation via workplace diversity and empowerment! Take your laptop 🖥. Also, have a look at these japanese symbols: シンボル. Bring to the table win-win survival strategies to ensure proactive domination. At the end of the day, going forward, a new normal that has evolved from generation X is on the runway heading towards 4 streamlined cloud solution. Consider mailing to what_is_this_4@i-dont-do-mail.org User generated content in real-time will have 4-128 touchpoints for offshoring. Capitalize on low hanging fruit to identify a ballpark value added activity to beta test. Override the digital divide with additional clickthroughs from DevOps. Nanotechnology immersion along the information highway will close the loop on focusing solely on the bottom line."
57 | , maxMatches = 4000
58 | , isEditing = False
59 | , cachedMatches = Nothing
60 | }
61 | }
62 |
63 | type alias NodeId = IdMap.Id
64 | type alias Nodes = IdMap NodeView
65 |
66 | -- TODO
67 | type alias Notifications =
68 | { cyclesError: Bool
69 | , parseRegexError: Bool
70 | , deletedNode: Bool -- TODO undo on click
71 | }
72 |
73 | type alias BuildResult a = Result String a
74 |
75 | type alias RegexBuild =
76 | { expression: String
77 | , flags: RegexFlags
78 | }
79 |
80 | type alias OutputNode =
81 | { id: Maybe NodeId
82 | , locked: Bool
83 | }
84 |
85 | type alias ExampleText =
86 | { isEditing: Bool
87 | , contents: String
88 | , maxMatches: Int
89 | , cachedMatches: Maybe (List (String, String))
90 | }
91 |
92 | type alias View =
93 | { magnification : Float
94 | , offset : Vec2
95 | }
96 |
97 | type DragMode
98 | = MoveNodeDrag { node: NodeId, mouse: Vec2 }
99 | | MoveViewDrag { mouse: Vec2 }
100 |
101 | | PrepareEditingConnection { node: NodeId, mouse: Vec2 }
102 | | CreateConnection { supplier: NodeId, openEnd: Vec2 }
103 | | RetainPrototypedConnection { node: NodeId, previousNodeValue: Maybe Node, mouse: Vec2 }
104 |
105 |
106 | type alias NodeView =
107 | { position : Vec2
108 | , node : Node
109 | }
110 |
111 | type Node
112 | = SymbolNode Symbol
113 |
114 | | CharSetNode String
115 | | NotInCharSetNode String
116 | | LiteralNode String
117 | | CharRangeNode Char Char
118 | | NotInCharRangeNode Char Char
119 |
120 | | SetNode (Array NodeId)
121 | | SequenceNode (Array NodeId)
122 |
123 | | CaptureNode (Maybe NodeId)
124 |
125 | | IfFollowedByNode { expression : Maybe NodeId, successor : Maybe NodeId }
126 | | IfNotFollowedByNode { expression : Maybe NodeId, successor : Maybe NodeId }
127 |
128 | | OptionalNode { expression: Maybe NodeId, minimal: Bool }
129 | | AtLeastOneNode { expression: Maybe NodeId, minimal: Bool }
130 | | AnyRepetitionNode { expression: Maybe NodeId, minimal: Bool }
131 | | RangedRepetitionNode { expression : Maybe NodeId, minimum: Int, maximum: Int, minimal: Bool }
132 | | MinimumRepetitionNode { expression : Maybe NodeId, count: Int, minimal: Bool }
133 | | MaximumRepetitionNode { expression : Maybe NodeId, count: Int, minimal: Bool }
134 | | ExactRepetitionNode { expression : Maybe NodeId, count : Int }
135 |
136 | | FlagsNode { expression : Maybe NodeId, flags : RegexFlags }
137 |
138 | -- | Pattern { expression: Maybe NodeId, name: String }
139 | -- | SubResultNode { expression: Maybe NodeId }
140 |
141 |
142 | type Pattern
143 | = MinimalAlphabetLowercase
144 | | MinimalAlphabetUppercase
145 | | DecimalNumber
146 | | Integer
147 | | Hex
148 | | ScientificNumber
149 | | Punctuation
150 |
151 | {-| Any group of chars that can be represented by a single regex character, for example `\n` for linebreaks -}
152 | type Symbol
153 | = WhitespaceChar
154 | | NonWhitespaceChar
155 | | DigitChar
156 | | NonDigitChar
157 | | WordChar
158 | | NonWordChar
159 | | WordBoundary
160 | | NonWordBoundary
161 | | LinebreakChar
162 | | NonLinebreakChar
163 | | TabChar
164 | | Never
165 | | Always
166 | | Start
167 | | End
168 |
169 | type alias Prototype =
170 | { name : String
171 | , node : Node
172 | , description: String
173 | }
174 |
175 |
176 |
177 | prototypes : List Prototype
178 | prototypes =
179 | [ symbolProto .whitespace WhitespaceChar
180 | , symbolProto .nonWhitespace NonWhitespaceChar
181 | , symbolProto .digit DigitChar
182 | , symbolProto .nonDigit NonDigitChar
183 |
184 | , typeProto .charset (CharSetNode "AEIOU")
185 | , typeProto .set (SetNode (Array.fromList []))
186 |
187 | , typeProto .literal (LiteralNode "the")
188 | , typeProto .sequence (SequenceNode (Array.fromList []))
189 |
190 | , typeProto .charRange (CharRangeNode 'A' 'Z')
191 | , typeProto .notInCharRange (NotInCharRangeNode 'A' 'Z')
192 | , typeProto .notInCharset (NotInCharSetNode ";:!?")
193 |
194 | , typeProto .optional (OptionalNode { expression = Nothing, minimal = False })
195 | , typeProto .atLeastOne (AtLeastOneNode { expression = Nothing, minimal = False })
196 | , typeProto .anyRepetition (AnyRepetitionNode { expression = Nothing, minimal = False })
197 |
198 | , symbolProto .end End
199 | , symbolProto .start Start
200 |
201 | , symbolProto .wordBoundary WordBoundary
202 | , symbolProto .nonWordBoundary NonWordBoundary
203 |
204 | , typeProto .rangedRepetition (RangedRepetitionNode { expression = Nothing, minimum = 2, maximum = 4, minimal = False })
205 | , typeProto .minimumRepetition (MinimumRepetitionNode { expression = Nothing, count = 2, minimal = False })
206 | , typeProto .maximumRepetition (MaximumRepetitionNode { expression = Nothing, count = 4, minimal = False })
207 | , typeProto .exactRepetition (ExactRepetitionNode { expression = Nothing, count = 3 })
208 |
209 | , symbolProto .word WordChar
210 | , symbolProto .nonWord NonWordChar
211 | , symbolProto .lineBreak LinebreakChar
212 | , symbolProto .nonLineBreak NonLinebreakChar
213 | , symbolProto .tab TabChar
214 |
215 | , typeProto .flags (FlagsNode { expression = Nothing, flags = defaultFlags })
216 | , typeProto .capture (CaptureNode Nothing)
217 |
218 | , symbolProto .none Never
219 | , symbolProto .any Always
220 |
221 |
222 | , typeProto .ifFollowedBy (IfFollowedByNode { expression = Nothing, successor = Nothing })
223 | , typeProto .ifNotFollowedBy (IfNotFollowedByNode { expression = Nothing, successor = Nothing })
224 | ]
225 |
226 |
227 | typeProto getter prototype = Prototype (getter typeNames) prototype (getter typeDescriptions)
228 | symbolProto getter symbol = Prototype (getter symbolNames) (SymbolNode symbol) (getter symbolDescriptions)
229 |
230 |
231 | symbolNames =
232 | { whitespace = "Whitespace Char"
233 | , nonWhitespace = "Non Whitespace Char"
234 | , digit = "Digit Char"
235 | , nonDigit = "Non Digit Char"
236 | , word = "Word Char"
237 | , nonWord = "Non Word Char"
238 | , wordBoundary = "Word Boundary"
239 | , nonWordBoundary = "Non Word Boundary"
240 | , lineBreak = "Linebreak Char"
241 | , nonLineBreak = "Non Linebreak Char"
242 | , tab = "Tab Char"
243 | , none = "Nothing"
244 | , any = "Anything"
245 | , end = "End of Text"
246 | , start = "Start of Text"
247 | }
248 |
249 | typeNames =
250 | { charset = "Any of Chars"
251 | , notInCharset = "None of Chars"
252 | , literal = "Literal"
253 | , charRange = "Any of Char Range"
254 | , notInCharRange = "None of Char Range"
255 | , optional = "Optional"
256 | , set = "Any Of"
257 | , capture = "Capture"
258 | , ifNotFollowedBy = "If Not Followed By"
259 | , sequence = "Sequence"
260 | , flags = "Configuration"
261 | , exactRepetition = "Exact Repetition"
262 | , atLeastOne = "At Least One"
263 | , anyRepetition = "Any Repetition"
264 | , minimumRepetition = "Minimum Repetition"
265 | , maximumRepetition = "Maximum Repetition"
266 | , rangedRepetition = "Ranged Repetition"
267 | , ifFollowedBy = "If Followed By"
268 | }
269 |
270 | symbolDescriptions =
271 | { whitespace = "Match any invisible char, such as the space between words and linebreaks"
272 | , nonWhitespace = "Match any char that is not invisible, for example neither space nor linebreaks"
273 | , digit = "Match any numerical char, from `0` to `9`, excluding punctuation"
274 | , nonDigit = "Match any char but numerical ones, matching punctuation"
275 | , word = "Match any alphabetical chars, and the underscore char `_`"
276 | , nonWord = "Match any char, but not alphabetical ones and not the underscore char `_`"
277 | , wordBoundary = "Matches where a word char has a whitespace neighbour"
278 | , nonWordBoundary = "Matches anywhere but not where a word char has a whitespace neighbour"
279 | , lineBreak = "Matches the linebreak, or newline, char `\\n`"
280 | , nonLineBreak = "Matches anything but the linebreak char `\\n`"
281 | , tab = "Matches the tab char `\\t`"
282 | , none = "Matches nothing ever, really"
283 | , any = "Matches any char, including linebreaks and whitespace"
284 | , end = "The end of line if Multiline Matches are enabled, or the end of the text otherwise"
285 | , start = "The start of line if Multiline Matches are enabled, or the start of the text otherwise"
286 | }
287 |
288 |
289 | typeDescriptions =
290 | { charset = "Matches, where any char of the set is matched"
291 | , notInCharset = "Matches where not a single char of the set is matched"
292 | , literal = "Matches where that exact sequence of chars is found"
293 | , charRange = "Matches any char between the lower and upper range bound"
294 | , notInCharRange = "Matches any char outside of the range"
295 | , optional = "Allow omitting this expression and match anyways"
296 | , set = "Matches, where at least one of the options matches"
297 | , capture = "Capture this expression for later use, like replacing"
298 | , ifNotFollowedBy = "Match this expression only if the successor is not matched, without matching the successor itself"
299 | , sequence = "Matches, where all members in the exact order are matched one after another"
300 | , flags = "Configure how the whole regex operates"
301 | , exactRepetition = "Match where an expression is repeated exactly n times"
302 | , atLeastOne = "Allow this expression to occur multiple times"
303 | , anyRepetition = "Allow this expression to occur multiple times or not at all"
304 | , minimumRepetition = "Match where an expression is repeated at least n times"
305 | , maximumRepetition = "Match where an expression is repeated no more than n times"
306 | , rangedRepetition = "Only match if the expression is repeated in range"
307 | , ifFollowedBy = "Match this expression only if the successor is also matched, without matching the successor itself"
308 | }
309 |
310 |
311 | type alias RegexFlags =
312 | { multiple : Bool
313 | , caseInsensitive : Bool
314 | , multiline : Bool
315 | }
316 |
317 |
318 | viewTransform { magnification, offset} =
319 | { translate = offset
320 | , scale = 2 ^ (magnification * 0.4)
321 | }
322 |
323 | defaultFlags = RegexFlags True False True
324 |
325 |
326 | summary node = case node of
327 | LiteralNode literal -> Just ("`" ++ literal ++ "`")
328 | CharSetNode options -> Just ("One of `" ++ options ++ "`")
329 | NotInCharSetNode options -> Just ("None of `" ++ options ++ "`")
330 | CharRangeNode start end -> Just ("One of `" ++ String.fromChar start ++ "` - `" ++ String.fromChar end ++ "`")
331 | NotInCharRangeNode start end -> Just ("None of `" ++ String.fromChar start ++ "` - `" ++ String.fromChar end ++ "`")
332 | _ -> Nothing
333 |
334 |
335 | toPattern nodeId nodes = case IdMap.get nodeId nodes of
336 | Just (CharRangeNode 'a' 'z') -> Just MinimalAlphabetLowercase
337 | Just (CharRangeNode 'A' 'Z') -> Just MinimalAlphabetUppercase
338 | _ -> Nothing
339 |
340 |
341 | symbolName = symbolProperty symbolNames
342 | symbolDescription = symbolProperty symbolDescriptions
343 |
344 | symbolProperty properties symbol = case symbol of
345 | WhitespaceChar -> properties.whitespace
346 | NonWhitespaceChar -> properties.nonWhitespace
347 | DigitChar -> properties.digit
348 | NonDigitChar -> properties.nonDigit
349 | WordChar -> properties.word
350 | NonWordChar -> properties.nonWord
351 | WordBoundary -> properties.wordBoundary
352 | NonWordBoundary -> properties.nonWordBoundary
353 | LinebreakChar -> properties.lineBreak
354 | NonLinebreakChar -> properties.nonLineBreak
355 | TabChar -> properties.tab
356 | Never -> properties.none
357 | Always -> properties.any
358 | Start -> properties.start
359 | End -> properties.end
360 |
361 | -- TODO DRY, like: getProperties: Node -> List Property,
362 | -- type alias Property = { inputs, name, description, has Output, updateValue ... }
363 |
364 |
365 |
366 | type alias Property =
367 | { name : String
368 | , description: String
369 | , contents : PropertyValue
370 | , connectOutput : Bool
371 | }
372 |
373 | -- if a property must be updated, return the whole new node
374 | type alias OnChange a = a -> Node
375 |
376 | type PropertyValue
377 | = BoolProperty Bool (OnChange Bool)
378 | | CharsProperty String (OnChange String)
379 | | CharProperty Char (OnChange Char)
380 | | IntProperty Int (OnChange Int)
381 | | ConnectingProperty (Maybe NodeId) (OnChange (Maybe NodeId))
382 | | ConnectingProperties Bool (Array NodeId) (OnChange (Array NodeId))
383 | | TitleProperty
384 |
385 |
386 | nodeProperties : Node -> List Property
387 | nodeProperties node =
388 | case node of
389 | SymbolNode symbol -> [ Property (symbolName symbol) (symbolDescription symbol) TitleProperty True ]
390 |
391 | CharSetNode chars ->
392 | [ Property typeNames.charset
393 | ("Matches " ++ String.join ", " (String.toList chars |> List.map String.fromChar))
394 | (CharsProperty chars CharSetNode) True
395 | ]
396 |
397 | NotInCharSetNode chars -> [ Property typeNames.notInCharset
398 | ("Matches any char but " ++ String.join ", " (String.toList chars |> List.map String.fromChar))
399 | (CharsProperty chars NotInCharSetNode) True ]
400 |
401 | LiteralNode literal ->
402 | [ Property typeNames.literal ("Matches exactly `" ++ literal ++ "` and nothing else")
403 | (CharsProperty literal LiteralNode) True
404 | ]
405 |
406 | CaptureNode captured ->
407 | [ Property typeNames.capture typeDescriptions.capture
408 | (ConnectingProperty captured CaptureNode) True
409 | ]
410 |
411 | CharRangeNode start end ->
412 | [ Property typeNames.charRange
413 | ("Match any char whose integer value is equal to or between " ++ String.fromChar start ++ " and " ++ String.fromChar end)
414 | TitleProperty True
415 |
416 | , Property "First Char" "The lower range bound char, will match itself" (CharProperty start (updateCharRangeFirst end)) False
417 | , Property "Last Char" "The upper range bound char, will match itself" (CharProperty end (updateCharRangeLast start)) False
418 | ]
419 |
420 | NotInCharRangeNode start end ->
421 | [ Property typeNames.notInCharRange
422 | ("Match any char whose integer value is neither equal to nor between " ++ String.fromChar start ++ " and " ++ String.fromChar end)
423 | TitleProperty True
424 |
425 | , Property "First Char" "The lower range bound char, will not match itself" (CharProperty start (updateNotInCharRangeFirst end)) False
426 | , Property "Last Char" "The upper range bound char, will not match itself" (CharProperty end (updateNotInCharRangeLast start)) False
427 | ]
428 |
429 | SetNode options ->
430 | [ Property typeNames.set typeDescriptions.set TitleProperty True
431 | , Property "•" "Match if this or any other option is matched" (ConnectingProperties False options SetNode) False
432 | ]
433 |
434 | SequenceNode members ->
435 | [ Property typeNames.sequence typeDescriptions.sequence TitleProperty True
436 | , Property "and then" "A member of the sequence" (ConnectingProperties True members SequenceNode) False
437 | ]
438 |
439 | FlagsNode flagsNode ->
440 | [ Property typeNames.flags typeDescriptions.flags
441 | (ConnectingProperty flagsNode.expression (updateFlagsExpression flagsNode)) False
442 |
443 | , Property "Multiple Matches" "Do not stop after the first match"
444 | (BoolProperty flagsNode.flags.multiple (updateFlagsMultiple flagsNode)) False
445 |
446 | , Property "Case Insensitive" "Match as if everything had the same case"
447 | (BoolProperty flagsNode.flags.caseInsensitive (updateFlagsInsensitivity flagsNode)) False
448 |
449 | , Property "Multiline Matches" "Allow every matches to be found across multiple lines"
450 | (BoolProperty flagsNode.flags.multiline (updateFlagsMultiline flagsNode)) False
451 | ]
452 |
453 | IfFollowedByNode followed ->
454 | [ Property typeNames.ifFollowedBy typeDescriptions.ifFollowedBy
455 | (ConnectingProperty followed.expression (IfFollowedByNode << updateExpression followed)) True
456 |
457 | , Property "Successor" "What needs to follow the expression"
458 | (ConnectingProperty followed.successor (IfFollowedByNode << updateSuccessor followed)) False
459 | ]
460 |
461 | IfNotFollowedByNode followed ->
462 | [ Property typeNames.ifNotFollowedBy typeDescriptions.ifNotFollowedBy
463 | (ConnectingProperty followed.expression (IfNotFollowedByNode << updateExpression followed)) True
464 |
465 | , Property "Successor" "What must not follow the expression"
466 | (ConnectingProperty followed.successor (IfNotFollowedByNode << updateSuccessor followed)) False
467 | ]
468 |
469 | OptionalNode option ->
470 | [ Property typeNames.optional typeDescriptions.optional
471 | (ConnectingProperty option.expression (OptionalNode << updateExpression option)) True
472 |
473 | , Property "Prefer None" "Prefer not to match"
474 | (BoolProperty option.minimal (OptionalNode << updateMinimal option)) False
475 | ]
476 |
477 | AtLeastOneNode counted ->
478 | [ Property typeNames.atLeastOne typeDescriptions.atLeastOne
479 | (ConnectingProperty counted.expression (AtLeastOneNode << updateExpression counted)) True
480 |
481 | , Property "Minimize Count" "Match as few occurences as possible"
482 | (BoolProperty counted.minimal (AtLeastOneNode << updateMinimal counted)) False
483 | ]
484 |
485 | AnyRepetitionNode counted ->
486 | [ Property typeNames.anyRepetition typeDescriptions.anyRepetition
487 | (ConnectingProperty counted.expression (AnyRepetitionNode << updateExpression counted)) True
488 |
489 | , Property "Minimize Count" "Match as few occurences as possible"
490 | (BoolProperty counted.minimal (AnyRepetitionNode << updateMinimal counted)) False
491 | ]
492 |
493 | ExactRepetitionNode repetition ->
494 | [ Property typeNames.exactRepetition
495 | ("Match only if this expression is repeated exactly " ++ String.fromInt repetition.count ++ " times")
496 | (ConnectingProperty repetition.expression (ExactRepetitionNode << updateExpression repetition)) True
497 |
498 | , Property "Count" "How often the expression is required"
499 | (IntProperty repetition.count (ExactRepetitionNode << updatePositiveCount repetition)) False
500 | ]
501 |
502 | RangedRepetitionNode counted ->
503 | [ Property typeNames.rangedRepetition
504 | ("Match only if the expression is repeated no less than " ++ String.fromInt counted.minimum
505 | ++ " and no more than " ++ String.fromInt counted.maximum ++ " times"
506 | )
507 | (ConnectingProperty counted.expression (RangedRepetitionNode << updateExpression counted)) True
508 |
509 | , Property "Minimum"
510 | ("Match only if the expression is repeated no less than " ++ String.fromInt counted.minimum ++ " times")
511 | (IntProperty counted.minimum (updateRangedRepetitionMinimum counted)) False
512 |
513 | , Property "Maximum"
514 | ("Match only if the expression is repeated no more than " ++ String.fromInt counted.maximum ++ " times")
515 | (IntProperty counted.maximum (updateRangedRepetitionMaximum counted)) False
516 |
517 | , Property "Minimize Count" "Match as few occurences as possible"
518 | (BoolProperty counted.minimal (RangedRepetitionNode << updateMinimal counted)) False
519 | ]
520 |
521 | MinimumRepetitionNode counted ->
522 | [ Property typeNames.minimumRepetition
523 | ("Match only if the expression is repeated no less than " ++ String.fromInt counted.count ++ " times")
524 | (ConnectingProperty counted.expression (MinimumRepetitionNode << updateExpression counted)) True
525 |
526 | , Property "Count" "Minimum number of repetitions"
527 | (IntProperty counted.count (MinimumRepetitionNode << updatePositiveCount counted)) False
528 |
529 | , Property "Minimize Count" "Match as few occurences as possible"
530 | (BoolProperty counted.minimal (MinimumRepetitionNode << updateMinimal counted)) False
531 | ]
532 |
533 | MaximumRepetitionNode counted ->
534 | [ Property typeNames.maximumRepetition
535 | ("Match only if the expression is repeated no more than " ++ String.fromInt counted.count ++ " times")
536 | (ConnectingProperty counted.expression (MaximumRepetitionNode << updateExpression counted)) True
537 |
538 | , Property "Count" "Maximum number of repetitions"
539 | (IntProperty counted.count (MaximumRepetitionNode << updatePositiveCount counted)) False
540 |
541 | , Property "Minimize Count" "Match as few occurences as possible"
542 | (BoolProperty counted.minimal (MaximumRepetitionNode << updateMinimal counted)) False
543 | ]
544 |
545 | -- SubResultNode result ->
546 | -- [ Property (compileRegexString expression nodes)
547 | -- ("This property shows the connected expression")
548 | -- (ConnectingProperty result.expression (SubResultNode << updateExpression result)) True
549 | -- ]
550 |
551 |
552 | -- TODO implement generically using (nodeProperties node)
553 | onNodeDeleted : NodeId -> Node -> Node
554 | onNodeDeleted deleted node =
555 | case node of
556 | SymbolNode _ -> node
557 | CharSetNode _ -> node
558 | NotInCharSetNode _ -> node
559 | LiteralNode _ -> node
560 | CharRangeNode _ _ -> node
561 | NotInCharRangeNode _ _ -> node
562 |
563 | SetNode members -> SetNode <| Array.filter ((/=) deleted) members
564 | SequenceNode members -> SequenceNode <| Array.filter ((/=) deleted) members
565 | CaptureNode child -> CaptureNode <| ifNotDeleted deleted child
566 |
567 | OptionalNode child -> OptionalNode <| ifExpressionNotDeleted deleted child
568 | AtLeastOneNode child -> AtLeastOneNode <| ifExpressionNotDeleted deleted child
569 | AnyRepetitionNode child -> AnyRepetitionNode <| ifExpressionNotDeleted deleted child
570 | FlagsNode value -> FlagsNode <| ifExpressionNotDeleted deleted value
571 | IfFollowedByNode value -> IfFollowedByNode <| ifExpressionNotDeleted deleted value
572 | IfNotFollowedByNode value -> IfNotFollowedByNode <| ifExpressionNotDeleted deleted value
573 | RangedRepetitionNode value -> RangedRepetitionNode <| ifExpressionNotDeleted deleted value
574 | MinimumRepetitionNode value -> MinimumRepetitionNode <| ifExpressionNotDeleted deleted value
575 | MaximumRepetitionNode value -> MaximumRepetitionNode <| ifExpressionNotDeleted deleted value
576 | ExactRepetitionNode value -> ExactRepetitionNode <| ifExpressionNotDeleted deleted value
577 |
578 |
579 |
580 | ifNotDeleted deleted node =
581 | if node == Just deleted
582 | then Nothing else node
583 |
584 | ifExpressionNotDeleted deleted values =
585 | { values | expression = ifNotDeleted deleted values.expression }
586 |
587 |
588 | insertWhitePlaceholder = String.replace " " "␣"
589 | removeWhitePlaceholder = String.replace "␣" " "
590 |
591 |
592 | updateExpression node expression = { node | expression = expression }
593 | updateSuccessor node successor = { node | successor = successor }
594 | updateMinimal node minimal = { node | minimal = minimal }
595 |
596 | updatePositiveCount node count = { node | count = positive count }
597 |
598 | updateCharRangeFirst end start = CharRangeNode (minChar start end) (maxChar start end) -- swaps chars if necessary
599 | updateCharRangeLast start end = CharRangeNode (minChar end start) (maxChar start end) -- swaps chars if necessary
600 |
601 | updateNotInCharRangeFirst end start = NotInCharRangeNode (minChar start end) (maxChar start end) -- swaps chars if necessary
602 | updateNotInCharRangeLast start end = NotInCharRangeNode (minChar end start) (maxChar start end) -- swaps chars if necessary
603 |
604 | updateRangedRepetitionMinimum repetition count = RangedRepetitionNode
605 | { repetition | minimum = positive count, maximum = max (positive count) repetition.maximum }
606 |
607 | updateRangedRepetitionMaximum repetition count = RangedRepetitionNode
608 | { repetition | maximum = positive count, minimum = min (positive count) repetition.minimum }
609 |
610 | updateFlagsExpression flags newInput = FlagsNode { flags | expression = newInput }
611 | updateFlags expression newFlags = FlagsNode { expression = expression, flags = newFlags }
612 | updateFlagsMultiple { expression, flags } multiple = updateFlags expression { flags | multiple = multiple }
613 | updateFlagsInsensitivity { expression, flags } caseInsensitive = updateFlags expression { flags | caseInsensitive = caseInsensitive }
614 | updateFlagsMultiline { expression, flags } multiline = updateFlags expression { flags | multiline = multiline }
615 |
616 | positive = Basics.max 0
617 | minChar a b = if a < b then a else b
618 | maxChar a b = if a > b then a else b
619 |
620 |
621 | -- LAYOUT
622 |
623 | propertyHeight = 25
624 |
625 | nodeWidth node = case node of
626 | SymbolNode symbol -> symbol |> symbolName |> mainTextWidth
627 | CharSetNode chars -> mainTextWidth typeNames.charset + codeTextWidth chars + 3
628 | NotInCharSetNode chars -> mainTextWidth typeNames.charset + codeTextWidth chars + 3
629 | CharRangeNode _ _ -> mainTextWidth typeNames.charRange
630 | NotInCharRangeNode _ _ -> mainTextWidth typeNames.notInCharRange
631 | LiteralNode chars -> mainTextWidth typeNames.literal + codeTextWidth chars + 3
632 | OptionalNode _ -> stringWidth 10
633 | SetNode _ -> mainTextWidth typeNames.set
634 | FlagsNode _ -> mainTextWidth typeNames.flags
635 | IfFollowedByNode _ -> mainTextWidth typeNames.ifFollowedBy
636 | ExactRepetitionNode _ -> mainTextWidth typeNames.exactRepetition
637 | SequenceNode _ -> mainTextWidth typeNames.sequence
638 | CaptureNode _ -> mainTextWidth typeNames.capture
639 | IfNotFollowedByNode _ -> mainTextWidth typeNames.ifNotFollowedBy
640 | AtLeastOneNode _ -> mainTextWidth typeNames.atLeastOne
641 | AnyRepetitionNode _ -> mainTextWidth typeNames.anyRepetition
642 | RangedRepetitionNode _ -> mainTextWidth typeNames.rangedRepetition
643 | MinimumRepetitionNode _ -> mainTextWidth typeNames.minimumRepetition
644 | MaximumRepetitionNode _ -> mainTextWidth typeNames.maximumRepetition
645 |
646 |
647 |
648 | -- Thanks, Html, for letting us hardcode those values <3
649 | codeTextWidth = String.length >> (*) 5 >> toFloat
650 | mainTextWidth text = text |> String.length |> toFloat |> stringWidth
651 | stringWidth length = (Basics.max 5 length) * (if length < 14 then 13 else 9)
652 |
653 |
654 |
655 |
656 |
657 |
658 |
659 |
660 |
661 |
662 |
663 |
664 |
665 |
666 |
667 |
--------------------------------------------------------------------------------
/src/Parse.elm:
--------------------------------------------------------------------------------
1 | module Parse exposing (..)
2 |
3 | import Array
4 | import IdMap
5 | import Model exposing (..)
6 | import Result
7 | import Vec2 exposing (Vec2)
8 | import LinearDict exposing (LinearDict)
9 |
10 | type alias ParseResult a = Result ParseError a
11 | type alias ParseSubResult a = ParseResult (a, String)
12 |
13 | type ParseError
14 | = ExpectedMoreChars
15 | | Expected String
16 |
17 |
18 | -- TODO this parsing code is more complex than it needs to be (brackets must always be escaped and such)
19 |
20 | -- TODO not all-or-nothing, but try the best guess at invalid input!
21 |
22 | -- closer to the textual JS regex than to the node graph
23 | type ParsedElement
24 | = ParsedSequence (List ParsedElement)
25 | | ParsedCharsetAtom CharsetAtom
26 | | ParsedCharset { inverted: Bool, contents: List CharsetAtom }
27 | | ParsedSet (List ParsedElement)
28 | | ParsedCapture ParsedElement
29 | | ParsedIfFollowedBy { expression: ParsedElement, successor: ParsedElement }
30 | | ParsedIfNotFollowedBy { expression: ParsedElement, successor: ParsedElement }
31 | | ParsedRangedRepetition { expression : ParsedElement, minimum: Int, maximum: Int, minimal: Bool }
32 | | ParsedMinimumRepetition { expression : ParsedElement, count: Int, minimal: Bool }
33 | | ParsedExactRepetition { expression : ParsedElement, count: Int }
34 | | ParsedAnyRepetition { expression : ParsedElement, minimal: Bool }
35 | | ParsedAtLeastOne { expression : ParsedElement, minimal: Bool }
36 | | ParsedOptional { expression: ParsedElement, minimal: Bool }
37 | | ParsedFlags { expression : ParsedElement, flags : RegexFlags }
38 |
39 |
40 | -- closer to the node graph than to the JS regex
41 | type CompiledElement
42 | = CompiledSequence (List CompiledElement)
43 | | CompiledSymbol Symbol
44 | | CompiledCharRange Bool (Char, Char)
45 | | CompiledCharSequence String
46 | | CompiledCharset { inverted: Bool, contents: String }
47 | | CompiledSet (List CompiledElement)
48 | | CompiledCapture CompiledElement
49 | | CompiledIfFollowedBy { expression: CompiledElement, successor: CompiledElement }
50 | | CompiledIfNotFollowedBy { expression: CompiledElement, successor: CompiledElement }
51 | | CompiledRangedRepetition { expression : CompiledElement, minimum: Int, maximum: Int, minimal: Bool }
52 | | CompiledMinimumRepetition { expression : CompiledElement, count: Int, minimal: Bool }
53 | | CompiledExactRepetition { expression : CompiledElement, count: Int }
54 | | CompiledOptional { expression: CompiledElement, minimal: Bool }
55 | | CompiledAtLeastOne { expression: CompiledElement, minimal: Bool }
56 | | CompiledAnyRepetition { expression: CompiledElement, minimal: Bool }
57 | | CompiledFlags { expression : CompiledElement, flags : RegexFlags }
58 |
59 |
60 | type CharsetAtom = Plain Char | Escaped Symbol | Range (Char, Char)
61 |
62 | addParsedRegexNode : Vec2 -> Nodes -> String -> ParseResult (NodeId, Nodes)
63 | addParsedRegexNode position nodes regex = parse regex
64 | |> Result.map (compile >> addCompiledElement position nodes)
65 |
66 | addCompiledElement : Vec2 -> Nodes -> CompiledElement -> (NodeId, Nodes)
67 | addCompiledElement position nodes parsed = let (a,b,_) = insert position parsed nodes LinearDict.empty in (a,b)
68 |
69 |
70 | type alias DuplicationGuard = LinearDict CompiledElement NodeId
71 |
72 | insert : Vec2 -> CompiledElement -> Nodes -> DuplicationGuard -> (NodeId, Nodes, DuplicationGuard)
73 | insert position element nodes guard =
74 | let
75 | simpleNode = NodeView position
76 | simpleInsert = insert position
77 |
78 | in case element of
79 | CompiledSequence members ->
80 | let
81 | (children, newNodes, newGuard) = insertElements members nodes guard
82 | nodeValue = children |> Array.fromList |> SequenceNode |> simpleNode
83 | in insertElement nodeValue newNodes element newGuard
84 |
85 | CompiledCharSequence sequence -> insertElement
86 | (simpleNode (LiteralNode sequence)) nodes element guard
87 |
88 | CompiledSymbol symbol -> insertElement
89 | (simpleNode (SymbolNode symbol)) nodes element guard
90 |
91 | CompiledCharRange inverted (a, b) -> insertElement
92 | (simpleNode ((if inverted then NotInCharRangeNode else CharRangeNode) a b)) nodes element guard
93 |
94 | CompiledCapture child ->
95 | let (childId, nodesWithChild, guardWithChild) = simpleInsert child nodes guard -- children will be reused if possible
96 | in insertElement (simpleNode (CaptureNode <| Just childId)) nodesWithChild element guardWithChild
97 |
98 | CompiledCharset { inverted, contents } -> insertElement
99 | (simpleNode (if inverted then NotInCharSetNode contents else CharSetNode contents)) nodes element guard
100 |
101 | CompiledSet options ->
102 | let
103 | (children, newNodes, newGuard) = insertElements options nodes guard
104 | nodeValue = children |> Array.fromList |> SetNode |> simpleNode
105 | in insertElement nodeValue newNodes element newGuard
106 |
107 | CompiledIfFollowedBy { expression, successor } ->
108 | let
109 | (expressionId, nodesWithExpression, guard1) = simpleInsert expression nodes guard -- children will be reused if possible
110 | (successorId, nodesWithChildren, guard2) = simpleInsert successor nodesWithExpression guard1
111 | in insertElement
112 | (simpleNode (IfFollowedByNode { expression = Just expressionId, successor = Just successorId }))
113 | nodesWithChildren element guard2
114 |
115 | CompiledIfNotFollowedBy { expression, successor } ->
116 | let
117 | (expressionId, nodesWithExpression, guard1) = simpleInsert expression nodes guard
118 | (successorId, nodesWithChildren, guard2) = simpleInsert successor nodesWithExpression guard1
119 | in insertElement
120 | (simpleNode (IfNotFollowedByNode { expression = Just expressionId, successor = Just successorId }))
121 | nodesWithChildren element guard2
122 |
123 | CompiledRangedRepetition { expression, minimum, maximum, minimal } ->
124 | let (expressionId, nodesWithChild, guard1) = simpleInsert expression nodes guard
125 | in insertElement
126 | (simpleNode (RangedRepetitionNode { expression = Just expressionId, minimum = minimum, maximum = maximum, minimal = minimal }))
127 | nodesWithChild element guard1
128 |
129 | CompiledExactRepetition { expression, count } ->
130 | let (expressionId, nodesWithChild, guardWithChild) = simpleInsert expression nodes guard
131 | in insertElement
132 | (simpleNode (ExactRepetitionNode { expression = Just expressionId, count = count }))
133 | nodesWithChild element guardWithChild
134 |
135 | CompiledMinimumRepetition { expression, count, minimal } ->
136 | let (expressionId, nodesWithChild, guardWithChild) = simpleInsert expression nodes guard
137 | in insertElement
138 | (simpleNode (MinimumRepetitionNode { expression = Just expressionId, count = count, minimal = minimal }))
139 | nodesWithChild element guardWithChild
140 |
141 | CompiledOptional { expression, minimal } ->
142 | let (expressionId, nodesWithChild, guardWithChild) = simpleInsert expression nodes guard
143 | in insertElement
144 | (simpleNode (OptionalNode { expression = Just expressionId, minimal = minimal }))
145 | nodesWithChild element guardWithChild
146 |
147 | CompiledAtLeastOne { expression, minimal } ->
148 | let (expressionId, nodesWithChild, guardWithChild) = simpleInsert expression nodes guard
149 | in insertElement
150 | (simpleNode (AtLeastOneNode { expression = Just expressionId, minimal = minimal }))
151 | nodesWithChild element guardWithChild
152 |
153 | CompiledAnyRepetition { expression, minimal } ->
154 | let (expressionId, nodesWithChild, guardWithChild) = simpleInsert expression nodes guard
155 | in insertElement
156 | (simpleNode (AnyRepetitionNode { expression = Just expressionId, minimal = minimal }))
157 | nodesWithChild element guardWithChild
158 |
159 | -- TODO DRY
160 | CompiledFlags { expression, flags } ->
161 | let (expressionId, nodesWithChild, guardWithChild) = simpleInsert expression nodes guard
162 | in insertElement
163 | (simpleNode (FlagsNode { expression = Just expressionId, flags = flags }))
164 | nodesWithChild element guardWithChild
165 |
166 |
167 | insertElement: NodeView -> Nodes -> CompiledElement -> DuplicationGuard -> (NodeId, Nodes, DuplicationGuard)
168 | insertElement newNode currentNodes newElement currentGuard =
169 | if isSimple newElement -- FIXME this simplification will sometimes lead to new but completely unconnected nodes??
170 | then insertNewElement newNode currentNodes newElement currentGuard
171 |
172 | else case LinearDict.get newElement currentGuard of
173 | Just existingId -> -- reconnect to existing node
174 | (existingId, currentNodes, currentGuard)
175 |
176 | Nothing -> insertNewElement newNode currentNodes newElement currentGuard
177 |
178 |
179 | insertNewElement newNode currentNodes newElement currentGuard =
180 | let -- actually insert new
181 | (id, map) = currentNodes |> IdMap.insert newNode
182 | newGuard = currentGuard |> LinearDict.insert newElement id
183 | in (id, map, newGuard)
184 |
185 | insertElements: List CompiledElement -> Nodes -> DuplicationGuard
186 | -> (List NodeId, Nodes, DuplicationGuard)
187 |
188 | insertElements newNodeIds currentNodes currentGuard =
189 | case newNodeIds of
190 | [] -> ([], currentNodes, currentGuard)
191 |
192 | element :: rest ->
193 | let
194 | (restIds, restNodes, restGuards) = insertElements rest currentNodes currentGuard
195 | (id, newNodes, newGuard) = insert Vec2.zero element restNodes restGuards
196 |
197 | in (id :: restIds, newNodes, newGuard)
198 |
199 | -- used to determine whether a node should be reused or (for simple nodes) just inserted again
200 | isSimple node =
201 | case node of
202 | CompiledCharSequence sequence -> String.length sequence < 4
203 | CompiledCharset options -> String.length options.contents < 4
204 | CompiledCharRange _ range -> range == ('a', 'z') || range == ('A', 'Z') || range == ('0', '9')
205 | CompiledSymbol _ -> True
206 | _ -> False
207 |
208 | -- TODO DRY
209 | compile : ParsedElement -> CompiledElement
210 | compile element = case element of
211 |
212 | ParsedSet [ onlyOneOption ] -> compile onlyOneOption
213 | ParsedSet options -> CompiledSet <| List.map compile options
214 | ParsedSequence manyMembers -> compileSequence manyMembers
215 | ParsedCapture child -> CompiledCapture <| compile child
216 | ParsedCharsetAtom charOrSymbol -> compileCharOrSymbol charOrSymbol
217 | ParsedCharset set -> compileCharset set
218 |
219 | ParsedIfFollowedBy { expression, successor } -> CompiledIfFollowedBy
220 | { expression = compile expression, successor = compile successor }
221 |
222 | ParsedIfNotFollowedBy { expression, successor } -> CompiledIfNotFollowedBy
223 | { expression = compile expression, successor = compile successor }
224 |
225 | ParsedRangedRepetition { expression, minimum, maximum, minimal } -> CompiledRangedRepetition
226 | { expression = compile expression, minimum = minimum, maximum = maximum, minimal = minimal }
227 |
228 | ParsedExactRepetition { expression, count } -> CompiledExactRepetition
229 | { expression = compile expression, count = count }
230 |
231 | ParsedMinimumRepetition { expression, count, minimal } -> CompiledMinimumRepetition
232 | { expression = compile expression, count = count, minimal = minimal }
233 |
234 | ParsedOptional { expression, minimal } -> CompiledOptional
235 | { expression = compile expression, minimal = minimal }
236 |
237 | ParsedAnyRepetition { expression, minimal } -> CompiledAnyRepetition
238 | { expression = compile expression, minimal = minimal }
239 |
240 | ParsedAtLeastOne { expression, minimal } -> CompiledAtLeastOne
241 | { expression = compile expression, minimal = minimal }
242 |
243 | ParsedFlags { expression, flags } -> CompiledFlags
244 | { expression = compile expression, flags = flags }
245 |
246 |
247 |
248 |
249 | compileCharOrSymbol charOrSymbol = case charOrSymbol of
250 | Plain char -> CompiledCharSequence <| String.fromChar char
251 | Escaped symbol -> CompiledSymbol symbol
252 | Range _ -> CompiledCharSequence "Error" -- should not happen outside of char sets?
253 |
254 | -- collapse all subsequent chars into a literal
255 | compileSequence members = case List.foldr compileSequenceMember [] members of
256 | [ onlyOneMember ] -> onlyOneMember -- must be checked AFTER collapsing members
257 | moreMembers -> CompiledSequence moreMembers
258 |
259 | compileSequenceMember member compiled = case member of
260 | ParsedCharsetAtom (Plain character) -> case compiled of
261 | CompiledCharSequence collapsed :: alreadyCompiled ->
262 | (CompiledCharSequence (String.fromChar character ++ collapsed)) :: alreadyCompiled
263 |
264 | -- cannot simplify chars if followed by something exotic
265 | nonCharsequenceCompiled ->
266 | CompiledCharSequence (String.fromChar character) :: nonCharsequenceCompiled -- ignore anything else
267 |
268 | symbol -> compile symbol :: compiled
269 |
270 |
271 | compileCharset { inverted, contents } = -- FIXME [^ax-z] != [^a]|[^x-z] !!!!
272 | case List.foldr compileCharsetOption ("", [], []) contents of
273 | (chars, [], []) -> CompiledCharset { inverted = inverted, contents = chars }
274 | ("", [symbol], []) -> CompiledSymbol symbol -- FIXME will ignore `inverted`
275 | ("", [], [range]) -> compileRange inverted range
276 | ("", symbols, ranges) -> CompiledSet (List.map CompiledSymbol symbols ++ List.map (CompiledCharRange inverted) ranges) -- FIXME will ignore `inverted`
277 | (chars, symbols, ranges) -> CompiledSet <|
278 | CompiledCharset { inverted = inverted, contents = chars }
279 | :: (List.map CompiledSymbol symbols) -- TODO dry -- FIXME will ignore `inverted` in symbols
280 | ++ (List.map (compileRange inverted) ranges) -- TODO dry -- FIXME will ignore `inverted` in symbols
281 |
282 | -- TODO filter duplicate options
283 | compileCharsetOption member (chars, symbols, ranges) = case member of
284 | Plain char -> (String.fromChar char ++ chars, symbols, ranges)
285 | Escaped symbol -> (chars, symbol :: symbols, ranges)
286 | Range (start, end) -> (chars, symbols, (start, end) :: ranges)
287 |
288 | compileRange inverted range =
289 | case (inverted, range) of
290 | (False, ('0', '9')) -> CompiledSymbol DigitChar
291 | _ -> CompiledCharRange inverted range
292 |
293 |
294 | parse : String -> ParseResult ParsedElement
295 | parse regex = parseFlags regex
296 |
297 | parseFlags : String -> ParseResult ParsedElement
298 | parseFlags text =
299 | let
300 | (hasModifiers, remaining) = skipIfNext "/" text
301 | expression = parseSet remaining
302 |
303 | parseRegexFlags chars =
304 | RegexFlags
305 | (String.contains "g" chars)
306 | (String.contains "i" chars)
307 | (String.contains "m" chars)
308 |
309 | wrapInFlags content flags =
310 | if flags /= defaultFlags
311 | then ParsedFlags { expression = content, flags = flags }
312 | else content
313 |
314 | parseModifiers (content, remaining1) =
315 | let
316 | skipSlash = skipOrErr "/" remaining1
317 | flags = skipSlash |> Result.map parseRegexFlags
318 |
319 | in flags |> Result.map (wrapInFlags content)
320 |
321 | in if hasModifiers
322 | then expression |> Result.andThen parseModifiers
323 | else expression |> Result.map Tuple.first
324 |
325 |
326 |
327 | parseSet : String -> ParseSubResult ParsedElement
328 | parseSet text =
329 | let firstOption = parseSequence text |> Result.map (Tuple.mapFirst List.singleton)
330 | in extendSet firstOption |> Result.map (Tuple.mapFirst ParsedSet)
331 |
332 | extendSet : ParseSubResult (List ParsedElement) -> ParseSubResult (List ParsedElement)
333 | extendSet current = case current of
334 | Err error -> Err error
335 | Ok (options, text) ->
336 | case skipIfNext "|" text of
337 | (True, rest) -> parseSequence rest
338 | |> Result.map (Tuple.mapFirst (appendTo options))
339 | |> extendSet
340 |
341 | (False, rest) ->
342 | Ok (options, rest)
343 |
344 |
345 | parseSequence: String -> ParseSubResult ParsedElement
346 | parseSequence text = extendSequence (Ok ([], text))
347 | |> Result.map (Tuple.mapFirst ParsedSequence)
348 |
349 | extendSequence : ParseSubResult (List ParsedElement) -> ParseSubResult (List ParsedElement)
350 | extendSequence current = case current of
351 | Err error -> Err error
352 | Ok (members, text) ->
353 | if String.isEmpty text || String.startsWith ")" text || String.startsWith "|" text || String.startsWith "/" text
354 | then Ok (members, text)
355 | else parseLookAhead text
356 | |> Result.map (Tuple.mapFirst (appendTo members))
357 | |> extendSequence
358 |
359 |
360 | parseLookAhead : String -> ParseSubResult ParsedElement
361 | parseLookAhead text =
362 | let
363 | expressionResult = parseQuantified text
364 | extract (content, rest) =
365 | if String.startsWith "(?=" rest
366 | then parseParentheses (\s -> ParsedIfFollowedBy { expression = content, successor = s }) "(?=" rest
367 |
368 | else if String.startsWith "(?!" rest
369 | then parseParentheses (\s -> ParsedIfNotFollowedBy { expression = content, successor = s }) "(?!" rest
370 |
371 | else Ok(content, rest)
372 |
373 | in expressionResult |> Result.andThen extract
374 |
375 |
376 |
377 | parseQuantified : String -> ParseSubResult ParsedElement
378 | parseQuantified text = parseOptional text
379 |
380 |
381 | parseOptional : String -> ParseSubResult ParsedElement
382 | parseOptional text =
383 | let
384 | expressionResult = parseAtLeastOne text
385 | parseIt (expression, rest) =
386 | let
387 | (optional, rest1) = skipIfNext "?" rest
388 | (isLazy, rest2) = if optional
389 | then skipIfNext "?" rest1
390 | else (False, rest1)
391 |
392 | in if optional
393 | then (ParsedOptional { expression = expression, minimal = isLazy }, rest2)
394 | else (expression, rest2)
395 |
396 | in expressionResult |> Result.map parseIt
397 |
398 | -- TODO DRY
399 | parseAtLeastOne : String -> ParseSubResult ParsedElement
400 | parseAtLeastOne text =
401 | let
402 | expressionResult = parseAnyRepetition text
403 | parseIt (expression, rest) =
404 | let
405 | (optional, rest1) = skipIfNext "+" rest
406 | (isLazy, rest2) = if optional
407 | then skipIfNext "?" rest1
408 | else (False, rest1)
409 |
410 | in if optional
411 | then (ParsedAtLeastOne { expression = expression, minimal = isLazy }, rest2)
412 | else (expression, rest2)
413 |
414 | in expressionResult |> Result.map parseIt
415 |
416 |
417 | parseAnyRepetition : String -> ParseSubResult ParsedElement
418 | parseAnyRepetition text =
419 | let
420 | expressionResult = parseRangedRepetition text
421 | parseIt (expression, rest) =
422 | let
423 | (repeat, rest1) = skipIfNext "*" rest
424 | (isLazy, rest2) = if repeat
425 | then skipIfNext "?" rest1
426 | else (False, rest1)
427 |
428 | in if repeat
429 | then (ParsedAnyRepetition { expression = expression, minimal = isLazy }, rest2)
430 | else (expression, rest2)
431 |
432 | in expressionResult |> Result.map parseIt
433 |
434 |
435 | parseRangedRepetition : String -> ParseSubResult ParsedElement
436 | parseRangedRepetition text =
437 | text |> parseAtom |> Result.andThen (\(atom, rest) ->
438 | let
439 | (started, rest1) = skipIfNext "{" rest
440 | range = if started
441 | then Just (parseRepetitionRange rest1)
442 | else Nothing
443 |
444 | toNodes repetition =
445 | case repetition of
446 | Exact count -> ParsedExactRepetition
447 | { expression = atom, count = count }
448 |
449 | Ranged (min, max, minimal) -> case max of
450 | Nothing -> ParsedMinimumRepetition
451 | { expression = atom, count = min, minimal = minimal }
452 |
453 | Just maximum -> ParsedRangedRepetition
454 | { expression = atom, minimum = min, maximum = maximum, minimal = minimal }
455 |
456 | in case range of
457 | Nothing -> Ok (atom, rest)
458 | Just result -> result |> Result.map (Tuple.mapFirst toNodes)
459 | )
460 |
461 |
462 | type Repetition = Exact Int | Ranged (Int, Maybe Int, Bool)
463 | parseRepetitionRange : String -> ParseSubResult Repetition
464 | parseRepetitionRange text =
465 | let
466 | contents = splitFirst "}" text
467 | parseNumberList = String.split "," >> List.map String.toInt
468 | ranges = contents |> Maybe.map (Tuple.mapFirst parseNumberList)
469 |
470 | in case ranges of
471 | Just ([Just singleCount], rest) -> Ok (Exact singleCount, rest)
472 |
473 | Just ([first, second], rest) ->
474 | let (minimal, rest1) = skipIfNext "?" rest
475 | in Ok (Ranged (Maybe.withDefault 0 first, second, minimal), rest1)
476 |
477 | _ -> Err (Expected "Invalid count specifier")
478 |
479 |
480 | -- split off after the first occurrence of the delimiter, and consume the delimiter
481 | splitFirst delimiter text =
482 | case String.split delimiter text of
483 | first :: rest -> Just (first, String.join delimiter rest) -- FIXME wow
484 | _ -> Nothing
485 |
486 |
487 | -- an atom is any thing which has the highest precedence possible, especially brackets and characters
488 | parseAtom : String -> ParseSubResult ParsedElement
489 | parseAtom text =
490 | let
491 | isNext character = String.startsWith character text
492 |
493 | in
494 | if isNext "[" then parseCharset text
495 | else if isNext "(?:" then parseGroup text
496 | else if isNext "(" then parseCapturingGroup text
497 | else parseGenericAtomicChar text
498 |
499 |
500 | parseGroup = parseParentheses identity "(?:"
501 | parseCapturingGroup = parseParentheses ParsedCapture "("
502 |
503 | parseParentheses : (ParsedElement -> ParsedElement) -> String -> String -> ParseSubResult ParsedElement
504 | parseParentheses map openParens text =
505 | let
506 | contents = skipOrErr openParens text
507 | result = contents |> Result.andThen parseSet
508 | in result |> Result.map (Tuple.mapSecond (String.dropLeft 1)) -- drop the closing parentheses
509 | |> Result.map (Tuple.mapFirst map)
510 |
511 | parseGenericAtomicChar : String -> ParseSubResult ParsedElement
512 | parseGenericAtomicChar text =
513 | case String.uncons text of
514 | Just ('.', rest) -> Ok (ParsedCharsetAtom <| Escaped NonLinebreakChar, rest)
515 | Just ('$', rest) -> Ok (ParsedCharsetAtom <| Escaped End, rest)
516 | Just ('^', rest) -> Ok (ParsedCharsetAtom <| Escaped Start, rest)
517 | _ -> text
518 | |> parseAtomicChar (maybeOptions symbolizeLetterbased symbolizeTabLinebreak)
519 | |> Result.map (Tuple.mapFirst ParsedCharsetAtom)
520 |
521 |
522 | symbolizeTabLinebreak : Char -> Maybe Symbol
523 | symbolizeTabLinebreak token = case token of
524 | 't' -> Just TabChar
525 | 'n' -> Just LinebreakChar
526 | _ -> Nothing
527 |
528 |
529 |
530 | parseCharset : String -> ParseSubResult ParsedElement
531 | parseCharset text =
532 | let
533 | withoutBracket = skipOrErr "[" text
534 | inversion = withoutBracket |> Result.map (skipIfNext "^")
535 | contents = extendCharset (inversion |> Result.map (Tuple.mapFirst <| always []))
536 |
537 | charsetFromResults (inverted, _) (options, remaining) =
538 | (ParsedCharset { inverted = inverted, contents = options }, remaining)
539 |
540 | in Result.map2 charsetFromResults inversion contents
541 |
542 |
543 | -- TODO use foldr or similar?
544 | extendCharset : ParseSubResult (List CharsetAtom) -> ParseSubResult (List CharsetAtom)
545 | extendCharset current = case current of
546 | Err error -> Err error
547 | Ok (options, remaining) ->
548 | if String.isEmpty remaining
549 | then Err (Expected "]")
550 |
551 | else case skipIfNext "]" remaining of
552 | (True, rest) -> Ok (options, rest)
553 | (False, rest) -> parseCharsetAtom rest
554 | |> Result.map (Tuple.mapFirst (appendTo options))
555 | |> extendCharset
556 |
557 |
558 |
559 | parseCharsetAtom : String -> ParseSubResult CharsetAtom
560 | parseCharsetAtom text =
561 | let
562 | atom = parseAtomicChar (maybeOptions symbolizeLetterbased symbolizeTabLinebreakDot)
563 |
564 | extractRange : (Char, String) -> ParseSubResult CharsetAtom
565 | extractRange (firstAtom, remaining) = case skipIfNext "-" remaining of
566 | (True, rest) -> if String.startsWith "]" rest
567 | then Ok (Plain '-', rest) -- `-` can be the last char in a set without being escaped????
568 | else atom rest |> Result.andThen atomToCharOrErr
569 | |> Result.map (Tuple.mapFirst (Tuple.pair firstAtom >> Range))
570 |
571 | _ -> Ok (Plain firstAtom, remaining)
572 |
573 | in case atom text of
574 | Ok (Plain char, rest) -> extractRange (char, rest)
575 | other -> other
576 |
577 |
578 | atomToCharOrErr : (CharsetAtom, String) -> ParseSubResult Char
579 | atomToCharOrErr (atom, rest) = case atom of
580 | Plain char -> Ok (char, rest)
581 | _ -> Err (Expected "Plain Character")
582 |
583 |
584 | -- in a charset, the dot char must be escaped, because a plain dot is just a dot and not anything but linebreak
585 | symbolizeTabLinebreakDot : Char -> Maybe Symbol
586 | symbolizeTabLinebreakDot token = case token of
587 | 't' -> Just TabChar
588 | 'n' -> Just LinebreakChar
589 | '.' -> Just NonLinebreakChar
590 | _ -> Nothing
591 |
592 | -- does not escape any brackets
593 | symbolizeLetterbased : Char -> Maybe Symbol
594 | symbolizeLetterbased token = case token of
595 | 'd' -> Just DigitChar
596 | 'D' -> Just NonDigitChar
597 | 's' -> Just WhitespaceChar
598 | 'S' -> Just NonWhitespaceChar
599 | 'w' -> Just WordChar
600 | 'W' -> Just NonWordChar
601 | 'b' -> Just WordBoundary
602 | 'B' -> Just NonWordBoundary
603 | _ -> Nothing
604 |
605 |
606 |
607 |
608 | parseAtomicChar : (Char -> Maybe Symbol) -> String -> ParseSubResult (CharsetAtom)
609 | parseAtomicChar escape text =
610 | let
611 | (isEscaped, charSubResult) = skipIfNext "\\" text |> Tuple.mapSecond parseSingleChar
612 |
613 | symbolOrElsePlainChar character = character |> escape
614 | |> Maybe.map Escaped |> Maybe.withDefault (Plain character)
615 |
616 | in if isEscaped
617 | then charSubResult |> Result.map (Tuple.mapFirst symbolOrElsePlainChar)
618 | else charSubResult |> Result.map (Tuple.mapFirst Plain)
619 |
620 |
621 | parseSingleChar : String -> ParseSubResult Char
622 | parseSingleChar string = String.uncons string |> okOrErr ExpectedMoreChars
623 |
624 |
625 | skipOrErr : String -> String -> ParseResult String
626 | skipOrErr symbol text = if String.startsWith symbol text
627 | then text |> String.dropLeft (String.length symbol) |> Ok
628 | else Err (Expected symbol)
629 |
630 | skipIfNext : String -> String -> (Bool, String)
631 | skipIfNext symbol text = if String.startsWith symbol text
632 | then (True, text |> String.dropLeft (String.length symbol))
633 | else (False, text)
634 |
635 | okOrErr : ParseError -> Maybe v -> ParseResult v
636 | okOrErr error = Maybe.map Ok >> Maybe.withDefault (Err error)
637 |
638 | appendTo : List a -> a -> List a
639 | appendTo list element = list ++ [element]
640 |
641 | maybeOptions : (a -> Maybe b) -> (a -> Maybe b) -> a -> Maybe b
642 | maybeOptions first second value = case first value of
643 | Just result -> Just result
644 | Nothing -> second value
645 |
646 |
647 |
648 |
649 | oneOf a b v = a v || b v
--------------------------------------------------------------------------------
/src/Update.elm:
--------------------------------------------------------------------------------
1 | module Update exposing (..)
2 |
3 | import AutoLayout
4 | import Model exposing (..)
5 | import Build exposing (buildRegex, compileRegex, cycles)
6 | import Vec2 exposing (Vec2)
7 | import Parse exposing (..)
8 | import Regex
9 | import IdMap exposing (IdMap)
10 |
11 |
12 | -- UPDATE
13 |
14 | type Message
15 | = SearchMessage SearchMessage
16 | | SetOutputLocked Bool
17 | | DragModeMessage DragModeMessage
18 | | UpdateNodeMessage NodeId Node
19 | | UpdateView ViewMessage
20 | | UpdateExampleText ExampleTextMessage
21 | | DeleteNode NodeId
22 | | DuplicateNode NodeId
23 | | AutoLayout Bool NodeId
24 | | DismissCyclesError -- TODO remove monolithic structure
25 | | Deselect
26 | | Undo
27 | | Redo
28 | | DoNothing
29 |
30 | type ExampleTextMessage
31 | = UpdateContents String
32 | | SetEditing Bool
33 | | UpdateMaxMatchLimit Int
34 |
35 | type SearchMessage
36 | = UpdateSearch String
37 | | FinishSearch SearchResult
38 |
39 | type SearchResult
40 | = InsertPrototype Node
41 | | ParseRegex String
42 | | InsertLiteral String
43 | | NoResult
44 |
45 | type ViewMessage
46 | = MagnifyView { amount: Float, focus: Vec2 }
47 |
48 | type DragModeMessage
49 | = StartNodeMove { node : NodeId, mouse : Vec2 }
50 | | StartViewMove { mouse: Vec2 }
51 |
52 | | StartPrepareEditingConnection { node: NodeId, mouse: Vec2 }
53 | | StartEditingConnection { nodeId: NodeId, node: Node, supplier: Maybe NodeId, mouse: Vec2 }
54 | | StartCreateConnection { supplier: NodeId, mouse: Vec2 }
55 | | RealizeConnection { nodeId: NodeId, newNode: Node, mouse: Vec2 }
56 | | FinishDrag
57 |
58 | | UpdateDrag { newMouse : Vec2 }
59 |
60 | maxUndoSteps = 100
61 |
62 |
63 | update : Message -> Model -> Model
64 | update message model =
65 | let
66 | coreModel = model.history.present
67 | advance newPresent = { model | history = advanceHistory model.history newPresent }
68 | advanceModel newModel = { newModel | history = advanceHistory model.history newModel.history.present }
69 |
70 | in case message of
71 | DoNothing -> model
72 |
73 | Undo -> { model | history = undo model.history }
74 | Redo -> { model | history = redo model.history }
75 |
76 | DismissCyclesError -> advance { coreModel | cyclesError = False }
77 |
78 | Deselect -> if coreModel.outputNode.locked
79 | then advance { coreModel | selectedNode = Nothing }
80 | else advance <| updateCache { coreModel | selectedNode = Nothing, outputNode = { locked = False, id = Nothing } } coreModel
81 |
82 | UpdateExampleText textMessage -> case textMessage of
83 |
84 | SetEditing enabled -> if not enabled
85 | -- update cache because text contents or match limit could have been changed
86 | then advance <| updateCache (enableEditingExampleText enabled coreModel) coreModel
87 | else advance <| enableEditingExampleText enabled coreModel
88 |
89 | UpdateContents text -> let old = coreModel.exampleText in
90 | advance { coreModel | exampleText = { old | contents = text } }
91 |
92 | UpdateMaxMatchLimit limit -> let old = coreModel.exampleText in
93 | advance { coreModel | exampleText = { old | maxMatches = limit } }
94 |
95 |
96 | UpdateView viewMessage ->
97 | if coreModel.exampleText.isEditing
98 | || IdMap.isEmpty coreModel.nodes
99 |
100 | then model
101 |
102 | else case viewMessage of
103 | MagnifyView { amount, focus } ->
104 | { model | view = updateView amount focus model.view }
105 |
106 | SetOutputLocked locked ->
107 | advance { coreModel | outputNode = { id = coreModel.outputNode.id, locked = locked } }
108 |
109 | UpdateNodeMessage id value ->
110 | advance <| updateCache { coreModel | nodes = updateNode coreModel.nodes id value } coreModel
111 |
112 | DuplicateNode id -> advance <| duplicateNode coreModel id
113 | DeleteNode id -> advanceModel <| deleteNode id model
114 |
115 | AutoLayout hard id -> advance <| { coreModel | nodes = AutoLayout.layout hard id coreModel.nodes }
116 |
117 | SearchMessage searchMessage ->
118 | case searchMessage of
119 | UpdateSearch query -> { model | search = Just query }
120 | FinishSearch result -> case result of
121 | NoResult -> { model | search = Nothing }
122 | InsertLiteral text -> advanceModel <| insertNode (LiteralNode text) model
123 | InsertPrototype prototype -> advanceModel <| (insertNode prototype (stopEditingExampleText model))
124 | ParseRegex regex -> advanceModel <|
125 | (parseRegexNodes (stopEditingExampleText model) regex)
126 |
127 | DragModeMessage modeMessage ->
128 | case modeMessage of
129 | StartNodeMove { node, mouse } ->
130 | advanceModel <| startNodeMove mouse node model
131 |
132 | StartViewMove drag ->
133 | { model | dragMode = (Just <| MoveViewDrag drag) }
134 |
135 | -- update the subject node (disconnecting the input)
136 | -- and then start editing the connection of the old supplier
137 | -- TODO if current drag mode is retain, then reconnect to old node?
138 | StartEditingConnection { nodeId, node, supplier, mouse } ->
139 | advanceModel <| startEditingConnection nodeId node supplier mouse model
140 |
141 |
142 | StartCreateConnection { supplier, mouse } ->
143 | { model | dragMode = Just (CreateConnection { supplier = supplier, openEnd = mouse }) }
144 |
145 | StartPrepareEditingConnection { node, mouse } ->
146 | { model | dragMode = Just (PrepareEditingConnection { node = node, mouse = mouse }) }
147 |
148 | UpdateDrag { newMouse } ->
149 | case model.dragMode of
150 | Just (MoveNodeDrag { node, mouse }) ->
151 | moveNodeInModel newMouse mouse node model
152 |
153 | Just (MoveViewDrag { mouse }) ->
154 | moveViewInModel newMouse mouse model
155 |
156 | Just (CreateConnection { supplier }) ->
157 | { model | dragMode = Just <| CreateConnection { supplier = supplier, openEnd = newMouse } }
158 |
159 | _ -> model
160 |
161 | -- when a connection is established, update the drag mode of the model,
162 | -- but also already make the connection real
163 | RealizeConnection { nodeId, newNode, mouse } ->
164 | advanceModel <| realizeConnection model nodeId newNode mouse
165 |
166 | FinishDrag ->
167 | { model | dragMode = Nothing }
168 |
169 |
170 |
171 | advanceHistory history model =
172 | { past = List.take maxUndoSteps (history.present :: history.past), present = model, future = [] }
173 |
174 | undo history = case history.past of
175 | last :: older -> { past = older, present = last, future = history.present :: history.future }
176 | _ -> history
177 |
178 | redo history = case history.future of
179 | next :: newer -> { past = history.present :: history.past, present = next, future = newer }
180 | _ -> history
181 |
182 |
183 | updatePresent model presentUpdater =
184 | let history = model.history in
185 | { model | history = { history | present = presentUpdater history.present } }
186 |
187 |
188 | -- FIXME should not connect at all
189 | realizeConnection model nodeId newNode mouse =
190 | let
191 | newPresent present = updateCache
192 | { present | nodes = updateNode present.nodes nodeId newNode }
193 | present
194 |
195 | newDragMode = RetainPrototypedConnection
196 | { mouse = mouse, node = nodeId
197 | , previousNodeValue = IdMap.get nodeId model.history.present.nodes |> Maybe.map .node
198 | }
199 |
200 | newModel = updatePresent model newPresent
201 |
202 | in
203 | { newModel | dragMode = Just newDragMode }
204 |
205 | deleteNode: NodeId -> Model -> Model
206 | deleteNode nodeId model =
207 | let
208 | output = if model.history.present.outputNode.id == Just nodeId
209 | then Nothing else model.history.present.outputNode.id
210 |
211 | newNodeValues = model.history.present.nodes |> IdMap.remove nodeId
212 | |> IdMap.updateAllValues (\view -> { view | node = onNodeDeleted nodeId view.node })
213 |
214 | newPresent present = updateCache
215 | { present
216 | | nodes = newNodeValues
217 | , outputNode = { id = output, locked = present.outputNode.locked }
218 | }
219 | present
220 |
221 | newModel = updatePresent model newPresent
222 |
223 | in
224 | { newModel | dragMode = Nothing }
225 |
226 |
227 | duplicateNode: CoreModel -> NodeId -> CoreModel
228 | duplicateNode model nodeId =
229 | let
230 | nodes = model.nodes
231 | node = IdMap.get nodeId nodes
232 |
233 | in case node of
234 | Nothing -> model
235 | Just original ->
236 | let
237 | position = Vec2.add original.position (Vec2 0 -28)
238 | clone = { original | position = position }
239 |
240 | in { model | nodes = IdMap.insertAnonymous clone nodes }
241 |
242 | insertNode: Node -> Model -> Model
243 | insertNode node model =
244 | let
245 | position = Vec2.inverseTransform (Vec2 800 400) (viewTransform model.view)
246 | newPresent present = { present | nodes = IdMap.insertAnonymous (NodeView position node) present.nodes }
247 | newModel = updatePresent model newPresent
248 | in { newModel | search = Nothing }
249 |
250 | parseRegexNodes: Model -> String -> Model
251 | parseRegexNodes model regex =
252 | let
253 | history = model.history
254 | coreModel = history.present
255 |
256 | position = Vec2.inverseTransform (Vec2 1000 400) (viewTransform model.view)
257 | resultNodes = addParsedRegexNode position coreModel.nodes regex
258 |
259 | -- select the generated result and autolayout
260 | resultHistory resultNodeId nodes =
261 | { history | present = selectNode resultNodeId { coreModel | nodes = AutoLayout.layout True resultNodeId nodes } }
262 |
263 | -- clear the search
264 | resultModel (resultNodeId, nodes) =
265 | { model | history = resultHistory resultNodeId nodes, search = Nothing }
266 |
267 | in resultNodes |> Result.map resultModel |> Result.withDefault model
268 |
269 |
270 | startNodeMove: Vec2 -> NodeId -> Model -> Model
271 | startNodeMove mouse node model =
272 | let
273 | history = model.history
274 | present = history.present
275 |
276 | in { model
277 | | dragMode = Just (MoveNodeDrag { node = node, mouse = mouse })
278 | , history = { history | present = selectNode node present }
279 | }
280 |
281 | startEditingConnection : NodeId -> Node -> Maybe NodeId -> Vec2 -> Model -> Model
282 | startEditingConnection nodeId node currentSupplier mouse model =
283 | let
284 | newPresent present = updateCache
285 | { present | nodes = updateNode present.nodes nodeId node }
286 | present
287 |
288 | newModel = updatePresent model newPresent
289 |
290 | updateIt oldSupplier =
291 | { newModel | dragMode = Just (CreateConnection { supplier = oldSupplier, openEnd = mouse }) }
292 |
293 | in currentSupplier |> Maybe.map updateIt |> Maybe.withDefault model
294 |
295 |
296 | selectNode: NodeId -> CoreModel -> CoreModel
297 | selectNode node model =
298 | let
299 | safeModel = { model | selectedNode = Just node }
300 |
301 | possiblyInvalidModel = { safeModel
302 | | outputNode = if not model.outputNode.locked || model.outputNode.id == Nothing
303 | then { id = Just node, locked = model.outputNode.locked }
304 | else model.outputNode
305 | }
306 |
307 | in if model.outputNode.id /= possiblyInvalidModel.outputNode.id
308 | -- only update cache if node really changed
309 | then updateCache possiblyInvalidModel safeModel else possiblyInvalidModel
310 |
311 |
312 |
313 | stopEditingExampleText: Model -> Model
314 | stopEditingExampleText model =
315 | updatePresent model (enableEditingExampleText False)
316 |
317 | enableEditingExampleText: Bool -> CoreModel -> CoreModel
318 | enableEditingExampleText enabled model =
319 | let old = model.exampleText in
320 | { model | exampleText = { old | isEditing = enabled } }
321 |
322 |
323 | updateNode : Nodes -> NodeId -> Node -> Nodes
324 | updateNode nodes id newNode =
325 | nodes |> IdMap.update id (\nodeView -> { nodeView | node = newNode })
326 |
327 |
328 | moveNodeInModel: Vec2 -> Vec2 -> NodeId -> Model -> Model
329 | moveNodeInModel newMouse mouse node model =
330 | let
331 | delta = Vec2.sub newMouse mouse
332 | newPresent present = { present |nodes = moveNode model.view present.nodes node delta }
333 | newModel = updatePresent model newPresent
334 |
335 | in { newModel | dragMode = Just (MoveNodeDrag { node = node, mouse = newMouse }) }
336 |
337 | moveViewInModel: Vec2 -> Vec2 -> Model -> Model
338 | moveViewInModel newMouse mouse model =
339 | let
340 | view = model.view
341 | delta = Vec2.sub newMouse mouse
342 |
343 | in { model | dragMode = Just <| MoveViewDrag { mouse = newMouse }
344 | , view = { view | offset = Vec2.add view.offset delta }
345 | }
346 |
347 | moveNode : View -> Nodes -> NodeId -> Vec2 -> Nodes
348 | moveNode view nodes nodeId movement =
349 | let
350 | transform = viewTransform view
351 | viewMovement = Vec2.scale (1 / transform.scale) movement
352 | updateNodePosition nodeView = { nodeView | position = Vec2.add nodeView.position viewMovement }
353 | in IdMap.update nodeId updateNodePosition nodes
354 |
355 |
356 | updateView amount focus oldView =
357 | let
358 | magnification = oldView.magnification + amount * 0.009 -- * 0.4
359 | transform = viewTransform { magnification = magnification, offset = oldView.offset }
360 |
361 | oldTransform = viewTransform oldView
362 | deltaScale = transform.scale / oldTransform.scale
363 |
364 | newView = if transform.scale < 0.1 || transform.scale > 16 then oldView else
365 | { magnification = magnification
366 | , offset =
367 | { x = (oldView.offset.x - focus.x) * deltaScale + focus.x
368 | , y = (oldView.offset.y - focus.y) * deltaScale + focus.y
369 | }
370 | }
371 |
372 | in newView
373 |
374 |
375 | -- TODO ( updateUrl "regex result" )
376 | updateCache : CoreModel -> CoreModel -> CoreModel
377 | updateCache model fallback =
378 | let
379 | example = model.exampleText
380 | regex = model.outputNode.id |> Maybe.map (buildRegex model.nodes)
381 |
382 | in if regex == Just (Err cycles)
383 | then { fallback | cyclesError = True } else
384 | let
385 | multiple = regex |> Maybe.map
386 | (Result.map (.flags >> .multiple) >> Result.withDefault False)
387 | |> Maybe.withDefault False
388 |
389 | compiled = regex |> Maybe.andThen (Result.map compileRegex >> Result.map Just >> Result.withDefault Nothing)
390 | newExample = { example | cachedMatches = Maybe.map (extractMatches multiple example.maxMatches example.contents) compiled }
391 |
392 | in { model | exampleText = newExample, cyclesError = False, cachedRegex = regex } -- hide error on success
393 |
394 |
395 | extractMatches : Bool -> Int -> String -> Regex.Regex -> List (String, String)
396 | extractMatches multiple maxMatches text regex =
397 | let
398 | matches = Regex.findAtMost (if multiple then maxMatches else 1) regex text
399 |
400 | extractMatch match (textStartIndex, extractedMatches) =
401 | let
402 | textBeforeMatch = String.slice textStartIndex match.index text
403 | indexAfterMatch = match.index + String.length match.match
404 | in (indexAfterMatch, extractedMatches ++ [(textBeforeMatch, match.match)])
405 |
406 | extract rawMatches =
407 | -- use foldr in order to utilize various optimizations
408 | let (indexAfterLastMatch, extractedMatches) = List.foldl extractMatch (0, []) rawMatches
409 |
410 | in if List.length matches == maxMatches
411 | -- do not append unprocessed text
412 | -- (if maximum matches were processed, any text after the last match has not been processed)
413 | then extractedMatches
414 |
415 | -- else, also add all the text after the last match
416 | else extractedMatches ++ [(String.slice indexAfterLastMatch (String.length text) text, "")]
417 |
418 |
419 | simplify = List.foldr simplifyMatch []
420 |
421 | simplifyMatch (before, match) alreadySimplified = case alreadySimplified of
422 | -- if text between this and successor match is empty, merge them into a single match
423 | ("", immediateSuccessor) :: moreRest -> (before, match ++ immediateSuccessor) :: moreRest
424 |
425 | -- just append otherwise
426 | other -> (before, match) :: other
427 |
428 |
429 | -- replace spaces by (hair-space ++ dot ++ hair-space) to visualize whitespace
430 | -- (separate function to avoid recursion stack overflow)
431 | visualizeMatch match = String.replace " " "\u{200B}␣\u{200B}" match -- " " "\u{200A}·\u{200A}" match
432 | visualize matchList = List.map (Tuple.mapSecond visualizeMatch) matchList
433 |
434 | in matches |> extract |> simplify |> visualize
435 |
436 |
--------------------------------------------------------------------------------
/src/Vec2.elm:
--------------------------------------------------------------------------------
1 | module Vec2 exposing (..)
2 |
3 |
4 | type alias Vec2 =
5 | { x : Float
6 | , y : Float
7 | }
8 |
9 |
10 | zero = Vec2 0 0
11 |
12 | add : Vec2 -> Vec2 -> Vec2
13 | add a b = Vec2 (a.x + b.x) (a.y + b.y)
14 |
15 | sub : Vec2 -> Vec2 -> Vec2
16 | sub a b = Vec2 (a.x - b.x) (a.y - b.y)
17 |
18 | scale : Float -> Vec2 -> Vec2
19 | scale s v = Vec2 (v.x * s) (v.y * s)
20 |
21 | fromTuple : (Float, Float) -> Vec2
22 | fromTuple value = Vec2 (Tuple.first value) (Tuple.second value)
23 |
24 | inverseTransform : Vec2 -> { scale: Float, translate: Vec2 } -> Vec2
25 | inverseTransform value transformation =
26 | sub value transformation.translate |> scale (1 / transformation.scale)
27 |
28 | transform : Vec2 -> { scale: Float, translate: Vec2 } -> Vec2
29 | transform value transformation =
30 | scale transformation.scale value |> add transformation.translate
31 |
32 | squareLength : Vec2 -> Float
33 | squareLength value = value.x * value.x + value.y * value.y
34 |
35 | length : Vec2 -> Float
36 | length value = sqrt (squareLength value)
37 |
38 | ray magnitude direction origin = Vec2
39 | (origin.x + magnitude * direction.x)
40 | (origin.y + magnitude * direction.y)
--------------------------------------------------------------------------------
/src/View.elm:
--------------------------------------------------------------------------------
1 | module View exposing (..)
2 |
3 | import Array exposing (Array)
4 | import Html exposing (..)
5 | import Html.Lazy exposing (lazy)
6 | import Html.Attributes exposing (..)
7 | import Html.Events exposing (onInput, onBlur, onFocus)
8 | import Dict exposing (Dict)
9 | import Html.Events.Extra.Mouse as Mouse
10 | import Html.Events.Extra.Wheel as Wheel
11 | import IdMap
12 | import Svg exposing (Svg, svg, line, g)
13 | import Svg.Attributes exposing (x1, x2, y1, y2)
14 | import Regex
15 | import Json.Decode
16 |
17 | import Vec2 exposing (Vec2)
18 | import Model exposing (..)
19 | import Build exposing (..)
20 | import Update exposing (..)
21 |
22 |
23 |
24 | type alias NodeView =
25 | { node: Html Message
26 | , connections: List (Svg Message)
27 | }
28 |
29 |
30 |
31 | -- VIEW
32 |
33 |
34 | view : Model -> Html Message
35 | view untrackedModel =
36 | let
37 | trackedModel = untrackedModel.history.present
38 | expressionResult = trackedModel.cachedRegex |> Maybe.map (Result.map constructRegexLiteral)
39 |
40 | (moveDragging, connectDragId, mousePosition) = case untrackedModel.dragMode of
41 | Just (MoveNodeDrag { mouse }) -> (True, Nothing, mouse)
42 | Just (CreateConnection { supplier, openEnd }) -> (False, Just supplier, openEnd)
43 | _ -> (False, Nothing, Vec2 0 0)
44 |
45 | connectDragging = connectDragId /= Nothing
46 |
47 | nodeViews = (List.map (viewNode untrackedModel.dragMode trackedModel.selectedNode trackedModel.outputNode.id trackedModel.nodes) (IdMap.toList trackedModel.nodes))
48 |
49 | connections = flattenList (List.map .connections nodeViews)
50 |
51 | startViewMove event =
52 | if event.button == Mouse.MiddleButton
53 | then DragModeMessage <| StartViewMove { mouse = Vec2.fromTuple event.clientPos }
54 | else Deselect
55 |
56 | in div
57 | [ conservativeOnMouse "mousemove" (\event -> DragModeMessage -- do not prevent default (which is text selection)
58 | (UpdateDrag { newMouse = Vec2.fromTuple event.clientPos })
59 | )
60 |
61 | , Mouse.onUp (always <| DragModeMessage FinishDrag)
62 | , Mouse.onLeave (always <| DragModeMessage FinishDrag)
63 |
64 |
65 | , id "main"
66 | , classes "" [(moveDragging, "move-dragging"), (connectDragging, "connect-dragging"), (trackedModel.exampleText.isEditing, "editing-example-text")]
67 | ]
68 |
69 | [ lazy viewExampleText trackedModel.exampleText
70 |
71 | , svg [ id "connection-graph" ]
72 | [ g [ magnifyAndOffsetSVG untrackedModel.view ]
73 | (if connectDragging then connections ++ [ viewConnectDrag untrackedModel.view trackedModel.nodes connectDragId mousePosition ] else connections)
74 | ]
75 |
76 | , div
77 |
78 | (
79 | [ id "node-graph"
80 | , (Mouse.onWithOptions "mousedown" { stopPropagation = False, preventDefault = False } startViewMove) -- do not prevent input blur on click
81 | , Wheel.onWheel wheel
82 | , preventContextMenu (always DoNothing)
83 | ]
84 | )
85 |
86 | [ div [ class "transform-wrapper", magnifyAndOffsetHTML untrackedModel.view ]
87 | (List.map .node nodeViews)
88 | ]
89 |
90 | , div [ id "overlay" ]
91 | [ nav []
92 | [ header []
93 | [ img [ src "html/img/logo.svg" ] [] -- TODO also link image to "reset application"
94 | , h1 []
95 | [ a
96 | [ href "https://johannesvollmer.github.io/regex-nodes/"
97 | , title "Restart Application"
98 | ]
99 | [ text "Regex Nodes" ]
100 | ]
101 | , a
102 | [ href "https://johannesvollmer.github.io/2019/announcing-regex-nodes/", target "_blank", rel "noopener noreferrer"
103 | , title "johannesvollmer.github.io/announcing-regex-nodes"
104 | ]
105 | [ text " About " ]
106 | , a
107 | [ href "https://johannesvollmer.github.io/2019/announcing-regex-nodes/#functionality-reference", target "_blank", rel "noopener noreferrer"
108 | , title "johannesvollmer.github.io/functionality-reference"
109 | ]
110 | [ text " Help " ]
111 | , a
112 | [ href "https://github.com/johannesvollmer/regex-nodes", target "_blank", rel "noopener noreferrer"
113 | , title "github.com/johannesvollmer/regex-nodes"
114 | ]
115 | [ text " Github " ]
116 | ]
117 |
118 | , div [ id "example-options" ]
119 | [ div
120 | [ id "match-limit"
121 | , title ("Display no more than " ++ String.fromInt trackedModel.exampleText.maxMatches
122 | ++ " Matches from the example text, in order to perserve responsivenes"
123 | )
124 | ]
125 | [ text "Example Match Limit"
126 | , viewPositiveIntInput trackedModel.exampleText.maxMatches (UpdateExampleText << UpdateMaxMatchLimit)
127 | ]
128 |
129 | , div
130 | [ id "edit-example", class "button"
131 | , checked trackedModel.exampleText.isEditing
132 | , Mouse.onClick (always <| UpdateExampleText <| SetEditing <| not trackedModel.exampleText.isEditing)
133 | , title "Edit the Text which is displayed in the background"
134 | ]
135 |
136 | [ text "Edit Example" ]
137 | ]
138 | ]
139 |
140 | , div [ id "search" ]
141 | [ viewSearchBar untrackedModel.search
142 | , viewSearchResults untrackedModel.search
143 | ]
144 |
145 | , div [ id "history" ]
146 | [ div
147 | [ id "undo", title "Undo the last action"
148 | , classes "button" [(List.isEmpty untrackedModel.history.past, "disabled")]
149 | , Mouse.onClick (always Undo)
150 | ]
151 | [ img [ src "html/img/arrow-left.svg" ] [] ]
152 |
153 | , div
154 | [ id "redo", title "Undo the last action"
155 | , classes "button" [(List.isEmpty untrackedModel.history.future, "disabled")]
156 | , Mouse.onClick (always Redo)
157 | ]
158 | [ img [ src "html/img/arrow-left.svg" ] [] ]
159 | ]
160 |
161 | , div [ id "expression-result", classes "" [(expressionResult == Nothing, "no")] ]
162 | [ code []
163 | [ span [ id "declaration" ] [ text "const regex = " ]
164 | , text (expressionResult |> Maybe.withDefault (Ok "/(nothing)/") |> unwrapResult)
165 | ]
166 |
167 | , div
168 | [ id "lock", classes "button" [(trackedModel.outputNode.locked, "checked")]
169 | , Mouse.onClick (always <| SetOutputLocked <| not trackedModel.outputNode.locked)
170 | , title "Always show the regex of the selected Node"
171 | ]
172 | [ lockSvg ]
173 | ]
174 | ]
175 |
176 | {-, div
177 | [ id "confirm-deletion"
178 | , classes "alert" [(model.confirmDeletion /= Nothing, "show")]
179 | , Mouse.onClick <| always <| ConfirmDeleteNode False
180 | , stopMousePropagation "wheel"
181 | ]
182 |
183 | [ div [ class "dialog-box" ]
184 | [ p [] [ text ("Delete that node?") ]
185 | , div [ class "options" ]
186 | [ div
187 | [ class "confirm", class "button"
188 | , onMouseWithStopPropagation "click" (always <| ConfirmDeleteNode True)
189 | ]
190 | [ text "Delete" ]
191 |
192 | , div [ class "cancel", class "button" ] [ text "Cancel" ]
193 | ]
194 |
195 | ]
196 | ]-}
197 |
198 | , div
199 | [ id "cycles-detected"
200 | , classes "notification button" [(trackedModel.cyclesError, "show")]
201 | , Mouse.onClick <| always <| DismissCyclesError
202 | ]
203 |
204 | [ text ("Some actions cannot be performed due to cycles in the node graph.")
205 | , div [] [ text ("Make sure there are no cyclic connections. Click to dismiss.") ]
206 | -- TODO or maybe the graph is just too complex, enable increasing limit in ui
207 | ]
208 | ]
209 |
210 |
211 |
212 | wheel event =
213 | { amount = case event.deltaMode of
214 | Wheel.DeltaPixel -> -event.deltaY
215 | Wheel.DeltaLine -> -event.deltaY * 40
216 | Wheel.DeltaPage -> -event.deltaY * 1000
217 |
218 | , focus = (Vec2.fromTuple event.mouseEvent.clientPos)
219 | }
220 |
221 | |> MagnifyView |> UpdateView
222 |
223 | unwrapResult result = case result of
224 | Ok a -> a
225 | Err a -> a
226 |
227 | lockSvg =
228 | Svg.svg
229 | [ Svg.Attributes.width "50", Svg.Attributes.height "50", Svg.Attributes.viewBox "0 0 10 10" ]
230 | [ Svg.path [ Svg.Attributes.id "bracket", Svg.Attributes.d "M 3,3 v -1.5 c 0,-2 4,-2 4,0 v 4" ] []
231 | , Svg.rect
232 | [ Svg.Attributes.x "2", Svg.Attributes.y "5"
233 | , Svg.Attributes.width "6", Svg.Attributes.height "4"
234 | , Svg.Attributes.id "body"
235 | ] [ ]
236 | ]
237 |
238 | preventContextMenu handler = Mouse.onWithOptions "contextmenu"
239 | { preventDefault = True, stopPropagation = False }
240 | handler
241 |
242 | conservativeOnMouse tag handler = Mouse.onWithOptions tag
243 | { preventDefault = False, stopPropagation = False }
244 | handler
245 |
246 | viewSearchResults search =
247 | div
248 | [ id "results"
249 | , stopMousePropagation "wheel"
250 | ]
251 | (Maybe.withDefault [] (Maybe.map viewSearch search) )
252 |
253 | viewSearchBar search = input
254 | [ placeholder (if search == Nothing then "Add Nodes" else "Search Nodes or Enter A Regular Expression")
255 | , type_ "text"
256 | , value (Maybe.withDefault "" search)
257 | , onFocus (SearchMessage (UpdateSearch ""))
258 | , onInput (\text -> SearchMessage (UpdateSearch text)) -- TODO if enter, submit first
259 | , onBlur (SearchMessage (FinishSearch NoResult))
260 | ] []
261 |
262 |
263 |
264 | viewSearch : String -> List (Html Message)
265 | viewSearch query =
266 | let
267 | isEmpty = String.isEmpty query
268 | lowercaseQuery = String.toLower query
269 | regex = Maybe.withDefault Regex.never (Regex.fromStringWith { caseInsensitive = True, multiline = False } query)
270 |
271 | test name = isEmpty
272 | || String.contains lowercaseQuery (String.toLower name)
273 | || List.all (\word -> String.contains word (String.toLower name)) (String.words lowercaseQuery)
274 | || (Regex.contains regex name)
275 |
276 | matches prototype = test prototype.name
277 |
278 | render prototype = div
279 | [ class "button"
280 | , Mouse.onWithOptions
281 | "mousedown"
282 | { stopPropagation = False, preventDefault = False } -- do not prevent blurring the textbox on selecting a result
283 | (\_ -> SearchMessage (FinishSearch (InsertPrototype prototype.node)))
284 | ]
285 |
286 | [ p [ class "name" ] [ text prototype.name ]
287 | , p [ class "description" ] [ text prototype.description ]
288 | ]
289 |
290 | asRegex = div
291 | [ class "button"
292 | , Mouse.onWithOptions
293 | "mousedown"
294 | { stopPropagation = False, preventDefault = False } -- do not prevent blurring the textbox on selecting a result
295 | (\_ -> SearchMessage (FinishSearch (ParseRegex query)))
296 | ]
297 | [ text "Insert regular expression `"
298 | , code [ ] [ text (insertWhitePlaceholder query) ]
299 | , text "` as Nodes"
300 | , p [ class "description" ] [ text "Add that regular expression by converting it to a network of Nodes" ]
301 | ]
302 |
303 | asLiteral = div
304 | [ class "button"
305 | , Mouse.onWithOptions
306 | "mousedown"
307 | { stopPropagation = False, preventDefault = False } -- do not prevent blurring the textbox on selecting a result
308 | (\_ -> SearchMessage (FinishSearch (InsertLiteral query)))
309 | ]
310 | [ text "Insert literal `"
311 | , code [][ text(insertWhitePlaceholder query) ]
312 | , text "` as Node "
313 | , p [ class "description" ] [ text ("Add a Node which matches exactly `" ++ query ++ "` and nothing else") ]
314 | ]
315 |
316 | results = (prototypes |> List.filter matches |> List.map render)
317 |
318 | in if isEmpty then results
319 | else asRegex :: (asLiteral :: results)
320 |
321 |
322 | viewExampleText example =
323 | if example.isEditing
324 | then textarea [ id "example-text", onInput (UpdateExampleText << UpdateContents) ]
325 | [ text example.contents ]
326 |
327 | else
328 | let
329 | texts = example.cachedMatches |> Maybe.map viewExampleTexts
330 | |> Maybe.withDefault [ text example.contents ]
331 |
332 | in div [ id "example-text" ] texts
333 |
334 |
335 | viewExampleTexts : List (String, String) -> List (Html Message)
336 | viewExampleTexts matches =
337 | let
338 | render matchPair =
339 | [ Html.text (Tuple.first matchPair)
340 | , span [ class "match" ] [ Html.text (Tuple.second matchPair) ]
341 | ]
342 |
343 | in matches |> List.concatMap render
344 |
345 |
346 | viewNode : Maybe DragMode -> Maybe NodeId -> Maybe NodeId -> Nodes -> (NodeId, Model.NodeView) -> NodeView
347 | viewNode dragMode selectedNode outputNode nodes (nodeId, nodeView) =
348 | let props = nodeProperties nodeView.node in
349 | NodeView (viewNodeContent nodes dragMode selectedNode outputNode nodeId props nodeView) (viewNodeConnections nodes props nodeView)
350 |
351 |
352 | viewNodeConnections : Nodes -> List Property -> Model.NodeView -> List (Svg Message)
353 | viewNodeConnections nodes props nodeView =
354 | let
355 | connectionLine from width to index = Svg.path
356 | [ Svg.Attributes.class "connection"
357 | , bezierSvgConnectionpath
358 | (Vec2 to.x (to.y + ((toFloat index) + 0.5) * propertyHeight))
359 | (Vec2 (from.x + width) (from.y + 0.5 * propertyHeight))
360 | ]
361 | []
362 |
363 | connect : NodeId -> Model.NodeView -> (Int -> Maybe (Svg Message))
364 | connect supplierId node index =
365 | let
366 | supplier = IdMap.get supplierId nodes
367 | viewSupplier supplierNodeView =
368 | connectionLine supplierNodeView.position (nodeWidth supplierNodeView.node) node.position index
369 |
370 | in Maybe.map viewSupplier supplier
371 |
372 | viewInputConnection : Property -> List (Int -> Maybe (Svg Message))
373 | viewInputConnection property = case property.contents of
374 | ConnectingProperty (Just supplier) _ ->
375 | [ connect supplier nodeView ]
376 |
377 | ConnectingProperties _ suppliers _ ->
378 | suppliers |> Array.toList |> List.map (\supplier -> connect supplier nodeView)
379 |
380 | _ -> [ always Nothing ]
381 |
382 | -- TODO use lazy html!
383 | flattened = props |> List.map viewInputConnection |> flattenList
384 | indexed = flattened |> List.indexedMap (\index at -> at index)
385 | filtered = List.filterMap identity indexed
386 |
387 | in filtered
388 |
389 |
390 | viewConnectDrag : View -> Nodes -> Maybe NodeId -> Vec2 -> Html Message
391 | viewConnectDrag viewTransformation nodes dragId mouse =
392 | let
393 | node = Maybe.andThen (\id -> IdMap.get id nodes) dragId
394 | nodePosition = Maybe.map (.position) node |> Maybe.withDefault Vec2.zero
395 |
396 | nodeAnchor = Vec2
397 | (nodePosition.x + (Maybe.map (.node >> nodeWidth) node |> Maybe.withDefault 0))
398 | (nodePosition.y + 0.5 * 25.0)
399 |
400 | transform = viewTransform viewTransformation
401 | transformedMouse = Vec2.inverseTransform mouse transform
402 |
403 | in Svg.path
404 | [ Svg.Attributes.class "prototype connection"
405 | , bezierSvgConnectionpath transformedMouse nodeAnchor
406 | ]
407 | []
408 |
409 |
410 | bezierSvgConnectionpath from to =
411 | let
412 | tangentX1 = from.x - abs(to.x - from.x) * 0.4
413 | tangentX2 = to.x + abs(to.x - from.x) * 0.4
414 |
415 | in svgConnectionPath
416 | from (Vec2 tangentX1 from.y) (Vec2 tangentX2 to.y) to
417 |
418 | svgConnectionPath from fromTangent toTangent to =
419 | let
420 | vec2ToString vec = String.fromFloat vec.x ++ "," ++ String.fromFloat vec.y ++ " "
421 | path = "M" ++ vec2ToString from ++ "C" ++ vec2ToString fromTangent
422 | ++ vec2ToString toTangent ++ vec2ToString to
423 | in Svg.Attributes.d path
424 |
425 |
426 | hasDragConnectionPrototype dragMode nodeId = case dragMode of
427 | Just (CreateConnection { supplier }) -> nodeId == supplier
428 | _ -> False
429 |
430 | -- TODO use lazy html!
431 | viewNodeContent : Nodes -> Maybe DragMode -> Maybe NodeId -> Maybe NodeId -> NodeId -> List Property -> Model.NodeView -> Html Message
432 | viewNodeContent nodes dragMode selectedNode outputNode nodeId props nodeView =
433 | let
434 | contentWidth = (nodeWidth nodeView.node |> String.fromFloat) ++ "px"
435 |
436 | mayDragConnect = case dragMode of
437 | Just (PrepareEditingConnection { node }) -> nodeId == node
438 | Just (RetainPrototypedConnection { node }) -> nodeId == node
439 | _ -> False
440 |
441 | -- equivalent to on right mouse down on MacOS
442 | onContextMenu event =
443 | if dragMode == Nothing then -- only do that on MacOs (windows mouse up will fail this check)
444 | DragModeMessage (StartPrepareEditingConnection { node = nodeId, mouse = Vec2.fromTuple event.clientPos })
445 |
446 | else DoNothing
447 |
448 | onMouseDownAndStopPropagation event =
449 | if event.button == Mouse.SecondButton then
450 | { message = DragModeMessage (StartPrepareEditingConnection { node = nodeId, mouse = Vec2.fromTuple event.clientPos })
451 | , stopPropagation = True
452 | , preventDefault = True
453 | }
454 |
455 | else if event.button == Mouse.MainButton then
456 | { message = DragModeMessage (StartNodeMove { node = nodeId, mouse = Vec2.fromTuple event.clientPos })
457 | , stopPropagation = True
458 | , preventDefault = True
459 | }
460 |
461 | -- do not stop event propagation on middle mouse down
462 | else { message = DoNothing, stopPropagation = False, preventDefault = False }
463 |
464 | -- TODO dry
465 |
466 | duplicateAndStopPropagation event =
467 | if event.button == Mouse.MainButton
468 | then (DuplicateNode nodeId, True)
469 | else (DoNothing, False) -- do not stop event propagation on non-primary mouse down
470 |
471 | deleteAndStopPropagation event =
472 | if event.button == Mouse.MainButton
473 | then (DeleteNode nodeId, True)
474 | else (DoNothing, False) -- do not stop event propagation on non-primary mouse down
475 |
476 | autolayoutAndStopPropagation event =
477 | if event.button == Mouse.MainButton
478 | then (AutoLayout False nodeId, True)
479 | else (DoNothing, False) -- do not stop event propagation on non-primary mouse down
480 |
481 | mayStopPropagation : String -> (Mouse.Event -> (Message, Bool)) -> Attribute Message
482 | mayStopPropagation tag handler = Html.Events.stopPropagationOn
483 | tag (Mouse.eventDecoder |> Json.Decode.map handler)
484 |
485 |
486 | preventDefaultAndMayStopPropagation : String ->
487 | (Mouse.Event -> { message: Message, preventDefault: Bool, stopPropagation: Bool })
488 | -> Attribute Message
489 |
490 | preventDefaultAndMayStopPropagation tag handler = Html.Events.custom
491 | tag (Mouse.eventDecoder |> Json.Decode.map handler)
492 |
493 | in div
494 | [ style "width" contentWidth
495 | , translateHTML nodeView.position
496 | , classes "graph-node"
497 | [ (hasDragConnectionPrototype dragMode nodeId, "connecting")
498 | , (outputNode == Just nodeId, "output")
499 | , (selectedNode == Just nodeId, "selected")
500 | , (mayDragConnect, "may-drag-connect")
501 | ]
502 | ]
503 |
504 | [ div
505 | [ class "properties"
506 | , preventDefaultAndMayStopPropagation "mousedown" onMouseDownAndStopPropagation -- always prevent default (to prevent native drag)?
507 | , preventContextMenu onContextMenu
508 | ]
509 | (viewProperties nodes nodeId dragMode props)
510 |
511 | , div
512 | [ class "menu" ]
513 | [ div
514 | [ mayStopPropagation "mousedown" autolayoutAndStopPropagation -- must be mousedown because click would be triggered after deselect on mouse down
515 | , class "autolayout button"
516 | , title "Automatically layout all inputs of this node"
517 | ]
518 | [ img [ src "html/img/tidy.svg" ] [] ]
519 | , div
520 | [ mayStopPropagation "mousedown" duplicateAndStopPropagation -- must be mousedown because click would be triggered after deselect on mouse down
521 | , class "duplicate button"
522 | , title "Duplicate this Node"
523 | ]
524 | [ img [ src "html/img/copy.svg" ] [] ]
525 |
526 | , div
527 | [ mayStopPropagation "mousedown" deleteAndStopPropagation -- must be mousedown because click would be triggered after deselect on mouse down
528 | , class "delete button"
529 | , title "Delete this Node"
530 | ]
531 | [ img [ src "html/img/bin.svg" ] [] ]
532 | ]
533 |
534 | ]
535 |
536 |
537 | viewProperties : Nodes -> NodeId -> Maybe DragMode -> List Property -> List (Html Message)
538 | viewProperties nodes nodeId dragMode props =
539 | let
540 |
541 | mayStartConnectDrag = case dragMode of
542 | Just (PrepareEditingConnection { node }) -> nodeId == node
543 | Just (RetainPrototypedConnection { node }) -> nodeId == node
544 | _ -> False
545 |
546 | enableDisconnect = case dragMode of
547 | Just (PrepareEditingConnection _) -> True
548 | Just (RetainPrototypedConnection _) -> True
549 | _ -> False
550 |
551 | onLeave : Maybe { supplier: Maybe NodeId, onChange: OnChange (Maybe NodeId) } -> Bool -> Mouse.Event -> Message
552 | onLeave input output event = DragModeMessage (
553 | case dragMode of
554 | -- Just (RetainPrototypedConnection { mouse }) -> TODO
555 |
556 | Just (PrepareEditingConnection { mouse }) ->
557 | case input of
558 | Just { supplier, onChange } ->
559 | if (Tuple.first event.clientPos) < mouse.x && supplier /= Nothing then
560 | StartEditingConnection { supplier = supplier, node = onChange Nothing, nodeId = nodeId, mouse = Vec2.fromTuple event.clientPos }
561 |
562 | else if output then StartCreateConnection { supplier = nodeId, mouse = Vec2.fromTuple event.clientPos }
563 | else FinishDrag
564 |
565 | Nothing -> if output then StartCreateConnection { supplier = nodeId, mouse = Vec2.fromTuple event.clientPos }
566 | else FinishDrag
567 |
568 | _ -> UpdateDrag { newMouse = Vec2.fromTuple event.clientPos }
569 | )
570 |
571 | leftConnector active = div
572 | [ (classes "left connector" [(not active, "inactive")]) ]
573 | []
574 |
575 | rightConnector active = div
576 | [ (classes "right connector" [(not active, "inactive")]) ]
577 | []
578 |
579 |
580 | propertyHTML: List (Attribute Message) -> Html Message -> String -> String -> Bool -> Html Message -> Html Message -> Html Message
581 | propertyHTML attributes directInput name description connectableInput left right = div
582 | ((classes "property" [(connectableInput, "connectable-input")]) :: (title description :: attributes))
583 |
584 | [ left
585 | , span [ class "title" ] [ text name ]
586 | , directInput
587 | , right
588 | ]
589 |
590 | updateNode = UpdateNodeMessage nodeId
591 |
592 | simpleInputProperty property directInput = propertyHTML
593 | [ Mouse.onLeave (onLeave Nothing property.connectOutput) ] directInput property.name property.description False (leftConnector False) (rightConnector property.connectOutput)
594 |
595 | connectInputProperty property currentSupplier onChange maybePreviewRegex =
596 | let
597 | connectOnEnter supplier event =
598 | DragModeMessage (RealizeConnection { mouse = Vec2.fromTuple event.clientPos, nodeId = nodeId, newNode = onChange (Just supplier) })
599 |
600 | onEnter = case dragMode of
601 | Just (CreateConnection { supplier }) ->
602 | if supplier == nodeId then [] else -- TODO check for real cycles
603 | [ Mouse.onEnter (connectOnEnter supplier) ]
604 |
605 | _ -> []
606 |
607 | onLeaveHandlers = if enableDisconnect && mayStartConnectDrag
608 | then [ Mouse.onLeave (onLeave (Just { supplier = currentSupplier, onChange = onChange }) property.connectOutput) ] else []
609 |
610 | left = leftConnector True
611 |
612 | preview = case maybePreviewRegex of
613 | Nothing -> div[][]
614 | Just regex -> div[ class "regex-preview" ][ text regex ]
615 |
616 | in propertyHTML (onEnter ++ onLeaveHandlers) preview property.name property.description True left (rightConnector property.connectOutput)
617 |
618 | singleProperty property = case property.contents of
619 | BoolProperty value onChange -> [ simpleInputProperty property (viewBoolInput value (onChange (not value) |> updateNode)) ]
620 | CharsProperty chars onChange -> [ simpleInputProperty property (viewCharsInput chars (onChange >> updateNode)) ]
621 | CharProperty char onChange -> [ simpleInputProperty property (viewCharInput char (onChange >> updateNode)) ]
622 | IntProperty number onChange -> [ simpleInputProperty property (viewPositiveIntInput number (onChange >> updateNode)) ]
623 | ConnectingProperty currentSupplier onChange -> [ connectInputProperty property currentSupplier onChange Nothing ]
624 |
625 | ConnectingProperties countThem connectedProps onChange ->
626 | let
627 | count index prop = if countThem
628 | then { prop | name = String.fromInt (index + 1) ++ "." }
629 | else prop
630 |
631 | onChangePropertyAtIndex index newInput = case newInput of
632 | Just newInputId -> onChange (insertIntoArray index newInputId connectedProps)
633 | Nothing -> onChange (removeFromArray index connectedProps)
634 |
635 | onChangeStubProperty newInput = case newInput of
636 | Just newInputId -> onChange (Array.push newInputId connectedProps)
637 | Nothing -> onChange (removeFromArray ((Array.length connectedProps) - 1) connectedProps)
638 |
639 | viewRealProperty index supplier =
640 | let
641 | baseAttributes = (count index property)
642 | nodeExprString = (buildRegex nodes supplier) |> Result.map .expression |> Result.toMaybe
643 |
644 | in connectInputProperty baseAttributes (Just supplier) (onChangePropertyAtIndex index) nodeExprString
645 |
646 | realProperties = Array.toList <| Array.indexedMap viewRealProperty connectedProps
647 |
648 | propCount = Array.length connectedProps
649 | stubProperty = connectInputProperty (count propCount property) Nothing onChangeStubProperty Nothing
650 |
651 | in realProperties ++ [ stubProperty ]
652 |
653 |
654 | TitleProperty ->
655 | [ propertyHTML
656 | [ Mouse.onLeave (onLeave Nothing True) ]
657 | (div[][])
658 | property.name property.description
659 | False
660 | (leftConnector False)
661 | (rightConnector property.connectOutput)
662 | ]
663 |
664 | in
665 | flattenList (List.map singleProperty props)
666 |
667 |
668 | onMouseWithStopPropagation eventName eventHandler = Mouse.onWithOptions
669 | eventName { preventDefault = False, stopPropagation = True } eventHandler
670 |
671 | stopMousePropagation eventName =
672 | onMouseWithStopPropagation eventName (always DoNothing)
673 |
674 | viewBoolInput : Bool -> Message -> Html Message
675 | viewBoolInput value onToggle = input
676 | [ type_ "checkbox"
677 | , checked value
678 | , onMouseWithStopPropagation "click" (always onToggle)
679 | , stopMousePropagation "mousedown"
680 | , stopMousePropagation "mouseup"
681 | ]
682 | []
683 |
684 |
685 | viewCharsInput : String -> (String -> Message) -> Html Message
686 | viewCharsInput chars onChange = input
687 | [ type_ "text"
688 | , placeholder "chars"
689 | , value (insertWhitePlaceholder chars)-- FIXME this will reset the cursor position
690 | , onInput (removeWhitePlaceholder >> onChange)
691 | , class "chars input"
692 | , stopMousePropagation "mousedown"
693 | , stopMousePropagation "mouseup"
694 | ]
695 | []
696 |
697 | viewCharInput : Char -> (Char -> Message) -> Html Message
698 | viewCharInput char onChange = input
699 | [ type_ "text"
700 | , placeholder "a"
701 | , value (String.fromChar char |> insertWhitePlaceholder) -- FIXME this will reset the cursor position
702 |
703 | -- Take the last char of the string
704 | , onInput (onChange << stringToChar char << removeWhitePlaceholder)
705 |
706 | , class "char input"
707 | , stopMousePropagation "mousedown"
708 | , stopMousePropagation "mouseup"
709 | ]
710 | []
711 |
712 |
713 | viewPositiveIntInput : Int -> (Int -> Message) -> Html Message
714 | viewPositiveIntInput number onChange = input
715 | [ type_ "number"
716 | , value (String.fromInt number)
717 | , onInput (onChange << stringToInt number)
718 | , class "int input"
719 | , stopMousePropagation "mousedown"
720 | , stopMousePropagation "mouseup"
721 | , Html.Attributes.min "0"
722 | ]
723 | []
724 |
725 |
726 | stringToInt fallback string = string
727 | |> String.toInt |> Maybe.withDefault fallback
728 |
729 | stringToChar fallback string = string
730 | |> String.right 1 |> String.uncons
731 | |> Maybe.map Tuple.first |> Maybe.withDefault fallback
732 |
733 |
734 | flattenList list = List.foldr (++) [] list
735 |
736 | removeFromList index list =
737 | List.take index list ++ List.drop (index + 1) list
738 |
739 | removeFromArray index =
740 | Array.toList >> removeFromList index >> Array.fromList
741 |
742 | insertIntoArray index element array =
743 | let -- TODO simplify!
744 | left = Array.slice 0 index array
745 | right = Array.slice index (Array.length array) array
746 | in Array.fromList ((Array.toList left) ++ [element] ++ (Array.toList right))
747 |
748 | classes : String -> List (Bool, String) -> Attribute Message
749 | classes base elements = base ++ " " ++
750 | (List.filterMap (\(condition, class) -> if condition then Just class else Nothing) elements |> String.join " ")
751 | |> class
752 |
753 |
754 | translateHTML = translate "px"
755 | translate unit position = style "transform" ("translate(" ++ (String.fromFloat position.x) ++ unit ++ "," ++ (String.fromFloat position.y) ++ unit ++ ")")
756 |
757 | magnifyAndOffsetHTML transformView = style "transform" (magnifyAndOffset "px" transformView)
758 | magnifyAndOffsetSVG transformView = Svg.Attributes.transform (magnifyAndOffset "" transformView)
759 | magnifyAndOffset unit transformView =
760 | let transform = viewTransform transformView
761 | in
762 | ( "translate(" ++ (String.fromFloat transform.translate.x) ++ unit ++ "," ++ (String.fromFloat transform.translate.y) ++ unit ++ ") "
763 | ++ "scale(" ++ (String.fromFloat transform.scale) ++ ")"
764 | )
765 |
766 |
--------------------------------------------------------------------------------