Skip to content

Commit 88052c7

Browse files
authored
Create client_diff_floating_cursors.py
1 parent dfa3045 commit 88052c7

1 file changed

Lines changed: 173 additions & 0 deletions

File tree

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
# client_diff_floating_cursors.py
2+
import asyncio
3+
import json
4+
import tkinter as tk
5+
import threading
6+
import time
7+
from tkinter import simpledialog, messagebox
8+
from difflib import SequenceMatcher
9+
import random
10+
11+
import ttkbootstrap as tb
12+
from ttkbootstrap.widgets.scrolled import ScrolledText
13+
import websockets
14+
15+
# ---------------- CONFIG ---------------- #
16+
SERVER_URI = "ws://127.0.0.1:8765"
17+
18+
# ---------------- GUI ---------------- #
19+
app = tb.Window(title="Collaborative Editor (Floating Cursors)", themename="flatly", size=(900, 600))
20+
21+
top = tb.Frame(app, padding=10)
22+
top.pack(fill=tk.X)
23+
tb.Label(top, text="Collaborative Editor (Floating Cursors)", font=("Segoe UI", 16, "bold")).pack(anchor=tk.W)
24+
25+
editor_frame = tb.Frame(app, padding=10)
26+
editor_frame.pack(fill=tk.BOTH, expand=True)
27+
28+
scrolled = ScrolledText(editor_frame, autohide=True)
29+
scrolled.pack(fill=tk.BOTH, expand=True)
30+
text_widget = scrolled.text
31+
text_widget.configure(wrap="word")
32+
33+
# ---------------- USER SETUP ---------------- #
34+
username = simpledialog.askstring("Username", "Enter your username:", parent=app)
35+
if not username:
36+
messagebox.showerror("Error", "Username required")
37+
app.destroy()
38+
exit()
39+
40+
# ---------------- STATE ---------------- #
41+
ignore_event = False
42+
last_content = ""
43+
cursor_widgets = {} # username -> (canvas rectangle, label)
44+
user_colors = {} # username -> color
45+
colors_list = ["#FF6F61", "#6B5B95", "#88B04B", "#F7CAC9", "#92A8D1",
46+
"#955251", "#B565A7", "#009B77", "#DD4124", "#45B8AC"]
47+
48+
def get_user_color(user):
49+
if user not in user_colors:
50+
color = random.choice(colors_list)
51+
user_colors[user] = color
52+
return user_colors[user]
53+
54+
# ---------------- DIFF HELPERS ---------------- #
55+
def generate_diff(old, new):
56+
seq = SequenceMatcher(None, old, new)
57+
ops = []
58+
for tag, i1, i2, j1, j2 in seq.get_opcodes():
59+
ops.append({
60+
"tag": tag,
61+
"i1": i1, "i2": i2,
62+
"j1": j1, "j2": j2,
63+
"text": new[j1:j2] if tag in ("replace", "insert") else ""
64+
})
65+
return ops
66+
67+
def apply_diff(content, diff_ops):
68+
new_content = []
69+
for op in diff_ops:
70+
tag = op["tag"]
71+
i1, i2, text = op["i1"], op["i2"], op.get("text", "")
72+
if tag == "equal":
73+
new_content.append(content[i1:i2])
74+
elif tag in ("replace", "insert"):
75+
new_content.append(text)
76+
elif tag == "delete":
77+
pass
78+
return "".join(new_content)
79+
80+
# ---------------- CURSOR LABEL HELPERS ---------------- #
81+
def update_cursor(user, position):
82+
"""Place a floating colored label at given position for user"""
83+
if user == username:
84+
return # Don't show your own cursor
85+
86+
color = get_user_color(user)
87+
# Remove old widgets
88+
if user in cursor_widgets:
89+
rect, lbl = cursor_widgets[user]
90+
text_widget.delete(rect)
91+
lbl.destroy()
92+
93+
# Compute bbox of position
94+
try:
95+
bbox = text_widget.bbox(f"1.0+{position}c")
96+
if not bbox:
97+
return
98+
x, y, width, height = bbox
99+
# Create colored rectangle for cursor
100+
rect = text_widget.create_rectangle(x, y, x+2, y+height, fill=color, width=0)
101+
# Create floating label
102+
lbl = tk.Label(text_widget, text=user, bg=color, fg="white", font=("Segoe UI", 8, "bold"))
103+
text_widget.window_create(f"1.0+{position}c", window=lbl)
104+
cursor_widgets[user] = (rect, lbl)
105+
except Exception:
106+
pass
107+
108+
# ---------------- WEBSOCKET CLIENT ---------------- #
109+
async def connect_to_server():
110+
global ignore_event, last_content
111+
async with websockets.connect(SERVER_URI) as ws:
112+
await ws.send(username)
113+
114+
async def send_diff(ops):
115+
await ws.send(json.dumps({"type": "diff", "ops": ops}))
116+
117+
async def send_cursor(pos):
118+
await ws.send(json.dumps({"type": "cursor", "position": pos}))
119+
120+
async def receive_messages():
121+
global ignore_event, last_content
122+
async for message in ws:
123+
data = json.loads(message)
124+
if data["type"] == "full_update":
125+
ignore_event = True
126+
text_widget.delete("1.0", "end")
127+
text_widget.insert("1.0", data["content"])
128+
last_content = data["content"]
129+
ignore_event = False
130+
elif data["type"] == "diff":
131+
ignore_event = True
132+
last_content = apply_diff(last_content, data["ops"])
133+
text_widget.delete("1.0", "end")
134+
text_widget.insert("1.0", last_content)
135+
ignore_event = False
136+
elif data["type"] == "cursor":
137+
user = data["user"]
138+
position = data["position"]
139+
update_cursor(user, position)
140+
141+
# Thread to send cursor updates
142+
def track_cursor():
143+
while True:
144+
try:
145+
pos_index = text_widget.index("insert")
146+
row, col = map(int, pos_index.split("."))
147+
position = int(text_widget.count("1.0", f"{row}.{col}", "chars")[0])
148+
asyncio.run_coroutine_threadsafe(send_cursor(position), loop)
149+
time.sleep(0.2)
150+
except Exception:
151+
break
152+
153+
threading.Thread(target=track_cursor, daemon=True).start()
154+
155+
# Bind text edits
156+
def on_edit(event=None):
157+
global last_content
158+
if ignore_event:
159+
return
160+
new_content = text_widget.get("1.0", "end-1c")
161+
ops = generate_diff(last_content, new_content)
162+
last_content = new_content
163+
asyncio.run_coroutine_threadsafe(send_diff(ops), loop)
164+
165+
text_widget.bind("<<Modified>>", lambda e: on_edit())
166+
await receive_messages()
167+
168+
# ---------------- START ASYNCIO LOOP ---------------- #
169+
loop = asyncio.new_event_loop()
170+
asyncio.set_event_loop(loop)
171+
threading.Thread(target=lambda: loop.run_until_complete(connect_to_server()), daemon=True).start()
172+
173+
app.mainloop()

0 commit comments

Comments
 (0)