crdt library in ocaml implementing json-joy

Fix wbr hang in Local mode by skipping message sends

In Local mode, send_message was adding to the outgoing Eio stream
but nothing consumed from it. After 100 messages (stream capacity),
Eio.Stream.add blocked forever, causing the application to hang.

Changed files
+1536 -351
.beads
bin
+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
··· 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
··· 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
··· 1 + (executable 2 + (name wbr) 3 + (libraries crdt eio eio_main raylib simdjsont))
+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%!"