Compare commits

...

8 commits

Author SHA1 Message Date
d3df09f4de
Fix editor search/replace parsing, dirty flag, and cursor tracking 2026-02-07 23:06:47 -05:00
0574457404
Add syntax highlighting to editor buffer display 2026-02-07 23:01:55 -05:00
a799b6716c
Add editor mode shell integration and edit command
Integrates the Editor class into the MUD server's shell loop, allowing
players to enter and use the text editor from the game.

Changes:
- Add editor field to Player dataclass
- Modify shell input loop to check player mode and route to editor
- Add edit command to enter editor mode from normal mode
- Use inp (not command.strip()) for editor to preserve indentation
- Show line-numbered prompt in editor mode
- Pop mode and clear editor when done=True
- Add comprehensive integration tests
- Fix test isolation issue in test_movement_updates_position
2026-02-07 22:59:37 -05:00
23507d0e70
Add editor class with buffer, commands, and undo 2026-02-07 22:55:53 -05:00
5e255f192c
Simplify test recipe 2026-02-07 22:49:01 -05:00
b0fcb080d3
Wire client capabilities into Player & terrain
Parse MTTS from telnetlib3 writer during connection and store capabilities
on Player.caps field. Add convenience property Player.color_depth that
delegates to caps.color_depth for easy access by rendering code.

Changes:
- Add caps field to Player with default 16-color ANSI capabilities
- Parse MTTS in server shell after Player creation using parse_mtts()
- Add Player.color_depth property for quick capability checks
- Add tests verifying Player caps integration and color_depth property
2026-02-07 22:44:45 -05:00
6549d09683
Add 256-color and truecolor SGR helpers
Extended ansi.py with fg_256, bg_256, fg_rgb, and bg_rgb functions
for generating 256-color and truecolor escape sequences. All functions
include value clamping to valid ranges (0-255).
2026-02-07 22:44:23 -05:00
388e693f8c
Add MTTS capability parsing module with client color detection
Parses MTTS bitfield values from telnetlib3 ttype3 into a ClientCaps dataclass.
Includes color_depth property that returns the best available color mode
(truecolor, 256, or 16) based on client capabilities.
2026-02-07 22:44:23 -05:00
19 changed files with 1799 additions and 31 deletions

View file

@ -43,12 +43,26 @@ for MUD clients — they prefer MTTS.
meanwhile, in the same negotiation, tintin++ already told us via MTTS: meanwhile, in the same negotiation, tintin++ already told us via MTTS:
ttype3: MTTS 2825 ttype3: MTTS 2825 = 0b101100001001
bit 0 (1) = ANSI color bit 0 (1) = ANSI
bit 3 (8) = UTF-8 bit 1 (2) = VT100
bit 8 (256) = 256 colors bit 2 (4) = UTF-8
bit 9 (512) = OSC color palette bit 3 (8) = 256 COLORS [SET]
bit 11 (2048) = true color bit 4 (16) = MOUSE TRACKING
bit 5 (32) = OSC COLOR PALETTE
bit 6 (64) = SCREEN READER
bit 7 (128) = PROXY
bit 8 (256) = TRUECOLOR [SET]
bit 9 (512) = MNES [SET]
bit 10 (1024) = MSLP
bit 11 (2048) = SSL [SET]
for MTTS 2825: bits 0, 3, 8, 9, 11 are set
NOTE: The original version of this doc had incorrect MTTS bit mappings (copied
from an unreliable source). This was corrected on 2026-02-07 to match the
actual MTTS spec from tintin.mudhalla.net/protocols/mtts/. The wrong values
caused a bug in caps.py that misinterpreted client capabilities.
two protocols, one answer. MTTS (via TTYPE round 3) is what the MUD ecosystem two protocols, one answer. MTTS (via TTYPE round 3) is what the MUD ecosystem
uses. RFC 2066 CHARSET is technically correct but practically ignored. uses. RFC 2066 CHARSET is technically correct but practically ignored.

View file

@ -6,13 +6,7 @@ typecheck:
uvx ty check uvx ty check
test: test:
uv run pytest uv run pytest -n auto --testmon
test-fast:
uv run pytest --testmon
test-parallel:
uv run pytest -n auto
check: lint typecheck test check: lint typecheck test

View file

@ -5,6 +5,7 @@ description = "a telnet mud engine"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"telnetlib3 @ file:///home/jtm/src/telnetlib3", "telnetlib3 @ file:///home/jtm/src/telnetlib3",
"pygments>=2.17.0",
] ]
[dependency-groups] [dependency-groups]

90
src/mudlib/caps.py Normal file
View file

@ -0,0 +1,90 @@
"""MTTS (Mud Terminal Type Standard) capability parsing.
Parses MTTS bitfield values from telnetlib3's ttype3 into a structured dataclass.
MTTS is advertised via TTYPE round 3 as "MTTS <number>" where the number is a
bitfield of client capabilities.
See docs/lessons/charset-vs-mtts.txt for protocol details.
"""
from dataclasses import dataclass
from typing import Literal
@dataclass
class ClientCaps:
"""Parsed client capabilities from MTTS bitfield."""
ansi: bool = False
vt100: bool = False
utf8: bool = False
colors_256: bool = False
mouse_tracking: bool = False
osc_color_palette: bool = False
screen_reader: bool = False
proxy: bool = False
truecolor: bool = False
mnes: bool = False
mslp: bool = False
ssl: bool = False
@property
def color_depth(self) -> Literal["truecolor", "256", "16"]:
"""Return best available color mode: truecolor, 256, or 16."""
if self.truecolor:
return "truecolor"
if self.colors_256:
return "256"
return "16"
def parse_mtts(ttype3: str | None) -> ClientCaps:
"""Parse MTTS capability string into ClientCaps dataclass.
Args:
ttype3: String from writer.get_extra_info("ttype3"), e.g., "MTTS 2825"
Returns:
ClientCaps with parsed capabilities. Returns defaults for
None/empty/invalid input.
Bit values from MTTS spec (tintin.mudhalla.net/protocols/mtts/):
Bit 0 (1) = ANSI
Bit 1 (2) = VT100
Bit 2 (4) = UTF-8
Bit 3 (8) = 256 COLORS
Bit 4 (16) = MOUSE TRACKING
Bit 5 (32) = OSC COLOR PALETTE
Bit 6 (64) = SCREEN READER
Bit 7 (128) = PROXY
Bit 8 (256) = TRUECOLOR
Bit 9 (512) = MNES
Bit 10 (1024) = MSLP
Bit 11 (2048) = SSL
"""
if not ttype3:
return ClientCaps()
parts = ttype3.split()
if len(parts) != 2 or parts[0] != "MTTS":
return ClientCaps()
try:
value = int(parts[1])
except ValueError:
return ClientCaps()
return ClientCaps(
ansi=bool(value & 1),
vt100=bool(value & 2),
utf8=bool(value & 4),
colors_256=bool(value & 8),
mouse_tracking=bool(value & 16),
osc_color_palette=bool(value & 32),
screen_reader=bool(value & 64),
proxy=bool(value & 128),
truecolor=bool(value & 256),
mnes=bool(value & 512),
mslp=bool(value & 1024),
ssl=bool(value & 2048),
)

View file

@ -0,0 +1,29 @@
"""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",
color_depth=player.color_depth,
)
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"))

View file

@ -64,10 +64,10 @@ async def cmd_look(player: Player, args: str) -> None:
for x, tile in enumerate(row): for x, tile in enumerate(row):
# Check if this is the player's position # Check if this is the player's position
if x == center_x and y == center_y: if x == center_x and y == center_y:
line.append(colorize_terrain("@")) line.append(colorize_terrain("@", player.color_depth))
# Check if this is another player's position # Check if this is another player's position
elif (x, y) in other_player_positions: elif (x, y) in other_player_positions:
line.append(colorize_terrain("*")) line.append(colorize_terrain("*", player.color_depth))
else: else:
# Check for active effects at this world position # Check for active effects at this world position
world_x, world_y = world.wrap( world_x, world_y = world.wrap(
@ -80,7 +80,7 @@ async def cmd_look(player: Player, args: str) -> None:
e = effects[-1] e = effects[-1]
line.append(f"{e.color}{e.char}{RESET}") line.append(f"{e.color}{e.char}{RESET}")
else: else:
line.append(colorize_terrain(tile)) line.append(colorize_terrain(tile, player.color_depth))
output_lines.append("".join(line)) output_lines.append("".join(line))
# Send to player # Send to player

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

@ -0,0 +1,403 @@
"""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)

View file

@ -1,10 +1,16 @@
"""Player state and registry.""" """Player state and registry."""
from dataclasses import dataclass, field from __future__ import annotations
from typing import Any
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from mudlib.caps import ClientCaps
from mudlib.entity import Entity from mudlib.entity import Entity
if TYPE_CHECKING:
from mudlib.editor import Editor
@dataclass @dataclass
class Player(Entity): class Player(Entity):
@ -14,12 +20,19 @@ class Player(Entity):
reader: Any = None # telnetlib3 TelnetReader for reading input reader: Any = None # telnetlib3 TelnetReader for reading input
flying: bool = False flying: bool = False
mode_stack: list[str] = field(default_factory=lambda: ["normal"]) mode_stack: list[str] = field(default_factory=lambda: ["normal"])
caps: ClientCaps = field(default_factory=lambda: ClientCaps(ansi=True))
editor: Editor | None = None
@property @property
def mode(self) -> str: def mode(self) -> str:
"""Current mode is the top of the stack.""" """Current mode is the top of the stack."""
return self.mode_stack[-1] return self.mode_stack[-1]
@property
def color_depth(self) -> str:
"""Best available color mode: truecolor, 256, or 16."""
return self.caps.color_depth
async def send(self, message: str) -> None: async def send(self, message: str) -> None:
"""Send a message to the player via their telnet writer.""" """Send a message to the player via their telnet writer."""
self.writer.write(message) self.writer.write(message)

View file

@ -24,7 +24,68 @@ BRIGHT_MAGENTA = "\033[95m"
BRIGHT_CYAN = "\033[96m" BRIGHT_CYAN = "\033[96m"
BRIGHT_WHITE = "\033[97m" BRIGHT_WHITE = "\033[97m"
# terrain color mapping
def fg_256(n: int) -> str:
"""Generate 256-color foreground escape code.
Args:
n: Color index (0-255). Values outside range are clamped.
Returns:
ANSI escape sequence for 256-color foreground.
"""
clamped = max(0, min(255, n))
return f"\033[38;5;{clamped}m"
def bg_256(n: int) -> str:
"""Generate 256-color background escape code.
Args:
n: Color index (0-255). Values outside range are clamped.
Returns:
ANSI escape sequence for 256-color background.
"""
clamped = max(0, min(255, n))
return f"\033[48;5;{clamped}m"
def fg_rgb(r: int, g: int, b: int) -> str:
"""Generate truecolor foreground escape code.
Args:
r: Red component (0-255). Values outside range are clamped.
g: Green component (0-255). Values outside range are clamped.
b: Blue component (0-255). Values outside range are clamped.
Returns:
ANSI escape sequence for truecolor foreground.
"""
r_clamped = max(0, min(255, r))
g_clamped = max(0, min(255, g))
b_clamped = max(0, min(255, b))
return f"\033[38;2;{r_clamped};{g_clamped};{b_clamped}m"
def bg_rgb(r: int, g: int, b: int) -> str:
"""Generate truecolor background escape code.
Args:
r: Red component (0-255). Values outside range are clamped.
g: Green component (0-255). Values outside range are clamped.
b: Blue component (0-255). Values outside range are clamped.
Returns:
ANSI escape sequence for truecolor background.
"""
r_clamped = max(0, min(255, r))
g_clamped = max(0, min(255, g))
b_clamped = max(0, min(255, b))
return f"\033[48;2;{r_clamped};{g_clamped};{b_clamped}m"
# terrain color mapping (16-color baseline)
TERRAIN_COLORS = { TERRAIN_COLORS = {
".": GREEN, # grass ".": GREEN, # grass
"^": BRIGHT_BLACK, # mountain "^": BRIGHT_BLACK, # mountain
@ -35,19 +96,63 @@ TERRAIN_COLORS = {
"*": BOLD + BRIGHT_RED, # other entity "*": BOLD + BRIGHT_RED, # other entity
} }
# 256-color terrain palette
TERRAIN_COLORS_256 = {
".": fg_256(34), # grass - richer green
"^": fg_256(242), # mountain - gray
"~": fg_256(27), # water - deeper blue
"T": fg_256(22), # forest - darker green
":": fg_256(221), # sand - warm yellow
"@": BOLD + fg_256(231), # player - bright white
"*": BOLD + fg_256(196), # other entity - bright red
}
# truecolor terrain palette
TERRAIN_COLORS_RGB = {
".": fg_rgb(76, 175, 80), # grass - material green
"^": fg_rgb(96, 125, 139), # mountain - blue-gray
"~": fg_rgb(33, 150, 243), # water - material blue
"T": fg_rgb(27, 94, 32), # forest - dark green
":": fg_rgb(255, 235, 59), # sand - bright yellow
"@": BOLD + fg_rgb(255, 255, 255), # player - pure white
"*": BOLD + fg_rgb(244, 67, 54), # other entity - material red
}
def colorize_terrain(char: str, color_depth: str = "16") -> str:
"""Return ANSI-colored version of terrain character.
Args:
char: Terrain character to colorize
color_depth: Color mode - "16", "256", or "truecolor"
Returns:
ANSI-colored character with reset code, or unchanged char if no color mapping.
"""
if color_depth == "truecolor":
color = TERRAIN_COLORS_RGB.get(char, "")
elif color_depth == "256":
color = TERRAIN_COLORS_256.get(char, "")
else:
color = TERRAIN_COLORS.get(char, "")
def colorize_terrain(char: str) -> str:
"""Return ANSI-colored version of terrain character."""
color = TERRAIN_COLORS.get(char, "")
if color: if color:
return f"{color}{char}{RESET}" return f"{color}{char}{RESET}"
return char return char
def colorize_map(grid: list[list[str]]) -> str: def colorize_map(grid: list[list[str]], color_depth: str = "16") -> str:
"""Colorize a 2D grid of terrain and return as string.""" """Colorize a 2D grid of terrain and return as string.
Args:
grid: 2D list of terrain characters
color_depth: Color mode - "16", "256", or "truecolor"
Returns:
Newline-separated string of colorized terrain rows.
"""
lines = [] lines = []
for row in grid: for row in grid:
colored_row = "".join(colorize_terrain(char) for char in row) colored_row = "".join(colorize_terrain(char, color_depth) for char in row)
lines.append(colored_row) lines.append(colored_row)
return "\n".join(lines) return "\n".join(lines)

View file

@ -0,0 +1,78 @@
"""Syntax highlighting using Pygments with ANSI color depth awareness."""
from pygments import highlight as pygments_highlight
from pygments.formatters.terminal import TerminalFormatter
from pygments.formatters.terminal256 import Terminal256Formatter
from pygments.lexers import get_lexer_by_name
from pygments.util import ClassNotFound
def highlight(
code: str,
language: str = "python",
color_depth: str = "16",
line_numbers: bool = False,
) -> str:
"""Highlight code with syntax coloring appropriate for client's terminal.
Args:
code: Source code to highlight
language: Language name (python, toml, etc.)
color_depth: Client color capability ("truecolor", "256", or "16")
line_numbers: Whether to prefix lines with line numbers
Returns:
ANSI-colored code string, or original code if language is unknown.
Trailing newlines are stripped for clean telnet display.
"""
if not code:
return ""
# Try to get the appropriate lexer
try:
lexer = get_lexer_by_name(language)
except ClassNotFound:
# Unknown language, return original text
return code
# Choose formatter based on color depth
# Use 256-color formatter for both "256" and "truecolor"
# Pygments doesn't have a true truecolor formatter, 256 is the best we have
formatter = TerminalFormatter() if color_depth == "16" else Terminal256Formatter()
# Apply syntax highlighting
result = pygments_highlight(code, lexer, formatter)
# Strip trailing newline that Pygments adds
if result.endswith("\n"):
result = result[:-1]
# Add line numbers if requested
if line_numbers:
result = _add_line_numbers(result)
return result
def _add_line_numbers(highlighted_code: str) -> str:
"""Add line numbers to highlighted code using ANSI dim styling.
Args:
highlighted_code: ANSI-colored code string
Returns:
Code with line numbers prefixed to each line
"""
lines = highlighted_code.split("\n")
numbered_lines = []
# ANSI dim styling for line numbers
dim = "\033[2m"
reset = "\033[0m"
for i, line in enumerate(lines, start=1):
# Format: dim line number, reset, then the code
numbered_line = f"{dim}{i:3d}{reset} {line}"
numbered_lines.append(numbered_line)
return "\n".join(numbered_lines)

View file

@ -11,10 +11,12 @@ import telnetlib3
from telnetlib3.server_shell import readline2 from telnetlib3.server_shell import readline2
import mudlib.commands import mudlib.commands
import mudlib.commands.edit
import mudlib.commands.fly import mudlib.commands.fly
import mudlib.commands.look import mudlib.commands.look
import mudlib.commands.movement import mudlib.commands.movement
import mudlib.commands.quit import mudlib.commands.quit
from mudlib.caps import parse_mtts
from mudlib.combat.commands import register_combat_commands from mudlib.combat.commands import register_combat_commands
from mudlib.combat.engine import process_combat from mudlib.combat.engine import process_combat
from mudlib.content import load_commands from mudlib.content import load_commands
@ -267,6 +269,16 @@ async def shell(
reader=_reader, reader=_reader,
) )
# Parse and store client capabilities from MTTS
ttype3 = _writer.get_extra_info("ttype3")
player.caps = parse_mtts(ttype3)
log.debug(
"%s capabilities: %s (color_depth=%s)",
player_name,
player.caps,
player.color_depth,
)
# Register player # Register player
players[player_name] = player players[player_name] = player
log.info( log.info(
@ -286,7 +298,11 @@ async def shell(
# Command loop # Command loop
try: try:
while not _writer.is_closing(): 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() await _writer.drain()
inp = await readline2(_reader, _writer) inp = await readline2(_reader, _writer)
@ -294,11 +310,20 @@ async def shell(
break break
command = inp.strip() command = inp.strip()
if not command: if not command and player.mode != "editor":
continue continue
# Dispatch command # Handle editor mode
await mudlib.commands.dispatch(player, command) 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 # Check if writer was closed by quit command
if _writer.is_closing(): if _writer.is_closing():

View file

@ -1,4 +1,12 @@
from mudlib.render.ansi import RESET, colorize_map, colorize_terrain from mudlib.render.ansi import (
RESET,
bg_256,
bg_rgb,
colorize_map,
colorize_terrain,
fg_256,
fg_rgb,
)
def test_colorize_terrain_grass(): def test_colorize_terrain_grass():
@ -45,3 +53,109 @@ def test_colorize_map():
# should have ANSI codes # should have ANSI codes
assert "\033[" in result assert "\033[" in result
def test_fg_256_basic():
"""Generate 256-color foreground code."""
assert fg_256(0) == "\033[38;5;0m"
assert fg_256(15) == "\033[38;5;15m"
assert fg_256(42) == "\033[38;5;42m"
assert fg_256(255) == "\033[38;5;255m"
def test_bg_256_basic():
"""Generate 256-color background code."""
assert bg_256(0) == "\033[48;5;0m"
assert bg_256(15) == "\033[48;5;15m"
assert bg_256(42) == "\033[48;5;42m"
assert bg_256(255) == "\033[48;5;255m"
def test_fg_256_clamping():
"""Clamp 256-color values to valid range (0-255)."""
assert fg_256(-1) == "\033[38;5;0m"
assert fg_256(256) == "\033[38;5;255m"
assert fg_256(999) == "\033[38;5;255m"
def test_bg_256_clamping():
"""Clamp 256-color values to valid range (0-255)."""
assert bg_256(-1) == "\033[48;5;0m"
assert bg_256(256) == "\033[48;5;255m"
assert bg_256(999) == "\033[48;5;255m"
def test_fg_rgb_basic():
"""Generate truecolor foreground code."""
assert fg_rgb(0, 0, 0) == "\033[38;2;0;0;0m"
assert fg_rgb(255, 255, 255) == "\033[38;2;255;255;255m"
assert fg_rgb(128, 64, 192) == "\033[38;2;128;64;192m"
def test_bg_rgb_basic():
"""Generate truecolor background code."""
assert bg_rgb(0, 0, 0) == "\033[48;2;0;0;0m"
assert bg_rgb(255, 255, 255) == "\033[48;2;255;255;255m"
assert bg_rgb(128, 64, 192) == "\033[48;2;128;64;192m"
def test_fg_rgb_clamping():
"""Clamp RGB values to valid range (0-255)."""
assert fg_rgb(-1, 0, 0) == "\033[38;2;0;0;0m"
assert fg_rgb(0, 300, 0) == "\033[38;2;0;255;0m"
assert fg_rgb(0, 0, 999) == "\033[38;2;0;0;255m"
assert fg_rgb(-10, 300, 500) == "\033[38;2;0;255;255m"
def test_bg_rgb_clamping():
"""Clamp RGB values to valid range (0-255)."""
assert bg_rgb(-1, 0, 0) == "\033[48;2;0;0;0m"
assert bg_rgb(0, 300, 0) == "\033[48;2;0;255;0m"
assert bg_rgb(0, 0, 999) == "\033[48;2;0;0;255m"
assert bg_rgb(-10, 300, 500) == "\033[48;2;0;255;255m"
def test_colorize_terrain_default_16color():
"""Default color_depth uses 16-color palette."""
result = colorize_terrain(".")
assert "\033[32m" in result # GREEN
assert "." in result
assert RESET in result
def test_colorize_terrain_256color():
"""color_depth=256 uses 256-color palette."""
result = colorize_terrain(".", "256")
assert "\033[38;5;" in result # 256-color code
assert "." in result
assert RESET in result
def test_colorize_terrain_truecolor():
"""color_depth=truecolor uses RGB palette."""
result = colorize_terrain(".", "truecolor")
assert "\033[38;2;" in result # truecolor code
assert "." in result
assert RESET in result
def test_colorize_map_default_16color():
"""colorize_map with default uses 16-color palette."""
grid = [[".", "~"], ["^", "T"]]
result = colorize_map(grid)
assert "\033[32m" in result # GREEN for grass
assert "\033[34m" in result # BLUE for water
def test_colorize_map_256color():
"""colorize_map with color_depth=256 uses 256-color palette."""
grid = [[".", "~"]]
result = colorize_map(grid, "256")
assert "\033[38;5;" in result # 256-color codes present
def test_colorize_map_truecolor():
"""colorize_map with color_depth=truecolor uses RGB palette."""
grid = [[".", "~"]]
result = colorize_map(grid, "truecolor")
assert "\033[38;2;" in result # truecolor codes present

117
tests/test_caps.py Normal file
View file

@ -0,0 +1,117 @@
"""Tests for MTTS capability parsing."""
from mudlib.caps import parse_mtts
def test_parse_mtts_tintin():
"""Parse tintin++ MTTS 2825 correctly.
2825 in binary = 0b101100001001
Bits set: 0, 3, 8, 9, 11
= ANSI(1) + 256colors(8) + truecolor(256) + MNES(512) + SSL(2048)
"""
caps = parse_mtts("MTTS 2825")
assert caps.ansi is True
assert caps.vt100 is False
assert caps.utf8 is False
assert caps.colors_256 is True
assert caps.mouse_tracking is False
assert caps.osc_color_palette is False
assert caps.screen_reader is False
assert caps.proxy is False
assert caps.truecolor is True
assert caps.mnes is True
assert caps.mslp is False
assert caps.ssl is True
assert caps.color_depth == "truecolor"
def test_parse_mtts_basic():
"""Parse MTTS 137 (ANSI + 256colors + proxy)."""
# 137 = 1 (ANSI) + 8 (256colors) + 128 (proxy)
caps = parse_mtts("MTTS 137")
assert caps.ansi is True
assert caps.utf8 is False
assert caps.colors_256 is True
assert caps.proxy is True
assert caps.truecolor is False
assert caps.color_depth == "256"
def test_parse_mtts_zero():
"""Parse MTTS 0 (nothing supported)."""
caps = parse_mtts("MTTS 0")
assert caps.ansi is False
assert caps.utf8 is False
assert caps.colors_256 is False
assert caps.truecolor is False
assert caps.color_depth == "16"
def test_parse_mtts_256_colors_only():
"""Parse MTTS with JUST 256 colors (no truecolor)."""
# 9 = 1 (ANSI) + 8 (256colors)
caps = parse_mtts("MTTS 9")
assert caps.ansi is True
assert caps.utf8 is False
assert caps.colors_256 is True
assert caps.truecolor is False
assert caps.color_depth == "256"
def test_parse_mtts_utf8_no_colors():
"""Parse MTTS with UTF-8 but no extended colors."""
# 5 = 1 (ANSI) + 4 (UTF-8)
caps = parse_mtts("MTTS 5")
assert caps.ansi is True
assert caps.utf8 is True
assert caps.colors_256 is False
assert caps.truecolor is False
assert caps.color_depth == "16"
def test_parse_mtts_empty():
"""Handle empty string gracefully."""
caps = parse_mtts("")
assert caps.ansi is False
assert caps.utf8 is False
assert caps.color_depth == "16"
def test_parse_mtts_none():
"""Handle None gracefully."""
caps = parse_mtts(None)
assert caps.ansi is False
assert caps.utf8 is False
assert caps.color_depth == "16"
def test_parse_mtts_non_mtts():
"""Handle non-MTTS ttype3 strings (e.g., UNKNOWN-TERMINAL)."""
caps = parse_mtts("UNKNOWN-TERMINAL")
assert caps.ansi is False
assert caps.utf8 is False
assert caps.color_depth == "16"
def test_parse_mtts_malformed():
"""Handle malformed MTTS strings."""
caps = parse_mtts("MTTS")
assert caps.ansi is False
assert caps.utf8 is False
assert caps.color_depth == "16"
def test_color_depth_priority():
"""Verify color_depth returns best available mode."""
# truecolor wins
caps = parse_mtts("MTTS 2825")
assert caps.color_depth == "truecolor"
# 256 wins over 16
caps = parse_mtts("MTTS 9")
assert caps.color_depth == "256"
# fallback to 16
caps = parse_mtts("MTTS 5") # 1 (ANSI) + 4 (UTF-8)
assert caps.color_depth == "16"

View file

@ -138,6 +138,11 @@ async def test_movement_updates_position(player, mock_world):
movement.world = mock_world movement.world = mock_world
look.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 original_x, original_y = player.x, player.y
await movement.move_north(player, "") await movement.move_north(player, "")

436
tests/test_editor.py Normal file
View file

@ -0,0 +1,436 @@
"""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)
# Syntax highlighting tests
@pytest.mark.asyncio
async def test_view_buffer_with_python_includes_ansi():
"""Viewing buffer with content_type=python includes ANSI codes."""
editor = Editor(
initial_content="def foo():\n return 42",
content_type="python",
color_depth="16",
)
response = await editor.handle_input(":")
# Should contain ANSI escape codes from syntax highlighting
assert "\033[" in response.output
# Should contain the code
assert "def" in response.output
assert "foo" in response.output
@pytest.mark.asyncio
async def test_view_buffer_with_text_no_ansi():
"""Viewing buffer with content_type=text does NOT include ANSI codes."""
editor = Editor(
initial_content="plain text line 1\nplain text line 2", content_type="text"
)
response = await editor.handle_input(":")
# Should NOT contain ANSI escape codes (plain line numbers)
assert "\033[" not in response.output
# Should contain plain line numbers
assert "1 plain text line 1" in response.output
assert "2 plain text line 2" in response.output
@pytest.mark.asyncio
async def test_view_specific_line_with_python_includes_ansi():
"""Viewing a specific line with python content includes ANSI codes."""
content = "\n".join([f"def func{i}():\n return {i}" for i in range(10)])
editor = Editor(
initial_content=content,
content_type="python",
color_depth="16",
)
response = await editor.handle_input(":5")
# Should contain ANSI escape codes from syntax highlighting
assert "\033[" in response.output
# Should contain code from the context window
assert "def" in response.output
@pytest.mark.asyncio
async def test_color_depth_passed_to_highlight():
"""Color depth is passed through to highlight function."""
editor = Editor(
initial_content="x = 1\ny = 2",
content_type="python",
color_depth="256",
)
response = await editor.handle_input(":")
# Should contain ANSI codes (256-color formatter)
assert "\033[" in response.output
# Should contain the code
assert "x" in response.output
assert "y" in response.output
@pytest.mark.asyncio
async def test_empty_buffer_with_python_content_type():
"""Empty buffer with content_type=python still works."""
editor = Editor(content_type="python", color_depth="16")
response = await editor.handle_input(":")
# Should show empty buffer message
assert "empty" in response.output.lower()
@pytest.mark.asyncio
async def test_undo_past_save_marks_dirty():
"""Undoing after save should mark buffer dirty again."""
saved = []
async def cb(content: str):
saved.append(content)
editor = Editor(initial_content="line 1", save_callback=cb)
await editor.handle_input("line 2") # dirty
await editor.handle_input(":w") # clean
assert not editor.dirty
await editor.handle_input(":u") # undo the append - should be dirty
assert editor.dirty

View file

@ -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")
# After appending, cursor should be at the last line (1 in 0-indexed)
# This test verifies the cursor field exists and can be used for prompts
assert player.editor.cursor == 1
# Shell loop prompt: f" {player.editor.cursor + 1}> " = " 2> "

125
tests/test_highlight.py Normal file
View file

@ -0,0 +1,125 @@
"""Tests for syntax highlighting module."""
from mudlib.render.highlight import highlight
def test_highlight_python_returns_ansi_codes():
"""Test that Python code returns string with ANSI escape codes."""
code = "def foo():\n return 42"
result = highlight(code, language="python")
# Should contain ANSI escape codes
assert "\033[" in result
# Should contain the original code somewhere
assert "def" in result
assert "foo" in result
def test_highlight_toml_returns_ansi_codes():
"""Test that TOML code returns string with ANSI escape codes."""
code = '[section]\nkey = "value"'
result = highlight(code, language="toml")
# Should contain ANSI escape codes
assert "\033[" in result
# Should contain the original code somewhere
assert "section" in result
assert "key" in result
def test_highlight_truecolor_depth():
"""Test with color_depth=truecolor uses appropriate formatter."""
code = "x = 1"
result = highlight(code, language="python", color_depth="truecolor")
# Should contain ANSI codes (either 256-color or truecolor format)
assert "\033[" in result
# Should contain the code
assert "x" in result
def test_highlight_256_depth():
"""Test with color_depth=256 uses 256-color formatter."""
code = "x = 1"
result = highlight(code, language="python", color_depth="256")
# Should contain ANSI codes
assert "\033[" in result
# Should contain the code
assert "x" in result
def test_highlight_16_depth():
"""Test with color_depth=16 uses basic terminal formatter."""
code = "x = 1"
result = highlight(code, language="python", color_depth="16")
# Should contain ANSI codes
assert "\033[" in result
# Should contain the code
assert "x" in result
def test_highlight_unknown_language_returns_original():
"""Test unknown language returns original text unmodified (no crash)."""
code = "some random text"
result = highlight(code, language="unknown_language_xyz")
# Should return original text unchanged
assert result == code
# Should not contain ANSI codes
assert "\033[" not in result
def test_highlight_empty_string():
"""Test empty string input returns empty string."""
result = highlight("", language="python")
assert result == ""
def test_highlight_no_extra_trailing_newlines():
"""Test that output doesn't end with extra newlines."""
code = "x = 1"
result = highlight(code, language="python")
# Pygments tends to add a trailing newline, we should strip it
# The original code has no trailing newline, so neither should the result
assert not result.endswith("\n\n")
# A single trailing newline might be ok if original had one,
# but our test input doesn't, so result shouldn't either
assert not result.endswith("\n")
def test_highlight_with_line_numbers():
"""Test highlighting with line numbers enabled."""
code = "def foo():\n return 42\n return 99"
result = highlight(code, language="python", line_numbers=True)
# Should contain line numbers
assert "1" in result
assert "2" in result
assert "3" in result
# Should still contain ANSI codes
assert "\033[" in result
# Should contain the code (split by ANSI codes, so check separately)
assert "def" in result
assert "foo" in result
assert "return" in result
assert "42" in result
def test_highlight_line_numbers_use_ansi_dim():
"""Test that line numbers use ANSI dim/gray styling."""
code = "x = 1\ny = 2"
result = highlight(code, language="python", line_numbers=True)
# Line numbers should have some ANSI styling
# We'll check that there are escape codes before the digits
assert "\033[" in result
# This is a bit fragile, but we can check that the result
# starts with an escape code (for line number 1)
lines = result.split("\n")
# First line should start with escape code or digit
assert lines[0][0] in ("\033", "1", " ")
def test_highlight_preserves_code_with_trailing_newline():
"""Test code with trailing newline is handled correctly."""
code = "x = 1\n"
result = highlight(code, language="python")
# Should contain the code
assert "x" in result
# Should not accumulate extra newlines beyond what Pygments naturally adds
# We strip the trailing newline, so even input with \n shouldn't get extra
assert not result.endswith("\n\n")

48
tests/test_player_caps.py Normal file
View file

@ -0,0 +1,48 @@
"""Tests for Player client capabilities integration."""
from mudlib.caps import ClientCaps
from mudlib.player import Player
def test_player_default_caps():
"""Player has default 16-color ANSI caps when created without explicit caps."""
player = Player(name="TestPlayer", x=0, y=0)
assert player.caps.ansi is True
assert player.caps.colors_256 is False
assert player.caps.truecolor is False
assert player.color_depth == "16"
def test_player_caps_truecolor():
"""Player with truecolor caps reports truecolor depth."""
caps = ClientCaps(ansi=True, utf8=True, colors_256=True, truecolor=True)
player = Player(name="TestPlayer", x=0, y=0, caps=caps)
assert player.color_depth == "truecolor"
def test_player_caps_256():
"""Player with 256-color caps reports 256 depth."""
caps = ClientCaps(ansi=True, utf8=True, colors_256=True, truecolor=False)
player = Player(name="TestPlayer", x=0, y=0, caps=caps)
assert player.color_depth == "256"
def test_player_caps_16():
"""Player with basic ANSI caps reports 16 depth."""
caps = ClientCaps(ansi=True, utf8=True)
player = Player(name="TestPlayer", x=0, y=0, caps=caps)
assert player.color_depth == "16"
def test_player_caps_property_delegates():
"""Player.color_depth delegates to caps.color_depth."""
player = Player(name="TestPlayer", x=0, y=0)
# Modify caps and verify color_depth reflects the change
player.caps = ClientCaps(ansi=True, colors_256=True, truecolor=True)
assert player.color_depth == "truecolor"
player.caps = ClientCaps(ansi=True, colors_256=True, truecolor=False)
assert player.color_depth == "256"
player.caps = ClientCaps(ansi=True)
assert player.color_depth == "16"

View file

@ -108,6 +108,7 @@ name = "mudlib"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "pygments" },
{ name = "telnetlib3" }, { name = "telnetlib3" },
] ]
@ -121,7 +122,10 @@ dev = [
] ]
[package.metadata] [package.metadata]
requires-dist = [{ name = "telnetlib3", directory = "../../../../src/telnetlib3" }] requires-dist = [
{ name = "pygments", specifier = ">=2.17.0" },
{ name = "telnetlib3", directory = "../../src/telnetlib3" },
]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [