403 lines
14 KiB
Python
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)
|