mud/src/mudlib/editor.py

403 lines
14 KiB
Python

"""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)