|
#!/usr/bin/env python3
|
|
import curses
|
|
import threading
|
|
import sys
|
|
import os
|
|
import re
|
|
import socket
|
|
import pickle
|
|
import queue
|
|
|
|
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, 'r') 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': [line for line in 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': [line for line in 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': [line for line in 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()
|
|
|
|
|