"""Text editor for in-game content editing.""" from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from mudlib.render.highlight import highlight @dataclass class EditorResponse: """Response from editor input handling.""" output: str # text to send back to the player done: bool # True if editor should be closed (quit/save-and-quit) saved: bool = False # True if save was performed @dataclass class Editor: """Line-based text editor for in-game content. Doesn't know about telnet — takes string input, returns string output. Will be integrated into the shell loop in a later step. """ buffer: list[str] = field(default_factory=list) cursor: int = 0 # current line (0-indexed internally) undo_stack: list[list[str]] = field(default_factory=list) save_callback: Callable[[str], Awaitable[None]] | None = None content_type: str = "text" # hint for syntax highlighting later color_depth: str = "16" # "16", "256", or "truecolor" dirty: bool = False def __init__( self, initial_content: str = "", save_callback: Callable[[str], Awaitable[None]] | None = None, content_type: str = "text", color_depth: str = "16", ): """Initialize editor with optional content.""" self.save_callback = save_callback self.content_type = content_type self.color_depth = color_depth self.cursor = 0 self.undo_stack = [] self.dirty = False if initial_content: self.buffer = initial_content.split("\n") else: self.buffer = [] def _save_undo_state(self) -> None: """Save current buffer state to undo stack.""" self.undo_stack.append(self.buffer.copy()) # Cap undo stack at 50 entries if len(self.undo_stack) > 50: self.undo_stack.pop(0) def _mark_dirty(self) -> None: """Mark buffer as modified.""" self.dirty = True def _view_buffer(self) -> str: """Return formatted view of entire buffer.""" if not self.buffer: return "(empty buffer)" # Use syntax highlighting if content_type is not "text" if self.content_type != "text": content = "\n".join(self.buffer) highlighted = highlight( content, language=self.content_type, color_depth=self.color_depth, line_numbers=True, ) # If highlight() returned the original (unknown language), fall through if "\033[" in highlighted: return highlighted # Fall back to plain line-number formatting max_line_num = len(self.buffer) padding = len(str(max_line_num)) lines = [] for i, line in enumerate(self.buffer, 1): lines.append(f"{i:>{padding}} {line}") return "\n".join(lines) def _view_line(self, line_num: int) -> str: """Return formatted view of specific line with context.""" if line_num < 1 or line_num > len(self.buffer): return f"Error: Line {line_num} does not exist" # Show 3 lines of context on each side context = 3 start = max(0, line_num - 1 - context) end = min(len(self.buffer), line_num + context) # Use syntax highlighting if content_type is not "text" if self.content_type != "text": # Get the context window context_lines = self.buffer[start:end] content = "\n".join(context_lines) highlighted = highlight( content, language=self.content_type, color_depth=self.color_depth, line_numbers=True, ) # If highlight() returned highlighted content, use it if "\033[" in highlighted: return highlighted # Fall back to plain line-number formatting max_line_num = end padding = len(str(max_line_num)) lines = [] for i in range(start, end): display_num = i + 1 lines.append(f"{display_num:>{padding}} {self.buffer[i]}") return "\n".join(lines) def _insert_line(self, line_num: int, text: str) -> str: """Insert text at specified line number.""" if line_num < 1: return "Error: Line number must be >= 1" self._save_undo_state() # Insert at end if line_num is beyond buffer if line_num > len(self.buffer): self.buffer.append(text) self.cursor = len(self.buffer) - 1 else: self.buffer.insert(line_num - 1, text) self.cursor = line_num - 1 self._mark_dirty() return f"Inserted at line {line_num}" def _delete_line(self, start: int, end: int | None = None) -> str: """Delete line(s) at specified range.""" if start < 1 or start > len(self.buffer): return f"Error: Line {start} does not exist" self._save_undo_state() if end is None: # Delete single line self.buffer.pop(start - 1) self.cursor = min(start - 1, len(self.buffer) - 1) self._mark_dirty() return f"Deleted line {start}" else: # Delete range if end < start or end > len(self.buffer): return f"Error: Invalid range {start}-{end}" # Delete from start to end (inclusive) del self.buffer[start - 1 : end] self.cursor = min(start - 1, len(self.buffer) - 1) self._mark_dirty() return f"Deleted lines {start}-{end}" def _replace_line(self, line_num: int, text: str) -> str: """Replace line at specified number with new text.""" if line_num < 1 or line_num > len(self.buffer): return f"Error: Line {line_num} does not exist" self._save_undo_state() self.buffer[line_num - 1] = text self._mark_dirty() return f"Replaced line {line_num}" def _search_replace(self, old: str, new: str, replace_all: bool = False) -> str: """Search and replace text in buffer.""" self._save_undo_state() count = 0 for i, line in enumerate(self.buffer): if old in line: if replace_all: self.buffer[i] = line.replace(old, new) count += line.count(old) else: self.buffer[i] = line.replace(old, new, 1) count = 1 self._mark_dirty() return f"Replaced '{old}' with '{new}' on line {i + 1}" if count > 0: self._mark_dirty() return f"Replaced {count} occurrence(s) of '{old}' with '{new}'" else: # Restore undo state since nothing changed self.undo_stack.pop() return f"Not found: '{old}'" def _undo(self) -> str: """Undo last buffer change.""" if not self.undo_stack: return "Nothing to undo" self.buffer = self.undo_stack.pop() # Conservative approach: if we undid something, the buffer is now different # from what was saved (even if we undo past a save point) self.dirty = True # Reset cursor to end of buffer after undo self.cursor = len(self.buffer) - 1 if self.buffer else 0 return "Undone last change" async def _save(self) -> str: """Save buffer content via callback.""" if self.save_callback is None: return "Error: Cannot save (no save callback configured)" content = "\n".join(self.buffer) await self.save_callback(content) self.dirty = False return "Saved" def _help(self) -> str: """Return help text.""" return """Editor Commands: : - view entire buffer with line numbers :N - view line N with context :h - show this help :i N text - insert 'text' at line N :d N - delete line N :d N M - delete lines N through M :r N text - replace line N with 'text' :s old new - search and replace first occurrence :sa old new - search and replace all occurrences :w - save :wq - save and quit :q - quit (warns if unsaved changes) :q! - force quit without saving :u - undo last change . - save and quit (MOO convention) Any line not starting with : is appended to the buffer.""" async def handle_input(self, line: str) -> EditorResponse: """Handle editor input and return response.""" # Special case: dot on its own saves and quits if line == ".": if self.save_callback is None: return EditorResponse( output="Error: Cannot save (no save callback configured)", done=False, ) save_msg = await self._save() return EditorResponse(output=save_msg, done=True, saved=True) # Check if it's a command if not line.startswith(":"): # Append to buffer self._save_undo_state() self.buffer.append(line) self.cursor = len(self.buffer) - 1 self._mark_dirty() return EditorResponse(output="", done=False) # Parse command cmd = line[1:].strip() # Empty command - view buffer if not cmd: output = self._view_buffer() return EditorResponse(output=output, done=False) # Check for numeric line view if cmd.isdigit(): line_num = int(cmd) output = self._view_line(line_num) return EditorResponse(output=output, done=False) # Parse command parts parts = cmd.split(None, 1) command = parts[0] args = parts[1] if len(parts) > 1 else "" # Help if command == "h": output = self._help() return EditorResponse(output=output, done=False) # Insert if command == "i": if not args: return EditorResponse( output="Error: :i requires line number and text", done=False ) parts = args.split(None, 1) if len(parts) < 2: return EditorResponse( output="Error: :i requires line number and text", done=False ) try: line_num = int(parts[0]) text = parts[1] output = self._insert_line(line_num, text) return EditorResponse(output=output, done=False) except ValueError: return EditorResponse(output="Error: Invalid line number", done=False) # Delete if command == "d": if not args: return EditorResponse( output="Error: :d requires line number(s)", done=False ) parts = args.split() try: start = int(parts[0]) end = int(parts[1]) if len(parts) > 1 else None output = self._delete_line(start, end) return EditorResponse(output=output, done=False) except (ValueError, IndexError): return EditorResponse( output="Error: Invalid line number(s)", done=False ) # Replace if command == "r": if not args: return EditorResponse( output="Error: :r requires line number and text", done=False ) parts = args.split(None, 1) if len(parts) < 2: return EditorResponse( output="Error: :r requires line number and text", done=False ) try: line_num = int(parts[0]) text = parts[1] output = self._replace_line(line_num, text) return EditorResponse(output=output, done=False) except ValueError: return EditorResponse(output="Error: Invalid line number", done=False) # Search and replace if command == "s": split_args = args.split(None, 1) if len(split_args) < 2: return EditorResponse( output="Error: :s requires old and new text", done=False ) old = split_args[0] new = split_args[1] output = self._search_replace(old, new, replace_all=False) return EditorResponse(output=output, done=False) # Search and replace all if command == "sa": parts = args.split(None, 1) if len(parts) < 2: return EditorResponse( output="Error: :sa requires old and new text", done=False ) old = parts[0] new = parts[1] output = self._search_replace(old, new, replace_all=True) return EditorResponse(output=output, done=False) # Undo if command == "u": output = self._undo() return EditorResponse(output=output, done=False) # Save if command == "w": output = await self._save() return EditorResponse(output=output, done=False, saved=True) # Save and quit if command == "wq": output = await self._save() return EditorResponse(output=output, done=True, saved=True) # Quit if command == "q": if self.dirty: return EditorResponse( output=( "Unsaved changes! Use :wq to save and quit, " "or :q! to force quit" ), done=False, ) return EditorResponse(output="", done=True) # Force quit if command == "q!": return EditorResponse(output="", done=True) # Unknown command return EditorResponse(output=f"Unknown command: :{command}", done=False)