+3
-3
.beads/issues.jsonl
+3
-3
.beads/issues.jsonl
···
56
56
{"id":"automerge-P8","title":"Phase 8: Conformance testing","description":"Implement full conformance test suite using json-crdt-traces fixtures plus fuzzing/property tests.","status":"open","priority":1,"issue_type":"epic","created_at":"2025-12-26T11:45:05.443231113+01:00","updated_at":"2025-12-26T11:45:05.443231113+01:00","labels":["conformance","epic","phase-8","testing"],"dependencies":[{"issue_id":"automerge-P8","depends_on_id":"automerge-P7","type":"parent-child","created_at":"2025-12-26T13:51:16.084904641+01:00","created_by":"gdiazlo"}]}
57
57
{"id":"crdt-4pq","title":"Fix RGA concurrent insert ordering for json-joy compatibility","description":"The RGA algorithm's tie-breaking for concurrent inserts doesn't match json-joy's implementation. When two replicas insert at the same position concurrently, the resulting order differs.\n\nEvidence from conformance tests:\n- Expected: \"An epic synopsis\" \n- Got: \"A synepic nopsis\" (the \"n\" from \"An\" appears in wrong position)\n\nNeed to analyze json-joy's RGA implementation and match its ordering rules:\n- Understand how json-joy determines concurrent inserts\n- Match tie-breaking logic for timestamp comparison\n- May need to track causal dependencies to determine concurrency","status":"closed","priority":1,"issue_type":"bug","assignee":"claude","created_at":"2025-12-26T17:19:31.628018647+01:00","updated_at":"2025-12-26T17:45:10.110299973+01:00","closed_at":"2025-12-26T17:45:10.110299973+01:00","labels":["conformance","crdt","json-joy","rga"]}
58
58
{"id":"crdt-4ra","title":"Reduce binary codec output size (currently 4x larger than json-joy)","description":"Binary output is 4.9KB vs json-joy's ~1.2KB for 1K chars.\nInvestigate:\n1. Timestamp encoding efficiency (delta encoding?)\n2. RGA chunk representation\n3. Node type encoding overhead\n4. Compare byte-by-byte with json-joy format","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-27T00:34:56.319523298+01:00","updated_at":"2025-12-27T09:29:04.871828606+01:00","closed_at":"2025-12-27T09:29:04.871828606+01:00","labels":["binary-codec","optimization","performance","size"],"dependencies":[{"issue_id":"crdt-4ra","depends_on_id":"crdt-icx","type":"blocks","created_at":"2025-12-27T00:34:56.320955254+01:00","created_by":"gdiazlo"}]}
59
-
{"id":"crdt-68q","title":"Fix compact codec failing tests (friendsforever_flat)","description":"","status":"in_progress","priority":0,"issue_type":"bug","created_at":"2026-01-02T12:15:25.878861438+01:00","updated_at":"2026-01-02T12:15:34.21425901+01:00"}
60
-
{"id":"crdt-6rt","title":"Fix remaining test failures after simdjsont migration","description":"","status":"in_progress","priority":0,"issue_type":"bug","assignee":"gdiazlo","created_at":"2026-01-02T11:36:44.354508505+01:00","updated_at":"2026-01-02T11:36:52.177356478+01:00"}
59
+
{"id":"crdt-68q","title":"Fix compact codec failing tests (friendsforever_flat)","description":"","status":"closed","priority":0,"issue_type":"bug","created_at":"2026-01-02T12:15:25.878861438+01:00","updated_at":"2026-01-02T18:42:18.462948762+01:00","closed_at":"2026-01-02T18:42:18.462948762+01:00"}
60
+
{"id":"crdt-6rt","title":"Fix remaining test failures after simdjsont migration","description":"","status":"closed","priority":0,"issue_type":"bug","assignee":"gdiazlo","created_at":"2026-01-02T11:36:44.354508505+01:00","updated_at":"2026-01-02T18:42:13.420876009+01:00","closed_at":"2026-01-02T18:42:13.420876009+01:00"}
61
61
{"id":"crdt-7f0","title":"Optimize RGA sequential insert (O(n²) -\u003e O(n log n))","description":"Sequential insert of 10K chars takes 284ms - this is O(n²) behavior.\nOptions:\n1. Use tree-based structure (AVL, finger tree) instead of list\n2. Add index for fast position lookup\n3. Consider json-joy's B+tree approach\n\nTarget: \u003c 50ms for 10K sequential inserts","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-27T00:35:00.551809524+01:00","updated_at":"2025-12-27T00:46:27.873263358+01:00","closed_at":"2025-12-27T00:46:27.873263358+01:00","labels":["optimization","performance","rga"],"dependencies":[{"issue_id":"crdt-7f0","depends_on_id":"crdt-icx","type":"blocks","created_at":"2025-12-27T00:35:00.557418209+01:00","created_by":"gdiazlo"}]}
62
62
{"id":"crdt-9h3","title":"Optimize binary encoding performance (currently 3x slower than json-joy)","description":"Binary encoding is ~21K ops/sec vs json-joy's ~108K ops/sec.\nInvestigate:\n1. Buffer allocation strategy\n2. Varint encoding efficiency\n3. CBOR encoding overhead\n4. Consider pre-allocating buffers based on model size","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-27T00:34:52.454913078+01:00","updated_at":"2025-12-27T00:41:24.590935842+01:00","closed_at":"2025-12-27T00:41:24.590935842+01:00","labels":["binary-codec","optimization","performance"],"dependencies":[{"issue_id":"crdt-9h3","depends_on_id":"crdt-icx","type":"blocks","created_at":"2025-12-27T00:34:52.456412445+01:00","created_by":"gdiazlo"}]}
63
63
{"id":"crdt-9ry","title":"Run final benchmarks and compare with json-joy","description":"After all optimizations:\n1. Run full benchmark suite\n2. Verify all tests pass\n3. Document comparison with json-joy\n4. Target: exceed json-joy performance across all metrics","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-27T00:35:14.61471182+01:00","updated_at":"2025-12-27T00:46:58.828283923+01:00","closed_at":"2025-12-27T00:46:58.828283923+01:00","labels":["benchmarks","documentation"],"dependencies":[{"issue_id":"crdt-9ry","depends_on_id":"crdt-apw","type":"blocks","created_at":"2025-12-27T00:35:14.620794117+01:00","created_by":"gdiazlo"},{"issue_id":"crdt-9ry","depends_on_id":"crdt-9h3","type":"blocks","created_at":"2025-12-27T00:35:14.621445689+01:00","created_by":"gdiazlo"},{"issue_id":"crdt-9ry","depends_on_id":"crdt-4ra","type":"blocks","created_at":"2025-12-27T00:35:14.621869631+01:00","created_by":"gdiazlo"},{"issue_id":"crdt-9ry","depends_on_id":"crdt-7f0","type":"blocks","created_at":"2025-12-27T00:35:14.622264163+01:00","created_by":"gdiazlo"},{"issue_id":"crdt-9ry","depends_on_id":"crdt-z3n","type":"blocks","created_at":"2025-12-27T00:35:14.622793455+01:00","created_by":"gdiazlo"}]}
64
64
{"id":"crdt-apw","title":"Investigate binary decode performance (35M ops/sec seems wrong)","description":"Binary decode shows 35M ops/sec which is suspiciously fast. Need to verify:\n1. The decode is actually doing work (not returning cached/memoized result)\n2. The decoded model is correct and usable\n3. Add validation that decoded model matches original","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-27T00:34:47.948086278+01:00","updated_at":"2025-12-27T00:38:53.472587773+01:00","closed_at":"2025-12-27T00:38:53.472587773+01:00","labels":["binary-codec","investigation","performance"],"dependencies":[{"issue_id":"crdt-apw","depends_on_id":"crdt-icx","type":"blocks","created_at":"2025-12-27T00:34:47.954081564+01:00","created_by":"gdiazlo"}]}
65
-
{"id":"crdt-bwe","title":"Fix A decode_timestamp","description":"","status":"open","priority":1,"issue_type":"bug","created_at":"2026-01-02T12:06:12.866717163+01:00","updated_at":"2026-01-02T12:06:12.866717163+01:00"}
65
+
{"id":"crdt-bwe","title":"Fix A decode_timestamp","description":"","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-01-02T12:06:12.866717163+01:00","updated_at":"2026-01-02T18:42:23.50415162+01:00","closed_at":"2026-01-02T18:42:23.50415162+01:00"}
66
66
{"id":"crdt-icx","title":"Investigate and optimize benchmark performance issues","description":"Investigation and optimization of CRDT library performance based on benchmark comparison with json-joy.\n\nKey findings from initial benchmarks:\n1. Binary decode shows suspiciously fast results (35M ops/sec) - needs verification\n2. Binary encoding is 3x slower than json-joy\n3. Binary codec produces 4x larger output than json-joy\n4. RGA sequential insert is O(n²) - 284ms for 10K chars\n\nTarget: Match or exceed json-joy performance given our more capable CPU.","status":"closed","priority":1,"issue_type":"epic","created_at":"2025-12-27T00:34:36.713327259+01:00","updated_at":"2025-12-27T00:47:00.530534521+01:00","closed_at":"2025-12-27T00:47:00.530534521+01:00","labels":["benchmarks","optimization","performance"]}
67
67
{"id":"crdt-z3n","title":"Add benchmark validation to ensure correctness","description":"Add validation steps to benchmarks:\n1. Verify encode/decode round-trip produces identical model\n2. Verify model view matches expected content\n3. Add checksums/hashes to detect corruption","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-27T00:35:03.801590973+01:00","updated_at":"2025-12-27T00:38:54.776020436+01:00","closed_at":"2025-12-27T00:38:54.776020436+01:00","labels":["benchmarks","testing"],"dependencies":[{"issue_id":"crdt-z3n","depends_on_id":"crdt-icx","type":"blocks","created_at":"2025-12-27T00:35:03.802861658+01:00","created_by":"gdiazlo"}]}
-229
bin/debug_model_codec.ml
-229
bin/debug_model_codec.ml
···
1
-
open Crdt
2
-
3
-
let read_file path =
4
-
if Sys.file_exists path then begin
5
-
let ic = open_in_bin path in
6
-
let len = in_channel_length ic in
7
-
let data = Bytes.create len in
8
-
really_input ic data 0 len;
9
-
close_in ic;
10
-
Some (Bytes.to_string data)
11
-
end
12
-
else None
13
-
14
-
let[@warning "-32"] test_compact dir =
15
-
let compact_path = dir ^ "/model.compact.json" in
16
-
let view_path = dir ^ "/view.json" in
17
-
match (read_file compact_path, read_file view_path) with
18
-
| Some compact_json, Some view_json -> (
19
-
Printf.printf "Testing: %s\n" dir;
20
-
match Model_codec.Compact.decode_string compact_json with
21
-
| None -> print_endline " FAIL: decode failed"
22
-
| Some model -> (
23
-
(* Debug: show raw str content if it's a string *)
24
-
(match model.root with
25
-
| Node.Node_val { val_ref = Some ref_id; _ } -> (
26
-
match Model.get_node model ref_id with
27
-
| Some (Node.Node_str { str_rga; _ }) ->
28
-
let text = Rga.view_string str_rga in
29
-
Printf.printf " Raw str (first 100): %s\n"
30
-
(String.sub text 0 (min 100 (String.length text)))
31
-
| _ -> ())
32
-
| _ -> ());
33
-
let model_view = Model.view model in
34
-
match Value_codec.decode view_json with
35
-
| Error e -> Printf.printf " FAIL: view parse error: %s\n" e
36
-
| Ok expected_view ->
37
-
if Value.equal model_view expected_view then
38
-
print_endline " PASS: views match"
39
-
else begin
40
-
print_endline " FAIL: view mismatch";
41
-
print_endline " Model view (first 500 chars):";
42
-
let view_str = Value.to_string model_view in
43
-
print_endline
44
-
(String.sub view_str 0 (min 500 (String.length view_str)));
45
-
print_endline " Expected view (first 500 chars):";
46
-
let expected_str = Value.to_string expected_view in
47
-
print_endline
48
-
(String.sub expected_str 0
49
-
(min 500 (String.length expected_str)))
50
-
end))
51
-
| _ -> Printf.printf "Skipping %s (files not found)\n" dir
52
-
53
-
let test_verbose dir =
54
-
let verbose_path = dir ^ "/model.verbose.json" in
55
-
let view_path = dir ^ "/view.json" in
56
-
match (read_file verbose_path, read_file view_path) with
57
-
| Some verbose_json, Some view_json -> (
58
-
Printf.printf "Testing verbose: %s\n" dir;
59
-
match Model_codec.Verbose.decode_string verbose_json with
60
-
| None -> print_endline " FAIL: decode failed"
61
-
| Some model -> (
62
-
(* Debug: show raw str content if it's a string *)
63
-
(match model.root with
64
-
| Node.Node_val { val_ref = Some ref_id; _ } -> (
65
-
match Model.get_node model ref_id with
66
-
| Some (Node.Node_str { str_rga; _ }) ->
67
-
let text = Rga.view_string str_rga in
68
-
Printf.printf " Raw str (first 100): %s\n"
69
-
(String.sub text 0 (min 100 (String.length text)))
70
-
| _ -> ())
71
-
| _ -> ());
72
-
let model_view = Model.view model in
73
-
match Value_codec.decode view_json with
74
-
| Error e -> Printf.printf " FAIL: view parse error: %s\n" e
75
-
| Ok expected_view ->
76
-
if Value.equal model_view expected_view then
77
-
print_endline " PASS: views match"
78
-
else begin
79
-
print_endline " FAIL: view mismatch"
80
-
end))
81
-
| _ -> Printf.printf "Skipping %s (files not found)\n" dir
82
-
83
-
let test_roundtrip dir =
84
-
let verbose_path = dir ^ "/model.verbose.json" in
85
-
match read_file verbose_path with
86
-
| Some verbose_json -> (
87
-
Printf.printf "Testing roundtrip: %s\n" dir;
88
-
match Model_codec.Verbose.decode_string verbose_json with
89
-
| None -> print_endline " FAIL: verbose decode failed"
90
-
| Some model -> (
91
-
(* Encode to compact then decode back *)
92
-
let compact_encoded = Model_codec.Compact.encode_string model in
93
-
match Model_codec.Compact.decode_string compact_encoded with
94
-
| None -> print_endline " FAIL: compact decode failed"
95
-
| Some model2 ->
96
-
let view1 = Model.view model in
97
-
let view2 = Model.view model2 in
98
-
if Value.equal view1 view2 then
99
-
print_endline " PASS: roundtrip works"
100
-
else begin
101
-
print_endline " FAIL: roundtrip mismatch";
102
-
Printf.printf " View1 (first 100): %s\n"
103
-
(String.sub (Value.to_string view1) 0
104
-
(min 100 (String.length (Value.to_string view1))));
105
-
Printf.printf " View2 (first 100): %s\n"
106
-
(String.sub (Value.to_string view2) 0
107
-
(min 100 (String.length (Value.to_string view2))))
108
-
end))
109
-
| None -> Printf.printf "Skipping %s\n" dir
110
-
111
-
let test_binary_roundtrip dir =
112
-
let verbose_path = dir ^ "/model.verbose.json" in
113
-
match read_file verbose_path with
114
-
| Some verbose_json -> (
115
-
Printf.printf "Testing binary roundtrip: %s\n" dir;
116
-
match Model_codec.Verbose.decode_string verbose_json with
117
-
| None -> print_endline " FAIL: verbose decode failed"
118
-
| Some model -> (
119
-
(* Encode to binary *)
120
-
let binary = Model_codec.Binary.encode model in
121
-
Printf.printf " Binary size: %d bytes\n" (Bytes.length binary);
122
-
(* Decode back *)
123
-
match Model_codec.Binary.decode binary with
124
-
| None -> print_endline " FAIL: binary decode failed"
125
-
| Some decoded_model ->
126
-
let original_view = Model.view model in
127
-
let decoded_view = Model.view decoded_model in
128
-
if Value.equal original_view decoded_view then
129
-
print_endline " PASS: roundtrip works"
130
-
else begin
131
-
print_endline " FAIL: roundtrip mismatch";
132
-
Printf.printf " Original (first 100): %s\n"
133
-
(String.sub
134
-
(Value.to_string original_view)
135
-
0
136
-
(min 100 (String.length (Value.to_string original_view))));
137
-
Printf.printf " Decoded (first 100): %s\n"
138
-
(String.sub
139
-
(Value.to_string decoded_view)
140
-
0
141
-
(min 100 (String.length (Value.to_string decoded_view))))
142
-
end))
143
-
| None -> Printf.printf "Skipping %s\n" dir
144
-
145
-
let test_compact_direct dir =
146
-
let compact_path = dir ^ "/model.compact.json" in
147
-
let view_path = dir ^ "/view.json" in
148
-
match (read_file compact_path, read_file view_path) with
149
-
| Some compact_json, Some view_json -> (
150
-
Printf.printf "Testing compact direct: %s\n" dir;
151
-
match Model_codec.Compact.decode_string compact_json with
152
-
| None -> print_endline " FAIL: decode failed"
153
-
| Some model -> (
154
-
let model_view = Model.view model in
155
-
match Value_codec.decode view_json with
156
-
| Error e -> Printf.printf " FAIL: view parse error: %s\n" e
157
-
| Ok expected_view ->
158
-
if Value.equal model_view expected_view then
159
-
print_endline " PASS: views match"
160
-
else begin
161
-
print_endline " FAIL: view mismatch";
162
-
Printf.printf " Got (first 200): %s\n"
163
-
(String.sub
164
-
(Value.to_string model_view)
165
-
0
166
-
(min 200 (String.length (Value.to_string model_view))));
167
-
Printf.printf " Expected (first 200): %s\n"
168
-
(String.sub
169
-
(Value.to_string expected_view)
170
-
0
171
-
(min 200 (String.length (Value.to_string expected_view))))
172
-
end))
173
-
| _ -> Printf.printf "Skipping %s (files not found)\n" dir
174
-
175
-
let test_binary_direct dir =
176
-
let binary_path = dir ^ "/model.bin" in
177
-
let view_path = dir ^ "/view.json" in
178
-
match (read_file binary_path, read_file view_path) with
179
-
| Some binary_data, Some view_json -> (
180
-
Printf.printf "Testing binary direct: %s\n" dir;
181
-
match Model_codec.Binary.decode (Bytes.of_string binary_data) with
182
-
| None -> print_endline " FAIL: decode failed"
183
-
| Some model -> (
184
-
let model_view = Model.view model in
185
-
match Value_codec.decode view_json with
186
-
| Error e -> Printf.printf " FAIL: view parse error: %s\n" e
187
-
| Ok expected_view ->
188
-
if Value.equal model_view expected_view then
189
-
print_endline " PASS: views match"
190
-
else begin
191
-
print_endline " FAIL: view mismatch";
192
-
Printf.printf " Got (first 200): %s\n"
193
-
(String.sub
194
-
(Value.to_string model_view)
195
-
0
196
-
(min 200 (String.length (Value.to_string model_view))));
197
-
Printf.printf " Expected (first 200): %s\n"
198
-
(String.sub
199
-
(Value.to_string expected_view)
200
-
0
201
-
(min 200 (String.length (Value.to_string expected_view))))
202
-
end))
203
-
| _ -> Printf.printf "Skipping %s (files not found)\n" dir
204
-
205
-
let () =
206
-
let dirs =
207
-
[
208
-
"test/fixtures/traces/text/sequential/friendsforever_flat";
209
-
"test/fixtures/traces/text/sequential/automerge-paper";
210
-
"test/fixtures/traces/fuzzer/processed/short";
211
-
"test/fixtures/traces/fuzzer/processed/str-only";
212
-
"test/fixtures/traces/fuzzer/processed/low-concurrency";
213
-
"test/fixtures/traces/fuzzer/processed/trace-1";
214
-
]
215
-
in
216
-
print_endline "=== Testing Verbose ===";
217
-
List.iter test_verbose dirs;
218
-
print_endline "";
219
-
print_endline "=== Testing Compact Direct ===";
220
-
List.iter test_compact_direct dirs;
221
-
print_endline "";
222
-
print_endline "=== Testing Binary Direct ===";
223
-
List.iter test_binary_direct dirs;
224
-
print_endline "";
225
-
print_endline "=== Testing Compact Roundtrip ===";
226
-
List.iter test_roundtrip dirs;
227
-
print_endline "";
228
-
print_endline "=== Testing Binary Roundtrip ===";
229
-
List.iter test_binary_roundtrip dirs
-119
bin/rga_debug.ml
-119
bin/rga_debug.ml
···
1
-
open Crdt
2
-
3
-
let project_root =
4
-
let candidates = [ "."; ".."; "../.."; "../../.." ] in
5
-
let rec find = function
6
-
| [] -> "."
7
-
| dir :: rest ->
8
-
if Sys.file_exists (dir ^ "/dune-project") then dir else find rest
9
-
in
10
-
find candidates
11
-
12
-
let read_file path =
13
-
let ic = open_in_bin path in
14
-
let len = in_channel_length ic in
15
-
let data = Bytes.create len in
16
-
really_input ic data 0 len;
17
-
close_in ic;
18
-
Bytes.to_string data
19
-
20
-
let print_op (op_id : Clock.timestamp) op =
21
-
Printf.printf " op_id=(%d,%d) %s" op_id.Clock.sid op_id.Clock.time
22
-
(Op.op_name op);
23
-
match op with
24
-
| Op.Op_ins_str { ins_str_obj; ins_str_after; ins_str_value } ->
25
-
Printf.printf " obj=(%d,%d) after=(%d,%d) value=%S\n" ins_str_obj.sid
26
-
ins_str_obj.time ins_str_after.sid ins_str_after.time ins_str_value
27
-
| Op.Op_new_str -> Printf.printf "\n"
28
-
| Op.Op_ins_val { ins_val_obj; ins_val_value } ->
29
-
Printf.printf " obj=(%d,%d) value=(%d,%d)\n" ins_val_obj.sid
30
-
ins_val_obj.time ins_val_value.sid ins_val_value.time
31
-
| Op.Op_del { del_obj; del_what } ->
32
-
Printf.printf " obj=(%d,%d) spans=[" del_obj.sid del_obj.time;
33
-
List.iter
34
-
(fun (span : Clock.timespan) ->
35
-
Printf.printf "(%d,%d,%d)" span.sid span.time span.span)
36
-
del_what;
37
-
Printf.printf "]\n"
38
-
| _ -> Printf.printf "\n"
39
-
40
-
let () =
41
-
(* Simple test: apply just the first 3 patches step by step *)
42
-
let trace_dir =
43
-
project_root ^ "/test/fixtures/traces/text/sequential/friendsforever_flat"
44
-
in
45
-
let bin_file = trace_dir ^ "/patches.bin" in
46
-
47
-
let bin_data = read_file bin_file in
48
-
let patches =
49
-
match Patch_codec_binary.decode_batch (Bytes.of_string bin_data) with
50
-
| Ok p -> p
51
-
| Error e -> failwith e
52
-
in
53
-
54
-
Printf.printf "Total patches: %d\n\n" (List.length patches);
55
-
56
-
let model = Model.create 0 in
57
-
58
-
(* Apply patch 1 *)
59
-
Printf.printf "=== Patch 1 ===\n";
60
-
let p1 = List.nth patches 0 in
61
-
Patch.iter_with_id print_op p1;
62
-
Model.apply model p1;
63
-
Printf.printf "Result: %S\n\n"
64
-
(match Model.view model with Value.String s -> s | _ -> "(none)");
65
-
66
-
(* Apply patch 2 *)
67
-
Printf.printf "=== Patch 2 ===\n";
68
-
let p2 = List.nth patches 1 in
69
-
Patch.iter_with_id print_op p2;
70
-
Model.apply model p2;
71
-
let v2 = match Model.view model with Value.String s -> s | _ -> "(none)" in
72
-
Printf.printf "Result: %S\n" v2;
73
-
Printf.printf "Length: %d\n\n" (String.length v2);
74
-
75
-
(* Now apply patch 3 operation by operation *)
76
-
Printf.printf "=== Patch 3 (step by step) ===\n";
77
-
let p3 = List.nth patches 2 in
78
-
let ops = ref [] in
79
-
Patch.iter_with_id (fun id op -> ops := (id, op) :: !ops) p3;
80
-
let ops = List.rev !ops in
81
-
82
-
(* Get the string node to inspect its RGA *)
83
-
let str_node =
84
-
match Model.get_node model { Clock.sid = 1000000; time = 1 } with
85
-
| Some n -> n
86
-
| None -> failwith "no str node"
87
-
in
88
-
89
-
let print_chunks () =
90
-
match str_node with
91
-
| Node.Node_str { str_rga; _ } ->
92
-
Printf.printf " Chunks: ";
93
-
List.iter
94
-
(fun (c : string Rga.chunk) ->
95
-
Printf.printf "[id=%d,sp=%d,%S%s] " c.id.time c.span
96
-
(if String.length c.data > 8 then String.sub c.data 0 8 ^ ".."
97
-
else c.data)
98
-
(if c.deleted then "(D)" else ""))
99
-
(Rga.to_list str_rga);
100
-
Printf.printf "\n"
101
-
| _ -> ()
102
-
in
103
-
104
-
Printf.printf "Before patch 3:\n";
105
-
print_chunks ();
106
-
Printf.printf "\n";
107
-
108
-
List.iter
109
-
(fun (op_id, op) ->
110
-
Printf.printf "Applying: ";
111
-
print_op op_id op;
112
-
Model.apply_op model op_id op;
113
-
print_chunks ();
114
-
let view =
115
-
match Model.view model with Value.String s -> s | _ -> "(none)"
116
-
in
117
-
Printf.printf " Result: %S\n\n"
118
-
(if String.length view > 60 then String.sub view 0 60 ^ "..." else view))
119
-
ops
+3
bin/wbr/dune
+3
bin/wbr/dune
+1530
bin/wbr/wbr.ml
+1530
bin/wbr/wbr.ml
···
1
+
(** Collaborative Whiteboard Demo
2
+
3
+
A real-time collaborative whiteboard with:
4
+
- Drawing with multiple colors and brush sizes
5
+
- Real-time cursor presence
6
+
- Chat functionality
7
+
- Multi-user networking via Eio
8
+
9
+
Usage: ./whiteboard --name "Alice" --host # Start as server ./whiteboard
10
+
--name "Bob" --connect 192.168.1.1 # Connect to server ./whiteboard --name
11
+
"Carol" # Local-only mode *)
12
+
13
+
[@@@warning "-69-32-27-26"]
14
+
15
+
open Raylib
16
+
module J = Simdjsont.Json
17
+
18
+
(* ============================================================================
19
+
Configuration & Scaling
20
+
============================================================================ *)
21
+
22
+
let base_width = 1200
23
+
let base_height = 800
24
+
let default_port = 9999
25
+
let max_chat_messages = 50
26
+
let presence_timeout = 10.0 (* Time before cursor fades *)
27
+
let user_timeout = 30.0 (* Time before user is removed from list *)
28
+
29
+
let sync_interval =
30
+
0.5 (* Presence update interval - reduced to avoid flooding *)
31
+
32
+
(* Mutable layout values that update on resize *)
33
+
let window_width = ref base_width
34
+
let window_height = ref base_height
35
+
36
+
(* DPI scale - set once at startup based on display, doesn't change on resize *)
37
+
let dpi_scale = ref 1.0
38
+
39
+
(* Scale for DPI only (not window size) *)
40
+
let scale x = int_of_float (float_of_int x *. !dpi_scale)
41
+
let scalef x = x *. !dpi_scale
42
+
43
+
(* Get mouse position *)
44
+
let get_mouse_pos () =
45
+
let mx = float_of_int (get_mouse_x ()) in
46
+
let my = float_of_int (get_mouse_y ()) in
47
+
(mx, my)
48
+
49
+
(* Fixed UI element sizes (scaled for DPI only) *)
50
+
let margin () = scale 10
51
+
let sidebar_width () = scale 280
52
+
let palette_height () = scale 50
53
+
let chat_height () = scale 280
54
+
55
+
(* Layout calculations - canvas grows/shrinks with window, other elements stay fixed *)
56
+
let canvas_x () = margin ()
57
+
let canvas_y () = margin ()
58
+
59
+
let canvas_width () =
60
+
!window_width - canvas_x () - sidebar_width () - (margin () * 2)
61
+
62
+
let canvas_height () = !window_height - (margin () * 2) - palette_height ()
63
+
let sidebar_x () = !window_width - sidebar_width () - margin ()
64
+
let chat_y () = !window_height - chat_height () - margin ()
65
+
let presence_y () = canvas_y ()
66
+
let presence_height () = chat_y () - presence_y () - margin ()
67
+
68
+
(* ============================================================================
69
+
Types
70
+
============================================================================ *)
71
+
72
+
type point = { x : float; y : float }
73
+
74
+
type stroke = {
75
+
stroke_id : string;
76
+
color : int; (* Store as int for serialization *)
77
+
thickness : float;
78
+
points : point list;
79
+
}
80
+
81
+
type user_presence = {
82
+
user_id : string;
83
+
user_name : string;
84
+
cursor_x : float;
85
+
cursor_y : float;
86
+
user_color : int;
87
+
last_seen : float;
88
+
}
89
+
90
+
type chat_message = {
91
+
msg_id : string;
92
+
sender : string;
93
+
text : string;
94
+
timestamp : float;
95
+
}
96
+
97
+
type app_mode = Local | Server | Client of string
98
+
99
+
type drawing_segment = {
100
+
draw_user_id : string;
101
+
draw_color : int;
102
+
draw_thickness : float;
103
+
draw_from : point;
104
+
draw_to : point;
105
+
}
106
+
107
+
type net_message =
108
+
| MsgStroke of stroke
109
+
| MsgDrawing of drawing_segment
110
+
| MsgPresence of user_presence
111
+
| MsgChat of chat_message
112
+
| MsgSync of {
113
+
strokes : stroke list;
114
+
messages : chat_message list;
115
+
users : user_presence list;
116
+
}
117
+
| MsgJoin of { user_id : string; user_name : string; user_color : int }
118
+
| MsgLeave of { user_id : string }
119
+
120
+
type app_state = {
121
+
mutable mode : app_mode;
122
+
mutable my_id : string;
123
+
mutable my_name : string;
124
+
mutable my_color : int;
125
+
mutable is_drawing : bool;
126
+
mutable current_stroke : point list;
127
+
mutable strokes : stroke list;
128
+
mutable brush_color : int;
129
+
mutable brush_size : float;
130
+
mutable palette_index : int;
131
+
users : (string, user_presence) Hashtbl.t; (* user_id -> presence *)
132
+
mutable messages : chat_message list;
133
+
mutable chat_input : string;
134
+
mutable chat_focused : bool;
135
+
mutable connected : bool;
136
+
mutable last_presence_sync : float;
137
+
mutable name_input : string;
138
+
mutable entering_name : bool;
139
+
(* Remote drawings: segments from other users currently being drawn *)
140
+
mutable remote_drawings : drawing_segment list;
141
+
(* Eio streams for message passing *)
142
+
incoming : net_message Eio.Stream.t;
143
+
outgoing : net_message Eio.Stream.t;
144
+
}
145
+
146
+
(* ============================================================================
147
+
Colors
148
+
============================================================================ *)
149
+
150
+
(* Multiple palettes - each has black, white, and 8 colors *)
151
+
(* Palette 0: Pastel light *)
152
+
let palette_pastel_light =
153
+
[|
154
+
0x000000ff;
155
+
0xffffffff;
156
+
0xffb3baff;
157
+
0xffdfbaff;
158
+
0xffffbaff;
159
+
0xbaffc9ff;
160
+
0xbae1ffff;
161
+
0xbac8ffff;
162
+
0xe0baffff;
163
+
0xffbaffff;
164
+
|]
165
+
166
+
(* Palette 1: Pastel medium *)
167
+
let palette_pastel_medium =
168
+
[|
169
+
0x000000ff;
170
+
0xffffffff;
171
+
0xf8a5a5ff;
172
+
0xf8cba5ff;
173
+
0xf8f8a5ff;
174
+
0xa5f8b8ff;
175
+
0xa5d8f8ff;
176
+
0xa5a5f8ff;
177
+
0xd8a5f8ff;
178
+
0xf8a5d8ff;
179
+
|]
180
+
181
+
(* Palette 2: Pastel muted *)
182
+
let palette_pastel_muted =
183
+
[|
184
+
0x000000ff;
185
+
0xffffffff;
186
+
0xd4a5a5ff;
187
+
0xd4bfa5ff;
188
+
0xd4d4a5ff;
189
+
0xa5d4b0ff;
190
+
0xa5c4d4ff;
191
+
0xa5a5d4ff;
192
+
0xc4a5d4ff;
193
+
0xd4a5c4ff;
194
+
|]
195
+
196
+
(* Palette 3: Soft warm *)
197
+
let palette_soft_warm =
198
+
[|
199
+
0x000000ff;
200
+
0xffffffff;
201
+
0xe8998dff;
202
+
0xeab69fff;
203
+
0xead89fff;
204
+
0xb5deadff;
205
+
0x9fd5e8ff;
206
+
0x9fb5e8ff;
207
+
0xc49fe8ff;
208
+
0xe89fc4ff;
209
+
|]
210
+
211
+
(* Palette 4: Soft cool *)
212
+
let palette_soft_cool =
213
+
[|
214
+
0x000000ff;
215
+
0xffffffff;
216
+
0x9db5c4ff;
217
+
0x9dc4b5ff;
218
+
0xb5c49dff;
219
+
0xc4b59dff;
220
+
0xc49db5ff;
221
+
0xb59dc4ff;
222
+
0x9dc5c4ff;
223
+
0xa5c4c4ff;
224
+
|]
225
+
226
+
(* Palette 5: Vibrant *)
227
+
let palette_vibrant =
228
+
[|
229
+
0x000000ff;
230
+
0xffffffff;
231
+
0xff6b6bff;
232
+
0xffa94dff;
233
+
0xffd93dff;
234
+
0x6bcb77ff;
235
+
0x4d96ffff;
236
+
0x6b5bffff;
237
+
0xc56bffff;
238
+
0xff6bb5ff;
239
+
|]
240
+
241
+
(* Palette 6: Earth tones *)
242
+
let palette_earth =
243
+
[|
244
+
0x000000ff;
245
+
0xffffffff;
246
+
0xc9a87cff;
247
+
0xa8c97cff;
248
+
0x7cc9a8ff;
249
+
0x7ca8c9ff;
250
+
0xa87cc9ff;
251
+
0xc97ca8ff;
252
+
0xb5a88fff;
253
+
0x8fb5a8ff;
254
+
|]
255
+
256
+
(* Palette 7: Classic *)
257
+
let palette_classic =
258
+
[|
259
+
0x000000ff;
260
+
0xffffffff;
261
+
0xff0000ff;
262
+
0xff8000ff;
263
+
0xffff00ff;
264
+
0x00ff00ff;
265
+
0x00ffffff;
266
+
0x0000ffff;
267
+
0x8000ffff;
268
+
0xff00ffff;
269
+
|]
270
+
271
+
let all_palettes =
272
+
[|
273
+
palette_pastel_light;
274
+
palette_pastel_medium;
275
+
palette_pastel_muted;
276
+
palette_soft_warm;
277
+
palette_soft_cool;
278
+
palette_vibrant;
279
+
palette_earth;
280
+
palette_classic;
281
+
|]
282
+
283
+
let num_palettes = Array.length all_palettes
284
+
285
+
(* Get current palette colors *)
286
+
let get_palette idx = all_palettes.(idx mod num_palettes)
287
+
288
+
let user_colors =
289
+
[|
290
+
0xe74c3cff;
291
+
0x2ecc71ff;
292
+
0x3498dbff;
293
+
0x9b59b6ff;
294
+
0xf1c40fff;
295
+
0xe67e22ff;
296
+
0x1abc9cff;
297
+
0x34495eff;
298
+
|]
299
+
300
+
let color_of_int n =
301
+
Color.create
302
+
((n lsr 24) land 0xff)
303
+
((n lsr 16) land 0xff)
304
+
((n lsr 8) land 0xff)
305
+
(n land 0xff)
306
+
307
+
let get_user_color idx = user_colors.(abs idx mod Array.length user_colors)
308
+
309
+
(* ============================================================================
310
+
Utilities
311
+
============================================================================ *)
312
+
313
+
let generate_id () =
314
+
Random.self_init ();
315
+
String.init 8 (fun _ ->
316
+
let n = Random.int 36 in
317
+
if n < 10 then Char.chr (48 + n) else Char.chr (97 + n - 10))
318
+
319
+
let current_time () = Unix.gettimeofday ()
320
+
let clamp v lo hi = max lo (min hi v)
321
+
322
+
let point_in_rect x y rx ry rw rh =
323
+
x >= float_of_int rx
324
+
&& x <= float_of_int (rx + rw)
325
+
&& y >= float_of_int ry
326
+
&& y <= float_of_int (ry + rh)
327
+
328
+
(* ============================================================================
329
+
JSON Serialization using Simdjsont
330
+
============================================================================ *)
331
+
332
+
(* Helper to build JSON objects *)
333
+
let obj members = J.Object members
334
+
let arr items = J.Array items
335
+
let str s = J.String s
336
+
let num f = J.Float f
337
+
let int_num i = J.Float (Float.of_int i)
338
+
let mem k v = (k, v)
339
+
340
+
(* Encode to JSON string - MUST be minified (no newlines) for line-based protocol *)
341
+
let json_to_string json = J.to_string json
342
+
343
+
(* Build JSON for point *)
344
+
let point_to_json p = arr [ num p.x; num p.y ]
345
+
346
+
(* Build JSON for stroke (with or without type tag) *)
347
+
let stroke_to_json_obj ~with_type s =
348
+
let base =
349
+
[
350
+
mem "i" (str s.stroke_id);
351
+
mem "c" (int_num s.color);
352
+
mem "k" (num s.thickness);
353
+
mem "p" (arr (List.map point_to_json s.points));
354
+
]
355
+
in
356
+
obj (if with_type then mem "t" (str "s") :: base else base)
357
+
358
+
(* Build JSON for user/presence (with or without type tag) *)
359
+
let user_to_json_obj ~with_type p =
360
+
let base =
361
+
[
362
+
mem "i" (str p.user_id);
363
+
mem "n" (str p.user_name);
364
+
mem "x" (num p.cursor_x);
365
+
mem "y" (num p.cursor_y);
366
+
mem "c" (int_num p.user_color);
367
+
mem "s" (num p.last_seen);
368
+
]
369
+
in
370
+
obj (if with_type then mem "t" (str "p") :: base else base)
371
+
372
+
(* Build JSON for chat message (with or without type tag) *)
373
+
let chat_to_json_obj ~with_type m =
374
+
let base =
375
+
[
376
+
mem "i" (str m.msg_id);
377
+
mem "n" (str m.sender);
378
+
mem "x" (str m.text);
379
+
mem "s" (num m.timestamp);
380
+
]
381
+
in
382
+
obj (if with_type then mem "t" (str "m") :: base else base)
383
+
384
+
(* Build JSON for join message *)
385
+
let join_to_json_obj user_id user_name user_color =
386
+
obj
387
+
[
388
+
mem "t" (str "j");
389
+
mem "i" (str user_id);
390
+
mem "n" (str user_name);
391
+
mem "c" (int_num user_color);
392
+
]
393
+
394
+
(* Build JSON for leave message *)
395
+
let leave_to_json_obj user_id = obj [ mem "t" (str "l"); mem "i" (str user_id) ]
396
+
397
+
(* Build JSON for sync message *)
398
+
let sync_to_json_obj strokes messages users =
399
+
obj
400
+
[
401
+
mem "t" (str "y");
402
+
mem "s" (arr (List.map (stroke_to_json_obj ~with_type:false) strokes));
403
+
mem "m" (arr (List.map (chat_to_json_obj ~with_type:false) messages));
404
+
mem "u" (arr (List.map (user_to_json_obj ~with_type:false) users));
405
+
]
406
+
407
+
(* Build JSON for drawing segment (real-time line) *)
408
+
let drawing_to_json_obj d =
409
+
obj
410
+
[
411
+
mem "t" (str "d");
412
+
mem "i" (str d.draw_user_id);
413
+
mem "c" (int_num d.draw_color);
414
+
mem "k" (num d.draw_thickness);
415
+
mem "f" (point_to_json d.draw_from);
416
+
mem "o" (point_to_json d.draw_to);
417
+
]
418
+
419
+
(* Convert message to JSON string *)
420
+
let message_to_json = function
421
+
| MsgStroke s -> json_to_string (stroke_to_json_obj ~with_type:true s)
422
+
| MsgDrawing d -> json_to_string (drawing_to_json_obj d)
423
+
| MsgPresence p -> json_to_string (user_to_json_obj ~with_type:true p)
424
+
| MsgChat m -> json_to_string (chat_to_json_obj ~with_type:true m)
425
+
| MsgJoin { user_id; user_name; user_color } ->
426
+
json_to_string (join_to_json_obj user_id user_name user_color)
427
+
| MsgLeave { user_id } -> json_to_string (leave_to_json_obj user_id)
428
+
| MsgSync { strokes; messages; users } ->
429
+
json_to_string (sync_to_json_obj strokes messages users)
430
+
431
+
(* JSON parsing helpers *)
432
+
let get_string_field obj key =
433
+
List.find_map
434
+
(fun (k, v) ->
435
+
if k = key then match v with J.String s -> Some s | _ -> None else None)
436
+
obj
437
+
438
+
let get_int_field obj key =
439
+
List.find_map
440
+
(fun (k, v) ->
441
+
if k = key then
442
+
match v with
443
+
| J.Float f -> Some (Float.to_int f)
444
+
| J.Int i -> Some (Int64.to_int i)
445
+
| _ -> None
446
+
else None)
447
+
obj
448
+
449
+
let get_float_field obj key =
450
+
List.find_map
451
+
(fun (k, v) ->
452
+
if k = key then
453
+
match v with
454
+
| J.Float f -> Some f
455
+
| J.Int i -> Some (Int64.to_float i)
456
+
| _ -> None
457
+
else None)
458
+
obj
459
+
460
+
let get_array_field obj key =
461
+
List.find_map
462
+
(fun (k, v) ->
463
+
if k = key then match v with J.Array items -> Some items | _ -> None
464
+
else None)
465
+
obj
466
+
467
+
(* Parse point from JSON *)
468
+
let parse_point_json = function
469
+
| J.Array [ J.Float x; J.Float y ] -> Some { x; y }
470
+
| J.Array [ J.Int x; J.Int y ] ->
471
+
Some { x = Int64.to_float x; y = Int64.to_float y }
472
+
| J.Array [ J.Float x; J.Int y ] -> Some { x; y = Int64.to_float y }
473
+
| J.Array [ J.Int x; J.Float y ] -> Some { x = Int64.to_float x; y }
474
+
| _ -> None
475
+
476
+
(* Parse stroke from JSON object members *)
477
+
let parse_stroke_json obj =
478
+
let stroke_id =
479
+
Option.value (get_string_field obj "i") ~default:(generate_id ())
480
+
in
481
+
let color = Option.value (get_int_field obj "c") ~default:0x000000ff in
482
+
let thickness = Option.value (get_float_field obj "k") ~default:3.0 in
483
+
let points =
484
+
match get_array_field obj "p" with
485
+
| Some items -> List.filter_map parse_point_json items
486
+
| None -> []
487
+
in
488
+
{ stroke_id; color; thickness; points }
489
+
490
+
(* Parse presence/user from JSON object members *)
491
+
let parse_presence_json obj =
492
+
let user_id = Option.value (get_string_field obj "i") ~default:"" in
493
+
let user_name = Option.value (get_string_field obj "n") ~default:"?" in
494
+
let cursor_x = Option.value (get_float_field obj "x") ~default:0.0 in
495
+
let cursor_y = Option.value (get_float_field obj "y") ~default:0.0 in
496
+
let user_color = Option.value (get_int_field obj "c") ~default:0x808080ff in
497
+
let last_seen =
498
+
Option.value (get_float_field obj "s") ~default:(current_time ())
499
+
in
500
+
{ user_id; user_name; cursor_x; cursor_y; user_color; last_seen }
501
+
502
+
(* Parse chat from JSON object members *)
503
+
let parse_chat_json obj =
504
+
let msg_id =
505
+
Option.value (get_string_field obj "i") ~default:(generate_id ())
506
+
in
507
+
let sender = Option.value (get_string_field obj "n") ~default:"?" in
508
+
let text = Option.value (get_string_field obj "x") ~default:"" in
509
+
let timestamp =
510
+
Option.value (get_float_field obj "s") ~default:(current_time ())
511
+
in
512
+
{ msg_id; sender; text; timestamp }
513
+
514
+
(* Parse array of objects *)
515
+
let parse_array_json items parse_fn =
516
+
List.filter_map
517
+
(function J.Object obj -> Some (parse_fn obj) | _ -> None)
518
+
items
519
+
520
+
(* Parse drawing segment from JSON object members *)
521
+
let parse_drawing_json obj =
522
+
let user_id = Option.value (get_string_field obj "i") ~default:"" in
523
+
let color = Option.value (get_int_field obj "c") ~default:0x000000ff in
524
+
let thickness = Option.value (get_float_field obj "k") ~default:3.0 in
525
+
let from_point =
526
+
match get_array_field obj "f" with
527
+
| Some items -> (
528
+
match parse_point_json (J.Array items) with
529
+
| Some p -> p
530
+
| None -> { x = 0.; y = 0. })
531
+
| None -> { x = 0.; y = 0. }
532
+
in
533
+
let to_point =
534
+
match get_array_field obj "o" with
535
+
| Some items -> (
536
+
match parse_point_json (J.Array items) with
537
+
| Some p -> p
538
+
| None -> { x = 0.; y = 0. })
539
+
| None -> { x = 0.; y = 0. }
540
+
in
541
+
{
542
+
draw_user_id = user_id;
543
+
draw_color = color;
544
+
draw_thickness = thickness;
545
+
draw_from = from_point;
546
+
draw_to = to_point;
547
+
}
548
+
549
+
(* Parse a network message from JSON string *)
550
+
let parse_message json_str =
551
+
match Simdjsont.Decode.decode_string Simdjsont.Decode.value json_str with
552
+
| Error _ -> None
553
+
| Ok (J.Object obj) -> (
554
+
match get_string_field obj "t" with
555
+
| Some "s" -> Some (MsgStroke (parse_stroke_json obj))
556
+
| Some "d" -> Some (MsgDrawing (parse_drawing_json obj))
557
+
| Some "p" -> Some (MsgPresence (parse_presence_json obj))
558
+
| Some "m" -> Some (MsgChat (parse_chat_json obj))
559
+
| Some "j" ->
560
+
let user_id = Option.value (get_string_field obj "i") ~default:"" in
561
+
let user_name =
562
+
Option.value (get_string_field obj "n") ~default:"?"
563
+
in
564
+
let user_color =
565
+
Option.value (get_int_field obj "c") ~default:0x808080ff
566
+
in
567
+
Some (MsgJoin { user_id; user_name; user_color })
568
+
| Some "l" ->
569
+
let user_id = Option.value (get_string_field obj "i") ~default:"" in
570
+
Some (MsgLeave { user_id })
571
+
| Some "y" ->
572
+
let strokes =
573
+
match get_array_field obj "s" with
574
+
| Some items -> parse_array_json items parse_stroke_json
575
+
| None -> []
576
+
in
577
+
let messages =
578
+
match get_array_field obj "m" with
579
+
| Some items -> parse_array_json items parse_chat_json
580
+
| None -> []
581
+
in
582
+
let users =
583
+
match get_array_field obj "u" with
584
+
| Some items -> parse_array_json items parse_presence_json
585
+
| None -> []
586
+
in
587
+
Some (MsgSync { strokes; messages; users })
588
+
| _ -> None)
589
+
| Ok _ -> None
590
+
591
+
(* ============================================================================
592
+
State Management
593
+
============================================================================ *)
594
+
595
+
let create_state ~incoming ~outgoing mode name =
596
+
Random.self_init ();
597
+
let my_id = generate_id () in
598
+
let my_color = get_user_color (Hashtbl.hash my_id) in
599
+
{
600
+
mode;
601
+
my_id;
602
+
my_name = name;
603
+
my_color;
604
+
is_drawing = false;
605
+
current_stroke = [];
606
+
strokes = [];
607
+
brush_color = 0x000000ff;
608
+
brush_size = 3.0;
609
+
palette_index = 0;
610
+
users = Hashtbl.create 32;
611
+
messages = [];
612
+
chat_input = "";
613
+
chat_focused = false;
614
+
connected = false;
615
+
last_presence_sync = 0.0;
616
+
name_input = name;
617
+
entering_name = name = "";
618
+
remote_drawings = [];
619
+
incoming;
620
+
outgoing;
621
+
}
622
+
623
+
let send_message state msg =
624
+
match state.mode with
625
+
| Local -> ()
626
+
| Server | Client _ -> Eio.Stream.add state.outgoing msg
627
+
628
+
let add_stroke state stroke ~broadcast =
629
+
if not (List.exists (fun s -> s.stroke_id = stroke.stroke_id) state.strokes)
630
+
then begin
631
+
state.strokes <- state.strokes @ [ stroke ];
632
+
if broadcast then send_message state (MsgStroke stroke)
633
+
end
634
+
635
+
let add_chat state msg ~broadcast =
636
+
if not (List.exists (fun m -> m.msg_id = msg.msg_id) state.messages) then begin
637
+
state.messages <- state.messages @ [ msg ];
638
+
if List.length state.messages > max_chat_messages then
639
+
state.messages <-
640
+
List.filteri
641
+
(fun i _ -> i >= List.length state.messages - max_chat_messages)
642
+
state.messages;
643
+
if broadcast then send_message state (MsgChat msg)
644
+
end
645
+
646
+
let update_user state user ~broadcast =
647
+
Hashtbl.replace state.users user.user_id user;
648
+
if broadcast then send_message state (MsgPresence user)
649
+
650
+
let process_incoming state =
651
+
(* Non-blocking take from stream *)
652
+
let rec drain () =
653
+
match Eio.Stream.take_nonblocking state.incoming with
654
+
| None -> ()
655
+
| Some msg ->
656
+
(match msg with
657
+
| MsgStroke s -> add_stroke state s ~broadcast:false
658
+
| MsgDrawing d when d.draw_user_id <> state.my_id ->
659
+
(* Add to remote drawings for rendering *)
660
+
state.remote_drawings <- d :: state.remote_drawings;
661
+
(* Limit to last 100 segments to prevent memory issues *)
662
+
if List.length state.remote_drawings > 100 then
663
+
state.remote_drawings <-
664
+
List.filteri (fun i _ -> i < 100) state.remote_drawings
665
+
| MsgDrawing _ -> ()
666
+
| MsgPresence p when p.user_id <> state.my_id ->
667
+
update_user state p ~broadcast:false
668
+
| MsgPresence _ -> ()
669
+
| MsgChat m -> add_chat state m ~broadcast:false
670
+
| MsgJoin { user_id; user_name; user_color }
671
+
when user_id <> state.my_id && user_id <> "" ->
672
+
let p =
673
+
{
674
+
user_id;
675
+
user_name;
676
+
cursor_x = 0.;
677
+
cursor_y = 0.;
678
+
user_color;
679
+
last_seen = current_time ();
680
+
}
681
+
in
682
+
update_user state p ~broadcast:false;
683
+
add_chat state
684
+
{
685
+
msg_id = generate_id ();
686
+
sender = "System";
687
+
text = user_name ^ " joined";
688
+
timestamp = current_time ();
689
+
}
690
+
~broadcast:false
691
+
| MsgJoin _ -> ()
692
+
| MsgLeave { user_id } -> (
693
+
let user = Hashtbl.find_opt state.users user_id in
694
+
Hashtbl.remove state.users user_id;
695
+
(* Clear remote drawings from this user *)
696
+
state.remote_drawings <-
697
+
List.filter
698
+
(fun d -> d.draw_user_id <> user_id)
699
+
state.remote_drawings;
700
+
match user with
701
+
| Some u ->
702
+
add_chat state
703
+
{
704
+
msg_id = generate_id ();
705
+
sender = "System";
706
+
text = u.user_name ^ " left";
707
+
timestamp = current_time ();
708
+
}
709
+
~broadcast:false
710
+
| None -> ())
711
+
| MsgSync { strokes; messages; users } ->
712
+
List.iter (fun s -> add_stroke state s ~broadcast:false) strokes;
713
+
List.iter (fun m -> add_chat state m ~broadcast:false) messages;
714
+
List.iter
715
+
(fun u ->
716
+
if u.user_id <> state.my_id then
717
+
update_user state u ~broadcast:false)
718
+
users);
719
+
drain ()
720
+
in
721
+
drain ()
722
+
723
+
let cleanup_users state =
724
+
let now = current_time () in
725
+
let to_remove =
726
+
Hashtbl.fold
727
+
(fun user_id u acc ->
728
+
if user_id <> state.my_id && now -. u.last_seen >= user_timeout then
729
+
user_id :: acc
730
+
else acc)
731
+
state.users []
732
+
in
733
+
List.iter (Hashtbl.remove state.users) to_remove
734
+
735
+
(* ============================================================================
736
+
Networking with Eio
737
+
============================================================================ *)
738
+
739
+
(* Set TCP_NODELAY on a socket to disable Nagle's algorithm for low latency *)
740
+
let set_tcp_nodelay flow =
741
+
match Eio_unix.Resource.fd_opt flow with
742
+
| Some fd ->
743
+
Eio_unix.Fd.use_exn "setsockopt" fd (fun unix_fd ->
744
+
Unix.setsockopt unix_fd Unix.TCP_NODELAY true)
745
+
| None -> ()
746
+
747
+
(* Read a line from an Eio flow *)
748
+
let read_line flow buf_reader =
749
+
let buf = Buffer.create 256 in
750
+
let rec loop () =
751
+
match Eio.Buf_read.any_char buf_reader with
752
+
| '\n' -> Some (Buffer.contents buf)
753
+
| c ->
754
+
Buffer.add_char buf c;
755
+
loop ()
756
+
in
757
+
try loop () with End_of_file -> None
758
+
759
+
(* Write a line to an Eio flow *)
760
+
let write_line flow line = Eio.Flow.copy_string (line ^ "\n") flow
761
+
762
+
(* Client info tracked by server - using polymorphic type for flow *)
763
+
type 'a client_info = {
764
+
client_flow : 'a;
765
+
mutable client_user_id : string option;
766
+
}
767
+
768
+
(* Handle a single client connection (server-side) *)
769
+
let handle_client ~sw ~state ~clients ~clients_mutex client_info =
770
+
let flow = client_info.client_flow in
771
+
set_tcp_nodelay flow;
772
+
let buf_reader = Eio.Buf_read.of_flow ~max_size:65536 flow in
773
+
774
+
(* Send sync to new client *)
775
+
let my_presence =
776
+
{
777
+
user_id = state.my_id;
778
+
user_name = state.my_name;
779
+
cursor_x = 0.;
780
+
cursor_y = 0.;
781
+
user_color = state.my_color;
782
+
last_seen = current_time ();
783
+
}
784
+
in
785
+
let all_users =
786
+
my_presence :: Hashtbl.fold (fun _ u acc -> u :: acc) state.users []
787
+
in
788
+
let sync =
789
+
MsgSync
790
+
{ strokes = state.strokes; messages = state.messages; users = all_users }
791
+
in
792
+
(try write_line flow (message_to_json sync) with _ -> ());
793
+
794
+
(* Read messages from client *)
795
+
let rec read_loop () =
796
+
match read_line flow buf_reader with
797
+
| None -> () (* Client disconnected *)
798
+
| Some line -> (
799
+
match parse_message line with
800
+
| Some msg ->
801
+
(* Track user_id from join message *)
802
+
(match msg with
803
+
| MsgJoin { user_id; _ } ->
804
+
client_info.client_user_id <- Some user_id
805
+
| _ -> ());
806
+
(* Send to main via stream *)
807
+
Eio.Stream.add state.incoming msg;
808
+
(* Broadcast to other clients *)
809
+
Eio.Mutex.use_rw ~protect:true clients_mutex (fun () ->
810
+
List.iter
811
+
(fun ci ->
812
+
if ci.client_flow != flow then
813
+
try write_line ci.client_flow (message_to_json msg)
814
+
with _ -> ())
815
+
!clients);
816
+
(* If join, send server info back *)
817
+
(match msg with
818
+
| MsgJoin _ -> (
819
+
let server_join =
820
+
MsgJoin
821
+
{
822
+
user_id = state.my_id;
823
+
user_name = state.my_name;
824
+
user_color = state.my_color;
825
+
}
826
+
in
827
+
try write_line flow (message_to_json server_join) with _ -> ())
828
+
| _ -> ());
829
+
read_loop ()
830
+
| None -> read_loop ())
831
+
in
832
+
Fun.protect
833
+
~finally:(fun () ->
834
+
(* Send leave message when client disconnects *)
835
+
(match client_info.client_user_id with
836
+
| Some user_id ->
837
+
let leave_msg = MsgLeave { user_id } in
838
+
(* Notify server's main loop *)
839
+
Eio.Stream.add state.incoming leave_msg;
840
+
(* Broadcast to other clients *)
841
+
Eio.Mutex.use_rw ~protect:true clients_mutex (fun () ->
842
+
List.iter
843
+
(fun ci ->
844
+
if ci.client_flow != flow then
845
+
try write_line ci.client_flow (message_to_json leave_msg)
846
+
with _ -> ())
847
+
!clients)
848
+
| None -> ());
849
+
(* Remove from client list *)
850
+
Eio.Mutex.use_rw ~protect:true clients_mutex (fun () ->
851
+
clients := List.filter (fun ci -> ci.client_flow != flow) !clients))
852
+
read_loop
853
+
854
+
(* Server: accept connections and broadcast outgoing messages *)
855
+
let run_server ~sw ~net ~state ~clock port =
856
+
let addr = `Tcp (Eio.Net.Ipaddr.V4.any, port) in
857
+
let socket = Eio.Net.listen ~sw ~backlog:16 ~reuse_addr:true net addr in
858
+
let clients = ref [] in
859
+
let clients_mutex = Eio.Mutex.create () in
860
+
861
+
Printf.printf "Server listening on port %d\n%!" port;
862
+
state.connected <- true;
863
+
864
+
(* Fiber to broadcast outgoing messages from main *)
865
+
Eio.Fiber.fork ~sw (fun () ->
866
+
while true do
867
+
let msg = Eio.Stream.take state.outgoing in
868
+
let json = message_to_json msg in
869
+
Eio.Mutex.use_rw ~protect:true clients_mutex (fun () ->
870
+
clients :=
871
+
List.filter
872
+
(fun ci ->
873
+
try
874
+
write_line ci.client_flow json;
875
+
true
876
+
with _ -> false)
877
+
!clients)
878
+
done);
879
+
880
+
(* Accept connections *)
881
+
while true do
882
+
Eio.Net.accept_fork ~sw socket
883
+
~on_error:(fun _ -> ())
884
+
(fun flow addr ->
885
+
Printf.printf "Client connected\n%!";
886
+
let client_info = { client_flow = flow; client_user_id = None } in
887
+
Eio.Mutex.use_rw ~protect:true clients_mutex (fun () ->
888
+
clients := client_info :: !clients);
889
+
handle_client ~sw ~state ~clients ~clients_mutex client_info)
890
+
done
891
+
892
+
(* Client: connect and relay messages *)
893
+
let run_client ~sw ~net ~state ~clock host port =
894
+
(* Parse IP address from dotted decimal string using Unix *)
895
+
let inet_addr = Unix.inet_addr_of_string host in
896
+
let ip_bytes =
897
+
(* inet_addr is abstract, but we can get the string representation and parse it *)
898
+
let s = Unix.string_of_inet_addr inet_addr in
899
+
(* For IPv4, convert "a.b.c.d" to 4 bytes *)
900
+
let parts = String.split_on_char '.' s in
901
+
String.init 4 (fun i -> Char.chr (int_of_string (List.nth parts i)))
902
+
in
903
+
let ip = Eio.Net.Ipaddr.of_raw ip_bytes in
904
+
let addr = `Tcp (ip, port) in
905
+
let flow = Eio.Net.connect ~sw net addr in
906
+
set_tcp_nodelay flow;
907
+
let buf_reader = Eio.Buf_read.of_flow ~max_size:65536 flow in
908
+
909
+
Printf.printf "Connected to server\n%!";
910
+
state.connected <- true;
911
+
912
+
(* Send join *)
913
+
let join =
914
+
MsgJoin
915
+
{
916
+
user_id = state.my_id;
917
+
user_name = state.my_name;
918
+
user_color = state.my_color;
919
+
}
920
+
in
921
+
write_line flow (message_to_json join);
922
+
923
+
(* Fiber to send outgoing messages *)
924
+
Eio.Fiber.fork ~sw (fun () ->
925
+
try
926
+
while true do
927
+
let msg = Eio.Stream.take state.outgoing in
928
+
write_line flow (message_to_json msg)
929
+
done
930
+
with _ -> ());
931
+
932
+
(* Read from server *)
933
+
let rec read_loop () =
934
+
match read_line flow buf_reader with
935
+
| None -> ()
936
+
| Some line -> (
937
+
match parse_message line with
938
+
| Some msg ->
939
+
Eio.Stream.add state.incoming msg;
940
+
read_loop ()
941
+
| None -> read_loop ())
942
+
in
943
+
read_loop ()
944
+
945
+
(* ============================================================================
946
+
Input Handling
947
+
============================================================================ *)
948
+
949
+
let handle_name_input state ~start_network =
950
+
if not state.entering_name then ()
951
+
else begin
952
+
let rec get_chars () =
953
+
let key = get_char_pressed () in
954
+
let code = Uchar.to_int key in
955
+
if code >= 32 && code < 127 && String.length state.name_input < 20 then begin
956
+
state.name_input <- state.name_input ^ String.make 1 (Char.chr code);
957
+
get_chars ()
958
+
end
959
+
in
960
+
get_chars ();
961
+
962
+
if is_key_pressed Key.Backspace && String.length state.name_input > 0 then
963
+
state.name_input <-
964
+
String.sub state.name_input 0 (String.length state.name_input - 1);
965
+
966
+
if is_key_pressed Key.Enter && String.length state.name_input > 0 then begin
967
+
state.my_name <- state.name_input;
968
+
state.entering_name <- false;
969
+
start_network ()
970
+
end
971
+
end
972
+
973
+
let handle_drawing state =
974
+
if state.entering_name then ()
975
+
else
976
+
let mx, my = get_mouse_pos () in
977
+
let in_canvas =
978
+
point_in_rect mx my (canvas_x ()) (canvas_y ()) (canvas_width ())
979
+
(canvas_height ())
980
+
in
981
+
982
+
if in_canvas && not state.chat_focused then begin
983
+
if is_mouse_button_pressed MouseButton.Left then begin
984
+
state.is_drawing <- true;
985
+
state.current_stroke <- [ { x = mx; y = my } ]
986
+
end
987
+
else if is_mouse_button_down MouseButton.Left && state.is_drawing then begin
988
+
match state.current_stroke with
989
+
| p :: _ when ((mx -. p.x) ** 2.) +. ((my -. p.y) ** 2.) > 9. ->
990
+
let new_point = { x = mx; y = my } in
991
+
state.current_stroke <- new_point :: state.current_stroke;
992
+
(* Send real-time drawing update *)
993
+
send_message state
994
+
(MsgDrawing
995
+
{
996
+
draw_user_id = state.my_id;
997
+
draw_color = state.brush_color;
998
+
draw_thickness = state.brush_size;
999
+
draw_from = p;
1000
+
draw_to = new_point;
1001
+
})
1002
+
| _ -> ()
1003
+
end
1004
+
else if is_mouse_button_released MouseButton.Left && state.is_drawing then begin
1005
+
state.is_drawing <- false;
1006
+
if List.length state.current_stroke > 1 then begin
1007
+
let stroke =
1008
+
{
1009
+
stroke_id = generate_id ();
1010
+
color = state.brush_color;
1011
+
thickness = state.brush_size;
1012
+
points = List.rev state.current_stroke;
1013
+
}
1014
+
in
1015
+
add_stroke state stroke ~broadcast:true
1016
+
end;
1017
+
state.current_stroke <- []
1018
+
end
1019
+
end
1020
+
else if is_mouse_button_released MouseButton.Left then begin
1021
+
state.is_drawing <- false;
1022
+
state.current_stroke <- []
1023
+
end
1024
+
1025
+
let handle_palette state =
1026
+
if state.entering_name then ()
1027
+
else
1028
+
let palette_y = canvas_y () + canvas_height () + scale 10 in
1029
+
let swatch = scale 30 in
1030
+
let mx, my = get_mouse_pos () in
1031
+
1032
+
if is_mouse_button_pressed MouseButton.Left then
1033
+
let palette = get_palette state.palette_index in
1034
+
Array.iteri
1035
+
(fun i c ->
1036
+
let sx = canvas_x () + scale 70 + (i * (swatch + scale 5)) in
1037
+
if point_in_rect mx my sx palette_y swatch swatch then
1038
+
state.brush_color <- c)
1039
+
palette
1040
+
1041
+
let handle_brush_size_and_palette state =
1042
+
if state.entering_name || state.chat_focused then ()
1043
+
else
1044
+
let wheel = get_mouse_wheel_move () in
1045
+
if wheel <> 0. then begin
1046
+
if is_key_down Key.Left_shift || is_key_down Key.Right_shift then begin
1047
+
(* Shift + Wheel: cycle palette *)
1048
+
let delta = if wheel > 0. then 1 else -1 in
1049
+
state.palette_index <-
1050
+
(state.palette_index + delta + num_palettes) mod num_palettes
1051
+
end
1052
+
else
1053
+
(* Wheel alone: change brush size *)
1054
+
state.brush_size <- clamp (state.brush_size +. (wheel *. 2.)) 1. 30.
1055
+
end
1056
+
1057
+
let handle_chat state =
1058
+
if state.entering_name then ()
1059
+
else
1060
+
let mx, my = get_mouse_pos () in
1061
+
let input_y = chat_y () + chat_height () - scale 35 in
1062
+
1063
+
if is_mouse_button_pressed MouseButton.Left then
1064
+
state.chat_focused <-
1065
+
point_in_rect mx my (sidebar_x ()) input_y (sidebar_width ()) (scale 30);
1066
+
1067
+
if state.chat_focused then begin
1068
+
let rec get_chars () =
1069
+
let key = get_char_pressed () in
1070
+
let code = Uchar.to_int key in
1071
+
if code >= 32 && code < 127 then begin
1072
+
state.chat_input <- state.chat_input ^ String.make 1 (Char.chr code);
1073
+
get_chars ()
1074
+
end
1075
+
in
1076
+
get_chars ();
1077
+
1078
+
if is_key_pressed Key.Backspace && String.length state.chat_input > 0 then
1079
+
state.chat_input <-
1080
+
String.sub state.chat_input 0 (String.length state.chat_input - 1);
1081
+
1082
+
if is_key_pressed Key.Enter && String.length state.chat_input > 0 then begin
1083
+
let msg =
1084
+
{
1085
+
msg_id = generate_id ();
1086
+
sender = state.my_name;
1087
+
text = state.chat_input;
1088
+
timestamp = current_time ();
1089
+
}
1090
+
in
1091
+
add_chat state msg ~broadcast:true;
1092
+
state.chat_input <- ""
1093
+
end;
1094
+
1095
+
if is_key_pressed Key.Escape then state.chat_focused <- false
1096
+
end
1097
+
1098
+
let handle_shortcuts state =
1099
+
if state.entering_name || state.chat_focused then ()
1100
+
else if is_key_pressed Key.C then state.strokes <- []
1101
+
1102
+
let handle_resize () =
1103
+
(* Update window dimensions - DPI scale stays constant *)
1104
+
let w = get_screen_width () in
1105
+
let h = get_screen_height () in
1106
+
if w <> !window_width || h <> !window_height then begin
1107
+
window_width := w;
1108
+
window_height := h
1109
+
end
1110
+
1111
+
let handle_input state ~start_network =
1112
+
handle_resize ();
1113
+
handle_name_input state ~start_network;
1114
+
handle_drawing state;
1115
+
handle_palette state;
1116
+
handle_brush_size_and_palette state;
1117
+
handle_chat state;
1118
+
handle_shortcuts state
1119
+
1120
+
(* ============================================================================
1121
+
Rendering
1122
+
============================================================================ *)
1123
+
1124
+
let draw_name_dialog state =
1125
+
let dw = scale 500 in
1126
+
let dh = scale 180 in
1127
+
let dx = (!window_width - dw) / 2 in
1128
+
let dy = (!window_height - dh) / 2 in
1129
+
1130
+
draw_rectangle 0 0 !window_width !window_height (Color.create 0 0 0 180);
1131
+
draw_rectangle dx dy dw dh Color.raywhite;
1132
+
draw_rectangle_lines dx dy dw dh Color.darkgray;
1133
+
1134
+
draw_text "Enter Your Name"
1135
+
(dx + scale 140)
1136
+
(dy + scale 25)
1137
+
(scale 28) Color.darkgray;
1138
+
1139
+
let ix = dx + scale 50 in
1140
+
let iy = dy + scale 70 in
1141
+
let iw = dw - scale 100 in
1142
+
draw_rectangle ix iy iw (scale 50) Color.white;
1143
+
draw_rectangle_lines ix iy iw (scale 50) Color.blue;
1144
+
draw_text (state.name_input ^ "_")
1145
+
(ix + scale 15)
1146
+
(iy + scale 12)
1147
+
(scale 26) Color.black;
1148
+
1149
+
draw_text "Press ENTER to continue"
1150
+
(dx + scale 140)
1151
+
(dy + scale 140)
1152
+
(scale 18) Color.gray
1153
+
1154
+
let draw_stroke stroke =
1155
+
match stroke.points with
1156
+
| [] | [ _ ] -> ()
1157
+
| points ->
1158
+
let color = color_of_int stroke.color in
1159
+
let rec draw = function
1160
+
| [] | [ _ ] -> ()
1161
+
| p1 :: p2 :: rest ->
1162
+
draw_line_ex (Vector2.create p1.x p1.y) (Vector2.create p2.x p2.y)
1163
+
stroke.thickness color;
1164
+
draw (p2 :: rest)
1165
+
in
1166
+
draw points
1167
+
1168
+
let draw_current_stroke state =
1169
+
if state.is_drawing && List.length state.current_stroke > 1 then begin
1170
+
let color = color_of_int state.brush_color in
1171
+
let points = List.rev state.current_stroke in
1172
+
let rec draw = function
1173
+
| [] | [ _ ] -> ()
1174
+
| p1 :: p2 :: rest ->
1175
+
draw_line_ex (Vector2.create p1.x p1.y) (Vector2.create p2.x p2.y)
1176
+
state.brush_size color;
1177
+
draw (p2 :: rest)
1178
+
in
1179
+
draw points
1180
+
end
1181
+
1182
+
let draw_remote_drawings state =
1183
+
List.iter
1184
+
(fun d ->
1185
+
let color = color_of_int d.draw_color in
1186
+
draw_line_ex
1187
+
(Vector2.create d.draw_from.x d.draw_from.y)
1188
+
(Vector2.create d.draw_to.x d.draw_to.y)
1189
+
d.draw_thickness color)
1190
+
state.remote_drawings
1191
+
1192
+
let draw_canvas state =
1193
+
let cx, cy, cw, ch =
1194
+
(canvas_x (), canvas_y (), canvas_width (), canvas_height ())
1195
+
in
1196
+
draw_rectangle cx cy cw ch Color.white;
1197
+
draw_rectangle_lines cx cy cw ch Color.lightgray;
1198
+
1199
+
begin_scissor_mode cx cy cw ch;
1200
+
List.iter draw_stroke state.strokes;
1201
+
draw_remote_drawings state;
1202
+
draw_current_stroke state;
1203
+
1204
+
(* Other users' cursors - skip if outside canvas (cursor at -1,-1) *)
1205
+
let now = current_time () in
1206
+
Hashtbl.iter
1207
+
(fun _ u ->
1208
+
if
1209
+
u.user_id <> state.my_id
1210
+
&& now -. u.last_seen < presence_timeout
1211
+
&& u.cursor_x >= 0. && u.cursor_y >= 0.
1212
+
then begin
1213
+
let color = color_of_int u.user_color in
1214
+
draw_circle (int_of_float u.cursor_x) (int_of_float u.cursor_y)
1215
+
(scalef 6.) color;
1216
+
draw_circle_lines (int_of_float u.cursor_x) (int_of_float u.cursor_y)
1217
+
(scalef 7.) Color.black;
1218
+
draw_text u.user_name
1219
+
(int_of_float u.cursor_x + scale 12)
1220
+
(int_of_float u.cursor_y - scale 6)
1221
+
(scale 14) color
1222
+
end)
1223
+
state.users;
1224
+
end_scissor_mode ()
1225
+
1226
+
let draw_palette state =
1227
+
let py = canvas_y () + canvas_height () + scale 10 in
1228
+
let swatch = scale 30 in
1229
+
let palette = get_palette state.palette_index in
1230
+
1231
+
draw_text "Color:" (canvas_x ()) (py + scale 5) (scale 18) Color.darkgray;
1232
+
1233
+
Array.iteri
1234
+
(fun i c ->
1235
+
let sx = canvas_x () + scale 70 + (i * (swatch + scale 5)) in
1236
+
draw_rectangle sx py swatch swatch (color_of_int c);
1237
+
if c = state.brush_color then
1238
+
draw_rectangle_lines (sx - 2) (py - 2) (swatch + 4) (swatch + 4)
1239
+
Color.gold
1240
+
else draw_rectangle_lines sx py swatch swatch Color.darkgray)
1241
+
palette;
1242
+
1243
+
let size_x =
1244
+
canvas_x () + scale 70
1245
+
+ (Array.length palette * (swatch + scale 5))
1246
+
+ scale 30
1247
+
in
1248
+
draw_text
1249
+
(Printf.sprintf "Size: %.0f" state.brush_size)
1250
+
size_x
1251
+
(py + scale 5)
1252
+
(scale 18) Color.darkgray;
1253
+
draw_circle
1254
+
(size_x + scale 100)
1255
+
(py + scale 15)
1256
+
state.brush_size
1257
+
(color_of_int state.brush_color)
1258
+
1259
+
let draw_users state =
1260
+
let sx, sy, sw, sh =
1261
+
(sidebar_x (), presence_y (), sidebar_width (), presence_height ())
1262
+
in
1263
+
draw_rectangle sx sy sw sh Color.raywhite;
1264
+
draw_rectangle_lines sx sy sw sh Color.lightgray;
1265
+
1266
+
draw_text "Users Online"
1267
+
(sx + scale 10)
1268
+
(sy + scale 10)
1269
+
(scale 22) Color.darkgray;
1270
+
1271
+
let status, color =
1272
+
match state.mode with
1273
+
| Local -> ("Local Mode", Color.gray)
1274
+
| Server ->
1275
+
( (if state.connected then "Hosting" else "Starting..."),
1276
+
if state.connected then Color.green else Color.orange )
1277
+
| Client _ ->
1278
+
( (if state.connected then "Connected" else "Connecting..."),
1279
+
if state.connected then Color.green else Color.orange )
1280
+
in
1281
+
draw_text status (sx + scale 10) (sy + scale 40) (scale 16) color;
1282
+
1283
+
let y = ref (sy + scale 70) in
1284
+
draw_circle
1285
+
(sx + scale 20)
1286
+
(!y + scale 10)
1287
+
(scalef 8.)
1288
+
(color_of_int state.my_color);
1289
+
draw_text (state.my_name ^ " (you)") (sx + scale 40) !y (scale 16) Color.black;
1290
+
y := !y + scale 30;
1291
+
1292
+
let now = current_time () in
1293
+
(* Sort users by user_id for stable ordering *)
1294
+
let sorted_users =
1295
+
Hashtbl.fold (fun _ u acc -> u :: acc) state.users []
1296
+
|> List.sort (fun a b -> String.compare a.user_id b.user_id)
1297
+
in
1298
+
List.iter
1299
+
(fun u ->
1300
+
if u.user_id <> state.my_id then begin
1301
+
let alpha =
1302
+
if now -. u.last_seen < presence_timeout then 255 else 100
1303
+
in
1304
+
let c = color_of_int u.user_color in
1305
+
let c = Color.create (Color.r c) (Color.g c) (Color.b c) alpha in
1306
+
draw_circle (sx + scale 20) (!y + scale 10) (scalef 8.) c;
1307
+
draw_text u.user_name
1308
+
(sx + scale 40)
1309
+
!y (scale 16)
1310
+
(if alpha = 255 then Color.black else Color.gray);
1311
+
y := !y + scale 30
1312
+
end)
1313
+
sorted_users
1314
+
1315
+
let draw_chat state =
1316
+
let sx, cy, sw, ch =
1317
+
(sidebar_x (), chat_y (), sidebar_width (), chat_height ())
1318
+
in
1319
+
draw_rectangle sx cy sw ch Color.raywhite;
1320
+
draw_rectangle_lines sx cy sw ch Color.lightgray;
1321
+
1322
+
draw_text "Chat" (sx + scale 10) (cy + scale 10) (scale 22) Color.darkgray;
1323
+
1324
+
let msg_y = ref (cy + scale 40) in
1325
+
let msg_h = ch - scale 80 in
1326
+
let line_h = scale 22 in
1327
+
let max_visible = msg_h / line_h in
1328
+
let visible =
1329
+
let total = List.length state.messages in
1330
+
if total > max_visible then
1331
+
List.filteri (fun i _ -> i >= total - max_visible) state.messages
1332
+
else state.messages
1333
+
in
1334
+
1335
+
begin_scissor_mode sx (cy + scale 40) sw msg_h;
1336
+
List.iter
1337
+
(fun m ->
1338
+
let text = Printf.sprintf "%s: %s" m.sender m.text in
1339
+
let max_chars = sw / scale 8 in
1340
+
let display =
1341
+
if String.length text > max_chars then
1342
+
String.sub text 0 (max_chars - 3) ^ "..."
1343
+
else text
1344
+
in
1345
+
let color = if m.sender = "System" then Color.blue else Color.black in
1346
+
draw_text display (sx + scale 10) !msg_y (scale 16) color;
1347
+
msg_y := !msg_y + line_h)
1348
+
visible;
1349
+
end_scissor_mode ();
1350
+
1351
+
let iy = cy + ch - scale 35 in
1352
+
let input_bg = if state.chat_focused then Color.white else Color.lightgray in
1353
+
draw_rectangle (sx + scale 5) iy (sw - scale 10) (scale 30) input_bg;
1354
+
draw_rectangle_lines
1355
+
(sx + scale 5)
1356
+
iy
1357
+
(sw - scale 10)
1358
+
(scale 30)
1359
+
(if state.chat_focused then Color.blue else Color.gray);
1360
+
1361
+
let display =
1362
+
if state.chat_input = "" && not state.chat_focused then "Type to chat..."
1363
+
else state.chat_input ^ if state.chat_focused then "_" else ""
1364
+
in
1365
+
draw_text display
1366
+
(sx + scale 12)
1367
+
(iy + scale 6)
1368
+
(scale 16)
1369
+
(if state.chat_input = "" && not state.chat_focused then Color.gray
1370
+
else Color.black)
1371
+
1372
+
let draw_help state =
1373
+
let py = canvas_y () + canvas_height () + palette_height () + scale 5 in
1374
+
draw_text
1375
+
(Printf.sprintf
1376
+
"LMB: Draw | Wheel: Size | Shift+Wheel: Palette (%d/%d) | C: Clear"
1377
+
(state.palette_index + 1) num_palettes)
1378
+
(canvas_x ()) py (scale 14) Color.gray
1379
+
1380
+
let render state =
1381
+
begin_drawing ();
1382
+
clear_background (Color.create 240 240 240 255);
1383
+
1384
+
draw_canvas state;
1385
+
draw_palette state;
1386
+
draw_users state;
1387
+
draw_chat state;
1388
+
draw_help state;
1389
+
1390
+
if state.entering_name then draw_name_dialog state;
1391
+
end_drawing ()
1392
+
1393
+
(* ============================================================================
1394
+
Update
1395
+
============================================================================ *)
1396
+
1397
+
let update_presence state =
1398
+
if state.entering_name then ()
1399
+
else
1400
+
let now = current_time () in
1401
+
if now -. state.last_presence_sync > sync_interval then begin
1402
+
state.last_presence_sync <- now;
1403
+
let mx, my = get_mouse_pos () in
1404
+
(* Always send presence to keep connection alive, use -1,-1 when outside canvas *)
1405
+
let in_canvas =
1406
+
point_in_rect mx my (canvas_x ()) (canvas_y ()) (canvas_width ())
1407
+
(canvas_height ())
1408
+
in
1409
+
let cursor_x, cursor_y = if in_canvas then (mx, my) else (-1., -1.) in
1410
+
let p =
1411
+
{
1412
+
user_id = state.my_id;
1413
+
user_name = state.my_name;
1414
+
cursor_x;
1415
+
cursor_y;
1416
+
user_color = state.my_color;
1417
+
last_seen = now;
1418
+
}
1419
+
in
1420
+
update_user state p ~broadcast:true
1421
+
end
1422
+
1423
+
let update state ~start_network =
1424
+
handle_input state ~start_network;
1425
+
process_incoming state;
1426
+
update_presence state;
1427
+
cleanup_users state
1428
+
1429
+
(* ============================================================================
1430
+
Main
1431
+
============================================================================ *)
1432
+
1433
+
let parse_args () =
1434
+
let mode = ref Local in
1435
+
let name = ref "" in
1436
+
let i = ref 1 in
1437
+
while !i < Array.length Sys.argv do
1438
+
(match Sys.argv.(!i) with
1439
+
| "--host" | "-h" -> mode := Server
1440
+
| ("--connect" | "-c") when !i + 1 < Array.length Sys.argv ->
1441
+
incr i;
1442
+
mode := Client Sys.argv.(!i)
1443
+
| ("--name" | "-n") when !i + 1 < Array.length Sys.argv ->
1444
+
incr i;
1445
+
name := Sys.argv.(!i)
1446
+
| s when String.length s > 0 && s.[0] <> '-' && !mode = Local ->
1447
+
mode := Client s
1448
+
| _ -> ());
1449
+
incr i
1450
+
done;
1451
+
(!mode, !name)
1452
+
1453
+
let () =
1454
+
let mode, name = parse_args () in
1455
+
1456
+
Eio_main.run @@ fun env ->
1457
+
Eio.Switch.run @@ fun sw ->
1458
+
let net = Eio.Stdenv.net env in
1459
+
let clock = Eio.Stdenv.clock env in
1460
+
1461
+
(* Create message streams *)
1462
+
let incoming = Eio.Stream.create 100 in
1463
+
let outgoing = Eio.Stream.create 100 in
1464
+
let state = create_state ~incoming ~outgoing mode name in
1465
+
1466
+
(* Track if networking has been started *)
1467
+
let network_started = ref false in
1468
+
1469
+
(* Function to start networking (called when name is entered) *)
1470
+
let start_network () =
1471
+
if not !network_started then begin
1472
+
network_started := true;
1473
+
match state.mode with
1474
+
| Local -> ()
1475
+
| Server ->
1476
+
Eio.Fiber.fork ~sw (fun () ->
1477
+
run_server ~sw ~net ~state ~clock default_port)
1478
+
| Client host ->
1479
+
Eio.Fiber.fork ~sw (fun () ->
1480
+
run_client ~sw ~net ~state ~clock host default_port)
1481
+
end
1482
+
in
1483
+
1484
+
(match mode with
1485
+
| Local -> Printf.printf "Local mode\n%!"
1486
+
| Server -> Printf.printf "Server mode on port %d\n%!" default_port
1487
+
| Client h -> Printf.printf "Connecting to %s:%d\n%!" h default_port);
1488
+
1489
+
(* Start networking if name provided *)
1490
+
if name <> "" then start_network ();
1491
+
1492
+
set_config_flags [ ConfigFlags.Msaa_4x_hint; ConfigFlags.Window_resizable ];
1493
+
init_window base_width base_height "CRDT Whiteboard";
1494
+
set_window_min_size 800 600;
1495
+
(* Don't use Raylib's FPS limiter - it blocks and prevents Eio fibers from running *)
1496
+
set_target_fps 0;
1497
+
1498
+
(* Initial dimensions *)
1499
+
window_width := get_screen_width ();
1500
+
window_height := get_screen_height ();
1501
+
1502
+
(* Get DPI scale from system - this detects HiDPI displays *)
1503
+
let dpi_vec = get_window_scale_dpi () in
1504
+
let detected_dpi = max (Vector2.x dpi_vec) (Vector2.y dpi_vec) in
1505
+
(* Use DPI scale if > 1, otherwise use 1.0 *)
1506
+
dpi_scale := if detected_dpi > 1.0 then detected_dpi else 1.0;
1507
+
Printf.printf "DPI scale: %.2f (detected: %.2f x %.2f)\n%!" !dpi_scale
1508
+
(Vector2.x dpi_vec) (Vector2.y dpi_vec);
1509
+
1510
+
let frame_time = 1.0 /. 60.0 in
1511
+
(* Target 60 FPS *)
1512
+
let last_frame = ref (Eio.Time.now clock) in
1513
+
1514
+
while not (window_should_close ()) do
1515
+
update state ~start_network;
1516
+
render state;
1517
+
1518
+
(* Use Eio sleep for frame timing - this allows network fibers to run *)
1519
+
let now = Eio.Time.now clock in
1520
+
let elapsed = now -. !last_frame in
1521
+
let sleep_time = frame_time -. elapsed in
1522
+
if sleep_time > 0.001 then Eio.Time.sleep clock sleep_time;
1523
+
last_frame := Eio.Time.now clock
1524
+
done;
1525
+
1526
+
if state.connected then
1527
+
send_message state (MsgLeave { user_id = state.my_id });
1528
+
1529
+
close_window ();
1530
+
Printf.printf "Goodbye!\n%!"