Add editor class with buffer, commands, and undo

This commit is contained in:
Jared Miller 2026-02-07 22:55:53 -05:00
parent 5e255f192c
commit 23507d0e70
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
2 changed files with 719 additions and 0 deletions

373
src/mudlib/editor.py Normal file
View file

@ -0,0 +1,373 @@
"""Text editor for in-game content editing."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
@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
dirty: bool = False
def __init__(
self,
initial_content: str = "",
save_callback: Callable[[str], Awaitable[None]] | None = None,
content_type: str = "text",
):
"""Initialize editor with optional content."""
self.save_callback = save_callback
self.content_type = content_type
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)"
# Calculate padding for line numbers
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)
# Calculate padding for line numbers
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)
else:
self.buffer.insert(line_num - 1, text)
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._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._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()
# Undo might restore clean state, but we'll keep dirty flag
# (conservative approach - user can save to clear it)
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._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":
parts = args.split(None, 1)
if len(parts) < 2:
return EditorResponse(
output="Error: :s requires old and new text", done=False
)
old_new = parts[1].split(None, 1) if len(parts) > 1 else []
if len(old_new) < 2:
# Try parsing the whole args as "old new"
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]
else:
old = parts[0]
new = old_new[1] if len(old_new) > 1 else old_new[0]
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)

346
tests/test_editor.py Normal file
View file

@ -0,0 +1,346 @@
"""Tests for the text editor."""
import pytest
from mudlib.editor import Editor
@pytest.mark.asyncio
async def test_create_editor_with_empty_buffer():
"""Editor starts with empty buffer."""
editor = Editor()
assert editor.buffer == []
assert editor.cursor == 0
assert editor.dirty is False
@pytest.mark.asyncio
async def test_create_editor_with_initial_content():
"""Editor can be initialized with content."""
editor = Editor(initial_content="line 1\nline 2\nline 3")
assert editor.buffer == ["line 1", "line 2", "line 3"]
assert editor.dirty is False
@pytest.mark.asyncio
async def test_append_line():
"""Typing text that isn't a command appends to buffer."""
editor = Editor()
response = await editor.handle_input("hello world")
assert response.output == ""
assert response.done is False
assert editor.buffer == ["hello world"]
assert editor.dirty is True
@pytest.mark.asyncio
async def test_append_multiple_lines():
"""Multiple lines can be appended."""
editor = Editor()
await editor.handle_input("line 1")
await editor.handle_input("line 2")
await editor.handle_input("line 3")
assert editor.buffer == ["line 1", "line 2", "line 3"]
assert editor.dirty is True
@pytest.mark.asyncio
async def test_view_buffer():
"""':' shows the entire buffer with line numbers."""
editor = Editor(initial_content="line 1\nline 2\nline 3")
response = await editor.handle_input(":")
assert "1 line 1" in response.output
assert "2 line 2" in response.output
assert "3 line 3" in response.output
@pytest.mark.asyncio
async def test_view_empty_buffer():
"""':' on empty buffer shows appropriate message."""
editor = Editor()
response = await editor.handle_input(":")
assert "empty" in response.output.lower() or response.output == ""
@pytest.mark.asyncio
async def test_view_specific_line():
"""':5' shows line 5 with context."""
editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 11)]))
response = await editor.handle_input(":5")
# Should show lines 2-8 (3 lines of context on each side)
assert "5 line 5" in response.output
assert "2 line 2" in response.output
assert "8 line 8" in response.output
@pytest.mark.asyncio
async def test_view_specific_line_at_start():
"""Viewing line near start shows available context."""
editor = Editor(initial_content="line 1\nline 2\nline 3")
response = await editor.handle_input(":1")
assert "1 line 1" in response.output
@pytest.mark.asyncio
async def test_insert_at_line():
"""':i 5 text' inserts 'text' at line 5."""
editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 6)]))
await editor.handle_input(":i 3 inserted text")
assert editor.buffer[2] == "inserted text"
assert editor.buffer[3] == "line 3"
assert len(editor.buffer) == 6
assert editor.dirty is True
@pytest.mark.asyncio
async def test_insert_at_end():
"""':i' can insert at end of buffer."""
editor = Editor(initial_content="line 1\nline 2")
await editor.handle_input(":i 3 line 3")
assert editor.buffer[2] == "line 3"
@pytest.mark.asyncio
async def test_delete_single_line():
"""':d 5' deletes line 5."""
editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 6)]))
await editor.handle_input(":d 3")
assert editor.buffer == ["line 1", "line 2", "line 4", "line 5"]
assert editor.dirty is True
@pytest.mark.asyncio
async def test_delete_range():
"""':d 5 8' deletes lines 5 through 8."""
editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 11)]))
await editor.handle_input(":d 3 6")
expected = ["line 1", "line 2", "line 7", "line 8", "line 9", "line 10"]
assert editor.buffer == expected
assert editor.dirty is True
@pytest.mark.asyncio
async def test_delete_invalid_line():
"""Deleting invalid line shows error."""
editor = Editor(initial_content="line 1\nline 2")
response = await editor.handle_input(":d 10")
assert "error" in response.output.lower() or "invalid" in response.output.lower()
assert editor.buffer == ["line 1", "line 2"]
@pytest.mark.asyncio
async def test_replace_line():
"""':r 5 text' replaces line 5 with 'text'."""
editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 6)]))
await editor.handle_input(":r 3 replaced text")
assert editor.buffer[2] == "replaced text"
assert len(editor.buffer) == 5
assert editor.dirty is True
@pytest.mark.asyncio
async def test_search_and_replace_first():
"""':s old new' replaces first occurrence."""
editor = Editor(initial_content="hello world\nhello again\nhello there")
await editor.handle_input(":s hello hi")
assert editor.buffer[0] == "hi world"
assert editor.buffer[1] == "hello again"
assert editor.dirty is True
@pytest.mark.asyncio
async def test_search_and_replace_all():
"""':sa old new' replaces all occurrences."""
editor = Editor(initial_content="hello world\nhello again\nhello there")
await editor.handle_input(":sa hello hi")
assert editor.buffer[0] == "hi world"
assert editor.buffer[1] == "hi again"
assert editor.buffer[2] == "hi there"
assert editor.dirty is True
@pytest.mark.asyncio
async def test_search_replace_no_match():
"""Search/replace with no match shows message."""
editor = Editor(initial_content="hello world")
response = await editor.handle_input(":s foo bar")
assert (
"not found" in response.output.lower() or "no match" in response.output.lower()
)
@pytest.mark.asyncio
async def test_undo():
"""':u' undoes last buffer change."""
editor = Editor(initial_content="line 1\nline 2")
await editor.handle_input("line 3")
assert len(editor.buffer) == 3
response = await editor.handle_input(":u")
assert editor.buffer == ["line 1", "line 2"]
assert "undo" in response.output.lower()
@pytest.mark.asyncio
async def test_undo_multiple_times():
"""Undo can be called multiple times."""
editor = Editor(initial_content="line 1")
await editor.handle_input("line 2")
await editor.handle_input("line 3")
await editor.handle_input(":u")
await editor.handle_input(":u")
assert editor.buffer == ["line 1"]
@pytest.mark.asyncio
async def test_undo_when_empty():
"""Undo with no history shows message."""
editor = Editor()
response = await editor.handle_input(":u")
assert (
"nothing to undo" in response.output.lower()
or "no undo" in response.output.lower()
)
@pytest.mark.asyncio
async def test_save():
"""':w' calls save callback."""
saved_content = None
async def save_callback(content: str):
nonlocal saved_content
saved_content = content
editor = Editor(initial_content="line 1\nline 2", save_callback=save_callback)
await editor.handle_input("line 3")
response = await editor.handle_input(":w")
assert response.saved is True
assert response.done is False
assert saved_content == "line 1\nline 2\nline 3"
assert editor.dirty is False
@pytest.mark.asyncio
async def test_save_without_callback():
"""':w' without callback shows error."""
editor = Editor(initial_content="line 1")
response = await editor.handle_input(":w")
assert (
"cannot save" in response.output.lower() or "no save" in response.output.lower()
)
@pytest.mark.asyncio
async def test_save_and_quit():
"""':wq' saves and quits."""
saved_content = None
async def save_callback(content: str):
nonlocal saved_content
saved_content = content
editor = Editor(initial_content="line 1", save_callback=save_callback)
response = await editor.handle_input(":wq")
assert response.saved is True
assert response.done is True
assert saved_content == "line 1"
@pytest.mark.asyncio
async def test_quit_when_dirty_warns():
"""':q' when dirty warns and doesn't quit."""
editor = Editor(initial_content="line 1")
await editor.handle_input("line 2")
response = await editor.handle_input(":q")
assert response.done is False
assert "unsaved" in response.output.lower() or "save" in response.output.lower()
@pytest.mark.asyncio
async def test_quit_when_clean():
"""':q' when clean quits successfully."""
editor = Editor(initial_content="line 1")
response = await editor.handle_input(":q")
assert response.done is True
@pytest.mark.asyncio
async def test_force_quit():
"""':q!' quits without saving."""
editor = Editor(initial_content="line 1")
await editor.handle_input("line 2")
response = await editor.handle_input(":q!")
assert response.done is True
assert response.saved is False
@pytest.mark.asyncio
async def test_dot_saves_and_quits():
"""'.' on its own line saves and quits (MOO convention)."""
saved_content = None
async def save_callback(content: str):
nonlocal saved_content
saved_content = content
editor = Editor(initial_content="line 1", save_callback=save_callback)
await editor.handle_input("line 2")
response = await editor.handle_input(".")
assert response.done is True
assert response.saved is True
assert saved_content == "line 1\nline 2"
@pytest.mark.asyncio
async def test_dirty_flag_tracking():
"""Dirty flag is properly tracked."""
editor = Editor(initial_content="line 1")
assert editor.dirty is False
await editor.handle_input("line 2")
assert editor.dirty is True
# Mock save
saved_content = None
async def save_callback(content: str):
nonlocal saved_content
saved_content = content
editor.save_callback = save_callback
await editor.handle_input(":w")
assert editor.dirty is False
@pytest.mark.asyncio
async def test_help():
"""':h' shows help."""
editor = Editor()
response = await editor.handle_input(":h")
assert ":w" in response.output
assert ":q" in response.output
assert ":i" in response.output
assert ":d" in response.output
@pytest.mark.asyncio
async def test_undo_stack_size_limit():
"""Undo stack is capped at 50 entries."""
editor = Editor()
# Add 60 lines
for i in range(60):
await editor.handle_input(f"line {i}")
# Undo stack should be capped
assert len(editor.undo_stack) <= 50
@pytest.mark.asyncio
async def test_line_numbers_padded():
"""Line numbers in view are left-padded."""
editor = Editor(initial_content="\n".join([f"line {i}" for i in range(1, 12)]))
response = await editor.handle_input(":")
# Line 1 should have padding, line 11 should not have extra padding
lines = response.output.split("\n")
# Find lines with numbers
assert any(" 1 line 1" in line for line in lines)
assert any("11 line 11" in line for line in lines)