Repository navigation
Expand file tree
/
Copy pathmain.roc
More file actions
3370 lines (2968 loc) · 115 KB
/
Copy pathmain.roc
File metadata and controls
3370 lines (2968 loc) · 115 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
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
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
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
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
## Plot the line lengths of files while a source tree is still being scanned.
##
## Run this app from the directory you want to inspect. Use the wheel to scroll,
## Shift-wheel to zoom, drag to pan, R to return to new results, N to change the
## horizontal scale, and Escape to quit. Run with `--record-demo` to write
## `examples/gallery/live_plot.gif` from built-in sample data. This larger example uses Tasks for
## file work, limits how much work and data it keeps at once, and draws many
## points efficiently.
app [Model, program] {
rr: platform "../../platform/main.roc",
}
import rr.App
import rr.Files
import rr.Trace
import "assets/fonts/LiberationSans-Regular.ttf" as liberation_sans : List(U8)
import rr.Camera
import rr.Capture
import rr.Color
import rr.Draw
import rr.Math
import rr.Task
import rr.Text
## Walk a source tree and plot every line while it is still being discovered.
##
## Nothing about the dataset is known when this starts. There is no list of
## files: `Files.list!` reports one directory, the app decides which of its
## entries are worth descending into or reading, and enqueues more work. Run
## from the root of this repository it finds around five hundred files and a
## quarter of a million lines, and it is drawing the first of them a few frames
## in.
##
## Each line is one point -- x is how far into its file it falls, y is how many
## columns it runs to -- and each file gets a lane. The lanes stack downward
## into a strip far taller than any window, so the figure scrolls.
##
## Five things have to compose for that to work, and this example exists to show
## that they do.
##
## Discovery is incremental. `Files.list!` lists one directory and never its
## children, so a walk is the app's own loop: a listing arrives, its
## subdirectories become more listings and its files become reads, and all of it
## goes through the same backlog as everything else. A host-side recursive walk
## would be one unbounded operation that no backlog could pace.
##
## Reads never block the frame. Each listing and each read runs inside
## `Task.spawn!`. The task parks on the host's event loop while the frame loop
## keeps drawing, and its return value arrives later as an ordinary `Msg`. The
## delivered `List(U8)` is a seamless view onto the buffer the host already
## allocated, so no file bytes are copied to hand it over.
##
## Everything is paced. The host runs 32 tasks at once and queues the rest, so
## nothing is ever refused -- but this walk would happily ask for five hundred
## files at once and hold every one of them in memory. So the app keeps its own
## backlog of `Work` and starts at most `max_in_flight` tasks from it.
## `arrival_limit` bounds the other end, the bytes that have already been
## delivered and not yet parsed, which no host limit knows anything about. Both
## bounds are plain data rather than opaque handles, so the pacing is testable
## with ordinary equality. The queue depth in the masthead is the backlog being
## real.
##
## Parsing is incremental. `update!` runs inside the frame, so it may not scan a
## 400 KB file in one go. `scan_chunk` walks a bounded window of one file per
## cycle and answers with the points it found and where to resume. The frame
## time does not depend on how big the files are, only on the budget.
##
## Drawing is batched and bounded. Every point is one `Draw.TextureInstance` in
## a single list, drawn with one `frame.texture_instances!` call from one sprite
## the batch tints per lane.
##
## That leaves what is kept and what is not. A quarter of a million points is
## about sixteen megabytes of instances, and the model is copied once per frame,
## so keeping all of them would cost a gigabyte a second of copying to draw a
## figure that is mostly off screen. So they are not kept.
##
## There are two tiers, and the split is the whole memory story. Every file
## keeps a summary, forever: its path, its length, and the distribution of its
## line lengths in twenty buckets. That is a couple of hundred bytes a file, it
## is what the gutter and the density strips are drawn from, and it never grows
## with how long the app runs. Only `point_budget` points are kept, though. They
## are held as runs, one per file, in the order they were parsed; when the
## budget is exceeded the oldest run is dropped. A lane whose points have gone
## still draws -- as the density its summary describes, rather than as
## line-by-line detail.
##
## Scrolling to a lane whose points were dropped asks for the file again. That
## is the point rather than a workaround: a re-read is `Work` like any other, it
## is paced like any other, and a figure that can throw away detail and fetch it
## back on demand is one that does not care how large the tree is.
##
## Wheel scrolls, shift-wheel zooms at the pointer, drag pans, `R` returns to
## the live edge, `N` switches the x scale, `ESC` quits.
##
## Paths are resolved relative to the working directory, so run it from the
## root of the tree it should plot:
##
## roc examples/live_plot/main.roc
##
## Run with `--record-demo` to plot a deterministic built-in source tree and
## write `examples/gallery/live_plot.gif`.
## State kept between updates: queued and active file work, partial parsing
## results, bounded point data, permanent per-file summaries, camera position,
## drawing resources, and prepared labels. These categories let scanning,
## parsing, interaction, and drawing advance a little at a time without keeping
## every line from every file in memory.
Model : {
demo : Bool,
## The directory being walked: the working directory, read-only, as the
## config declares. Every listing and read names a path beneath it.
tree : Files.ReadDir,
## The one sprite behind every point, and the offscreen buffer it is
## painted into. A batch draws a single texture, so hundreds of differently
## coloured lanes come from hundreds of tints of this, not from hundreds of
## textures and hundreds of batches.
##
## It is a render texture rather than a generated one because the sprite is
## a radial falloff -- a bright core inside a wide soft halo -- and
## `Assets.generate_color_texture!` only makes flat fills. Drawing two
## `circle_gradient!`s into a 64x64 buffer costs one scope at the top of
## `render!` and buys the whole look: tens of thousands of these composited
## additively stop being a scatter of dots and become a density field.
glow : Draw.RenderTexture,
## The backlog of listings and reads. Only `max_in_flight` of these are
## running as tasks at once, however much the walk has discovered.
queue : WorkQueue,
## What the walk has found so far, and what it is still owed.
walk : Walk,
## Byte lists the host has delivered but the parser has not started on.
##
## Holding one of these is the whole ownership story: there is no handle to
## release and no host resource to remember. It is an ordinary Roc list, it
## keeps the file's storage alive exactly as long as something references
## it, and dropping it is what frees the file.
arrivals : List(Arrival),
## The file being parsed right now. `Idle` between files.
parsing : [Idle, Parsing(Scan)],
## One entry per file that has been parsed, in the order they were parsed.
##
## This is the permanent tier, and a lane's index in it is its position in
## the figure: lane `n` is drawn at `n * lane_height`, which is what lets a
## point's y say which lane it belongs to without storing anything per point.
lanes : List(Lane),
## The batch. One instance per line of every file whose points are still
## being kept, in world coordinates.
instances : List(Draw.TextureInstance),
## Which lanes those points belong to, oldest first.
##
## `instances` is the concatenation of these runs in this order, so dropping
## the oldest run is dropping a prefix of the list, and appending a newly
## parsed file is appending to the end of both.
runs : List(Run),
## The lane a re-read is currently being fetched for, if any. At most one is
## outstanding, so scrolling fast through evicted lanes queues one file
## rather than hundreds.
refetching : [Nothing, Fetching(U64)],
## The longest line found anywhere so far, copied out of its file.
peak : Peak,
## Reads and lines per unit time, for the masthead's two graphs.
rates : Rates,
## Permanent summaries maintained as lanes change, so the masthead does not
## fold the entire lane list every presentation frame.
files_parsed : U64,
lines_parsed : U64,
## Pan and zoom. A camera is a plain value, so the view costs the plot
## nothing: the instances never move when it changes.
camera : Camera.Camera2D,
## Whether the view is pinned to the newest lane. Any scroll or drag drops
## out of it; `R` returns.
following : Bool,
## The logical window size this cycle. `render!` is handed the model and a
## frame but not the input, so laying the furniture out means sampling the
## size in `update!`, where the input is.
screen : Math.Vec2,
## Wrapped animation clock for the sweep, in seconds.
sweep : F32,
## The opening move, 0 to 1.
entrance : F32,
## Smoothed frame rate, for the figure's own instrumentation.
fps : F32,
## Whether traces are drawn at their true length or normalised to fill the
## plot. Toggled with `N`; see `XMode`.
x_mode : XMode,
## The zoom bucket `instances` was last sized for. Points are sized in world
## units, so a magnified view would magnify them too; rebuilding the list
## keeps them a constant size on screen. Doing it per frame would allocate
## per frame, so it is done per bucket -- see `dot_step_of`.
dot_step : I32,
## Two atlases of one face. A glyph atlas is rasterised at the size it is
## loaded at, so the figures and labels get one and the small tracked
## capitals get another.
font : Text.Font,
small : Text.Font,
title : Text.Prepared,
eyebrow : Text.Prepared,
deck : Text.Prepared,
hint : Text.Prepared,
}
## How the x axis is scaled.
##
## `Normalised` gives every file the full width of the plot, so its trace is the
## shape of that file and two files can be compared however different their
## lengths. `True` puts every file on one shared scale, so the width of a trace
## is how long the file is -- which is the more honest picture and the less
## readable one, because these files run from nine lines to eleven thousand.
##
## The figure opens `Normalised` and keeps the length comparison the other mode
## would have given: the gutter draws each file's length as a bar.
XMode : [Normalised, True]
## What the directory walk has found and what it still owes.
##
## Only counts: the work itself lives in `pending`, and a walk that kept its own
## copy of the backlog would be a second place for it to go wrong.
Walk : {
dirs_found : U64,
dirs_listed : U64,
dirs_failed : U64,
files_found : U64,
files_skipped : U64,
bytes_read : U64,
}
## A delivered file, waiting its turn to be parsed.
Arrival : {
path : Str,
bytes : List(U8),
## Set when this file is being fetched back for a lane that already exists,
## rather than being met for the first time.
replaces : [New, Lane(U64)],
}
## One retained run of points, and which lane's they are.
Run : {
lane : U64,
count : U64,
}
## Where the incremental parser is inside one file.
##
## `cursor` is the byte to resume at, `col` is how many columns of the current
## line were already seen by an earlier cycle, and `line` is the index of that
## line within the file -- which is also its x coordinate.
Scan : {
lane : U64,
bytes : List(U8),
cursor : U64,
col : U64,
line : U64,
tint : Color.Rgba,
## World units per line index, for the points this scan emits. While a file
## is still arriving its length is not known yet, so this is an estimate
## from its byte count; `scan_once` corrects it in one pass when the file
## ends. See `estimated_lines`.
x_scale : F32,
## World-unit size of the points this scan emits, refreshed from the model
## each cycle so that zooming mid-parse sizes new points like the old ones.
size : F32,
}
## Everything kept about one file, forever.
##
## This is the tier that does not grow with how long the app runs: a couple of
## hundred bytes a file, whatever the file's size, and it is what a lane whose
## points have been dropped is still drawn from.
Lane : {
path : Str,
tint : Color.Rgba,
lines : U64,
bytes : U64,
progress : Progress,
## World units per line index for this lane's points, as they are currently
## laid out. Every point in a lane is `line * x_scale` across, so changing a
## lane's scale is one multiplication per point -- which is what makes
## `relayout` cheap enough to run on an event rather than on a frame.
x_scale : F32,
## Distribution of line lengths, `hist_buckets` buckets from zero columns to
## `max_columns`. It is drawn as a violin in the gutter, and -- for a lane
## whose points have been dropped -- as the density strip that stands in for
## them in the plot.
hist : List(U64),
}
## A file's position in the pipeline.
##
## There is no failure case: a file that could not be read never becomes a lane
## at all, because a lane is created from the bytes rather than from the
## discovery, and it is counted in the masthead's skipped total instead. A lane
## in this figure is always a file that was read.
Progress : [Working, Ready]
## The longest line seen, as a bounded copy of its bytes rather than a view.
Peak : {
path : Str,
columns : U64,
text : Str,
}
## Throughput over the last `rate_window` samples.
##
## The counters accumulate within the current sample and the list holds the
## finished ones, so a graph never shows a partial bucket as a dip.
Rates := {
samples : List(Sample),
clock : F32,
bytes : U64,
lines : U64,
}.{
is_eq : _
new = || Rates.{ samples: [], clock: 0, bytes: 0, lines: 0 }
add_bytes = |rates, count| Rates.{ samples: rates.samples, clock: rates.clock, bytes: rates.bytes + count, lines: rates.lines }
add_lines = |rates, count| Rates.{ samples: rates.samples, clock: rates.clock, bytes: rates.bytes, lines: rates.lines + count }
sample = |rates, delta| {
ticked = rates.clock + delta
if ticked < sample_period {
Rates.{ samples: rates.samples, clock: ticked, bytes: rates.bytes, lines: rates.lines }
} else {
kept = if List.len(rates.samples) >= rate_window {
List.drop_first(rates.samples, 1)
} else {
rates.samples
}
Rates.{
samples: List.append(kept, { bytes: rates.bytes, lines: rates.lines }),
clock: ticked - sample_period,
bytes: 0,
lines: 0,
}
}
}
peak = |rates, measure| List.fold(rates.samples, 1, |most, item| U64.max(most, measure(item)))
latest = |rates, measure|
match List.last(rates.samples) {
Ok(item) => measure(item)
Err(_) => 0
}
recent_mean = |rates, measure| {
len = List.len(rates.samples)
span = U64.min(len, mean_window)
if span == 0 {
0
} else {
total = List.fold(List.sublist(rates.samples, { start: len - span, len: span }), 0, |sum, item| sum + measure(item))
U64.to_f32(total) / U64.to_f32(span)
}
}
}
## A sample stays open until its period has elapsed.
expect {
part = Rates.{ samples: [], clock: 0, bytes: 900, lines: 4 }.sample(sample_period / 2)
List.is_empty(part.samples) and part.bytes == 900
}
expect {
closed = Rates.{ samples: [], clock: 0, bytes: 900, lines: 4 }.sample(sample_period)
closed.samples == [{ bytes: 900, lines: 4 }] and closed.bytes == 0 and closed.lines == 0
}
## Sampling keeps a fixed-size history.
expect {
full = List.repeat({ bytes: 1, lines: 1 }, rate_window)
rolled = Rates.{ samples: full, clock: 0, bytes: 7, lines: 7 }.sample(sample_period)
List.len(rolled.samples) == rate_window and List.last(rolled.samples) == Ok({ bytes: 7, lines: 7 })
}
expect Rates.{ samples: [{ bytes: 3, lines: 1 }, { bytes: 9, lines: 2 }], clock: 0, bytes: 0, lines: 0 }.peak(|item| item.bytes) == 9
## An empty history still has a peak because it is used as a divisor.
expect Rates.new().peak(|item| item.bytes) == 1
expect Rates.new().latest(|item| item.lines) == 0
expect Rates.{ samples: [{ bytes: 1, lines: 5 }], clock: 0, bytes: 0, lines: 0 }.latest(|item| item.lines) == 5
Sample : {
bytes : U64,
lines : U64,
}
## How much of one file a single `update!` is allowed to scan.
Budget : {
max_lines : U64,
max_bytes : U64,
}
## What one bounded scan found, and how to resume after it.
##
## `full` says the line budget, not the end of the window, is what stopped the
## scan. `best` locates the longest line inside this chunk as an offset into the
## file, so the caller can decide whether it is worth copying out.
ScanResult : {
dots : List(Draw.TextureInstance),
## The column count of each line this chunk closed, in order. It is the same
## information the dots carry, in the form the histogram wants; recovering it
## from a point's y would mean inverting the plot's own geometry.
cols : List(U64),
consumed : U64,
col : U64,
lines : U64,
best : BestLine,
full : Bool,
}
## Where a line starts in the file, and how long it is.
BestLine : {
start : U64,
columns : U64,
}
Msg : [
## The path travels with the result on purpose. The task that produced it is
## gone by the time the message arrives, so this message is all the app has
## to work from -- to attribute the result, and to rebuild the work if it
## ever needs asking for again.
Listed(Str, Try(List(Files.Entry), Files.ListError)),
FileRead(Str, [New, Lane(U64)], Try(List(U8), Files.ReadBytesError)),
]
## One unit of deferred work the walk has discovered but not yet started.
##
## Ordinary data, so the backlog can be compared, counted and tested without a
## window. `start_work!` is the only place it becomes an effect.
Work : [ListDir(Str), ReadFile(Str, [New, Lane(U64)])]
max_in_flight = 6.U64
## The app-owned backlog and the number of tasks still expected to answer.
## Keeping them in one type puts all changes to the work limit in one place.
WorkQueue := { pending : List(Work), in_flight : U64 }.{
is_eq : _
new = || WorkQueue.{ pending: [], in_flight: 0 }
enqueue = |queue, work| WorkQueue.{ pending: List.append(queue.pending, work), in_flight: queue.in_flight }
completed = |queue| WorkQueue.{ pending: queue.pending, in_flight: if queue.in_flight == 0 0 else queue.in_flight - 1 }
take_ready = |queue| {
room = if queue.in_flight >= max_in_flight 0 else max_in_flight - queue.in_flight
split = List.split_at(queue.pending, U64.min(room, List.len(queue.pending)))
{
queue: WorkQueue.{ pending: split.others, in_flight: queue.in_flight + List.len(split.before) },
starting: split.before,
}
}
}
expect WorkQueue.{ pending: [ListDir("a"), ListDir("b"), ListDir("c")], in_flight: 4 }.take_ready()
== { queue: WorkQueue.{ pending: [ListDir("c")], in_flight: 6 }, starting: [ListDir("a"), ListDir("b")] }
expect WorkQueue.new().take_ready() == { queue: WorkQueue.new(), starting: [] }
expect WorkQueue.new().completed() == WorkQueue.new()
program = { init!, update!, render! }
## Recording mode. `--record-demo` hides the window, feeds the plot the
## built-in `demo_paths` through `demo_message` instead of walking the disk,
## and writes a GIF for the README gallery into `examples/gallery/`. The
## recording stops itself after `demo_frames` frames, and `update!` exits when
## it has been written.
record_demo_flag = "--record-demo"
demo_frames = 150.U64
demo_recording : Capture.Recording
demo_recording =
Capture.default
.with_path("live_plot.gif")
.with_format(Gif)
.with_fps(25)
.with_max_frames(demo_frames)
.with_scale(Half)
.with_timing(FixedStep)
## Declares the working directory, read-only, then selects an interactive
## window or a hidden one that records the demo.
live_plot_config : List(Str) -> App.Config
live_plot_config = |args| {
base = App.default
.with_title("RocRay Live Plot")
.with_size({ width: 1240, height: 860 })
.with_min_size({ width: 980, height: 640 })
.with_resizable(Bool.True)
.with_frame_pacing(VSync)
# The plot walks and reads the directory it was launched from.
.with_permission(WorkingDirectory(ReadOnly))
if List.contains(args, record_demo_flag) base.with_visible(Bool.False).with_output_dir("examples/gallery") else base
}
## A demo run is over once its recording has been written, or has failed.
demo_finished : Capture.Status -> Try({}, [Exit(I64)])
demo_finished = |status|
match status {
Finished(_) => Err(Exit(0))
Failed(_) => Err(Exit(1))
_ => Ok({})
}
## A small built-in tree for reproducible capture. Each file still enters as a
## `FileRead` message and goes through the ordinary bounded parser.
demo_paths : List(Str)
demo_paths = [
"platform/App.roc",
"platform/Draw.roc",
"src/runtime.zig",
"src/capture.zig",
"examples/breakout.roc",
"examples/particles.roc",
"docs/guide.md",
"platform/Color.roc",
"src/render.zig",
"tests/host.c",
"examples/snake.roc",
"platform/Task.roc",
"scripts/gallery.py",
"docs/design.md",
"platform/Files.roc",
"src/input.zig",
"examples/plot.roc",
"README.md",
]
demo_bytes : U64 -> List(U8)
demo_bytes = |file_index| {
lines = List.map_with_index(
List.repeat({}, 96),
|_unit, line_index| {
width = 12 + ((line_index * 29 + file_index * 17 + (line_index % 7) * 11) % 104)
Str.repeat(
if line_index % 5 == 0 {
"#"
} else {
"x"
},
width,
)
},
)
Str.to_utf8(Str.join_with(lines, "\n"))
}
demo_message : U64 -> Try(Msg, [None])
demo_message = |cycle| {
interval = 6
if cycle % interval != 0 {
Err(None)
} else {
index = cycle / interval
match List.get(demo_paths, index) {
Ok(path) => Ok(FileRead(path, New, Ok(demo_bytes(index))))
Err(_) => Err(None)
}
}
}
# ---------------------------------------------------------------------------
# The tree
# ---------------------------------------------------------------------------
## Directory names the walk will not descend into.
##
## Not a general ignore mechanism: it is the four or five places a source tree
## keeps things that are not source, and a walk that went into them would spend
## its whole budget on build output and object files.
skipped_dirs : List(Str)
skipped_dirs = [".git", ".jj", ".hg", ".svn", ".zig-cache", "zig-out", "node_modules", ".roc", "target", ".venv", "__pycache__"]
## The file extensions the walk reads, and the colour each one is drawn in.
##
## One table doing two jobs on purpose. A file whose extension is not here is
## not read at all, so this is the filter; and the tint is looked up from the
## same row, so the figure's palette is the set of languages in the tree rather
## than an arbitrary ramp. Reading the legend tells you what the tree is made of.
##
## Blues are the C family, greens are the scripting languages, sand and amber
## are the ones this repository is actually written in, and greys are the
## surrounding text and configuration.
languages : List({ ext : Str, tint : Color.Rgba })
languages = [
{ ext: "roc", tint: Color.from_hex_rgb(0xe6b168) },
{ ext: "zig", tint: Color.from_hex_rgb(0xe08a5a) },
{ ext: "c", tint: Color.from_hex_rgb(0x5f9fd0) },
{ ext: "h", tint: Color.from_hex_rgb(0x4d7fb0) },
{ ext: "cpp", tint: Color.from_hex_rgb(0x5f9fd0) },
{ ext: "hpp", tint: Color.from_hex_rgb(0x4d7fb0) },
{ ext: "py", tint: Color.from_hex_rgb(0x6fbf8f) },
{ ext: "sh", tint: Color.from_hex_rgb(0x7fc0ae) },
{ ext: "js", tint: Color.from_hex_rgb(0xd6c46e) },
{ ext: "ts", tint: Color.from_hex_rgb(0xc8b866) },
{ ext: "css", tint: Color.from_hex_rgb(0x8f9fd8) },
{ ext: "html", tint: Color.from_hex_rgb(0xc78fb2) },
{ ext: "xml", tint: Color.from_hex_rgb(0xb08fd0) },
{ ext: "tmx", tint: Color.from_hex_rgb(0xa885c8) },
{ ext: "tsx", tint: Color.from_hex_rgb(0xa885c8) },
{ ext: "fs", tint: Color.from_hex_rgb(0x9f8fd0) },
{ ext: "vs", tint: Color.from_hex_rgb(0x9f8fd0) },
{ ext: "md", tint: Color.from_hex_rgb(0x9aa8bd) },
{ ext: "txt", tint: Color.from_hex_rgb(0x8b98ab) },
{ ext: "toml", tint: Color.from_hex_rgb(0x79879a) },
{ ext: "json", tint: Color.from_hex_rgb(0x79879a) },
{ ext: "yml", tint: Color.from_hex_rgb(0x6d7b8d) },
{ ext: "yaml", tint: Color.from_hex_rgb(0x6d7b8d) },
]
## The colour a file is drawn in, or nothing if the walk should not read it.
tint_of : Str -> Try(Color.Rgba, [Unread])
tint_of = |path|
match List.find_first(languages, |language| language.ext == extension_of(path)) {
Ok(language) => Ok(language.tint)
Err(_) => Err(Unread)
}
## Everything after a path's last dot, if that dot is in its last segment.
##
## `main.roc` is `roc`, `.gitignore` is nothing rather than `gitignore` -- a
## leading dot names the file, it does not give it a type -- and a path with a
## dot in a directory name but not in its filename has no extension at all.
extension_of : Str -> Str
extension_of = |path| {
bytes = Str.to_utf8(path)
at = last_dot(bytes, List.len(bytes))
if at == 0 or at >= List.len(bytes) {
""
} else {
Str.from_utf8_lossy(List.release_excess_capacity(List.sublist(bytes, { start: at, len: List.len(bytes) - at })))
}
}
## Where a path's extension starts, or zero if it has none.
last_dot : List(U8), U64 -> U64
last_dot = |bytes, at|
if at == 0 {
0
} else {
before = at - 1
match List.get(bytes, before) {
# A dot at the start of a segment names the file rather than typing
# it, so it is not an extension.
Ok(46) => if before == 0 or is_separator(bytes, before - 1) {
0
} else {
at
}
Ok(47) => 0
Ok(_) => last_dot(bytes, before)
Err(_) => 0
}
}
is_separator : List(U8), U64 -> Bool
is_separator = |bytes, at| List.get(bytes, at) == Ok(47)
## Join a directory to one of its entries.
join_path : Str, Str -> Str
join_path = |dir, name|
if dir == "" {
name
} else {
Str.concat(dir, Str.concat("/", name))
}
## The root the walk starts from, relative to the directory handle: the
## handle's own directory. Everything else is discovered.
walk_root : Str
walk_root = ""
## Run one unit of work as a task. The only effectful line in the walk.
start_work! : Files.ReadDir, App.Input(Msg), Work => {}
start_work! = |files, input, work|
match work {
ListDir(path) => Task.spawn!(input, || Listed(path, files.list!(path)))
ReadFile(path, slot) => Task.spawn!(input, || FileRead(path, slot, files.read_bytes!(path)))
}
## Turn one directory's entries into the work they imply.
##
## Directories become listings and readable files become reads; everything else
## is counted and dropped. Both go on the same queue, which is what makes the
## walk breadth-first in practice and, more to the point, paced: discovering a
## directory of four hundred files cannot outrun the reads.
enqueue_entries : Model, Str, List(Files.Entry) -> Model
enqueue_entries = |model, dir, entries|
List.fold(
entries,
model,
|acc, entry| {
path = join_path(dir, entry.name)
match classify(entry) {
Descend => {
..acc,
walk: { ..acc.walk, dirs_found: acc.walk.dirs_found + 1 },
queue: acc.queue.enqueue(ListDir(path)),
}
Read => {
..acc,
walk: { ..acc.walk, files_found: acc.walk.files_found + 1 },
queue: acc.queue.enqueue(ReadFile(path, New)),
}
Ignore => { ..acc, walk: { ..acc.walk, files_skipped: acc.walk.files_skipped + 1 } }
}
},
)
## What the walk does with one entry.
##
## Pulled out of `enqueue_entries` so it can be tested: `enqueue_entries` needs
## a `Model`, and a `Model` holds host resources that only `init!` can produce,
## but every decision it makes lives here and needs nothing.
Decision : [Descend, Read, Ignore]
classify : Files.Entry -> Decision
classify = |entry|
match entry.kind {
Dir => if List.contains(skipped_dirs, entry.name) {
Ignore
} else {
Descend
}
File =>
match tint_of(entry.name) {
Ok(_) => Read
Err(_) => Ignore
}
Other => Ignore
}
describe_list_error : Files.ListError -> Str
describe_list_error = |reason|
match reason {
NotFound => "not found"
NotADirectory => "not a directory"
PermissionDenied => "outside the declared directories"
PathInvalid => "path refused"
ReadFailed => "read failed"
Busy => "host busy"
Unavailable => "listings unavailable"
TooLarge => "too many entries"
}
describe_read_error : Files.ReadBytesError -> Str
describe_read_error = |reason|
match reason {
NotFound => "not found"
PermissionDenied => "outside the declared directories"
PathInvalid => "path refused"
ReadFailed => "read failed"
Busy => "host busy"
Unavailable => "reads unavailable"
TooLarge => "over the host's per-file limit"
}
# ---------------------------------------------------------------------------
# Pacing
# ---------------------------------------------------------------------------
## How many delivered-but-unparsed files the app will hold before it stops
## asking for more.
##
## This is backpressure, and it is a different bound from `max_in_flight`: that
## one limits what the host is working on, this one limits what has already
## arrived. The parser consumes one bounded window a frame whatever it is given,
## so without this the reads win the race and the model ends up holding the
## whole tree in memory to parse it a window at a time.
arrival_limit = 8.U64
# ---------------------------------------------------------------------------
# What is kept
# ---------------------------------------------------------------------------
## How many points may be held at once.
##
## The binding cost is not drawing them -- one batch of a quarter of a million
## instances draws fine -- it is that the model is copied once per frame, so
## every retained point is paid for on every frame whether or not it is on
## screen. Sixty thousand instances is about four megabytes, which is what this
## figure spends. Raising it costs frame time in exact proportion.
point_budget = 60_000.U64
## Drop whole runs from the front until the budget is met.
##
## Oldest first, and never the run currently being parsed: `instances` is the
## runs concatenated in order, so dropping the oldest is dropping a prefix, and
## a prefix drop leaves every remaining point exactly where it was.
evict : Model -> Model
evict = |model| {
trimmed = trim(model.instances, model.runs, point_budget)
{ ..model, instances: trimmed.instances, runs: trimmed.runs }
}
## The pure half of `evict`, so the retention rule can be tested without a
## `Model`. Never drops the last run: that is the file being parsed right now,
## and dropping it would leave the scan appending to points that are not there.
trim : List(Draw.TextureInstance), List(Run), U64 -> { instances : List(Draw.TextureInstance), runs : List(Run) }
trim = |instances, runs, budget|
if List.len(instances) <= budget or List.len(runs) <= 1 {
{ instances: instances, runs: runs }
} else {
match List.first(runs) {
Err(_) => { instances: instances, runs: runs }
Ok(oldest) => trim(List.drop_first(instances, oldest.count), List.drop_first(runs, 1), budget)
}
}
## Whether a lane's points are still being kept.
has_run : List(Run), U64 -> Bool
has_run = |runs, lane| List.any(runs, |run| run.lane == lane)
## Add this cycle's points to the run being parsed.
grow_run : List(Run), U64 -> List(Run)
grow_run = |runs, added|
match List.last(runs) {
Err(_) => runs
Ok(current) =>
match List.set(runs, List.len(runs) - 1, { ..current, count: current.count + added }) {
Ok(grown) => grown
Err(_) => runs
}
}
# ---------------------------------------------------------------------------
# Incremental parsing
# ---------------------------------------------------------------------------
## What one `update!` may scan.
##
## Both halves matter. `max_lines` bounds how many points a cycle appends, which
## is what keeps the growing instance list from being rebuilt in one huge step.
## `max_bytes` bounds the scan itself, because a line budget alone does not:
## a generated file with one 300 KB line has a single line in it, and scanning
## for its end would cost a frame. `update!` runs inside the frame, so its cost
## has to be bounded by something the input cannot inflate.
scan_budget : Budget
scan_budget = { max_lines: 2_048, max_bytes: 65_536 }
newline = 10.U8
## Scan the next bounded window of one file into plot points.
##
## The window is a sublist of the delivered bytes, which for a host-delivered
## read is a view rather than a copy. The fold is the whole parser: a newline
## closes a line, emits its point and resets the column counter; anything else
## is one more column. Once `max_lines` lines have been closed the fold stops
## consuming, so `consumed` -- not the window length -- is what advances the
## cursor, and the next cycle resumes exactly where this one stopped.
##
## Taking the budget as an argument rather than reading the constant is what
## makes the boundary testable: the `expect`s below drive it with budgets of one
## and two lines over inputs small enough to write down.
scan_chunk : Scan, Budget -> ScanResult
scan_chunk = |scan, budget| {
place = placement_for(scan)
remaining = List.len(scan.bytes) - scan.cursor
window = List.sublist(scan.bytes, { start: scan.cursor, len: U64.min(remaining, budget.max_bytes) })
List.fold(
window,
{ dots: [], cols: [], consumed: 0, col: scan.col, lines: 0, best: { start: 0, columns: 0 }, full: Bool.False },
|acc, byte|
if acc.full {
acc
} else if byte == newline {
lines = acc.lines + 1
{
dots: List.append(acc.dots, plot_dot(scan.line + acc.lines, acc.col, place)),
cols: List.append(acc.cols, acc.col),
consumed: acc.consumed + 1,
col: 0,
lines: lines,
best: longer_line(acc.best, scan.cursor + acc.consumed - acc.col, acc.col),
full: lines >= budget.max_lines,
}
} else {
{ ..acc, consumed: acc.consumed + 1, col: acc.col + 1 }
},
)
}
## Everything about where one lane's points go that does not vary per line.
Placement : {
baseline : F32,
tint : Color.Rgba,
x_scale : F32,
size : F32,
}
placement_for : Scan -> Placement
placement_for = |scan| {
baseline: lane_baseline(scan.lane),
tint: scan.tint,
x_scale: scan.x_scale,
size: scan.size,
}
## Keep whichever of two lines has more columns.
longer_line : BestLine, U64, U64 -> BestLine
longer_line = |current, start, columns|
if columns > current.columns {
{ start, columns }
} else {
current
}
## Place one line's point in world space.
##
## `x` is the line's index times the lane's scale and nothing else, which is
## what lets `relayout` move a whole lane by multiplying: a point at index `n`
## under scale `a` is at `n * a`, so the same point under scale `b` is at
## `x * b / a`, and no line index has to be stored anywhere to find it again.
plot_dot : U64, U64, Placement -> Draw.TextureInstance
plot_dot = |line, columns, place| {
x = U64.to_f32(line) * place.x_scale
y = place.baseline - column_offset(U64.to_f32(columns))
{
source: dot_source,
dest: Math.rect(x, y, place.size, place.size),
origin: { x: place.size / 2, y: place.size / 2 },
rotation: 0,
tint: place.tint,
}
}
## Do this cycle's parsing: start a file if none is open, then scan one window.
##
## Exactly one window is scanned per cycle whatever happens here, which is what
## makes the bound a bound. A file that finishes leaves the parser `Idle` and
## the next one starts on the following cycle.
advance : Model -> Model
advance = |model|
match model.parsing {
# The scale and the point size are refreshed from the model rather than
# carried, so a zoom or a mode change part-way through a file does not
# leave the rest of it laid out under the old one.
Parsing(scan) => scan_once(model, { ..scan, x_scale: scale_of(model, scan.lane), size: current_dot_size(model) })
Idle =>
match List.first(model.arrivals) {
Ok(arrival) => open_scan(model, arrival)
Err(_) => model
}
}
## Give a delivered file a lane and start scanning it.
##
## A lane is created here rather than when the file was discovered, so a lane's
## index is its position in parse order. That is what makes the retained runs a
## prefix-droppable sequence, and it is why nothing has to be sorted anywhere.
open_scan : Model, Arrival -> Model
open_scan = |model, arrival| {
tint =
match tint_of(arrival.path) {
Ok(found) => found
Err(_) => ink_faint
}
slot =
match arrival.replaces {
New => List.len(model.lanes)
Lane(index) => index
}
previous =
match arrival.replaces {
New => { files: 0, lines: 0 }
Lane(index) =>