diff --git a/src/mudlib/commands/edit.py b/src/mudlib/commands/edit.py new file mode 100644 index 0000000..c0b3959 --- /dev/null +++ b/src/mudlib/commands/edit.py @@ -0,0 +1,28 @@ +"""Edit command for entering the text editor.""" + +from mudlib.commands import CommandDefinition, register +from mudlib.editor import Editor +from mudlib.player import Player + + +async def cmd_edit(player: Player, args: str) -> None: + """Enter the text editor. + + Args: + player: The player executing the command + args: Command arguments (unused for now) + """ + + async def save_callback(content: str) -> None: + await player.send("Content saved.\r\n") + + player.editor = Editor( + save_callback=save_callback, + content_type="text", + ) + player.mode_stack.append("editor") + await player.send("Entering editor. Type :h for help.\r\n") + + +# Register the edit command +register(CommandDefinition("edit", cmd_edit, mode="normal")) diff --git a/src/mudlib/player.py b/src/mudlib/player.py index 61fcef6..42ca584 100644 --- a/src/mudlib/player.py +++ b/src/mudlib/player.py @@ -1,11 +1,16 @@ """Player state and registry.""" +from __future__ import annotations + from dataclasses import dataclass, field -from typing import Any +from typing import TYPE_CHECKING, Any from mudlib.caps import ClientCaps from mudlib.entity import Entity +if TYPE_CHECKING: + from mudlib.editor import Editor + @dataclass class Player(Entity): @@ -16,6 +21,7 @@ class Player(Entity): flying: bool = False mode_stack: list[str] = field(default_factory=lambda: ["normal"]) caps: ClientCaps = field(default_factory=lambda: ClientCaps(ansi=True)) + editor: Editor | None = None @property def mode(self) -> str: diff --git a/src/mudlib/server.py b/src/mudlib/server.py index a29b4c3..7e7dc24 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -11,6 +11,7 @@ import telnetlib3 from telnetlib3.server_shell import readline2 import mudlib.commands +import mudlib.commands.edit import mudlib.commands.fly import mudlib.commands.look import mudlib.commands.movement @@ -297,7 +298,11 @@ async def shell( # Command loop try: while not _writer.is_closing(): - _writer.write("mud> ") + # Show appropriate prompt based on mode + if player.mode == "editor" and player.editor: + _writer.write(f" {player.editor.cursor + 1}> ") + else: + _writer.write("mud> ") await _writer.drain() inp = await readline2(_reader, _writer) @@ -305,11 +310,20 @@ async def shell( break command = inp.strip() - if not command: + if not command and player.mode != "editor": continue - # Dispatch command - await mudlib.commands.dispatch(player, command) + # Handle editor mode + if player.mode == "editor" and player.editor: + response = await player.editor.handle_input(inp) + if response.output: + await player.send(response.output) + if response.done: + player.editor = None + player.mode_stack.pop() + else: + # Dispatch normal command + await mudlib.commands.dispatch(player, command) # Check if writer was closed by quit command if _writer.is_closing(): diff --git a/tests/test_commands.py b/tests/test_commands.py index a742e52..0c6d86b 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -138,6 +138,11 @@ async def test_movement_updates_position(player, mock_world): movement.world = mock_world look.world = mock_world + # Clear players registry to avoid test pollution + from mudlib.player import players + + players.clear() + original_x, original_y = player.x, player.y await movement.move_north(player, "") diff --git a/tests/test_editor_integration.py b/tests/test_editor_integration.py new file mode 100644 index 0000000..e9c3e6c --- /dev/null +++ b/tests/test_editor_integration.py @@ -0,0 +1,167 @@ +"""Tests for editor integration with the shell and command system.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib import commands +from mudlib.commands.edit import cmd_edit +from mudlib.editor import Editor +from mudlib.player import Player + + +@pytest.fixture +def mock_writer(): + writer = MagicMock() + writer.write = MagicMock() + writer.drain = AsyncMock() + return writer + + +@pytest.fixture +def mock_reader(): + return MagicMock() + + +@pytest.fixture +def player(mock_reader, mock_writer): + return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer) + + +@pytest.mark.asyncio +async def test_edit_command_enters_editor_mode(player): + """Test that edit command sets up editor mode.""" + assert player.mode == "normal" + assert player.editor is None + + await cmd_edit(player, "") + + assert player.mode == "editor" + assert player.editor is not None + assert isinstance(player.editor, Editor) + assert player.mode_stack == ["normal", "editor"] + + +@pytest.mark.asyncio +async def test_edit_command_sends_welcome_message(player, mock_writer): + """Test that edit command sends welcome message.""" + await cmd_edit(player, "") + + assert mock_writer.write.called + output = "".join([call[0][0] for call in mock_writer.write.call_args_list]) + assert "editor" in output.lower() + assert ":h" in output + + +@pytest.mark.asyncio +async def test_edit_command_sets_save_callback(player): + """Test that edit command sets up a save callback.""" + await cmd_edit(player, "") + + assert player.editor is not None + assert player.editor.save_callback is not None + + +@pytest.mark.asyncio +async def test_editor_handle_input_preserves_whitespace(player): + """Test that editor receives input with whitespace preserved.""" + await cmd_edit(player, "") + + # Simulate code with indentation + response = await player.editor.handle_input(" def foo():") + + assert player.editor.buffer == [" def foo():"] + assert response.done is False + + +@pytest.mark.asyncio +async def test_editor_done_clears_editor_and_pops_mode(player): + """Test that when editor returns done=True, mode is popped and editor cleared.""" + await cmd_edit(player, "") + + assert player.mode == "editor" + assert player.editor is not None + + # Quit the editor + response = await player.editor.handle_input(":q") + assert response.done is True + + # Simulate what the shell loop should do + player.editor = None + player.mode_stack.pop() + + assert player.mode == "normal" + assert player.editor is None + + +@pytest.mark.asyncio +async def test_editor_save_callback_sends_message(player, mock_writer): + """Test that save callback sends confirmation to player.""" + await cmd_edit(player, "") + mock_writer.reset_mock() + + await player.editor.handle_input("test line") + response = await player.editor.handle_input(":w") + + assert response.saved is True + # Save callback should have sent a message + assert mock_writer.write.called + output = "".join([call[0][0] for call in mock_writer.write.call_args_list]) + assert "saved" in output.lower() + + +@pytest.mark.asyncio +async def test_edit_command_only_works_in_normal_mode(player): + """Test that edit command has mode='normal' restriction.""" + # Push a different mode onto the stack + player.mode_stack.append("combat") + + # Try to invoke edit command through dispatch + await commands.dispatch(player, "edit") + + # Should be blocked by mode check + assert player.mode == "combat" + assert player.editor is None + + +@pytest.mark.asyncio +async def test_mode_stack_push_and_pop(player): + """Test mode stack mechanics for editor mode.""" + assert player.mode_stack == ["normal"] + assert player.mode == "normal" + + # Enter editor mode + player.mode_stack.append("editor") + assert player.mode == "editor" + assert player.mode_stack == ["normal", "editor"] + + # Exit editor mode + player.mode_stack.pop() + assert player.mode == "normal" + assert player.mode_stack == ["normal"] + + +@pytest.mark.asyncio +async def test_empty_input_allowed_in_editor(player): + """Test that empty lines are valid in editor mode (for blank lines in code).""" + await cmd_edit(player, "") + + # Empty line should be appended to buffer + response = await player.editor.handle_input("") + assert response.done is False + assert player.editor.buffer == [""] + + +@pytest.mark.asyncio +async def test_editor_prompt_uses_cursor(player): + """Test that editor prompt should show line number from cursor.""" + await cmd_edit(player, "") + + # Add some lines + await player.editor.handle_input("line 1") + await player.editor.handle_input("line 2") + + # Cursor starts at 0. The prompt shows "cursor + 1" for 1-indexed display + # This test verifies the cursor field exists and can be used for prompts + assert player.editor.cursor == 0 + # Shell loop prompt: f" {player.editor.cursor + 1}> " = " 1> "