#!/usr/bin/env python3
"""
e3po pygame GUI - Full keyboard access version.
"""

# Support both pygame and pygame-ce (community edition)
try:
    import pygame_ce as pygame
except ImportError:
    import pygame
import pygame.freetype
import sys
import os
from pathlib import Path
from typing import Optional, List, Tuple

# Import core classes from the main editor
from .editor import (
    Buffer, Mark, MarkMode, UndoType, UndoEntry,
    _is_age_file, _decrypt_age_file, _encrypt_to_age_file,
    PYRAGE_AVAILABLE, AGE_AVAILABLE
)

# Try to import syntax highlighter
try:
    from .syntax.highlighter import SyntaxHighlighter
    SYNTAX_AVAILABLE = True
except ImportError:
    SYNTAX_AVAILABLE = False

# Try to import spell checker
try:
    from spellchecker import SpellChecker
    SPELL_AVAILABLE = True
except ImportError:
    SPELL_AVAILABLE = False


class PygameEditor:
    """Text editor using pygame for display and input."""

    # Colors (black background)
    BG_COLOR = (0, 0, 0)          # Black background
    FG_COLOR = (192, 192, 192)    # Light gray text
    CURSOR_COLOR = (255, 255, 255) # White cursor
    MARK_BG = (64, 64, 128)       # Blue-gray for marks (more visible)
    STATUS_BG = (32, 32, 32)      # Dark gray status bar
    STATUS_FG = (200, 200, 200)   # Light gray status text
    LINE_NUM_FG = (128, 128, 128) # Gray line numbers

    # Gutter width for modified line markers
    GUTTER_WIDTH = 5

    # Syntax highlighting colors (matching curses color pairs)
    SYNTAX_COLORS = {
        1: (128, 128, 255),   # Blue - keywords
        2: (128, 255, 128),   # Green - strings
        3: (255, 255, 128),   # Yellow - types
        4: (128, 255, 255),   # Cyan - functions
        5: (255, 128, 255),   # Magenta - comments
        6: (255, 128, 128),   # Red - errors
        7: (255, 255, 255),   # White - default
        8: (255, 200, 100),   # Orange - numbers
    }

    def __init__(self, filename: Optional[str] = None, font_size: int = 16, font_name: Optional[str] = None):
        pygame.init()
        pygame.freetype.init()

        # Font settings
        self.font_size = font_size
        self.font_name = font_name
        self._init_font()

        # Window setup
        self.width = 100 * self.char_width
        self.height = 40 * self.char_height
        self.screen = pygame.display.set_mode(
            (self.width, self.height),
            pygame.RESIZABLE
        )

        # Enable key repeat
        pygame.key.set_repeat(500, 30)

        # Editor state
        self.buffer = Buffer()
        self.cursor_row = 0
        self.cursor_col = 0
        self.top_row = 0
        self.left_col = 0
        self.message = ""
        self.running = True

        # Command mode
        self.command_mode = False
        self.command_buffer = ""
        self.last_search = ""

        # Mark state
        self.mark: Optional[Mark] = None

        # Multi-file support
        self.buffers: List[Buffer] = []
        self.current_buffer_idx = 0

        # Bookmarks (set of line numbers)
        self.bookmarks: set = set()

        # Minimap
        self.minimap_enabled = True
        self.minimap_width = 80
        self.minimap_surface = None
        self.minimap_dirty = True
        self.MINIMAP_MAX_LINES = 5000  # Disable for very large files

        # Fill pending
        self.fill_pending = False

        # Help screen
        self.showing_help = False

        # Confirmation prompts
        self.confirm_quit_pending = False

        # Double-press detection for Ctrl-A/E
        self.last_ctrl_a_time = 0
        self.last_ctrl_e_time = 0
        self.double_press_threshold = 0.4  # seconds

        # Replace mode
        self.replace_mode = False
        self.replace_old = ""
        self.replace_new = ""
        self.replace_flags = ""

        # Passphrase entry mode (for encrypt/passwd commands)
        self.passphrase_mode = False
        self.passphrase_buffer = ""
        self.passphrase_confirm = ""
        self.passphrase_step = 0  # 0=new, 1=confirm, 2=old (for passwd)
        self.passphrase_callback = None  # Function to call when done

        # Spell check mode
        self.spell_mode = False
        self.spell_checker = None
        self.spell_errors: List[Tuple[int, int, int, str]] = []  # (row, start_col, end_col, word)
        self.spell_index = -1
        self.spell_skip_words: set = set()
        self.spell_suggestions: List[str] = []

        # Syntax highlighting
        self.syntax_highlighter = None
        self.syntax_lang = None
        self.syntax_cache: dict = {}
        self.syntax_enabled = SYNTAX_AVAILABLE

        # Calculate visible area
        self._update_dimensions()

        # Load file if provided
        if filename:
            self.buffer.load(filename)
            self.buffer.filename = filename
            self._setup_syntax_highlighter()

        # Add initial buffer to ring
        self.buffers.append(self.buffer)

        # Set initial window title
        self._update_window_title()

    def _init_font(self):
        """Initialize or reinitialize font with current settings."""
        self.font = self._find_monospace_font()

        # Calculate character dimensions
        metrics = self.font.get_rect("M")
        self.char_width = metrics.width
        self.char_height = int(self.font_size * 1.4)
        # Use origin mode for proper baseline positioning
        self.font.origin = True
        # Ascent is distance from baseline to top (positive value)
        self.font_ascent = self.font.get_sized_ascender(self.font_size)
        if self.font_ascent < 0:
            self.font_ascent = -self.font_ascent  # Some fonts return negative

        # Update visible area if screen exists
        if hasattr(self, 'screen'):
            self._update_dimensions()

    def _find_monospace_font(self) -> pygame.freetype.Font:
        """Find a suitable monospace font."""
        # If a specific font is requested, try it first
        if self.font_name:
            try:
                font = pygame.freetype.SysFont(self.font_name, self.font_size)
                if font:
                    return font
            except:
                pass

        # Try common monospace fonts
        font_names = [
            "DejaVu Sans Mono",
            "Consolas",
            "Monaco",
            "Menlo",
            "Liberation Mono",
            "Courier New",
            "monospace"
        ]

        for name in font_names:
            try:
                font = pygame.freetype.SysFont(name, self.font_size)
                if font:
                    return font
            except:
                pass

        # Fall back to default
        return pygame.freetype.Font(None, self.font_size)

    def _change_font_size(self, delta: int):
        """Change font size by delta."""
        new_size = self.font_size + delta
        if 8 <= new_size <= 48:  # Reasonable range
            self.font_size = new_size
            self._init_font()
            self.message = f"Font size: {self.font_size}"

    def _update_dimensions(self):
        """Update visible dimensions after resize."""
        self.width, self.height = self.screen.get_size()
        # Account for minimap width if enabled
        edit_width = self.width - (self.minimap_width if self._show_minimap() else 0)
        self.visible_cols = edit_width // self.char_width
        self.visible_rows = (self.height - self.char_height * 2) // self.char_height  # Leave room for status
        self.minimap_dirty = True  # Redraw minimap on resize

    def _show_minimap(self) -> bool:
        """Whether to show the minimap."""
        return (self.minimap_enabled and
                len(self.buffer.lines) <= self.MINIMAP_MAX_LINES and
                self.width > 400)  # Need enough space

    def _update_window_title(self):
        """Update window title to show filename and modified state."""
        if self.buffer.filename:
            title = f"e3po: {self.buffer.filename}"
        else:
            title = "e3po: [new file]"
        if self.buffer.modified:
            title += "*"
        # Only update if changed
        if not hasattr(self, '_last_title') or self._last_title != title:
            self._last_title = title
            pygame.display.set_caption(title)

    def _setup_syntax_highlighter(self):
        """Set up syntax highlighter based on filename."""
        if not SYNTAX_AVAILABLE or not self.buffer.filename:
            self.syntax_highlighter = None
            self.syntax_lang = None
            return

        try:
            self.syntax_highlighter = SyntaxHighlighter()
            self.syntax_lang = self.syntax_highlighter.detect_language(self.buffer.filename)
            if self.syntax_lang:
                self.syntax_highlighter.load_language(self.syntax_lang)
                # Parse the full source for tree-sitter
                text = '\n'.join(self.buffer.lines)
                self.syntax_highlighter.parse(text, self.syntax_lang)
            self.syntax_cache = {}
        except Exception:
            self.syntax_highlighter = None
            self.syntax_lang = None

    def _get_syntax_colors(self, row: int) -> dict:
        """Get syntax highlighting colors for a row. Returns {col: color_id}."""
        if not self.syntax_highlighter or not self.syntax_enabled or not self.syntax_lang:
            return {}

        # Check cache
        line = self.buffer.get_line(row)
        cache_key = (row, line)
        if cache_key in self.syntax_cache:
            return self.syntax_cache[cache_key]

        try:
            # Get highlights for this row
            highlights = self.syntax_highlighter.get_highlights(row, row, [line])
            # Convert from {row: [(start, end, color), ...]} to {col: color}
            colors = {}
            if row in highlights:
                for start_col, end_col, color_idx in highlights[row]:
                    for c in range(start_col, end_col):
                        colors[c] = color_idx
            self.syntax_cache[cache_key] = colors
            return colors
        except Exception:
            return {}

    def _invalidate_syntax_cache(self):
        """Clear syntax cache when buffer changes."""
        self.syntax_cache = {}
        self.minimap_dirty = True  # Also invalidate minimap
        # Re-parse the source for tree-sitter
        if self.syntax_highlighter and self.syntax_lang:
            try:
                text = '\n'.join(self.buffer.lines)
                self.syntax_highlighter.parse(text, self.syntax_lang)
            except Exception:
                pass

    def _show_splash(self):
        """Show Star Wars style opening crawl with version and disclaimer."""
        import random

        VERSION = "0.3.12"

        # Star Wars yellow
        TEXT_COLOR = (229, 177, 58)
        PROMPT_COLOR = (100, 200, 150)

        # The crawl text (without "press any key" - that goes at bottom)
        crawl_lines = [
            "e3po",
            "",
            "E3 Python Option",
            f"Version {VERSION}",
            "",
            "",
            "A long time ago in a terminal",
            "far, far away....",
            "",
            "",
            "It is a period of text editing.",
            "Rebel coders, striking from",
            "hidden workstations, have won",
            "their first victory against",
            "the evil proprietary Empire.",
            "",
            "THIS SOFTWARE IS PROVIDED",
            "\"AS IS\", WITHOUT WARRANTY OF",
            "ANY KIND, EXPRESS OR IMPLIED,",
            "INCLUDING BUT NOT LIMITED TO",
            "THE WARRANTIES OF MERCHANTABILITY,",
            "FITNESS FOR A PARTICULAR PURPOSE",
            "AND NONINFRINGEMENT.",
            "",
            "IN NO EVENT SHALL THE AUTHORS",
            "BE LIABLE FOR ANY CLAIM, DAMAGES",
            "OR OTHER LIABILITY, WHETHER IN AN",
            "ACTION OF CONTRACT, TORT OR",
            "OTHERWISE, ARISING FROM, OUT OF",
            "OR IN CONNECTION WITH THE SOFTWARE",
            "OR THE USE OR OTHER DEALINGS",
            "IN THE SOFTWARE.",
        ]

        # Create starfield background
        stars = [(random.randint(0, self.width), random.randint(0, self.height),
                  random.randint(1, 3)) for _ in range(150)]

        # Create text surface - render all lines
        # Use None for default pygame font (avoids missing font warnings on Linux)
        line_height = 28
        text_font_size = 22
        text_font = pygame.freetype.SysFont(None, text_font_size)

        # Title font (bigger for "e3po")
        title_font_size = 48
        title_font = pygame.freetype.SysFont(None, title_font_size)

        # Prompt font
        prompt_font_size = 24
        prompt_font = pygame.freetype.SysFont(None, prompt_font_size)

        # Calculate text surface height
        text_height = len(crawl_lines) * line_height + 100
        text_width = 600

        # Create the text surface
        text_surface = pygame.Surface((text_width, text_height), pygame.SRCALPHA)

        y = 50
        for i, line in enumerate(crawl_lines):
            if i == 0:  # Title "e3po"
                rect = title_font.get_rect(line)
                x = (text_width - rect.width) // 2
                title_font.render_to(text_surface, (x, y), line, TEXT_COLOR)
                y += title_font_size + 10
            else:
                rect = text_font.get_rect(line)
                x = (text_width - rect.width) // 2
                text_font.render_to(text_surface, (x, y), line, TEXT_COLOR)
                y += line_height

        # Animation loop
        scroll_y = self.height  # Start below screen
        scroll_speed = 90  # Pixels per second
        clock = pygame.time.Clock()
        running = True
        scrolling = True

        # Vanishing point - not too high, keep text readable
        vanish_y = self.height * 0.25

        # Stop right when text finishes scrolling off
        # Scroll a bit further so all text is visible before disappearing
        stop_scroll_y = -100

        # Blink state for prompt
        blink_visible = True
        blink_timer = 0

        while running:
            dt = clock.tick(60) / 1000.0  # Delta time in seconds
            blink_timer += dt * 1000  # Convert to ms

            # Handle events
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    self.running = False
                    running = False
                elif event.type == pygame.KEYDOWN:
                    running = False
                elif event.type == pygame.MOUSEBUTTONDOWN:
                    running = False

            # Clear screen
            self.screen.fill((0, 0, 0))

            # Draw stars
            for sx, sy, brightness in stars:
                color = (brightness * 60, brightness * 60, brightness * 60)
                self.screen.set_at((sx, sy), color)

            # Draw the crawl with gentle perspective (not too extreme)
            for screen_y in range(int(vanish_y), self.height - 50):
                progress = (screen_y - vanish_y) / (self.height - vanish_y)
                # Gentler scale - minimum 0.5 instead of 0.1
                scale = 0.5 + progress * 0.5
                source_y = int((screen_y - scroll_y) / scale)

                if 0 <= source_y < text_height:
                    slice_width = int(text_width * scale)
                    if slice_width > 0:
                        try:
                            slice_rect = pygame.Rect(0, source_y, text_width, 1)
                            text_slice = text_surface.subsurface(slice_rect)
                            scaled_slice = pygame.transform.scale(text_slice, (slice_width, 1))
                            x = (self.width - slice_width) // 2

                            # Gentler fade
                            if progress < 0.2:
                                fade = int(255 * (progress / 0.2))
                                scaled_slice.set_alpha(fade)

                            self.screen.blit(scaled_slice, (x, screen_y))
                        except:
                            pass

            # Scroll up
            if scrolling:
                scroll_y -= scroll_speed * dt
                if scroll_y <= stop_scroll_y:
                    scrolling = False

            # Draw blinking "Press any key" at bottom when scrolling is done
            if not scrolling:
                if blink_timer >= 500:
                    blink_timer = 0
                    blink_visible = not blink_visible

                if blink_visible:
                    prompt = "Press any key to continue..."
                    prompt_rect = prompt_font.get_rect(prompt)
                    prompt_x = (self.width - prompt_rect.width) // 2
                    prompt_y = self.height - 40
                    prompt_font.render_to(self.screen, (prompt_x, prompt_y), prompt, PROMPT_COLOR)

            pygame.display.flip()

    def run(self):
        """Main event loop."""
        clock = pygame.time.Clock()

        # Show splash screen on startup
        self._show_splash()

        while self.running:
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    self.running = False
                elif event.type == pygame.VIDEORESIZE:
                    self.screen = pygame.display.set_mode(
                        (event.w, event.h),
                        pygame.RESIZABLE
                    )
                    self._update_dimensions()
                elif event.type == pygame.KEYDOWN:
                    self._handle_key(event)
                elif event.type == pygame.MOUSEBUTTONDOWN:
                    self._handle_mouse_click(event)

            self._draw()
            clock.tick(60)

        pygame.quit()

    def _handle_mouse_click(self, event):
        """Handle mouse click events."""
        x, y = event.pos

        # Check if click is in minimap area
        if self._show_minimap():
            map_x = self.width - self.minimap_width
            if x >= map_x and hasattr(self, 'minimap_pixels_per_line'):
                # Convert y position to line number
                map_y = 2  # Top offset
                click_y = y - map_y
                ppl = self.minimap_pixels_per_line
                target_row = int(click_y / ppl)
                target_row = max(0, min(target_row, len(self.buffer.lines) - 1))

                # Move cursor to that line
                self.cursor_row = target_row
                self.cursor_col = 0

                # Center the view on the target line
                self.top_row = max(0, target_row - self.visible_rows // 2)
                return

    def _handle_key(self, event: pygame.event.Event):
        """Handle keyboard input."""
        key = event.key
        mods = event.mod
        ctrl = mods & pygame.KMOD_CTRL
        shift = mods & pygame.KMOD_SHIFT
        alt = mods & pygame.KMOD_ALT

        # Dismiss help screen on any key
        if self.showing_help:
            self.showing_help = False
            return

        # Spell mode handling
        if self.spell_mode and not self.command_mode:
            if self._handle_spell_key(event):
                return

        # Replace mode handling (Y/N/G/Q prompts)
        if self.replace_mode:
            if key == pygame.K_y:
                self._do_replace_one()
                self._find_next_replace()
                return
            elif key == pygame.K_n:
                # Skip this one, find next
                self.cursor_col += 1
                self._find_next_replace()
                return
            elif key == pygame.K_g:
                # Replace all remaining
                while self.replace_mode:
                    self._do_replace_one()
                    self.cursor_col += 1
                    self._find_next_replace()
                return
            elif key == pygame.K_q or key == pygame.K_ESCAPE:
                # Quit replace mode
                self.replace_mode = False
                self.message = "Replace cancelled"
                return

        # Passphrase entry mode
        if self.passphrase_mode:
            self._handle_passphrase_key(event)
            return

        # Fill pending - waiting for fill character
        if self.fill_pending:
            if event.unicode and len(event.unicode) == 1:
                self._do_fill(event.unicode)
            else:
                self.fill_pending = False
                self.message = "Fill cancelled"
            return

        # Command mode handling
        if self.command_mode:
            self._handle_command_key(event)
            return

        # ESC - enter command mode
        if key == pygame.K_ESCAPE:
            self.command_mode = True
            self.command_buffer = ""
            return

        # Ctrl+Delete - DELETE LINE (the key combination we want!)
        if ctrl and key == pygame.K_DELETE:
            self._delete_current_line()
            self.message = "Ctrl+Delete: deleted line"
            return

        # Ctrl+Backspace - also delete line
        if ctrl and key == pygame.K_BACKSPACE:
            self._delete_current_line()
            self.message = "Ctrl+Backspace: deleted line"
            return

        # Ctrl+K - delete line (fallback)
        if ctrl and key == pygame.K_k:
            self._delete_current_line()
            return

        # Ctrl+Q or Ctrl+W - quit
        if ctrl and key in (pygame.K_q, pygame.K_w):
            self.running = False
            return

        # Ctrl+S - save
        if ctrl and key == pygame.K_s:
            if self.buffer.save():
                self.message = f"Saved: {self.buffer.filename}"
            else:
                self.message = "Save failed (no filename?)"
            return

        # Ctrl+Plus/Equals - increase font size
        if ctrl and key in (pygame.K_PLUS, pygame.K_EQUALS, pygame.K_KP_PLUS):
            self._change_font_size(2)
            return

        # Ctrl+Minus - decrease font size
        if ctrl and key in (pygame.K_MINUS, pygame.K_KP_MINUS):
            self._change_font_size(-2)
            return

        # Ctrl+F - repeat last search
        if ctrl and key == pygame.K_f:
            if self.last_search:
                self._search_forward(self.last_search)
            else:
                self.message = "No previous search"
            return

        # Ctrl+A - line start (double-press for file start)
        if ctrl and key == pygame.K_a:
            import time
            now = time.time()
            if now - self.last_ctrl_a_time < self.double_press_threshold:
                # Double press - go to file start
                self._save_jump_position()
                self.cursor_row = 0
                self.cursor_col = 0
                self.message = "Top of file"
            else:
                # Single press - go to line start
                self.cursor_col = 0
            self.last_ctrl_a_time = now
            return

        # Ctrl+E - double-press for file end, single for erase to EOL
        if ctrl and key == pygame.K_e:
            import time
            now = time.time()
            if now - self.last_ctrl_e_time < self.double_press_threshold:
                # Double press - go to file end
                self._save_jump_position()
                self.cursor_row = len(self.buffer.lines) - 1
                self.cursor_col = len(self.buffer.get_line(self.cursor_row))
                self.message = "End of file"
            else:
                # Single press - erase to end of line
                line = self.buffer.get_line(self.cursor_row)
                if self.cursor_col < len(line):
                    self.buffer.push_undo(
                        UndoType.LINE_CHANGE, self.cursor_row, self.cursor_col,
                        self.cursor_row, self.cursor_row
                    )
                    self.buffer.set_line(self.cursor_row, line[:self.cursor_col])
                    self.message = "Erased to EOL"
            self.last_ctrl_e_time = now
            return

        # Ctrl+B - toggle bookmark
        if ctrl and key == pygame.K_b:
            self._toggle_bookmark()
            return

        # Ctrl+N - next file in edit ring
        if ctrl and key == pygame.K_n:
            self._next_file()
            return

        # Ctrl+P - previous file in edit ring
        if ctrl and key == pygame.K_p:
            self._prev_file()
            return

        # Ctrl+V - paste from clipboard
        if ctrl and key == pygame.K_v:
            self._paste_clipboard()
            return

        # Ctrl+C - copy mark to clipboard
        if ctrl and key == pygame.K_c:
            self._copy_mark_to_clipboard()
            return

        # F2 - save
        if key == pygame.K_F2:
            if self.buffer.save():
                self.message = f"Saved: {self.buffer.filename}"
                self.confirm_quit_pending = False  # Clear any pending quit
            else:
                self.message = "Save failed"
            return

        # F3 - quit/close without save (confirm if modified)
        if key == pygame.K_F3:
            if self.buffer.modified:
                self.confirm_quit_pending = True
                if len(self.buffers) > 1:
                    self.message = "File modified! Close anyway? (Y/N/F2:Save/F4:Save&Close)"
                else:
                    self.message = "File modified! Quit anyway? (Y/N/F2:Save/F4:Save&Quit)"
            else:
                self._close_current_buffer()
            return

        # Handle quit confirmation
        if hasattr(self, 'confirm_quit_pending') and self.confirm_quit_pending:
            if key == pygame.K_y:
                self._close_current_buffer()
                self.confirm_quit_pending = False
                return
            elif key == pygame.K_n or key == pygame.K_ESCAPE:
                self.confirm_quit_pending = False
                self.message = ""
                return
            # F2/F4 handled elsewhere but will also clear this

        # F4 - save and quit/close
        if key == pygame.K_F4:
            if self.buffer.save():
                self._close_current_buffer()
            else:
                self.message = "Save failed"
            return

        # F1 - Help
        if key == pygame.K_F1:
            self.showing_help = not self.showing_help
            return

        # F7 - Shift mark left
        if key == pygame.K_F7:
            self._shift_mark_left()
            return

        # F8 - Shift mark right
        if key == pygame.K_F8:
            self._shift_mark_right()
            return

        # F9 - Undo (already handled below, but add here for clarity)

        # Alt key operations
        if alt:
            if key == pygame.K_l:  # Alt-L: Line mark
                self._toggle_line_mark()
                return
            if key == pygame.K_b:  # Alt-B: Block mark
                self._toggle_block_mark()
                return
            if key == pygame.K_z:  # Alt-Z: Char mark
                self._toggle_char_mark()
                return
            if key == pygame.K_w:  # Alt-W: Word mark
                self._mark_word()
                return
            if key == pygame.K_u:  # Alt-U: Unmark
                self.mark = None
                self.message = "Unmarked"
                return
            if key == pygame.K_d:  # Alt-D: Delete mark
                self._delete_mark()
                return
            if key == pygame.K_c:  # Alt-C: Copy mark
                self._copy_mark()
                return
            if key == pygame.K_m:  # Alt-M: Move mark
                self._move_mark()
                return
            if key == pygame.K_o:  # Alt-O: Overlay mark
                self._overlay_mark()
                return
            if key == pygame.K_y:  # Alt-Y: Go to mark start
                self._goto_mark_start()
                return
            if key == pygame.K_e:  # Alt-E: Go to mark end
                self._goto_mark_end()
                return
            if key == pygame.K_s:  # Alt-S: Split line
                self._split_line()
                return
            if key == pygame.K_j:  # Alt-J: Join lines
                self._join_lines()
                return
            if key == pygame.K_f:  # Alt-F: Fill
                self._start_fill()
                return
            if key == pygame.K_k:  # Alt-K: Copy mark to system clipboard
                self._copy_mark_to_clipboard()
                return

        # Arrow keys
        if key == pygame.K_UP:
            if ctrl:
                self._prev_bookmark()
            elif self.cursor_row > 0:
                self.cursor_row -= 1
            return
        if key == pygame.K_DOWN:
            if ctrl:
                self._next_bookmark()
            elif self.cursor_row < len(self.buffer.lines) - 1:
                self.cursor_row += 1
            return
        if key == pygame.K_LEFT:
            if ctrl:
                # Word left
                self._word_left()
            elif self.cursor_col > 0:
                self.cursor_col -= 1
            return
        if key == pygame.K_RIGHT:
            if ctrl:
                # Word right
                self._word_right()
            else:
                self.cursor_col += 1
            return

        # Home/End
        if key == pygame.K_HOME:
            if ctrl:
                self._save_jump_position()
                self.cursor_row = 0
            self.cursor_col = 0
            return
        if key == pygame.K_END:
            if ctrl:
                self._save_jump_position()
                self.cursor_row = len(self.buffer.lines) - 1
            self.cursor_col = len(self.buffer.get_line(self.cursor_row))
            return

        # Page up/down
        if key == pygame.K_PAGEUP:
            self.cursor_row = max(0, self.cursor_row - self.visible_rows)
            return
        if key == pygame.K_PAGEDOWN:
            self.cursor_row = min(len(self.buffer.lines) - 1,
                                  self.cursor_row + self.visible_rows)
            return

        # Backspace
        if key == pygame.K_BACKSPACE:
            if self.cursor_col > 0 or self.cursor_row > 0:
                self.buffer.push_undo(
                    UndoType.LINE_CHANGE if self.cursor_col > 0 else UndoType.LINE_JOIN,
                    self.cursor_row, self.cursor_col,
                    max(0, self.cursor_row - 1), self.cursor_row
                )
                self.cursor_row, self.cursor_col = self.buffer.backspace(
                    self.cursor_row, self.cursor_col
                )
                self._invalidate_syntax_cache()
            return

        # Delete
        if key == pygame.K_DELETE:
            line = self.buffer.get_line(self.cursor_row)
            if self.cursor_col < len(line):
                self.buffer.push_undo(
                    UndoType.LINE_CHANGE, self.cursor_row, self.cursor_col,
                    self.cursor_row, self.cursor_row
                )
                self.buffer.delete_char(self.cursor_row, self.cursor_col)
                self._invalidate_syntax_cache()
            elif self.cursor_row < len(self.buffer.lines) - 1:
                # Join with next line
                self.buffer.push_undo(
                    UndoType.LINE_JOIN, self.cursor_row, self.cursor_col,
                    self.cursor_row, self.cursor_row + 1
                )
                next_line = self.buffer.get_line(self.cursor_row + 1)
                self.buffer.set_line(self.cursor_row, line + next_line)
                del self.buffer.lines[self.cursor_row + 1]
                self._invalidate_syntax_cache()
            return

        # Enter
        if key == pygame.K_RETURN:
            self.buffer.push_undo(
                UndoType.LINE_SPLIT, self.cursor_row, self.cursor_col,
                self.cursor_row, self.cursor_row
            )
            self.buffer.split_line(self.cursor_row, self.cursor_col)
            self.cursor_row += 1
            self.cursor_col = 0
            self._invalidate_syntax_cache()
            return

        # Tab
        if key == pygame.K_TAB:
            spaces = 4 - (self.cursor_col % 4)
            for _ in range(spaces):
                self.buffer.insert_char(self.cursor_row, self.cursor_col, ' ')
                self.cursor_col += 1
            self._invalidate_syntax_cache()
            return

        # Undo (Ctrl+Z or F9)
        if (ctrl and key == pygame.K_z) or key == pygame.K_F9:
            self._undo()
            return

        # F10 - next file in ring
        if key == pygame.K_F10:
            self._next_file()
            return

        # Redo (Ctrl+Y or Ctrl+R or Ctrl+Shift+Z)
        if ctrl and key == pygame.K_y:
            self._redo()
            return
        if ctrl and key == pygame.K_r:
            self._redo()
            return
        if ctrl and shift and key == pygame.K_z:
            self._redo()
            return

        # Regular character input
        if event.unicode and len(event.unicode) == 1:
            ch = event.unicode
            if ch.isprintable():
                self.buffer.push_undo(
                    UndoType.LINE_CHANGE, self.cursor_row, self.cursor_col,
                    self.cursor_row, self.cursor_row
                )
                self.buffer.insert_char(self.cursor_row, self.cursor_col, ch)
                self.cursor_col += 1
                self._invalidate_syntax_cache()

    def _delete_current_line(self):
        """Delete the current line."""
        if len(self.buffer.lines) > 1 or self.buffer.get_line(0):
            self.buffer.push_undo(
                UndoType.LINE_DELETE, self.cursor_row, self.cursor_col,
                self.cursor_row, self.cursor_row
            )
            del self.buffer.lines[self.cursor_row]
            if not self.buffer.lines:
                self.buffer.lines = [""]
            self.cursor_row = min(self.cursor_row, len(self.buffer.lines) - 1)
            self.buffer.modified = True

    def _handle_command_key(self, event: pygame.event.Event):
        """Handle keyboard input in command mode."""
        key = event.key

        if key == pygame.K_ESCAPE:
            # Exit command mode
            self.command_mode = False
            self.command_buffer = ""
            self.message = ""
            return

        if key == pygame.K_RETURN:
            # Execute command
            self._execute_command(self.command_buffer)
            self.command_mode = False
            self.command_buffer = ""
            return

        if key == pygame.K_BACKSPACE:
            if self.command_buffer:
                self.command_buffer = self.command_buffer[:-1]
            else:
                # Empty backspace exits command mode
                self.command_mode = False
            return

        # Add character to command buffer
        if event.unicode and len(event.unicode) == 1:
            ch = event.unicode
            if ch.isprintable():
                self.command_buffer += ch

    def _execute_command(self, cmd: str):
        """Execute a command line command."""
        cmd = cmd.strip()
        if not cmd:
            return

        # Line number - go to line
        if cmd.isdigit():
            self._save_jump_position()
            line_num = int(cmd) - 1  # Convert to 0-based
            self.cursor_row = max(0, min(line_num, len(self.buffer.lines) - 1))
            self.cursor_col = 0
            self.message = f"Line {self.cursor_row + 1}"
            return

        # Top of file
        if cmd in ('t', 'top'):
            self._save_jump_position()
            self.cursor_row = 0
            self.cursor_col = 0
            self.message = "Top"
            return

        # Bottom of file
        if cmd in ('b', 'bot', 'bottom'):
            self._save_jump_position()
            self.cursor_row = len(self.buffer.lines) - 1
            self.cursor_col = 0
            self.message = "Bottom"
            return

        # Save
        if cmd in ('s', 'save', 'w'):
            if self.buffer.save():
                self.message = f"Saved: {self.buffer.filename}"
            else:
                self.message = "Save failed (no filename?)"
            return

        # Quit
        if cmd in ('q', 'quit', 'exit'):
            self.running = False
            return

        # Save and quit
        if cmd in ('wq', 'x'):
            self.buffer.save()
            self.running = False
            return

        # Set filename: n filename
        if cmd.startswith('n '):
            filename = cmd[2:].strip()
            if filename:
                self.buffer.filename = filename
                self.message = f"Filename: {filename}"
            return

        # Edit file: e filename
        if cmd.startswith('e '):
            filename = cmd[2:].strip()
            if filename:
                self._open_file(filename)
            return

        # Search: /pattern or /pattern/c (case insensitive)
        if cmd.startswith('/'):
            pattern = cmd[1:]
            case_insensitive = True
            if pattern.endswith('/c'):
                pattern = pattern[:-2]
                case_insensitive = True  # explicit case insensitive
            elif pattern.endswith('/C'):
                pattern = pattern[:-2]
                case_insensitive = False  # case sensitive
            if pattern:
                self.last_search = pattern
                self._search_forward(pattern, case_insensitive)
            elif self.last_search:
                self._search_forward(self.last_search, case_insensitive)
            return

        # Replace: c/old/new/ or c/old/new/* (all) or c/old/new/c (case insensitive)
        if cmd.startswith('c/'):
            self._parse_replace_command(cmd)
            return

        # Syntax highlighting toggle
        if cmd in ('syntax', 'syn'):
            if SYNTAX_AVAILABLE:
                self.syntax_enabled = not self.syntax_enabled
                if self.syntax_enabled and not self.syntax_highlighter:
                    self._setup_syntax_highlighter()
                self.message = f"Syntax highlighting {'ON' if self.syntax_enabled else 'OFF'}"
            else:
                self.message = "Syntax highlighting unavailable (install tree-sitter)"
            return

        # Minimap toggle
        if cmd in ('minimap', 'minmap', 'map', 'minimap on', 'minimap off', 'map on', 'map off',
                   'minmap on', 'minmap off'):
            if cmd.endswith(' on'):
                self.minimap_enabled = True
            elif cmd.endswith(' off'):
                self.minimap_enabled = False
            else:
                self.minimap_enabled = not self.minimap_enabled
            self._update_dimensions()  # Recalculate visible area
            self.message = f"Minimap {'ON' if self.minimap_enabled else 'OFF'}"
            return

        # Sort command
        if cmd == 'sort' or cmd.startswith('sort '):
            self._do_sort_command(cmd)
            return

        # Uniq command
        if cmd == 'uniq' or cmd.startswith('uniq ') or cmd == 'unique' or cmd.startswith('unique '):
            self._do_uniq_command(cmd)
            return

        # Grep command
        if cmd == 'grep' or cmd.startswith('grep '):
            self._do_grep_command(cmd)
            return

        # Encrypt command
        if cmd == 'encrypt':
            self._do_encrypt_command()
            return

        # Passwd command
        if cmd == 'passwd':
            self._do_passwd_command()
            return

        # Spell check command
        if cmd == 'spell':
            self._toggle_spell_mode()
            return

        # Font size: font 20 or fontsize 20
        if cmd.startswith('font ') or cmd.startswith('fontsize '):
            parts = cmd.split()
            if len(parts) >= 2:
                try:
                    size = int(parts[1])
                    if 8 <= size <= 48:
                        self.font_size = size
                        if len(parts) >= 3:
                            self.font_name = parts[2]
                        self._init_font()
                        self.message = f"Font: {self.font_name or 'default'} {self.font_size}pt"
                    else:
                        self.message = "Font size must be 8-48"
                except ValueError:
                    # First arg might be font name
                    self.font_name = parts[1]
                    if len(parts) >= 3:
                        try:
                            self.font_size = int(parts[2])
                        except ValueError:
                            pass
                    self._init_font()
                    self.message = f"Font: {self.font_name} {self.font_size}pt"
            return

        self.message = f"Unknown command: {cmd}"

    def _search_forward(self, pattern: str, case_insensitive: bool = True):
        """Search forward for pattern."""
        import re
        flags = re.IGNORECASE if case_insensitive else 0
        try:
            regex = re.compile(pattern, flags)
        except re.error:
            self.message = f"Invalid pattern: {pattern}"
            return False

        # Save position before jumping
        self._save_jump_position()

        # Start from current position
        start_row = self.cursor_row
        start_col = self.cursor_col + 1

        # Search from current line
        for row in range(start_row, len(self.buffer.lines)):
            line = self.buffer.get_line(row)
            col_start = start_col if row == start_row else 0
            match = regex.search(line[col_start:])
            if match:
                self.cursor_row = row
                self.cursor_col = col_start + match.start()
                self.message = f"Found: {pattern}"
                return True

        # Wrap around
        for row in range(0, start_row + 1):
            line = self.buffer.get_line(row)
            col_end = start_col if row == start_row else len(line)
            match = regex.search(line[:col_end])
            if match:
                self.cursor_row = row
                self.cursor_col = match.start()
                self.message = f"Found (wrapped): {pattern}"
                return True

        self.message = f"Not found: {pattern}"
        return False

    def _word_left(self):
        """Move cursor to previous word."""
        line = self.buffer.get_line(self.cursor_row)
        col = self.cursor_col

        # Skip whitespace
        while col > 0 and (col > len(line) or line[col-1].isspace()):
            col -= 1
        # Skip word chars
        while col > 0 and col <= len(line) and not line[col-1].isspace():
            col -= 1

        self.cursor_col = col

    def _word_right(self):
        """Move cursor to next word."""
        line = self.buffer.get_line(self.cursor_row)
        col = self.cursor_col

        # Skip word chars
        while col < len(line) and not line[col].isspace():
            col += 1
        # Skip whitespace
        while col < len(line) and line[col].isspace():
            col += 1

        self.cursor_col = col

    def _undo(self):
        """Undo last operation."""
        entry = self.buffer.pop_undo()
        if entry:
            # Save current state to redo (safely handle out-of-bounds)
            end_line = min(entry.line_num + entry.line_count, len(self.buffer.lines))
            current_lines = self.buffer.lines[entry.line_num:end_line] if entry.line_num < len(self.buffer.lines) else []
            self.buffer.redo_stack.append(UndoEntry(
                entry.undo_type, entry.line_num, self.cursor_col,
                current_lines,
                len(current_lines)
            ))

            # Restore saved state
            if entry.undo_type == UndoType.LINE_DELETE:
                for i, line in enumerate(entry.saved_lines):
                    self.buffer.lines.insert(entry.line_num + i, line)
            elif entry.undo_type == UndoType.LINE_INSERT:
                # Delete the lines that were inserted
                end = min(entry.line_num + entry.line_count, len(self.buffer.lines))
                if entry.line_num < len(self.buffer.lines) and end > entry.line_num:
                    del self.buffer.lines[entry.line_num:end]
                # If we deleted everything, buffer will be restored below
            elif entry.undo_type == UndoType.LINE_SPLIT:
                # Undo split: remove the line that was created, restore original
                if entry.line_num + 1 <= len(self.buffer.lines) - 1:
                    del self.buffer.lines[entry.line_num + 1]
                if entry.saved_lines and entry.line_num < len(self.buffer.lines):
                    self.buffer.lines[entry.line_num] = entry.saved_lines[0]
                elif entry.saved_lines:
                    # Line was deleted but we need to restore - shouldn't happen but handle it
                    self.buffer.lines.append(entry.saved_lines[0])
            elif entry.undo_type == UndoType.LINE_JOIN:
                # Undo join: restore both original lines
                if entry.saved_lines and len(entry.saved_lines) >= 2:
                    if entry.line_num < len(self.buffer.lines):
                        self.buffer.lines[entry.line_num] = entry.saved_lines[0]
                    self.buffer.lines.insert(entry.line_num + 1, entry.saved_lines[1])
            elif entry.undo_type == UndoType.BLOCK_CHANGE:
                # Check if this was a block copy that extended the file
                # (signaled by negative col_pos)
                if entry.col_pos < 0:
                    original_line_count = -entry.col_pos - 1
                    # Delete the added lines first
                    if len(self.buffer.lines) > original_line_count:
                        del self.buffer.lines[original_line_count:]

                # Restore the original lines
                for i, line in enumerate(entry.saved_lines):
                    if entry.line_num + i < len(self.buffer.lines):
                        self.buffer.lines[entry.line_num + i] = line

            else:
                # LINE_CHANGE - restore saved lines
                for i, line in enumerate(entry.saved_lines):
                    if entry.line_num + i < len(self.buffer.lines):
                        self.buffer.lines[entry.line_num + i] = line

            # Ensure buffer is never empty
            if not self.buffer.lines:
                self.buffer.lines = [""]

            # Clear mark on undo - content has changed, mark may be invalid
            self.mark = None

            self.cursor_row = min(entry.line_num, len(self.buffer.lines) - 1)
            self.cursor_col = 0  # Reset to start of line on undo
            self._invalidate_syntax_cache()

            # Check if we've returned to clean state
            if len(self.buffer.undo_stack) == self.buffer.clean_undo_depth:
                self.buffer.modified = False
                self.buffer.modified_lines.clear()
                self.message = "Undo (back to saved state)"
            else:
                self.buffer.modified = True
                self.message = "Undo"
        else:
            self.message = "Nothing to undo"

    def _redo(self):
        """Redo last undone operation."""
        if not self.buffer.redo_stack:
            self.message = "Nothing to redo"
            return

        entry = self.buffer.redo_stack.pop()
        self.buffer.push_undo(
            entry.undo_type, entry.line_num, self.cursor_col,
            entry.line_num, entry.line_num + entry.line_count - 1
        )
        self._invalidate_syntax_cache()

        # Check if we've returned to clean state (or moved away from it)
        if len(self.buffer.undo_stack) == self.buffer.clean_undo_depth:
            self.buffer.modified = False
            self.buffer.modified_lines.clear()
            self.message = "Redo (back to saved state)"
        else:
            self.buffer.modified = True
            self.message = "Redo"

    # ========== Mark Operations ==========

    def _toggle_line_mark(self):
        """Toggle or extend line mark."""
        if self.mark is None or self.mark.mode != MarkMode.LINE:
            # Start new line mark
            self.mark = Mark(
                mode=MarkMode.LINE,
                start_row=self.cursor_row,
                start_col=0,
                end_row=self.cursor_row,
                end_col=0
            )
            self.message = "LINE mark"
        else:
            # Extend/trim existing mark
            if self.cursor_row < self.mark.start_row:
                self.mark.start_row = self.cursor_row
            elif self.cursor_row > self.mark.end_row:
                self.mark.end_row = self.cursor_row
            else:
                self.mark.end_row = self.cursor_row
            self.message = f"LINE {self.mark.start_row + 1}-{self.mark.end_row + 1}"

    def _toggle_block_mark(self):
        """Toggle or extend block mark."""
        if self.mark is None or self.mark.mode != MarkMode.BLOCK:
            # Start new block mark
            self.mark = Mark(
                mode=MarkMode.BLOCK,
                start_row=self.cursor_row,
                start_col=self.cursor_col,
                end_row=self.cursor_row,
                end_col=self.cursor_col
            )
            self.message = "BLOCK mark"
        else:
            # Extend to cursor
            self.mark.end_row = self.cursor_row
            self.mark.end_col = self.cursor_col
            self.message = f"BLOCK ({self.mark.start_row + 1},{self.mark.start_col + 1})-({self.mark.end_row + 1},{self.mark.end_col + 1})"

    def _toggle_char_mark(self):
        """Toggle or extend character mark."""
        if self.mark is None or self.mark.mode != MarkMode.CHAR:
            self.mark = Mark(
                mode=MarkMode.CHAR,
                start_row=self.cursor_row,
                start_col=self.cursor_col,
                end_row=self.cursor_row,
                end_col=self.cursor_col
            )
            self.message = "CHAR mark"
        else:
            self.mark.end_row = self.cursor_row
            self.mark.end_col = self.cursor_col
            self.message = "CHAR mark extended"

    def _mark_word(self):
        """Mark the word under cursor."""
        line = self.buffer.get_line(self.cursor_row)
        if self.cursor_col >= len(line):
            self.message = "Not on a word"
            return

        # Find word boundaries
        start = self.cursor_col
        end = self.cursor_col

        # Expand left
        while start > 0 and (line[start - 1].isalnum() or line[start - 1] == '_'):
            start -= 1

        # Expand right
        while end < len(line) and (line[end].isalnum() or line[end] == '_'):
            end += 1

        if start == end:
            self.message = "Not on a word"
            return

        self.mark = Mark(
            mode=MarkMode.CHAR,
            start_row=self.cursor_row,
            start_col=start,
            end_row=self.cursor_row,
            end_col=end - 1
        )
        self.message = f"Word: {line[start:end]}"

    def _delete_mark(self):
        """Delete marked region."""
        if not self.mark:
            self.message = "No mark"
            return

        if self.mark.mode == MarkMode.LINE:
            start = min(self.mark.start_row, self.mark.end_row)
            end = max(self.mark.start_row, self.mark.end_row)
            self.buffer.push_undo(
                UndoType.LINE_DELETE, start, 0, start, end
            )
            del self.buffer.lines[start:end + 1]
            if not self.buffer.lines:
                self.buffer.lines = [""]
            self.cursor_row = min(start, len(self.buffer.lines) - 1)
            self.buffer.modified = True
            self.message = f"Deleted {end - start + 1} lines"

        elif self.mark.mode == MarkMode.BLOCK:
            start_row = min(self.mark.start_row, self.mark.end_row)
            end_row = max(self.mark.start_row, self.mark.end_row)
            start_col = min(self.mark.start_col, self.mark.end_col)
            end_col = max(self.mark.start_col, self.mark.end_col)

            self.buffer.push_undo(
                UndoType.BLOCK_CHANGE, start_row, start_col, start_row, end_row
            )
            for row in range(start_row, end_row + 1):
                line = self.buffer.get_line(row)
                if start_col < len(line):
                    new_line = line[:start_col] + line[end_col + 1:]
                    self.buffer.set_line(row, new_line)
            self.message = "Block deleted"

        elif self.mark.mode == MarkMode.CHAR:
            # Simple single-line char delete for now
            if self.mark.start_row == self.mark.end_row:
                start_col = min(self.mark.start_col, self.mark.end_col)
                end_col = max(self.mark.start_col, self.mark.end_col)
                line = self.buffer.get_line(self.mark.start_row)
                self.buffer.push_undo(
                    UndoType.LINE_CHANGE, self.mark.start_row, start_col,
                    self.mark.start_row, self.mark.start_row
                )
                self.buffer.set_line(self.mark.start_row, line[:start_col] + line[end_col + 1:])
                self.message = "Chars deleted"

        self._invalidate_syntax_cache()
        self.mark = None

    def _copy_mark(self):
        """Copy marked region to cursor position."""
        if not self.mark:
            self.message = "No mark"
            return

        if self.mark.mode == MarkMode.LINE:
            start = min(self.mark.start_row, self.mark.end_row)
            end = max(self.mark.start_row, self.mark.end_row)
            lines_to_copy = [self.buffer.lines[i] for i in range(start, end + 1)]

            # Insert after cursor line
            insert_row = self.cursor_row + 1

            # Create undo entry directly (push_undo has issues with extending buffer)
            self.buffer.redo_stack.clear()
            self.buffer.undo_stack.append(UndoEntry(
                undo_type=UndoType.LINE_INSERT,
                line_num=insert_row,
                col_pos=0,
                saved_lines=[],  # Not needed for LINE_INSERT undo
                line_count=len(lines_to_copy)
            ))

            for i, line in enumerate(lines_to_copy):
                self.buffer.lines.insert(insert_row + i, line)
            self.buffer.modified = True

            # Create new mark at destination
            self.mark = Mark(
                mode=MarkMode.LINE,
                start_row=insert_row,
                start_col=0,
                end_row=insert_row + len(lines_to_copy) - 1,
                end_col=0
            )
            self.message = f"Copied {len(lines_to_copy)} lines"

        elif self.mark.mode == MarkMode.BLOCK:
            start_row = min(self.mark.start_row, self.mark.end_row)
            end_row = max(self.mark.start_row, self.mark.end_row)
            start_col = min(self.mark.start_col, self.mark.end_col)
            end_col = max(self.mark.start_col, self.mark.end_col)

            # Extract block text
            block_lines = []
            for row in range(start_row, end_row + 1):
                line = self.buffer.get_line(row)
                block_text = line[start_col:end_col + 1] if start_col < len(line) else ""
                block_lines.append(block_text.ljust(end_col - start_col + 1))

            # Insert at cursor
            dest_row = self.cursor_row
            dest_col = self.cursor_col

            # Track original state for undo
            original_line_count = len(self.buffer.lines)
            lines_to_modify = []
            for i in range(len(block_lines)):
                target_row = dest_row + i
                if target_row < len(self.buffer.lines):
                    lines_to_modify.append(self.buffer.lines[target_row])
                else:
                    break

            # Create undo entry that tracks both modified lines and added lines
            # Use negative col_pos to signal "this is original line count" for undo
            self.buffer.redo_stack.clear()
            self.buffer.undo_stack.append(UndoEntry(
                undo_type=UndoType.BLOCK_CHANGE,
                line_num=dest_row,
                col_pos=-original_line_count - 1,  # Negative signals "original line count"
                saved_lines=lines_to_modify,
                line_count=len(block_lines)
            ))

            for i, block_text in enumerate(block_lines):
                target_row = dest_row + i
                if target_row >= len(self.buffer.lines):
                    self.buffer.lines.append("")
                line = self.buffer.get_line(target_row)
                line = line.ljust(dest_col)  # Pad if needed
                new_line = line[:dest_col] + block_text + line[dest_col:]
                self.buffer.set_line(target_row, new_line)

            # Create new mark at destination
            self.mark = Mark(
                mode=MarkMode.BLOCK,
                start_row=dest_row,
                start_col=dest_col,
                end_row=dest_row + len(block_lines) - 1,
                end_col=dest_col + end_col - start_col
            )
            self.message = "Block copied"

    def _move_mark(self):
        """Move marked region to cursor position."""
        if not self.mark:
            self.message = "No mark"
            return

        if self.mark.mode == MarkMode.LINE:
            start = min(self.mark.start_row, self.mark.end_row)
            end = max(self.mark.start_row, self.mark.end_row)

            # Can't move into itself
            if start <= self.cursor_row <= end:
                self.message = "Can't move into mark"
                return

            lines_to_move = self.buffer.lines[start:end + 1]

            # Delete from source
            del self.buffer.lines[start:end + 1]
            if not self.buffer.lines:
                self.buffer.lines = [""]

            # Adjust cursor if it was below the deleted region
            insert_row = self.cursor_row
            if self.cursor_row > end:
                insert_row -= (end - start + 1)
            insert_row += 1

            # Insert at new position
            for i, line in enumerate(lines_to_move):
                self.buffer.lines.insert(insert_row + i, line)
            self.buffer.modified = True

            # Create new mark at destination
            self.mark = Mark(
                mode=MarkMode.LINE,
                start_row=insert_row,
                start_col=0,
                end_row=insert_row + len(lines_to_move) - 1,
                end_col=0
            )
            self.message = f"Moved {len(lines_to_move)} lines"

    def _overlay_mark(self):
        """Overlay block at cursor (replace, don't insert)."""
        if not self.mark or self.mark.mode != MarkMode.BLOCK:
            self.message = "Need BLOCK mark for overlay"
            return

        start_row = min(self.mark.start_row, self.mark.end_row)
        end_row = max(self.mark.start_row, self.mark.end_row)
        start_col = min(self.mark.start_col, self.mark.end_col)
        end_col = max(self.mark.start_col, self.mark.end_col)
        block_width = end_col - start_col + 1

        # Extract block text
        block_lines = []
        for row in range(start_row, end_row + 1):
            line = self.buffer.get_line(row)
            block_text = line[start_col:end_col + 1] if start_col < len(line) else ""
            block_lines.append(block_text.ljust(block_width))

        # Overlay at cursor
        dest_row = self.cursor_row
        dest_col = self.cursor_col
        self.buffer.push_undo(
            UndoType.BLOCK_CHANGE, dest_row, dest_col, dest_row, dest_row + len(block_lines) - 1
        )
        for i, block_text in enumerate(block_lines):
            target_row = dest_row + i
            if target_row >= len(self.buffer.lines):
                self.buffer.lines.append("")
            line = self.buffer.get_line(target_row)
            line = line.ljust(dest_col + block_width)  # Pad if needed
            # Replace instead of insert
            new_line = line[:dest_col] + block_text + line[dest_col + block_width:]
            self.buffer.set_line(target_row, new_line)

        # Create new mark at destination
        self.mark = Mark(
            mode=MarkMode.BLOCK,
            start_row=dest_row,
            start_col=dest_col,
            end_row=dest_row + len(block_lines) - 1,
            end_col=dest_col + block_width - 1
        )
        self.message = "Block overlaid"

    def _goto_mark_start(self):
        """Move cursor to mark start."""
        if not self.mark:
            self.message = "No mark"
            return
        self._save_jump_position()
        self.cursor_row = min(self.mark.start_row, self.mark.end_row)
        self.cursor_col = min(self.mark.start_col, self.mark.end_col)
        self.message = "Mark start"

    def _goto_mark_end(self):
        """Move cursor to mark end."""
        if not self.mark:
            self.message = "No mark"
            return
        self._save_jump_position()
        self.cursor_row = max(self.mark.start_row, self.mark.end_row)
        self.cursor_col = max(self.mark.start_col, self.mark.end_col)
        self.message = "Mark end"

    def _shift_mark_left(self):
        """Shift marked lines left (remove leading space)."""
        if not self.mark:
            self.message = "No mark"
            return

        if self.mark.mode == MarkMode.LINE:
            start = min(self.mark.start_row, self.mark.end_row)
            end = max(self.mark.start_row, self.mark.end_row)
            self.buffer.push_undo(UndoType.BLOCK_CHANGE, start, 0, start, end)
            for row in range(start, end + 1):
                line = self.buffer.get_line(row)
                if line.startswith(' '):
                    self.buffer.set_line(row, line[1:])
                elif line.startswith('\t'):
                    self.buffer.set_line(row, line[1:])
            self.message = "Shifted left"
        else:
            self.message = "Need LINE mark"

    def _shift_mark_right(self):
        """Shift marked lines right (add leading space)."""
        if not self.mark:
            self.message = "No mark"
            return

        if self.mark.mode == MarkMode.LINE:
            start = min(self.mark.start_row, self.mark.end_row)
            end = max(self.mark.start_row, self.mark.end_row)
            self.buffer.push_undo(UndoType.BLOCK_CHANGE, start, 0, start, end)
            for row in range(start, end + 1):
                line = self.buffer.get_line(row)
                self.buffer.set_line(row, ' ' + line)
            self.message = "Shifted right"
        else:
            self.message = "Need LINE mark"

    def _split_line(self):
        """Split line at cursor (Alt-S)."""
        self.buffer.push_undo(
            UndoType.LINE_SPLIT, self.cursor_row, self.cursor_col,
            self.cursor_row, self.cursor_row
        )
        self.buffer.split_line(self.cursor_row, self.cursor_col)
        self.message = "Line split"

    def _join_lines(self):
        """Join current line with next (Alt-J)."""
        if self.cursor_row >= len(self.buffer.lines) - 1:
            self.message = "No line below"
            return
        self.buffer.push_undo(
            UndoType.LINE_JOIN, self.cursor_row, self.cursor_col,
            self.cursor_row, self.cursor_row + 1
        )
        line = self.buffer.get_line(self.cursor_row)
        next_line = self.buffer.get_line(self.cursor_row + 1)
        self.buffer.set_line(self.cursor_row, line + next_line)
        del self.buffer.lines[self.cursor_row + 1]
        self.message = "Lines joined"

    def _save_jump_position(self):
        """Deprecated - kept for compatibility. Does nothing."""
        pass

    def _toggle_bookmark(self):
        """Toggle bookmark on current line."""
        if self.cursor_row in self.bookmarks:
            self.bookmarks.remove(self.cursor_row)
            self.message = f"Bookmark removed (L{self.cursor_row + 1})"
        else:
            self.bookmarks.add(self.cursor_row)
            self.message = f"Bookmark set (L{self.cursor_row + 1})"

    def _prev_bookmark(self):
        """Move to previous bookmark."""
        if not self.bookmarks:
            self.message = "No bookmarks"
            return
        # Find bookmarks before current line
        prev_marks = sorted([b for b in self.bookmarks if b < self.cursor_row], reverse=True)
        if prev_marks:
            self.cursor_row = prev_marks[0]
            self.cursor_col = 0
            self.message = f"Bookmark L{self.cursor_row + 1}"
        else:
            # Wrap to last bookmark
            self.cursor_row = max(self.bookmarks)
            self.cursor_col = 0
            self.message = f"Bookmark L{self.cursor_row + 1} (wrapped)"

    def _next_bookmark(self):
        """Move to next bookmark."""
        if not self.bookmarks:
            self.message = "No bookmarks"
            return
        # Find bookmarks after current line
        next_marks = sorted([b for b in self.bookmarks if b > self.cursor_row])
        if next_marks:
            self.cursor_row = next_marks[0]
            self.cursor_col = 0
            self.message = f"Bookmark L{self.cursor_row + 1}"
        else:
            # Wrap to first bookmark
            self.cursor_row = min(self.bookmarks)
            self.cursor_col = 0
            self.message = f"Bookmark L{self.cursor_row + 1} (wrapped)"

    # === File ring (multiple buffers) ===

    def _save_view_to_buffer(self) -> None:
        """Save current view state to current buffer."""
        self.buffer.cursor_row = self.cursor_row
        self.buffer.cursor_col = self.cursor_col
        self.buffer.top_row = self.top_row
        self.buffer.left_col = self.left_col
        self.buffer.mark = self.mark

    def _restore_view_from_buffer(self) -> None:
        """Restore view state from current buffer."""
        self.cursor_row = self.buffer.cursor_row
        self.cursor_col = self.buffer.cursor_col
        self.top_row = self.buffer.top_row
        self.left_col = self.buffer.left_col
        self.mark = self.buffer.mark
        # Restore bookmarks if stored on buffer
        self.bookmarks = getattr(self.buffer, 'bookmarks', set())

    def _switch_to_buffer(self, idx: int) -> None:
        """Switch to buffer at index, wrapping around."""
        if len(self.buffers) <= 1:
            self.message = "No other files in ring"
            return

        # Clear spell mode when switching files
        if self.spell_mode:
            self.spell_mode = False
            self.spell_errors = []

        # Save current view state (including bookmarks)
        self._save_view_to_buffer()
        self.buffer.bookmarks = self.bookmarks

        # Switch to new buffer (wrap around)
        self.current_buffer_idx = idx % len(self.buffers)
        self.buffer = self.buffers[self.current_buffer_idx]

        # Restore view state from new buffer
        self._restore_view_from_buffer()

        # Reinitialize syntax highlighting for new file
        self._setup_syntax_highlighter()

        # Invalidate minimap
        self.minimap_dirty = True

        fname = self.buffer.filename or "[No Name]"
        self.message = f"[{self.current_buffer_idx + 1}/{len(self.buffers)}] {fname}"

    def _next_file(self) -> None:
        """Ctrl-N - next buffer in ring."""
        self._switch_to_buffer(self.current_buffer_idx + 1)

    def _prev_file(self) -> None:
        """Ctrl-P - previous buffer in ring."""
        self._switch_to_buffer(self.current_buffer_idx - 1)

    def _open_file(self, filename: str) -> None:
        """Open a file, adding to ring or switching if already open."""
        # Check if already in ring
        for i, buf in enumerate(self.buffers):
            if buf.filename == filename:
                self._switch_to_buffer(i)
                return

        # Clear spell mode when opening new file
        if self.spell_mode:
            self.spell_mode = False
            self.spell_errors = []

        # Save current view state before switching
        self._save_view_to_buffer()
        self.buffer.bookmarks = self.bookmarks

        # Create new buffer and load file
        new_buf = Buffer()
        if not new_buf.load(filename):
            self.message = f"Failed to open {filename}"
            return

        # Add to ring
        self.buffers.append(new_buf)
        self.current_buffer_idx = len(self.buffers) - 1
        self.buffer = new_buf

        # Reset view state for new buffer
        self.cursor_row = 0
        self.cursor_col = 0
        self.top_row = 0
        self.left_col = 0
        self.mark = None
        self.bookmarks = set()

        # Initialize syntax highlighting
        self._setup_syntax_highlighter()

        # Invalidate minimap
        self.minimap_dirty = True

        self.message = f"[{self.current_buffer_idx + 1}/{len(self.buffers)}] Opened {filename}"

    def _close_current_buffer(self) -> None:
        """Close current buffer and switch to another, or exit if last buffer."""
        # Clear spell mode
        if self.spell_mode:
            self.spell_mode = False
            self.spell_errors = []

        # Clear passphrase from memory
        self.buffer.clear_passphrase()

        if len(self.buffers) == 1:
            # Last buffer - exit editor
            self.running = False
        else:
            # Remove current buffer and switch to next
            del self.buffers[self.current_buffer_idx]
            if self.current_buffer_idx >= len(self.buffers):
                self.current_buffer_idx = 0
            self.buffer = self.buffers[self.current_buffer_idx]
            self._restore_view_from_buffer()
            self._setup_syntax_highlighter()
            self.minimap_dirty = True
            self.message = f"[{self.current_buffer_idx + 1}/{len(self.buffers)}] {self.buffer.filename or '[No Name]'}"

    def _parse_replace_command(self, cmd: str):
        """Parse c/old/new/flags replace command."""
        # Format: c/old/new/ or c/old/new/* or c/old/new/c
        parts = cmd[2:].split('/')
        if len(parts) < 2:
            self.message = "Usage: c/old/new/ or c/old/new/*"
            return

        self.replace_old = parts[0]
        self.replace_new = parts[1] if len(parts) > 1 else ""
        self.replace_flags = parts[2] if len(parts) > 2 else ""

        if not self.replace_old:
            self.message = "Empty search pattern"
            return

        if '*' in self.replace_flags:
            # Replace all
            self._replace_all()
        else:
            # Interactive mode
            self.replace_mode = True
            self._find_next_replace()

    def _find_next_replace(self):
        """Find next occurrence for replace."""
        import re
        flags = re.IGNORECASE if 'c' in self.replace_flags else 0
        try:
            regex = re.compile(re.escape(self.replace_old), flags)
        except re.error:
            self.message = f"Invalid pattern: {self.replace_old}"
            self.replace_mode = False
            return

        # Start from current position
        start_row = self.cursor_row
        start_col = self.cursor_col

        # Search from current line
        for row in range(start_row, len(self.buffer.lines)):
            line = self.buffer.get_line(row)
            col_start = start_col if row == start_row else 0
            match = regex.search(line[col_start:])
            if match:
                self.cursor_row = row
                self.cursor_col = col_start + match.start()
                self.message = f"Replace '{self.replace_old}' with '{self.replace_new}'? (Y/N/G/Q)"
                return

        # Wrap around
        for row in range(0, start_row + 1):
            line = self.buffer.get_line(row)
            col_end = start_col if row == start_row else len(line)
            match = regex.search(line[:col_end])
            if match:
                self.cursor_row = row
                self.cursor_col = match.start()
                self.message = f"Replace '{self.replace_old}' with '{self.replace_new}'? (Y/N/G/Q)"
                return

        self.message = "Replace complete"
        self.replace_mode = False

    def _do_replace_one(self):
        """Replace current occurrence."""
        line = self.buffer.get_line(self.cursor_row)
        import re
        flags = re.IGNORECASE if 'c' in self.replace_flags else 0
        regex = re.compile(re.escape(self.replace_old), flags)
        match = regex.search(line[self.cursor_col:])
        if match:
            self.buffer.push_undo(
                UndoType.LINE_CHANGE, self.cursor_row, self.cursor_col,
                self.cursor_row, self.cursor_row
            )
            new_line = line[:self.cursor_col + match.start()] + self.replace_new + line[self.cursor_col + match.end():]
            self.buffer.set_line(self.cursor_row, new_line)
            # Move cursor past replacement
            self.cursor_col = self.cursor_col + match.start() + len(self.replace_new)

    def _replace_all(self):
        """Replace all occurrences."""
        import re
        flags = re.IGNORECASE if 'c' in self.replace_flags else 0
        count = 0
        try:
            regex = re.compile(re.escape(self.replace_old), flags)
        except re.error:
            self.message = f"Invalid pattern: {self.replace_old}"
            return

        for row in range(len(self.buffer.lines)):
            line = self.buffer.get_line(row)
            if regex.search(line):
                if count == 0:
                    # Save undo for first line changed
                    self.buffer.push_undo(
                        UndoType.BLOCK_CHANGE, 0, 0, 0, len(self.buffer.lines) - 1
                    )
                new_line, num = regex.subn(self.replace_new, line)
                if num > 0:
                    self.buffer.set_line(row, new_line)
                    count += num

        self._invalidate_syntax_cache()
        self.message = f"Replaced {count} occurrence{'s' if count != 1 else ''}"

    def _paste_clipboard(self):
        """Paste text from system clipboard."""
        try:
            import pygame.scrap
            pygame.scrap.init()
            text = pygame.scrap.get(pygame.SCRAP_TEXT)
            if text:
                # Decode bytes if needed
                if isinstance(text, bytes):
                    text = text.decode('utf-8', errors='replace').rstrip('\x00')
                self._insert_text(text)
        except Exception as e:
            self.message = f"Paste failed: {e}"

    def _insert_text(self, text: str):
        """Insert text at cursor position, handling newlines."""
        if not text:
            return

        self.buffer.push_undo(
            UndoType.BLOCK_CHANGE, self.cursor_row, self.cursor_col,
            self.cursor_row, self.cursor_row
        )

        lines = text.split('\n')
        if len(lines) == 1:
            # Single line paste
            line = self.buffer.get_line(self.cursor_row)
            new_line = line[:self.cursor_col] + text + line[self.cursor_col:]
            self.buffer.set_line(self.cursor_row, new_line)
            self.cursor_col += len(text)
        else:
            # Multi-line paste
            line = self.buffer.get_line(self.cursor_row)
            first_part = line[:self.cursor_col]
            last_part = line[self.cursor_col:]

            # First line
            self.buffer.set_line(self.cursor_row, first_part + lines[0])

            # Middle lines
            for i, new_line in enumerate(lines[1:-1], 1):
                self.buffer.lines.insert(self.cursor_row + i, new_line)

            # Last line
            self.buffer.lines.insert(self.cursor_row + len(lines) - 1, lines[-1] + last_part)

            self.cursor_row += len(lines) - 1
            self.cursor_col = len(lines[-1])

        self.buffer.modified = True
        self._invalidate_syntax_cache()
        self.message = f"Pasted {len(lines)} line{'s' if len(lines) > 1 else ''}"

    def _copy_mark_to_clipboard(self):
        """Copy marked text to system clipboard."""
        if not self.mark:
            self.message = "No mark"
            return

        text = self._get_marked_text()
        if not text:
            self.message = "Nothing to copy"
            return

        try:
            import pygame.scrap
            pygame.scrap.init()
            pygame.scrap.put(pygame.SCRAP_TEXT, text.encode('utf-8'))
            self.message = "Copied to clipboard"
        except Exception as e:
            self.message = f"Copy failed: {e}"

    def _do_sort_command(self, cmd: str) -> None:
        """Execute sort command on marked lines or entire file.

        Options: -r (reverse), -n (numeric), -f (case-insensitive), -kN (field N)
        """
        import re

        # Parse sort options
        args = cmd[4:].strip() if len(cmd) > 4 else ""
        reverse = '-r' in args
        numeric = '-n' in args
        fold_case = '-f' in args

        # Parse -k options (field numbers, 1-based)
        field_nums = [int(m.group(1)) for m in re.finditer(r'-k(\d+)', args)]

        # Determine range based on mark
        if self.mark:
            start_row = min(self.mark.start_row, self.mark.end_row if self.mark.end_row is not None else self.mark.start_row)
            end_row = max(self.mark.start_row, self.mark.end_row if self.mark.end_row is not None else self.mark.start_row)
        else:
            start_row = 0
            end_row = len(self.buffer.lines) - 1

        # Get lines to sort
        lines = [self.buffer.get_line(row) for row in range(start_row, end_row + 1)]

        # Build sort key function
        if self.mark and self.mark.mode == MarkMode.BLOCK:
            start_col = min(self.mark.start_col, self.mark.end_col if self.mark.end_col is not None else self.mark.start_col)
            end_col = max(self.mark.start_col, self.mark.end_col if self.mark.end_col is not None else self.mark.start_col)

            def get_key(line):
                key = line[start_col:end_col + 1] if len(line) > start_col else ""
                if fold_case:
                    key = key.lower()
                if numeric:
                    try:
                        return float(key.strip())
                    except ValueError:
                        return float('-inf') if not reverse else float('inf')
                return key
        elif field_nums:
            def get_key(line):
                fields = line.split()
                keys = []
                for field_num in field_nums:
                    if field_num <= len(fields):
                        key = fields[field_num - 1]
                    else:
                        key = ""
                    if fold_case:
                        key = key.lower()
                    if numeric:
                        try:
                            keys.append(float(key.strip()))
                        except ValueError:
                            keys.append(float('-inf') if not reverse else float('inf'))
                    else:
                        keys.append(key)
                return tuple(keys) if len(keys) > 1 else keys[0] if keys else ""
        else:
            def get_key(line):
                key = line
                if fold_case:
                    key = key.lower()
                if numeric:
                    try:
                        return float(key.strip())
                    except ValueError:
                        return float('-inf') if not reverse else float('inf')
                return key

        # Sort the lines
        try:
            sorted_lines = sorted(lines, key=get_key, reverse=reverse)
        except Exception as e:
            self.message = f"Sort error: {e}"
            return

        # Save undo state
        self.buffer.push_undo(UndoType.BLOCK_CHANGE, start_row, 0, start_row, end_row)

        # Replace lines
        for i, row in enumerate(range(start_row, end_row + 1)):
            if i < len(sorted_lines):
                self.buffer.set_line(row, sorted_lines[i])

        self.buffer.modified = True
        self.mark = None
        self._invalidate_syntax_cache()
        count = end_row - start_row + 1
        self.message = f"Sorted {count} line{'s' if count != 1 else ''}"

    def _do_uniq_command(self, cmd: str) -> None:
        """Remove adjacent duplicate lines.

        Options: -i (case-insensitive)
        """
        # Parse options
        if cmd.upper().startswith('UNIQUE'):
            args = cmd[6:].strip()
        else:
            args = cmd[4:].strip()
        case_insensitive = '-i' in args

        # Determine range based on mark
        if self.mark:
            start_row = min(self.mark.start_row, self.mark.end_row if self.mark.end_row is not None else self.mark.start_row)
            end_row = max(self.mark.start_row, self.mark.end_row if self.mark.end_row is not None else self.mark.start_row)
        else:
            start_row = 0
            end_row = len(self.buffer.lines) - 1

        # Get lines
        lines = [self.buffer.get_line(row) for row in range(start_row, end_row + 1)]

        if not lines:
            self.message = "No lines to process"
            return

        # Remove adjacent duplicates
        result = [lines[0]]
        for line in lines[1:]:
            prev = result[-1]
            if case_insensitive:
                is_dup = line.lower() == prev.lower()
            else:
                is_dup = line == prev
            if not is_dup:
                result.append(line)

        removed = len(lines) - len(result)
        if removed == 0:
            self.message = "No duplicates found"
            return

        # Save undo state
        self.buffer.push_undo(UndoType.BLOCK_CHANGE, start_row, 0, start_row, end_row)

        # Delete the original lines and insert the unique ones
        for _ in range(start_row, end_row + 1):
            if start_row < len(self.buffer.lines):
                del self.buffer.lines[start_row]

        for i, line in enumerate(result):
            self.buffer.lines.insert(start_row + i, line)

        if not self.buffer.lines:
            self.buffer.lines = [""]

        self.buffer.modified = True
        self.mark = None
        self._invalidate_syntax_cache()
        self.message = f"Removed {removed} duplicate{'s' if removed != 1 else ''}"

    def _do_grep_command(self, cmd: str) -> None:
        """Filter lines using grep (keeps matching lines, deletes non-matching).

        Options: -v (inverse), -i (case-insensitive)
        Uses Python regex, not external grep command.
        """
        import re

        # Parse command: grep [-v] [-i] pattern
        args_str = cmd[4:].strip()

        if not args_str:
            self.message = "Usage: grep [-v] [-i] pattern"
            return

        # Parse options and pattern
        inverse = False
        case_insensitive = False

        parts = args_str.split()
        pattern = None

        for i, part in enumerate(parts):
            if part == '-v':
                inverse = True
            elif part == '-i':
                case_insensitive = True
            elif part == '-iv' or part == '-vi':
                inverse = True
                case_insensitive = True
            elif pattern is None:
                # Rest is the pattern (may contain spaces)
                pattern = ' '.join(parts[i:])
                break

        if not pattern:
            self.message = "Usage: grep [-v] [-i] pattern"
            return

        # Compile regex
        flags = re.IGNORECASE if case_insensitive else 0
        try:
            regex = re.compile(pattern, flags)
        except re.error as e:
            self.message = f"Invalid pattern: {e}"
            return

        # Determine range based on mark
        if self.mark:
            start_row = min(self.mark.start_row, self.mark.end_row if self.mark.end_row is not None else self.mark.start_row)
            end_row = max(self.mark.start_row, self.mark.end_row if self.mark.end_row is not None else self.mark.start_row)
        else:
            start_row = 0
            end_row = len(self.buffer.lines) - 1

        # For BLOCK mark, determine column range
        block_mode = self.mark and self.mark.mode == MarkMode.BLOCK
        if block_mode:
            start_col = min(self.mark.start_col, self.mark.end_col if self.mark.end_col is not None else self.mark.start_col)
            end_col = max(self.mark.start_col, self.mark.end_col if self.mark.end_col is not None else self.mark.start_col)

        # Get lines
        original_lines = [self.buffer.get_line(row) for row in range(start_row, end_row + 1)]

        # Find matching lines
        matching_indices = set()
        for i, line in enumerate(original_lines):
            if block_mode:
                search_text = line[start_col:end_col + 1] if len(line) > start_col else ""
            else:
                search_text = line
            if regex.search(search_text):
                matching_indices.add(i)

        # Check for no matches
        if not matching_indices and not inverse:
            self.message = "No matches found"
            return

        # Check for all matches in inverse mode
        if len(matching_indices) == len(original_lines) and inverse:
            self.message = "All lines match - nothing to keep"
            return

        # Determine which lines to keep
        if inverse:
            keep_indices = [i for i in range(len(original_lines)) if i not in matching_indices]
        else:
            keep_indices = [i for i in range(len(original_lines)) if i in matching_indices]

        kept_lines = [original_lines[i] for i in keep_indices]
        removed = len(original_lines) - len(kept_lines)

        if removed == 0:
            self.message = "No changes needed"
            return

        # Save undo state
        self.buffer.push_undo(UndoType.BLOCK_CHANGE, start_row, 0, start_row, end_row)

        # Delete original lines and insert kept ones
        for _ in range(start_row, end_row + 1):
            if start_row < len(self.buffer.lines):
                del self.buffer.lines[start_row]

        for i, line in enumerate(kept_lines):
            self.buffer.lines.insert(start_row + i, line)

        if not self.buffer.lines:
            self.buffer.lines = [""]

        self.buffer.modified = True
        self.mark = None
        self._invalidate_syntax_cache()
        kept = len(kept_lines)
        self.message = f"Kept {kept} line{'s' if kept != 1 else ''}, removed {removed}"

    def _do_encrypt_command(self) -> None:
        """Start encryption process - prompts for passphrase."""
        if not AGE_AVAILABLE:
            self.message = "Encryption unavailable (install pyrage)"
            return

        if self.buffer.encrypted:
            self.message = "Already encrypted"
            return

        # Start passphrase entry
        self.passphrase_mode = True
        self.passphrase_buffer = ""
        self.passphrase_confirm = ""
        self.passphrase_step = 0  # New passphrase
        self.passphrase_callback = self._finish_encrypt
        self.message = "New passphrase: "

    def _do_passwd_command(self) -> None:
        """Start password change process."""
        if not self.buffer.encrypted:
            self.message = "File is not encrypted"
            return

        # Start passphrase entry (old first)
        self.passphrase_mode = True
        self.passphrase_buffer = ""
        self.passphrase_confirm = ""
        self.passphrase_step = 2  # Old passphrase first
        self.passphrase_callback = self._finish_passwd
        self.message = "Current passphrase: "

    def _handle_passphrase_key(self, event):
        """Handle key input during passphrase entry."""
        key = event.key

        if key == pygame.K_ESCAPE:
            self.passphrase_mode = False
            self.passphrase_buffer = ""
            self.passphrase_confirm = ""
            self.message = "Cancelled"
            return

        if key == pygame.K_RETURN:
            if self.passphrase_step == 2:
                # Checking old passphrase
                if self.passphrase_buffer != self.buffer.passphrase:
                    self.passphrase_mode = False
                    self.passphrase_buffer = ""
                    self.message = "Wrong passphrase"
                    return
                # Move to new passphrase
                self.passphrase_buffer = ""
                self.passphrase_step = 0
                self.message = "New passphrase: "
            elif self.passphrase_step == 0:
                # Got new passphrase, need confirm
                if not self.passphrase_buffer:
                    self.passphrase_mode = False
                    self.message = "Cancelled - empty passphrase"
                    return
                self.passphrase_confirm = self.passphrase_buffer
                self.passphrase_buffer = ""
                self.passphrase_step = 1
                self.message = "Confirm passphrase: "
            elif self.passphrase_step == 1:
                # Got confirmation
                if self.passphrase_buffer != self.passphrase_confirm:
                    self.passphrase_mode = False
                    self.passphrase_buffer = ""
                    self.passphrase_confirm = ""
                    self.message = "Passphrases don't match"
                    return
                # Success - call callback
                passphrase = self.passphrase_buffer
                self.passphrase_mode = False
                self.passphrase_buffer = ""
                self.passphrase_confirm = ""
                if self.passphrase_callback:
                    self.passphrase_callback(passphrase)
            return

        if key == pygame.K_BACKSPACE:
            if self.passphrase_buffer:
                self.passphrase_buffer = self.passphrase_buffer[:-1]
            return

        # Add character (don't show it)
        if event.unicode and len(event.unicode) == 1 and event.unicode.isprintable():
            self.passphrase_buffer += event.unicode

    def _finish_encrypt(self, passphrase: str) -> None:
        """Complete encryption with passphrase."""
        self.buffer.encrypted = True
        self.buffer.passphrase = passphrase
        self.buffer.modified = True
        self.message = "Encrypted. Save to apply."

    def _finish_passwd(self, passphrase: str) -> None:
        """Complete password change."""
        self.buffer.passphrase = passphrase
        self.buffer.modified = True
        self.message = "Passphrase changed. Save to apply."

    # ========== Spell Check ==========

    def _toggle_spell_mode(self) -> None:
        """Toggle spell checking mode."""
        if not SPELL_AVAILABLE:
            self.message = "Spell check unavailable (pip install pyspellchecker)"
            return

        if self.spell_mode:
            self._clear_spell_mode()
            self.message = ""
        else:
            self.spell_mode = True
            self._init_spell_checker()
            self._scan_spelling()
            count = len(self.spell_errors)
            if count == 0:
                self.message = "SPELL: No errors found | o)ff"
            else:
                self.message = f"SPELL: {count} error{'s' if count != 1 else ''} - n)ext p)rev o)ff"

    def _clear_spell_mode(self) -> None:
        """Clear spell mode state."""
        self.spell_mode = False
        self.spell_errors = []
        self.spell_index = -1
        self.spell_suggestions = []

    def _init_spell_checker(self) -> None:
        """Initialize spell checker with personal dictionary."""
        if self.spell_checker is None:
            self.spell_checker = SpellChecker()
            path = self._get_dict_path()
            if path.exists():
                try:
                    words = path.read_text().strip().split('\n')
                    words = [w.strip() for w in words if w.strip()]
                    if words:
                        self.spell_checker.word_frequency.load_words(words)
                except Exception:
                    pass

    def _get_dict_path(self) -> Path:
        """Get path to personal dictionary."""
        return Path.home() / ".e3po" / "dictionary.txt"

    def _save_personal_dict(self, word: str) -> None:
        """Add word to personal dictionary file."""
        path = self._get_dict_path()
        try:
            path.parent.mkdir(parents=True, exist_ok=True)
            with open(path, 'a') as f:
                f.write(word.lower() + '\n')
        except Exception:
            pass

    def _scan_spelling(self) -> None:
        """Scan buffer for misspelled words."""
        import re
        self.spell_errors = []
        if not self.spell_checker:
            return

        word_pattern = re.compile(r"[a-zA-Z']+")

        for row, line in enumerate(self.buffer.lines):
            for match in word_pattern.finditer(line):
                word = match.group()
                start_col = match.start()
                end_col = match.end()

                # Skip short words or skipped words
                if len(word) < 2 or word.replace("'", "") == "":
                    continue
                if word.lower() in self.spell_skip_words:
                    continue

                # Check spelling
                if word.lower() not in self.spell_checker:
                    self.spell_errors.append((row, start_col, end_col, word))

    def _handle_spell_key(self, event) -> bool:
        """Handle spell mode keys. Returns True if handled."""
        key = event.key

        if key == pygame.K_n:
            self._spell_next()
            return True
        elif key == pygame.K_p:
            self._spell_prev()
            return True
        elif key == pygame.K_o:
            self._toggle_spell_mode()
            return True
        elif key == pygame.K_s:
            self._spell_skip()
            return True
        elif key == pygame.K_a:
            self._spell_add_to_dict()
            return True
        elif key == pygame.K_e:
            self.message = "SPELL: Edit word, then n)ext p)rev o)ff"
            return False  # Let normal editing happen
        elif pygame.K_1 <= key <= pygame.K_9:
            self._spell_replace(key - pygame.K_1)
            return True
        return False

    def _spell_next(self) -> None:
        """Move to next misspelled word."""
        if not self.spell_errors:
            self.spell_suggestions = []
            self.message = "SPELL: No errors found | o)ff"
            return
        self.spell_index = (self.spell_index + 1) % len(self.spell_errors)
        self._goto_spell_error()

    def _spell_prev(self) -> None:
        """Move to previous misspelled word."""
        if not self.spell_errors:
            self.spell_suggestions = []
            self.message = "SPELL: No errors found | o)ff"
            return
        self.spell_index = (self.spell_index - 1) % len(self.spell_errors)
        self._goto_spell_error()

    def _goto_spell_error(self) -> None:
        """Move cursor to current spell error and show suggestions."""
        if not (0 <= self.spell_index < len(self.spell_errors)):
            self.spell_suggestions = []
            return

        row, start_col, end_col, word = self.spell_errors[self.spell_index]
        self._save_jump_position()
        self.cursor_row = row
        self.cursor_col = start_col

        # Get suggestions
        suggestions = self.spell_checker.candidates(word)
        self.spell_suggestions = list(suggestions)[:9] if suggestions else []

        # Show suggestions in message
        if self.spell_suggestions:
            sugg_str = " ".join(f"{i+1}){s}" for i, s in enumerate(self.spell_suggestions))
            self.message = f"[{word}]: {sugg_str} | n/p/s/a/o"
        else:
            self.message = f"[{word}]: no suggestions | n)ext p)rev s)kip a)dd o)ff"

    def _spell_skip(self) -> None:
        """Skip current word for this session."""
        if not (0 <= self.spell_index < len(self.spell_errors)):
            return
        word = self.spell_errors[self.spell_index][3]
        self.spell_skip_words.add(word.lower())
        self._scan_spelling()
        if self.spell_index >= len(self.spell_errors):
            self.spell_index = len(self.spell_errors) - 1
        if self.spell_errors:
            self._goto_spell_error()
        else:
            self.spell_suggestions = []
            self.message = "SPELL: No more errors | o)ff"

    def _spell_add_to_dict(self) -> None:
        """Add current word to personal dictionary."""
        if not (0 <= self.spell_index < len(self.spell_errors)):
            return
        word = self.spell_errors[self.spell_index][3]
        self.spell_checker.word_frequency.load_words([word.lower()])
        self._save_personal_dict(word)
        self._scan_spelling()
        if self.spell_index >= len(self.spell_errors):
            self.spell_index = len(self.spell_errors) - 1
        if self.spell_errors:
            self._goto_spell_error()
        else:
            self.spell_suggestions = []
            self.message = "SPELL: No more errors | o)ff"

    def _spell_replace(self, suggestion_index: int) -> None:
        """Replace current word with numbered suggestion."""
        if not (0 <= self.spell_index < len(self.spell_errors)):
            return

        row, start_col, end_col, word = self.spell_errors[self.spell_index]

        if suggestion_index >= len(self.spell_suggestions):
            self.message = f"No suggestion #{suggestion_index + 1}"
            return

        replacement = self.spell_suggestions[suggestion_index]

        self.buffer.push_undo(UndoType.LINE_CHANGE, row, start_col, row, row)
        line = self.buffer.get_line(row)
        new_line = line[:start_col] + replacement + line[end_col:]
        self.buffer.set_line(row, new_line)
        self.buffer.modified = True
        self._invalidate_syntax_cache()

        self._scan_spelling()
        if self.spell_index >= len(self.spell_errors):
            self.spell_index = len(self.spell_errors) - 1
        if self.spell_errors:
            self._goto_spell_error()
        else:
            self.spell_suggestions = []
            self.message = "SPELL: No more errors | o)ff"

    def get_misspelled_cols(self, buf_row: int) -> Tuple[set, set]:
        """Get misspelled columns for a row. Returns (other_cols, current_cols)."""
        other_cols = set()
        current_cols = set()
        for i, (row, start_col, end_col, word) in enumerate(self.spell_errors):
            if row == buf_row:
                if i == self.spell_index:
                    current_cols.update(range(start_col, end_col))
                else:
                    other_cols.update(range(start_col, end_col))
        return other_cols, current_cols

    def _get_marked_text(self) -> str:
        """Get text within current mark."""
        if not self.mark:
            return ""

        if self.mark.mode == MarkMode.LINE:
            start = min(self.mark.start_row, self.mark.end_row)
            end = max(self.mark.start_row, self.mark.end_row)
            return '\n'.join(self.buffer.lines[start:end + 1])

        elif self.mark.mode == MarkMode.BLOCK:
            start_row = min(self.mark.start_row, self.mark.end_row)
            end_row = max(self.mark.start_row, self.mark.end_row)
            start_col = min(self.mark.start_col, self.mark.end_col)
            end_col = max(self.mark.start_col, self.mark.end_col)
            lines = []
            for row in range(start_row, end_row + 1):
                line = self.buffer.get_line(row)
                block_text = line[start_col:end_col + 1] if start_col < len(line) else ""
                lines.append(block_text)
            return '\n'.join(lines)

        elif self.mark.mode == MarkMode.CHAR:
            if self.mark.start_row == self.mark.end_row:
                start_col = min(self.mark.start_col, self.mark.end_col)
                end_col = max(self.mark.start_col, self.mark.end_col)
                line = self.buffer.get_line(self.mark.start_row)
                return line[start_col:end_col + 1]
            else:
                # Multi-line char mark
                start_row = min(self.mark.start_row, self.mark.end_row)
                end_row = max(self.mark.start_row, self.mark.end_row)
                lines = []
                for row in range(start_row, end_row + 1):
                    line = self.buffer.get_line(row)
                    if row == start_row:
                        lines.append(line[self.mark.start_col:])
                    elif row == end_row:
                        lines.append(line[:self.mark.end_col + 1])
                    else:
                        lines.append(line)
                return '\n'.join(lines)

        return ""

    def _start_fill(self):
        """Start fill operation (prompt for char)."""
        if not self.mark:
            self.message = "No mark for fill"
            return
        self.fill_pending = True
        self.message = "Fill with char: "

    def _do_fill(self, ch: str):
        """Fill marked region with character."""
        self.fill_pending = False

        if not self.mark:
            self.message = "No mark"
            return

        if self.mark.mode == MarkMode.LINE:
            start = min(self.mark.start_row, self.mark.end_row)
            end = max(self.mark.start_row, self.mark.end_row)
            self.buffer.push_undo(UndoType.BLOCK_CHANGE, start, 0, start, end)
            for row in range(start, end + 1):
                line = self.buffer.get_line(row)
                self.buffer.set_line(row, ch * len(line) if line else ch)

        elif self.mark.mode == MarkMode.BLOCK:
            start_row = min(self.mark.start_row, self.mark.end_row)
            end_row = max(self.mark.start_row, self.mark.end_row)
            start_col = min(self.mark.start_col, self.mark.end_col)
            end_col = max(self.mark.start_col, self.mark.end_col)
            width = end_col - start_col + 1
            fill_text = ch * width

            self.buffer.push_undo(UndoType.BLOCK_CHANGE, start_row, start_col, start_row, end_row)
            for row in range(start_row, end_row + 1):
                line = self.buffer.get_line(row)
                line = line.ljust(end_col + 1)
                new_line = line[:start_col] + fill_text + line[end_col + 1:]
                self.buffer.set_line(row, new_line)

        elif self.mark.mode == MarkMode.CHAR:
            if self.mark.start_row == self.mark.end_row:
                start_col = min(self.mark.start_col, self.mark.end_col)
                end_col = max(self.mark.start_col, self.mark.end_col)
                line = self.buffer.get_line(self.mark.start_row)
                self.buffer.push_undo(
                    UndoType.LINE_CHANGE, self.mark.start_row, start_col,
                    self.mark.start_row, self.mark.start_row
                )
                fill_text = ch * (end_col - start_col + 1)
                self.buffer.set_line(self.mark.start_row,
                    line[:start_col] + fill_text + line[end_col + 1:])

        self._invalidate_syntax_cache()
        self.message = f"Filled with '{ch}'"
        self.mark = None

    def _get_marked_cols(self, buf_row: int) -> set:
        """Get set of column indices that are marked on this row."""
        if not self.mark:
            return set()

        start_row = min(self.mark.start_row, self.mark.end_row)
        end_row = max(self.mark.start_row, self.mark.end_row)

        if buf_row < start_row or buf_row > end_row:
            return set()

        if self.mark.mode == MarkMode.LINE:
            # All columns in line are marked
            return set(range(200))  # Large range for "all"

        elif self.mark.mode == MarkMode.BLOCK:
            start_col = min(self.mark.start_col, self.mark.end_col)
            end_col = max(self.mark.start_col, self.mark.end_col)
            return set(range(start_col, end_col + 1))

        elif self.mark.mode == MarkMode.CHAR:
            if self.mark.start_row == self.mark.end_row:
                start_col = min(self.mark.start_col, self.mark.end_col)
                end_col = max(self.mark.start_col, self.mark.end_col)
                return set(range(start_col, end_col + 1))
            elif buf_row == start_row:
                return set(range(self.mark.start_col, 200))
            elif buf_row == end_row:
                return set(range(0, self.mark.end_col + 1))
            else:
                return set(range(200))

        return set()

    def _ensure_cursor_visible(self):
        """Scroll to keep cursor in view."""
        # Vertical scroll
        if self.cursor_row < self.top_row:
            self.top_row = self.cursor_row
        elif self.cursor_row >= self.top_row + self.visible_rows:
            self.top_row = self.cursor_row - self.visible_rows + 1

        # Horizontal scroll
        if self.cursor_col < self.left_col:
            self.left_col = self.cursor_col
        elif self.cursor_col >= self.left_col + self.visible_cols:
            self.left_col = self.cursor_col - self.visible_cols + 1

    def _draw(self):
        """Draw the editor screen."""
        # Update window title if needed
        self._update_window_title()

        # Show help screen if active
        if self.showing_help:
            self._draw_help()
            return

        self._ensure_cursor_visible()

        self.screen.fill(self.BG_COLOR)

        # Small top padding to prevent text from touching window edge
        y_offset = 2

        # Draw text lines with mark highlighting
        for screen_row in range(self.visible_rows):
            buf_row = self.top_row + screen_row
            y = screen_row * self.char_height + y_offset

            # Show ~ for lines past EOF
            if buf_row >= len(self.buffer.lines):
                self.font.render_to(
                    self.screen,
                    (0, y + self.font_ascent),
                    "~",
                    (64, 64, 64)  # Dark gray for EOF markers
                )
                continue

            line = self.buffer.get_line(buf_row)

            # Draw modified line marker on left edge
            if buf_row in self.buffer.modified_lines and self.left_col == 0:
                pygame.draw.rect(
                    self.screen,
                    (255, 180, 50),  # Orange/yellow
                    (0, y, 3, self.char_height)  # 3px wide bar
                )

            # Get marked columns for this row
            marked_cols = self._get_marked_cols(buf_row)

            # Get syntax colors for this row
            syntax_colors = self._get_syntax_colors(buf_row)

            # Get misspelled columns if in spell mode
            if self.spell_mode:
                other_misspelled, current_misspelled = self.get_misspelled_cols(buf_row)
            else:
                other_misspelled, current_misspelled = set(), set()

            # Draw character by character to handle marks and syntax
            for screen_col in range(self.visible_cols):
                buf_col = self.left_col + screen_col
                x = self.GUTTER_WIDTH + screen_col * self.char_width

                ch = line[buf_col] if buf_col < len(line) else " "

                # Draw mark background if marked
                if buf_col in marked_cols:
                    pygame.draw.rect(
                        self.screen,
                        self.MARK_BG,
                        (x, y, self.char_width, self.char_height)
                    )

                # Determine text color (syntax or default)
                if buf_col in syntax_colors:
                    color_id = syntax_colors[buf_col]
                    text_color = self.SYNTAX_COLORS.get(color_id, self.FG_COLOR)
                else:
                    text_color = self.FG_COLOR

                # Draw character (y + ascent for proper baseline)
                self.font.render_to(
                    self.screen,
                    (x, y + self.font_ascent),
                    ch,
                    text_color
                )

                # Draw underline for misspelled words
                if buf_col in current_misspelled:
                    # Current word - bright red underline
                    underline_y = y + self.char_height - 2
                    pygame.draw.line(self.screen, (255, 80, 80),
                                   (x, underline_y), (x + self.char_width, underline_y), 2)
                elif buf_col in other_misspelled:
                    # Other misspelled words - yellow underline
                    underline_y = y + self.char_height - 2
                    pygame.draw.line(self.screen, (255, 200, 100),
                                   (x, underline_y), (x + self.char_width, underline_y), 1)

        # Draw cursor
        cursor_x = self.GUTTER_WIDTH + (self.cursor_col - self.left_col) * self.char_width
        cursor_y = (self.cursor_row - self.top_row) * self.char_height + y_offset
        if 0 <= cursor_x < self.width and 0 <= cursor_y < self.height - self.char_height * 2:
            pygame.draw.rect(
                self.screen,
                self.CURSOR_COLOR,
                (cursor_x, cursor_y, 2, self.char_height)
            )

        # Draw status bar
        status_y = self.height - self.char_height * 2
        pygame.draw.rect(
            self.screen,
            self.STATUS_BG,
            (0, status_y, self.width, self.char_height)
        )

        # Status text with mark type and buffer ring indicator
        filename = self.buffer.filename or "[new]"
        modified = "*" if self.buffer.modified else ""
        ring_str = ""
        if len(self.buffers) > 1:
            ring_str = f" [{self.current_buffer_idx + 1}/{len(self.buffers)}]"
        mark_str = ""
        if self.mark:
            if self.mark.mode == MarkMode.BLOCK:
                mark_str = f" | BLOCK ({self.mark.start_row+1},{self.mark.start_col+1})-({self.mark.end_row+1},{self.mark.end_col+1})"
            else:
                mark_str = f" | {self.mark.mode.name}"
        status = f" {filename}{modified}{ring_str}  L{self.cursor_row + 1}:{self.cursor_col + 1}  {len(self.buffer.lines)} lines{mark_str}"
        self.font.render_to(
            self.screen,
            (0, status_y + self.font_ascent),
            status,
            self.STATUS_FG
        )

        # Command/Message line
        msg_y = self.height - self.char_height
        pygame.draw.rect(
            self.screen,
            self.BG_COLOR,
            (0, msg_y, self.width, self.char_height)
        )

        if self.passphrase_mode:
            # Show passphrase prompt with asterisks
            stars = '*' * len(self.passphrase_buffer)
            prompt = self.message + stars + "_"
            self.font.render_to(
                self.screen,
                (0, msg_y + self.font_ascent),
                prompt,
                self.CURSOR_COLOR
            )
        elif self.command_mode:
            # Show command prompt with cursor
            cmd_text = f"command: {self.command_buffer}_"
            self.font.render_to(
                self.screen,
                (0, msg_y + self.font_ascent),
                cmd_text,
                self.CURSOR_COLOR  # Bright color for command mode
            )
        elif self.message:
            self.font.render_to(
                self.screen,
                (0, msg_y + self.font_ascent),
                self.message,
                self.STATUS_FG
            )

        # Draw minimap
        if self._show_minimap():
            self._draw_minimap()

        pygame.display.flip()

    def _rebuild_minimap(self):
        """Rebuild the cached minimap surface with syntax-colored pixels."""
        num_lines = len(self.buffer.lines)
        if num_lines == 0:
            return

        # Calculate pixels per line (aim for 1-2px per line, compress if needed)
        available_height = self.height - self.char_height * 2  # Exclude status bars
        pixels_per_line = max(1, min(2, available_height // num_lines))
        map_height = num_lines * pixels_per_line

        # Usable width for text (leave margin for indicators)
        text_width = self.minimap_width - 6  # 3px left margin, 3px right

        # Create surface
        self.minimap_surface = pygame.Surface((self.minimap_width, map_height))
        self.minimap_surface.fill((30, 30, 30))  # Dark background

        # Get syntax colors if available
        syntax_colors = {}
        if self.syntax_highlighter and self.syntax_lang:
            try:
                highlights = self.syntax_highlighter.get_highlights(
                    0, num_lines - 1, self.buffer.lines
                )
                syntax_colors = highlights
            except:
                pass

        # Default text color for non-highlighted characters
        default_color = (140, 140, 140)

        # Helper to find color for a column from syntax ranges
        def get_color_for_col(ranges, col):
            """Find color for column from list of (start, end, color_id) tuples."""
            for start, end, color_id in ranges:
                if start <= col < end:
                    return self.SYNTAX_COLORS.get(color_id, default_color)
            return default_color

        # Draw each line with syntax-colored pixels
        for row, line in enumerate(self.buffer.lines):
            y = row * pixels_per_line
            line_len = len(line)

            if line_len == 0:
                continue

            # Get syntax color ranges for this row: [(start, end, color_id), ...]
            row_ranges = syntax_colors.get(row, [])

            # Scale factor: how many chars per pixel
            if line_len <= text_width:
                # Line fits - one pixel per char
                for col, ch in enumerate(line):
                    if ch == ' ' or ch == '\t':
                        continue  # Skip whitespace
                    color = get_color_for_col(row_ranges, col)
                    self.minimap_surface.set_at((3 + col, y), color)
                    if pixels_per_line > 1:
                        self.minimap_surface.set_at((3 + col, y + 1), color)
            else:
                # Line longer than minimap - scale down
                chars_per_pixel = line_len / text_width
                for px in range(text_width):
                    # Sample the character at this position
                    col = int(px * chars_per_pixel)
                    if col < line_len:
                        ch = line[col]
                        if ch == ' ' or ch == '\t':
                            continue
                        color = get_color_for_col(row_ranges, col)
                        self.minimap_surface.set_at((3 + px, y), color)
                        if pixels_per_line > 1:
                            self.minimap_surface.set_at((3 + px, y + 1), color)

            # Draw left-edge indicators (modified lines, bookmarks)
            if row in self.buffer.modified_lines:
                pygame.draw.rect(self.minimap_surface, (255, 180, 50), (0, y, 2, pixels_per_line))
            elif row in self.bookmarks:
                pygame.draw.rect(self.minimap_surface, (100, 200, 100), (0, y, 2, pixels_per_line))

        self.minimap_dirty = False
        self.minimap_pixels_per_line = pixels_per_line

    def _draw_minimap(self):
        """Draw the minimap on the right edge."""
        if self.minimap_dirty or self.minimap_surface is None:
            self._rebuild_minimap()

        if self.minimap_surface is None:
            return

        # Position minimap on right edge
        map_x = self.width - self.minimap_width
        map_y = 2  # Small top offset

        # Draw minimap background
        pygame.draw.rect(
            self.screen,
            (25, 25, 25),
            (map_x, 0, self.minimap_width, self.height - self.char_height * 2)
        )

        # Draw the cached minimap
        self.screen.blit(self.minimap_surface, (map_x, map_y))

        # Draw mark overlay (not cached - changes frequently)
        if self.mark and hasattr(self, 'minimap_pixels_per_line'):
            ppl = self.minimap_pixels_per_line
            start_row = min(self.mark.start_row, self.mark.end_row)
            end_row = max(self.mark.start_row, self.mark.end_row)
            mark_y = map_y + start_row * ppl
            mark_h = (end_row - start_row + 1) * ppl

            # Semi-transparent blue overlay for marked region
            mark_surface = pygame.Surface((self.minimap_width - 4, mark_h), pygame.SRCALPHA)
            mark_surface.fill((80, 80, 180, 100))  # Blue with alpha
            self.screen.blit(mark_surface, (map_x + 2, mark_y))

        # Draw viewport indicator (which lines are currently visible)
        if hasattr(self, 'minimap_pixels_per_line'):
            ppl = self.minimap_pixels_per_line
            viewport_y = map_y + self.top_row * ppl
            viewport_h = self.visible_rows * ppl

            # Semi-transparent viewport box
            viewport_rect = pygame.Rect(map_x, viewport_y, self.minimap_width, viewport_h)
            pygame.draw.rect(self.screen, (255, 255, 255), viewport_rect, 1)

            # Slightly lighter fill
            s = pygame.Surface((self.minimap_width, viewport_h), pygame.SRCALPHA)
            s.fill((255, 255, 255, 30))
            self.screen.blit(s, (map_x, viewport_y))

    def _invalidate_minimap(self):
        """Mark minimap for redraw."""
        self.minimap_dirty = True

    def _draw_help(self):
        """Draw the help screen."""
        self.screen.fill(self.BG_COLOR)

        help_lines = [
            "e3po Help - Press F1 or any key to close",
            "",
            "=== Function Keys ===",
            "F1  Help       F2  Save       F3  Quit       F4  Save+Quit",
            "F7  Shift <    F8  Shift >    F9  Undo       F10  Next file",
            "",
            "=== Marks (Alt+key) ===",
            "Alt-L  Line mark      Alt-B  Block mark     Alt-Z  Char mark",
            "Alt-W  Word mark      Alt-U  Unmark         Alt-K  Copy to clipboard",
            "Alt-C  Copy mark      Alt-M  Move mark      Alt-D  Delete mark",
            "Alt-O  Overlay block  Alt-Y  Goto start     Alt-E  Goto end",
            "Alt-S  Split line     Alt-J  Join lines     Alt-F  Fill",
            "",
            "=== Ctrl Keys ===",
            "Ctrl-K/Del/BS  Delete line    Ctrl-S  Save",
            "Ctrl-Z/F9      Undo           Ctrl-Y/R  Redo",
            "Ctrl-F         Repeat search  Ctrl-Q  Quit",
            "Ctrl-+/-       Font size      Ctrl-Left/Right  Word nav",
            "Ctrl-Home/End  File start/end Ctrl-V  Paste",
            "Ctrl-C         Copy to clipboard",
            "Ctrl-A         Line start (2x: file top)",
            "Ctrl-E         Erase to EOL (2x: file end)",
            "Ctrl-B         Toggle bookmark on line",
            "Ctrl-Up/Down   Navigate bookmarks",
            "Ctrl-N/P       Next/Previous file",
            "",
            "=== Command Line (press ESC) ===",
            "123         Go to line      /pattern    Search",
            "/pattern/c  Case insensitive search",
            "c/old/new/  Replace (Y/N/G/Q)  c/old/new/* Replace all",
            "t/top       Top of file     b/bot       Bottom",
            "s/save      Save            q/quit      Quit",
            "n name      Set filename    e name      Edit file",
            "font 20     Font size       font Monaco 16  Set font",
            "wq          Save & quit     syntax      Toggle highlighting",
            "minimap [on|off]  Toggle minimap (click to navigate)",
            "",
            "=== Text Processing ===",
            "sort [-r] [-n] [-f] [-kN]   Sort lines",
            "uniq [-i]                   Remove adjacent duplicates",
            "grep [-v] [-i] pattern      Filter lines by pattern",
            "",
            "=== Other ===",
            "spell       Spell check (n/p/s/a/1-9/o)",
            "encrypt     Encrypt file with passphrase",
            "passwd      Change passphrase",
            "",
            "Orange bar on left = modified line (cleared on save)",
        ]

        for i, line in enumerate(help_lines):
            y = i * self.char_height
            if y >= self.height - self.char_height:
                break
            self.font.render_to(
                self.screen,
                (10, y + self.font_ascent),
                line,
                self.FG_COLOR
            )

        pygame.display.flip()


def main():
    """Entry point for pygame editor."""
    import argparse
    import os

    parser = argparse.ArgumentParser(description='e3po pygame editor')
    parser.add_argument('files', nargs='*', help='Files to edit')
    parser.add_argument('--font-size', '-s', type=int, default=16,
                        help='Font size (default: 16)')
    parser.add_argument('--font', '-f', type=str, default=None,
                        help='Font name (e.g., "Monaco", "Consolas")')
    parser.add_argument('--no-detach', action='store_true',
                        help='Do not detach from terminal')
    args = parser.parse_args()

    # Detach from terminal (fork and let parent exit)
    if not args.no_detach and hasattr(os, 'fork'):
        pid = os.fork()
        if pid > 0:
            # Parent process exits, returning shell to user
            os._exit(0)
        # Child continues - create new session to fully detach
        os.setsid()
        # Redirect stdin/stdout/stderr to /dev/null
        devnull = os.open(os.devnull, os.O_RDWR)
        os.dup2(devnull, 0)  # stdin
        os.dup2(devnull, 1)  # stdout
        os.dup2(devnull, 2)  # stderr
        os.close(devnull)

    filenames = args.files if args.files else [None]
    editor = PygameEditor(filenames[0], font_size=args.font_size, font_name=args.font)
    # Load additional files into buffer ring
    for fname in filenames[1:]:
        editor._open_file(fname)
    # Switch back to first file
    if len(filenames) > 1:
        editor._switch_to_buffer(0)
    editor.run()


if __name__ == "__main__":
    main()
