|
#!/usr/bin/env python3
|
|
import curses
|
|
import pickle
|
|
import queue
|
|
import re
|
|
import socket
|
|
import sys
|
|
import threading
|
|
|
|
|
|
class RPEditor:
|
|
def __init__(self, filename=None):
|
|
self.filename = filename
|
|
self.lines = [""]
|
|
self.cursor_y = 0
|
|
self.cursor_x = 0
|
|
self.mode = "normal"
|
|
self.command = ""
|
|
self.stdscr = None
|
|
self.running = False
|
|
self.thread = None
|
|
self.socket_thread = None
|
|
self.prev_key = None
|
|
self.clipboard = ""
|
|
self.undo_stack = []
|
|
self.redo_stack = []
|
|
self.selection_start = None
|
|
self.selection_end = None
|
|
self.max_undo = 100
|
|
self.lock = threading.RLock()
|
|
self.client_sock, self.server_sock = socket.socketpair()
|
|
self.command_queue = queue.Queue()
|
|
if filename:
|
|
self.load_file()
|
|
|
|
def load_file(self):
|
|
try:
|
|
with open(self.filename) as f:
|
|
self.lines = f.read().splitlines()
|
|
if not self.lines:
|
|
self.lines = [""]
|
|
except:
|
|
self.lines = [""]
|
|
|
|
def _save_file(self):
|
|
with self.lock:
|
|
if self.filename:
|
|
with open(self.filename, "w") as f:
|
|
f.write("\n".join(self.lines))
|
|
|
|
def save_file(self):
|
|
self.client_sock.send(pickle.dumps({"command": "save_file"}))
|
|
|
|
def start(self):
|
|
self.running = True
|
|
self.socket_thread = threading.Thread(target=self.socket_listener)
|
|
self.socket_thread.start()
|
|
self.thread = threading.Thread(target=self.run)
|
|
self.thread.start()
|
|
|
|
def stop(self):
|
|
self.client_sock.send(pickle.dumps({"command": "stop"}))
|
|
self.running = False
|
|
if self.stdscr:
|
|
curses.endwin()
|
|
if self.thread:
|
|
self.thread.join()
|
|
if self.socket_thread:
|
|
self.socket_thread.join()
|
|
self.client_sock.close()
|
|
self.server_sock.close()
|
|
|
|
def run(self):
|
|
curses.wrapper(self.main_loop)
|
|
|
|
def main_loop(self, stdscr):
|
|
self.stdscr = stdscr
|
|
curses.curs_set(1)
|
|
self.stdscr.keypad(True)
|
|
while self.running:
|
|
with self.lock:
|
|
self.draw()
|
|
try:
|
|
while True:
|
|
command = self.command_queue.get_nowait()
|
|
with self.lock:
|
|
self.execute_command(command)
|
|
except queue.Empty:
|
|
pass
|
|
key = self.stdscr.getch()
|
|
with self.lock:
|
|
self.handle_key(key)
|
|
|
|
def draw(self):
|
|
self.stdscr.clear()
|
|
height, width = self.stdscr.getmaxyx()
|
|
for i, line in enumerate(self.lines):
|
|
if i < height - 1:
|
|
self.stdscr.addstr(i, 0, line[:width])
|
|
status = f"{self.mode.upper()} | {self.filename or 'untitled'} | {self.cursor_y+1}:{self.cursor_x+1}"
|
|
self.stdscr.addstr(height - 1, 0, status[:width])
|
|
if self.mode == "command":
|
|
self.stdscr.addstr(height - 1, 0, self.command[:width])
|
|
self.stdscr.move(self.cursor_y, min(self.cursor_x, width - 1))
|
|
self.stdscr.refresh()
|
|
|
|
def handle_key(self, key):
|
|
if self.mode == "normal":
|
|
self.handle_normal(key)
|
|
elif self.mode == "insert":
|
|
self.handle_insert(key)
|
|
elif self.mode == "command":
|
|
self.handle_command(key)
|
|
|
|
def handle_normal(self, key):
|
|
if key == ord("h") or key == curses.KEY_LEFT:
|
|
self.move_cursor(0, -1)
|
|
elif key == ord("j") or key == curses.KEY_DOWN:
|
|
self.move_cursor(1, 0)
|
|
elif key == ord("k") or key == curses.KEY_UP:
|
|
self.move_cursor(-1, 0)
|
|
elif key == ord("l") or key == curses.KEY_RIGHT:
|
|
self.move_cursor(0, 1)
|
|
elif key == ord("i"):
|
|
self.mode = "insert"
|
|
elif key == ord(":"):
|
|
self.mode = "command"
|
|
self.command = ":"
|
|
elif key == ord("x"):
|
|
self._delete_char()
|
|
elif key == ord("a"):
|
|
self.cursor_x += 1
|
|
self.mode = "insert"
|
|
elif key == ord("A"):
|
|
self.cursor_x = len(self.lines[self.cursor_y])
|
|
self.mode = "insert"
|
|
elif key == ord("o"):
|
|
self._insert_line(self.cursor_y + 1, "")
|
|
self.cursor_y += 1
|
|
self.cursor_x = 0
|
|
self.mode = "insert"
|
|
elif key == ord("O"):
|
|
self._insert_line(self.cursor_y, "")
|
|
self.cursor_x = 0
|
|
self.mode = "insert"
|
|
elif key == ord("d") and self.prev_key == ord("d"):
|
|
self.clipboard = self.lines[self.cursor_y]
|
|
self._delete_line(self.cursor_y)
|
|
if self.cursor_y >= len(self.lines):
|
|
self.cursor_y = len(self.lines) - 1
|
|
self.cursor_x = 0
|
|
elif key == ord("y") and self.prev_key == ord("y"):
|
|
self.clipboard = self.lines[self.cursor_y]
|
|
elif key == ord("p"):
|
|
self._insert_line(self.cursor_y + 1, self.clipboard)
|
|
self.cursor_y += 1
|
|
self.cursor_x = 0
|
|
elif key == ord("P"):
|
|
self._insert_line(self.cursor_y, self.clipboard)
|
|
self.cursor_x = 0
|
|
elif key == ord("w"):
|
|
line = self.lines[self.cursor_y]
|
|
i = self.cursor_x
|
|
while i < len(line) and not line[i].isalnum():
|
|
i += 1
|
|
while i < len(line) and line[i].isalnum():
|
|
i += 1
|
|
self.cursor_x = i
|
|
elif key == ord("b"):
|
|
line = self.lines[self.cursor_y]
|
|
i = self.cursor_x - 1
|
|
while i >= 0 and not line[i].isalnum():
|
|
i -= 1
|
|
while i >= 0 and line[i].isalnum():
|
|
i -= 1
|
|
self.cursor_x = i + 1
|
|
elif key == ord("0"):
|
|
self.cursor_x = 0
|
|
elif key == ord("$"):
|
|
self.cursor_x = len(self.lines[self.cursor_y])
|
|
elif key == ord("g"):
|
|
if self.prev_key == ord("g"):
|
|
self.cursor_y = 0
|
|
self.cursor_x = 0
|
|
elif key == ord("G"):
|
|
self.cursor_y = len(self.lines) - 1
|
|
self.cursor_x = 0
|
|
elif key == ord("u"):
|
|
self.undo()
|
|
elif key == ord("r") and self.prev_key == 18:
|
|
self.redo()
|
|
self.prev_key = key
|
|
|
|
def handle_insert(self, key):
|
|
if key == 27:
|
|
self.mode = "normal"
|
|
if self.cursor_x > 0:
|
|
self.cursor_x -= 1
|
|
elif key == 10:
|
|
self._split_line()
|
|
elif key == curses.KEY_BACKSPACE or key == 127:
|
|
self._backspace()
|
|
elif 32 <= key <= 126:
|
|
char = chr(key)
|
|
self._insert_char(char)
|
|
|
|
def handle_command(self, key):
|
|
if key == 10:
|
|
cmd = self.command[1:]
|
|
if cmd == "q" or cmd == "q!":
|
|
self.running = False
|
|
elif cmd == "w":
|
|
self._save_file()
|
|
elif (
|
|
cmd == "wq" or cmd == "wq!" or cmd == "x" or cmd == "xq" or cmd == "x!"
|
|
):
|
|
self._save_file()
|
|
self.running = False
|
|
elif cmd.startswith("w "):
|
|
self.filename = cmd[2:]
|
|
self._save_file()
|
|
elif cmd == "wq":
|
|
self._save_file()
|
|
self.running = False
|
|
self.mode = "normal"
|
|
self.command = ""
|
|
elif key == 27:
|
|
self.mode = "normal"
|
|
self.command = ""
|
|
elif key == curses.KEY_BACKSPACE or key == 127:
|
|
if len(self.command) > 1:
|
|
self.command = self.command[:-1]
|
|
elif 32 <= key <= 126:
|
|
self.command += chr(key)
|
|
|
|
def move_cursor(self, dy, dx):
|
|
new_y = self.cursor_y + dy
|
|
new_x = self.cursor_x + dx
|
|
if 0 <= new_y < len(self.lines):
|
|
self.cursor_y = new_y
|
|
self.cursor_x = max(0, min(new_x, len(self.lines[self.cursor_y])))
|
|
|
|
def save_state(self):
|
|
with self.lock:
|
|
state = {
|
|
"lines": list(self.lines),
|
|
"cursor_y": self.cursor_y,
|
|
"cursor_x": self.cursor_x,
|
|
}
|
|
self.undo_stack.append(state)
|
|
if len(self.undo_stack) > self.max_undo:
|
|
self.undo_stack.pop(0)
|
|
self.redo_stack.clear()
|
|
|
|
def undo(self):
|
|
with self.lock:
|
|
if self.undo_stack:
|
|
current_state = {
|
|
"lines": list(self.lines),
|
|
"cursor_y": self.cursor_y,
|
|
"cursor_x": self.cursor_x,
|
|
}
|
|
self.redo_stack.append(current_state)
|
|
state = self.undo_stack.pop()
|
|
self.lines = state["lines"]
|
|
self.cursor_y = state["cursor_y"]
|
|
self.cursor_x = state["cursor_x"]
|
|
|
|
def redo(self):
|
|
with self.lock:
|
|
if self.redo_stack:
|
|
current_state = {
|
|
"lines": list(self.lines),
|
|
"cursor_y": self.cursor_y,
|
|
"cursor_x": self.cursor_x,
|
|
}
|
|
self.undo_stack.append(current_state)
|
|
state = self.redo_stack.pop()
|
|
self.lines = state["lines"]
|
|
self.cursor_y = state["cursor_y"]
|
|
self.cursor_x = state["cursor_x"]
|
|
|
|
def _insert_text(self, text):
|
|
self.save_state()
|
|
lines = text.split("\n")
|
|
if len(lines) == 1:
|
|
self.lines[self.cursor_y] = (
|
|
self.lines[self.cursor_y][: self.cursor_x]
|
|
+ text
|
|
+ self.lines[self.cursor_y][self.cursor_x :]
|
|
)
|
|
self.cursor_x += len(text)
|
|
else:
|
|
first = self.lines[self.cursor_y][: self.cursor_x] + lines[0]
|
|
last = lines[-1] + self.lines[self.cursor_y][self.cursor_x :]
|
|
self.lines[self.cursor_y] = first
|
|
for i in range(1, len(lines) - 1):
|
|
self.lines.insert(self.cursor_y + i, lines[i])
|
|
self.lines.insert(self.cursor_y + len(lines) - 1, last)
|
|
self.cursor_y += len(lines) - 1
|
|
self.cursor_x = len(lines[-1])
|
|
|
|
def insert_text(self, text):
|
|
self.client_sock.send(pickle.dumps({"command": "insert_text", "text": text}))
|
|
|
|
def _delete_char(self):
|
|
self.save_state()
|
|
if self.cursor_x < len(self.lines[self.cursor_y]):
|
|
self.lines[self.cursor_y] = (
|
|
self.lines[self.cursor_y][: self.cursor_x]
|
|
+ self.lines[self.cursor_y][self.cursor_x + 1 :]
|
|
)
|
|
|
|
def delete_char(self):
|
|
self.client_sock.send(pickle.dumps({"command": "delete_char"}))
|
|
|
|
def _insert_char(self, char):
|
|
self.lines[self.cursor_y] = (
|
|
self.lines[self.cursor_y][: self.cursor_x]
|
|
+ char
|
|
+ self.lines[self.cursor_y][self.cursor_x :]
|
|
)
|
|
self.cursor_x += 1
|
|
|
|
def _split_line(self):
|
|
line = self.lines[self.cursor_y]
|
|
self.lines[self.cursor_y] = line[: self.cursor_x]
|
|
self.lines.insert(self.cursor_y + 1, line[self.cursor_x :])
|
|
self.cursor_y += 1
|
|
self.cursor_x = 0
|
|
|
|
def _backspace(self):
|
|
if self.cursor_x > 0:
|
|
self.lines[self.cursor_y] = (
|
|
self.lines[self.cursor_y][: self.cursor_x - 1]
|
|
+ self.lines[self.cursor_y][self.cursor_x :]
|
|
)
|
|
self.cursor_x -= 1
|
|
elif self.cursor_y > 0:
|
|
prev_len = len(self.lines[self.cursor_y - 1])
|
|
self.lines[self.cursor_y - 1] += self.lines[self.cursor_y]
|
|
del self.lines[self.cursor_y]
|
|
self.cursor_y -= 1
|
|
self.cursor_x = prev_len
|
|
|
|
def _insert_line(self, line_num, text):
|
|
self.save_state()
|
|
line_num = max(0, min(line_num, len(self.lines)))
|
|
self.lines.insert(line_num, text)
|
|
|
|
def _delete_line(self, line_num):
|
|
self.save_state()
|
|
if 0 <= line_num < len(self.lines):
|
|
if len(self.lines) > 1:
|
|
del self.lines[line_num]
|
|
else:
|
|
self.lines = [""]
|
|
|
|
def _set_text(self, text):
|
|
self.save_state()
|
|
self.lines = text.splitlines() if text else [""]
|
|
self.cursor_y = 0
|
|
self.cursor_x = 0
|
|
|
|
def set_text(self, text):
|
|
self.client_sock.send(pickle.dumps({"command": "set_text", "text": text}))
|
|
|
|
def _goto_line(self, line_num):
|
|
line_num = max(0, min(line_num, len(self.lines) - 1))
|
|
self.cursor_y = line_num
|
|
self.cursor_x = 0
|
|
|
|
def goto_line(self, line_num):
|
|
self.client_sock.send(
|
|
pickle.dumps({"command": "goto_line", "line_num": line_num})
|
|
)
|
|
|
|
def get_text(self):
|
|
self.client_sock.send(pickle.dumps({"command": "get_text"}))
|
|
try:
|
|
return pickle.loads(self.client_sock.recv(4096))
|
|
except:
|
|
return ""
|
|
|
|
def get_cursor(self):
|
|
self.client_sock.send(pickle.dumps({"command": "get_cursor"}))
|
|
try:
|
|
return pickle.loads(self.client_sock.recv(4096))
|
|
except:
|
|
return (0, 0)
|
|
|
|
def get_file_info(self):
|
|
self.client_sock.send(pickle.dumps({"command": "get_file_info"}))
|
|
try:
|
|
return pickle.loads(self.client_sock.recv(4096))
|
|
except:
|
|
return {}
|
|
|
|
def socket_listener(self):
|
|
while self.running:
|
|
try:
|
|
data = self.server_sock.recv(4096)
|
|
if not data:
|
|
break
|
|
command = pickle.loads(data)
|
|
self.command_queue.put(command)
|
|
except OSError:
|
|
break
|
|
|
|
def execute_command(self, command):
|
|
cmd = command.get("command")
|
|
if cmd == "insert_text":
|
|
self._insert_text(command["text"])
|
|
elif cmd == "delete_char":
|
|
self._delete_char()
|
|
elif cmd == "save_file":
|
|
self._save_file()
|
|
elif cmd == "set_text":
|
|
self._set_text(command["text"])
|
|
elif cmd == "goto_line":
|
|
self._goto_line(command["line_num"])
|
|
elif cmd == "get_text":
|
|
result = "\n".join(self.lines)
|
|
try:
|
|
self.server_sock.send(pickle.dumps(result))
|
|
except:
|
|
pass
|
|
elif cmd == "get_cursor":
|
|
result = (self.cursor_y, self.cursor_x)
|
|
try:
|
|
self.server_sock.send(pickle.dumps(result))
|
|
except:
|
|
pass
|
|
elif cmd == "get_file_info":
|
|
result = {
|
|
"filename": self.filename,
|
|
"lines": len(self.lines),
|
|
"cursor": (self.cursor_y, self.cursor_x),
|
|
"mode": self.mode,
|
|
}
|
|
try:
|
|
self.server_sock.send(pickle.dumps(result))
|
|
except:
|
|
pass
|
|
elif cmd == "stop":
|
|
self.running = False
|
|
|
|
def move_cursor_to(self, y, x):
|
|
with self.lock:
|
|
self.cursor_y = max(0, min(y, len(self.lines) - 1))
|
|
self.cursor_x = max(0, min(x, len(self.lines[self.cursor_y])))
|
|
|
|
def get_line(self, line_num):
|
|
with self.lock:
|
|
if 0 <= line_num < len(self.lines):
|
|
return self.lines[line_num]
|
|
return None
|
|
|
|
def get_lines(self, start, end):
|
|
with self.lock:
|
|
start = max(0, start)
|
|
end = min(end, len(self.lines))
|
|
return self.lines[start:end]
|
|
|
|
def insert_at_line(self, line_num, text):
|
|
with self.lock:
|
|
self.save_state()
|
|
line_num = max(0, min(line_num, len(self.lines)))
|
|
self.lines.insert(line_num, text)
|
|
|
|
def delete_lines(self, start, end):
|
|
with self.lock:
|
|
self.save_state()
|
|
start = max(0, start)
|
|
end = min(end, len(self.lines))
|
|
if start < end:
|
|
del self.lines[start:end]
|
|
if not self.lines:
|
|
self.lines = [""]
|
|
|
|
def replace_text(self, start_line, start_col, end_line, end_col, new_text):
|
|
with self.lock:
|
|
self.save_state()
|
|
if start_line == end_line:
|
|
line = self.lines[start_line]
|
|
self.lines[start_line] = line[:start_col] + new_text + line[end_col:]
|
|
else:
|
|
first_part = self.lines[start_line][:start_col]
|
|
last_part = self.lines[end_line][end_col:]
|
|
new_lines = new_text.split("\n")
|
|
self.lines[start_line] = first_part + new_lines[0]
|
|
del self.lines[start_line + 1 : end_line + 1]
|
|
for i, new_line in enumerate(new_lines[1:], 1):
|
|
self.lines.insert(start_line + i, new_line)
|
|
if len(new_lines) > 1:
|
|
self.lines[start_line + len(new_lines) - 1] += last_part
|
|
else:
|
|
self.lines[start_line] += last_part
|
|
|
|
def search(self, pattern, start_line=0):
|
|
with self.lock:
|
|
results = []
|
|
for i in range(start_line, len(self.lines)):
|
|
matches = re.finditer(pattern, self.lines[i])
|
|
for match in matches:
|
|
results.append((i, match.start(), match.end()))
|
|
return results
|
|
|
|
def replace_all(self, search_text, replace_text):
|
|
with self.lock:
|
|
self.save_state()
|
|
for i in range(len(self.lines)):
|
|
self.lines[i] = self.lines[i].replace(search_text, replace_text)
|
|
|
|
def select_range(self, start_line, start_col, end_line, end_col):
|
|
with self.lock:
|
|
self.selection_start = (start_line, start_col)
|
|
self.selection_end = (end_line, end_col)
|
|
|
|
def get_selection(self):
|
|
with self.lock:
|
|
if not self.selection_start or not self.selection_end:
|
|
return ""
|
|
sl, sc = self.selection_start
|
|
el, ec = self.selection_end
|
|
if sl == el:
|
|
return self.lines[sl][sc:ec]
|
|
result = [self.lines[sl][sc:]]
|
|
for i in range(sl + 1, el):
|
|
result.append(self.lines[i])
|
|
result.append(self.lines[el][:ec])
|
|
return "\n".join(result)
|
|
|
|
def delete_selection(self):
|
|
with self.lock:
|
|
if not self.selection_start or not self.selection_end:
|
|
return
|
|
self.save_state()
|
|
sl, sc = self.selection_start
|
|
el, ec = self.selection_end
|
|
self.replace_text(sl, sc, el, ec, "")
|
|
self.selection_start = None
|
|
self.selection_end = None
|
|
|
|
def apply_search_replace_block(self, search_block, replace_block):
|
|
with self.lock:
|
|
self.save_state()
|
|
search_lines = search_block.splitlines()
|
|
replace_lines = replace_block.splitlines()
|
|
for i in range(len(self.lines) - len(search_lines) + 1):
|
|
match = True
|
|
for j, search_line in enumerate(search_lines):
|
|
if self.lines[i + j].strip() != search_line.strip():
|
|
match = False
|
|
break
|
|
if match:
|
|
indent = len(self.lines[i]) - len(self.lines[i].lstrip())
|
|
indented_replace = [" " * indent + line for line in replace_lines]
|
|
self.lines[i : i + len(search_lines)] = indented_replace
|
|
return True
|
|
return False
|
|
|
|
def apply_diff(self, diff_text):
|
|
with self.lock:
|
|
self.save_state()
|
|
lines = diff_text.split("\n")
|
|
for line in lines:
|
|
if line.startswith("@@"):
|
|
match = re.search(r"@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@", line)
|
|
if match:
|
|
start_line = int(match.group(1)) - 1
|
|
elif line.startswith("-"):
|
|
if start_line < len(self.lines):
|
|
del self.lines[start_line]
|
|
elif line.startswith("+"):
|
|
self.lines.insert(start_line, line[1:])
|
|
start_line += 1
|
|
|
|
def get_context(self, line_num, context_lines=3):
|
|
with self.lock:
|
|
start = max(0, line_num - context_lines)
|
|
end = min(len(self.lines), line_num + context_lines + 1)
|
|
return self.get_lines(start, end)
|
|
|
|
def count_lines(self):
|
|
with self.lock:
|
|
return len(self.lines)
|
|
|
|
def close(self):
|
|
self.running = False
|
|
self.stop()
|
|
if self.thread:
|
|
self.thread.join()
|
|
|
|
|
|
def main():
|
|
filename = sys.argv[1] if len(sys.argv) > 1 else None
|
|
editor = RPEditor(filename)
|
|
editor.start()
|
|
editor.thread.join()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|