diff --git a/README.md b/README.md index 8379efa..ae28abd 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ - **Series Manager** — Multi-episode/season management with a shared series bible (characters, locations, props, themes, backstory) - **Git Snapshots** — Local version control with create, list, and restore - **Remote Sync** — Push/pull to GitHub, GitLab, or Codeberg -- **Cloud Export** — Export to Google Drive, iCloud, Dropbox, OneDrive, or Nextcloud +- **Cloud Export** — Export scaffolding for Google Drive, iCloud, Dropbox, OneDrive, and Nextcloud (provider SDKs not yet integrated) - **Share for Review** — Generate self-contained, watermarked HTML files for confidential review - **Real-time Collaboration** — LAN-based peer-to-peer editing with CRDT conflict resolution and presence indicators - **Plugin Marketplace** — Browse, install, and manage community plugins with a built-in marketplace UI @@ -153,6 +153,7 @@ Use **Import Script** from the Dashboard or Command Palette to open `.fountain` - **Python 3.12+** (recommended; 3.10+ supported) - **Rust 1.70+** (for the native core) +- **maturin** (`pip install maturin`) - **Qt 6** (installed via PySide6) ### Setup @@ -169,9 +170,9 @@ source .venv/bin/activate # or .venv\Scripts\activate on Windows # Install Python dependencies pip install -e ".[dev]" -# Build the Rust core +# Build the Rust core (PyO3 extension) cd src-tauri -cargo build --release +maturin develop --release cd .. # Run the app @@ -224,7 +225,7 @@ Ecrit/ │ ├── fountain/ # Fountain parser │ ├── project/ # Project file management │ └── lib.rs # PyO3 module -├── tests/ # Test suite (766 tests) +├── tests/ # Test suite (879 tests) ├── openspec/ # Feature tracking and roadmap └── public/ # Static assets ``` diff --git a/ecrit/collab/crdt.py b/ecrit/collab/crdt.py index b6db8b5..38b4a9a 100644 --- a/ecrit/collab/crdt.py +++ b/ecrit/collab/crdt.py @@ -95,10 +95,30 @@ def delete(self, position: int, length: int) -> Operation: def transform(self, op1: Operation, op2: Operation) -> Operation: """Operational transform: adjust op1 against already-applied op2.""" if op2.op_type == OperationType.INSERT: - if op1.position >= op2.position: + insert_len = len(op2.text) + if op1.op_type == OperationType.DELETE and op1.position < op2.position < op1.position + op1.length: + # Insert landed inside delete range — expand delete to cover the + # gap the insert created so all originally-targeted chars are removed return Operation( op_type=op1.op_type, - position=op1.position + len(op2.text), + position=op1.position, + text=op1.text, + length=op1.length + insert_len, + user_id=op1.user_id, + timestamp=op1.timestamp, + revision=op1.revision, + ) + if op1.position > op2.position or ( + op1.position == op2.position + and ( + op1.op_type == OperationType.DELETE + or (op1.op_type == OperationType.INSERT + and (op1.user_id or "") >= (op2.user_id or "")) + ) + ): + return Operation( + op_type=op1.op_type, + position=op1.position + insert_len, text=op1.text, length=op1.length, user_id=op1.user_id, @@ -106,7 +126,9 @@ def transform(self, op1: Operation, op2: Operation) -> Operation: revision=op1.revision, ) elif op2.op_type == OperationType.DELETE: - if op1.position >= op2.position + op2.length: + op2_end = op2.position + op2.length + if op1.position >= op2_end: + # op1 is entirely after op2's deleted range return Operation( op_type=op1.op_type, position=op1.position - op2.length, @@ -116,7 +138,36 @@ def transform(self, op1: Operation, op2: Operation) -> Operation: timestamp=op1.timestamp, revision=op1.revision, ) + if op1.op_type == OperationType.DELETE: + op1_end = op1.position + op1.length + # Compute overlap between the two delete ranges + overlap_start = max(op1.position, op2.position) + overlap_end = min(op1_end, op2_end) + overlap = max(0, overlap_end - overlap_start) + new_length = op1.length - overlap + if new_length <= 0: + # op1's delete is entirely subsumed by op2 — becomes a no-op + return Operation( + op_type=op1.op_type, + position=op2.position if op1.position >= op2.position else op1.position, + text=op1.text, + length=0, + user_id=op1.user_id, + timestamp=op1.timestamp, + revision=op1.revision, + ) + new_pos = min(op1.position, op2.position) + return Operation( + op_type=op1.op_type, + position=new_pos, + text=op1.text, + length=new_length, + user_id=op1.user_id, + timestamp=op1.timestamp, + revision=op1.revision, + ) elif op1.position >= op2.position: + # INSERT inside op2's deleted range — move to op2.position return Operation( op_type=op1.op_type, position=op2.position, diff --git a/ecrit/collab/lan.py b/ecrit/collab/lan.py index c320595..b40ef65 100644 --- a/ecrit/collab/lan.py +++ b/ecrit/collab/lan.py @@ -22,6 +22,7 @@ class LANPeer: ip_address: str port: int project_title: str = "" + session_id: str = "" last_seen: float = 0.0 def is_stale(self, timeout: float = 10.0) -> bool: @@ -76,6 +77,9 @@ def get_peers(self) -> list[LANPeer]: self._on_peer_lost(uid) return list(self._peers.values()) + def set_session_id(self, session_id: str) -> None: + self._session_id = session_id + def _make_announce(self) -> bytes: payload = json.dumps({ "service": SERVICE_ID, @@ -83,6 +87,7 @@ def _make_announce(self) -> bytes: "user_name": self.user_name, "port": self.port, "project_title": self._project_title, + "session_id": getattr(self, "_session_id", ""), }) return payload.encode("utf-8") @@ -125,6 +130,7 @@ def _listen_loop(self) -> None: ip_address=addr[0], port=payload.get("port", 8741), project_title=payload.get("project_title", ""), + session_id=payload.get("session_id", ""), last_seen=time.time(), ) with self._peers_lock: diff --git a/ecrit/collab/p2p.py b/ecrit/collab/p2p.py index 2477236..271366c 100644 --- a/ecrit/collab/p2p.py +++ b/ecrit/collab/p2p.py @@ -57,6 +57,7 @@ def __init__(self, user_id: str, user_name: str): self.user_name = user_name self._server_socket: Optional[socket.socket] = None self._clients: dict[str, socket.socket] = {} + self._clients_lock = threading.Lock() self._running = False self._listen_thread: Optional[threading.Thread] = None self._on_message: Optional[Callable[[str, str], None]] = None @@ -85,7 +86,7 @@ def host_session(self, port: int = 0, project_title: str = "") -> ConnectionToke self._server_socket.listen(8) self._server_socket.settimeout(1.0) self._port = self._server_socket.getsockname()[1] - self._session_id = base64.urlsafe_b64encode(os.urandom(6)).decode() + self._session_id = base64.urlsafe_b64encode(os.urandom(16)).decode().rstrip("=") self._running = True self._listen_thread = threading.Thread(target=self._accept_loop, daemon=True) @@ -113,7 +114,8 @@ def join_session(self, token: ConnectionToken) -> bool: "session_id": token.session_id, }) self._send_frame(sock, hello) - self._clients["host"] = sock + with self._clients_lock: + self._clients["host"] = sock self._running = True recv_thread = threading.Thread( @@ -125,7 +127,8 @@ def join_session(self, token: ConnectionToken) -> bool: return False def send(self, peer_id: str, message: str) -> bool: - sock = self._clients.get(peer_id) + with self._clients_lock: + sock = self._clients.get(peer_id) if sock: try: self._send_frame(sock, message) @@ -136,8 +139,10 @@ def send(self, peer_id: str, message: str) -> bool: return False def broadcast(self, message: str) -> None: + with self._clients_lock: + snapshot = list(self._clients.items()) dead = [] - for peer_id, sock in self._clients.items(): + for peer_id, sock in snapshot: try: self._send_frame(sock, message) except OSError: @@ -147,12 +152,14 @@ def broadcast(self, message: str) -> None: def close(self) -> None: self._running = False - for sock in self._clients.values(): + with self._clients_lock: + socks = list(self._clients.values()) + self._clients.clear() + for sock in socks: try: sock.close() except OSError: pass - self._clients.clear() if self._server_socket: try: self._server_socket.close() @@ -161,10 +168,16 @@ def close(self) -> None: self._server_socket = None def is_connected(self) -> bool: - return self._running and len(self._clients) > 0 + with self._clients_lock: + return self._running and len(self._clients) > 0 def get_peer_count(self) -> int: - return len(self._clients) + with self._clients_lock: + return len(self._clients) + + def get_client_ids(self) -> list[str]: + with self._clients_lock: + return list(self._clients.keys()) def _send_frame(self, sock: socket.socket, data: str) -> None: encoded = data.encode("utf-8") @@ -201,7 +214,8 @@ def _accept_loop(self) -> None: if hello.get("session_id") != self._session_id: client_sock.close() continue - self._clients[peer_id] = client_sock + with self._clients_lock: + self._clients[peer_id] = client_sock if self._on_connect: self._on_connect(peer_id) recv_thread = threading.Thread( @@ -229,7 +243,8 @@ def _recv_loop(self, peer_id: str, sock: socket.socket) -> None: self._remove_peer(peer_id) def _remove_peer(self, peer_id: str) -> None: - sock = self._clients.pop(peer_id, None) + with self._clients_lock: + sock = self._clients.pop(peer_id, None) if sock: try: sock.close() diff --git a/ecrit/collab/session.py b/ecrit/collab/session.py index 77c15eb..5b3ac0b 100644 --- a/ecrit/collab/session.py +++ b/ecrit/collab/session.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging +import threading import uuid from enum import Enum from dataclasses import dataclass, field @@ -12,6 +14,8 @@ from ecrit.collab.lan import LANDiscovery, LANPeer from ecrit.collab.p2p import P2PConnection, ConnectionToken +logger = logging.getLogger("ecrit.collab.session") + class CollabRole(str, Enum): HOST = "host" @@ -53,6 +57,7 @@ def __init__(self, user_name: str = ""): self._p2p = P2PConnection(self.user_id, self.user_name) self._participants: dict[str, Participant] = {} self._pending_ops: list = [] + self._lock = threading.Lock() self._color_index = 0 self._on_state_change: Optional[Callable[[SessionState], None]] = None @@ -102,7 +107,9 @@ def host(self, content: str, project_title: str = "", port: int = 0) -> Connecti ) token = self._p2p.host_session(port=port, project_title=project_title) + self._lan.port = self._p2p._port self._lan.set_project_title(project_title) + self._lan.set_session_id(self._p2p._session_id) self._lan.start() self._set_state(SessionState.CONNECTED) return token @@ -112,7 +119,7 @@ def join_lan(self, peer: LANPeer) -> bool: token = ConnectionToken( host=peer.ip_address, port=peer.port, - session_id="", + session_id=peer.session_id, user_name=peer.user_name, project_title=peer.project_title, ) @@ -159,14 +166,24 @@ def leave(self) -> None: # ── Editing methods ── def apply_local_insert(self, position: int, text: str) -> None: - op = self._crdt.insert(position, text) - self._pending_ops.append(op) + with self._lock: + op = self._crdt.insert(position, text) + self._pending_ops.append(op) + if len(self._pending_ops) > 100: + logger.warning("Pending ops exceeded 100; requesting full sync") + self._pending_ops.clear() + self._request_sync() msg = CollabMessage.operation(self.user_id, op) self._p2p.broadcast(msg.to_json()) def apply_local_delete(self, position: int, length: int) -> None: - op = self._crdt.delete(position, length) - self._pending_ops.append(op) + with self._lock: + op = self._crdt.delete(position, length) + self._pending_ops.append(op) + if len(self._pending_ops) > 100: + logger.warning("Pending ops exceeded 100; requesting full sync") + self._pending_ops.clear() + self._request_sync() msg = CollabMessage.operation(self.user_id, op) self._p2p.broadcast(msg.to_json()) @@ -177,6 +194,13 @@ def update_cursor(self, position: int, selection_end: int = -1) -> None: msg = CollabMessage.cursor_update(self.user_id, position, selection_end) self._p2p.broadcast(msg.to_json()) + def _request_sync(self) -> None: + """Ask the host for a full document sync to reset OT state.""" + if self.role == CollabRole.HOST: + return # host is authoritative; nothing to request + sync_msg = CollabMessage.sync_request(self.user_id) + self._p2p.broadcast(sync_msg.to_json()) + def get_text(self) -> str: return self._crdt.get_text() @@ -202,7 +226,8 @@ def get_lan_peers(self) -> list[LANPeer]: def _handle_message(self, peer_id: str, raw: str) -> None: try: msg = CollabMessage.from_json(raw) - except Exception: + except (ValueError, KeyError, AttributeError, TypeError): + logger.debug("Ignoring malformed collab message from %s", peer_id) return if msg.msg_type == MessageType.JOIN: @@ -221,19 +246,20 @@ def _handle_message(self, peer_id: str, raw: str) -> None: elif msg.msg_type == MessageType.OPERATION: op = Operation.from_dict(msg.payload) - transformed_pending = [] - for pending in self._pending_ops: - pending_prime = self._crdt.transform(pending, op) - op = self._crdt.transform(op, pending) - transformed_pending.append(pending_prime) - self._pending_ops = transformed_pending - self._crdt.apply_operation(op) + with self._lock: + transformed_pending = [] + for pending in self._pending_ops: + pending_prime = self._crdt.transform(pending, op) + op = self._crdt.transform(op, pending) + transformed_pending.append(pending_prime) + self._pending_ops = transformed_pending + self._crdt.apply_operation(op) if self._on_text_change: self._on_text_change(self._crdt.get_text()) if self.role == CollabRole.HOST: - relay = CollabMessage.operation(msg.user_id, op.to_dict()) + relay = CollabMessage.operation(msg.user_id, op) relay_data = relay.to_json() - for pid in self._p2p._clients: + for pid in self._p2p.get_client_ids(): if pid != peer_id: self._p2p.send(pid, relay_data) @@ -249,7 +275,7 @@ def _handle_message(self, peer_id: str, raw: str) -> None: msg.payload.get("selection_end", -1), ) if self.role == CollabRole.HOST: - for pid in self._p2p._clients: + for pid in self._p2p.get_client_ids(): if pid != peer_id: self._p2p.send(pid, raw) @@ -262,8 +288,9 @@ def _handle_message(self, peer_id: str, raw: str) -> None: elif msg.msg_type == MessageType.SYNC_RESPONSE: content = msg.payload.get("content", "") - self._crdt.set_text(content) - self._pending_ops.clear() + with self._lock: + self._crdt.set_text(content) + self._pending_ops.clear() if self._on_text_change: self._on_text_change(content) diff --git a/ecrit/companion/device_sync.py b/ecrit/companion/device_sync.py index 79b9cae..7ea0cce 100644 --- a/ecrit/companion/device_sync.py +++ b/ecrit/companion/device_sync.py @@ -154,9 +154,11 @@ def start_transfer_server(self, bundle_path: str, port: int = 8739) -> str: self.stop_transfer_server() token = secrets.token_urlsafe(32) - _TransferHandler.bundle_path = bundle_path - _TransferHandler.transfer_token = token - self._server = HTTPServer(("0.0.0.0", port), _TransferHandler) + handler = type("_Handler", (_TransferHandler,), { + "bundle_path": bundle_path, + "transfer_token": token, + }) + self._server = HTTPServer(("0.0.0.0", port), handler) self._server_thread = threading.Thread(target=self._server.serve_forever, daemon=True) self._server_thread.start() self._set_status(SyncStatus.TRANSFERRING) diff --git a/ecrit/export/epub_export.py b/ecrit/export/epub_export.py index bb99195..6486ed7 100644 --- a/ecrit/export/epub_export.py +++ b/ecrit/export/epub_export.py @@ -1,5 +1,6 @@ """EPUB 3 export — generate a valid EPUB ebook from Fountain screenplay content.""" +import logging import os import re import uuid @@ -245,6 +246,34 @@ def _generate_css() -> str: border: none; page-break-after: always; } +h2.page-header { + font-family: "Courier New", Courier, monospace; + font-size: 12pt; + font-weight: bold; + text-transform: uppercase; + margin-top: 1.5em; + margin-bottom: 0.5em; + page-break-before: always; +} +h2.page-header:first-of-type { + page-break-before: avoid; +} +p.panel-header { + font-weight: bold; + margin-top: 1em; + margin-bottom: 0.3em; + margin-left: 1em; +} +p.sfx { + font-weight: bold; + text-transform: uppercase; + margin: 0.3em 0; + margin-left: 1em; +} +p.caption { + margin: 0.3em 0; + margin-left: 1em; +} """ @@ -350,28 +379,38 @@ def _generate_title_page(title: str, author: str, extra_fields: dict[str, str] | "Section": "section", "Synopsis": "synopsis", "Note": "note", + "PageHeader": "page-header", + "PanelHeader": "panel-header", + "Sfx": "sfx", + "Caption": "caption", } -def _parse_fountain_to_xhtml(content: str) -> tuple[str, list[dict]]: +def _parse_fountain_to_xhtml(content: str, format_id: str = "fountain/core") -> tuple[str, list[dict]]: """Convert Fountain text to XHTML body content. Returns a tuple of (xhtml_body_content, scenes) where scenes is a list of {"id": str, "text": str} dicts for the table of contents. """ - elements = _parse_fountain_to_elements(content) + try: + import json + import ecrit_core + parsed = json.loads(ecrit_core.parse_fountain(content, format_id)) + elements = parsed.get("elements", []) + except Exception: + elements = _parse_fountain_to_elements(content) parts: list[str] = [] scenes: list[dict] = [] scene_count = 0 for elem in elements: - kind = elem["type"] - text = escape(elem["text"]) - - if kind == "PageBreak": - parts.append('
') + kind = elem.get("type", "Action") + if kind in ("PageBreak", "BlankLine"): + if kind == "PageBreak": + parts.append('
') continue + text = escape(elem.get("text", "")) if kind == "SceneHeading": scene_count += 1 @@ -380,6 +419,30 @@ def _parse_fountain_to_xhtml(content: str) -> tuple[str, list[dict]]: parts.append(f'

{text}

') continue + if kind == "PageHeader": + scene_count += 1 + scene_id = f"scene-{scene_count}" + scenes.append({"id": scene_id, "text": elem["text"]}) + parts.append(f' ') + continue + + if kind == "PanelHeader": + parts.append(f'

{text}

') + continue + + if kind == "Sfx": + num = elem.get("number", "") + display = f"{num}. SFX: {text}" if num else f"SFX: {text}" + parts.append(f'

{display}

') + continue + + if kind == "Caption": + num = elem.get("number", "") + subtype = elem.get("subtype", "CAPTION") + display = f"{num}. {subtype}: {text}" if num else f"{subtype}: {text}" + parts.append(f'

{display}

') + continue + css_class = _ELEMENT_CLASS.get(kind, "action") parts.append(f'

{text}

') @@ -402,7 +465,7 @@ def _generate_script_xhtml(title: str, body_content: str) -> str: f'' -def _create_epub_bytes(content: str, title: str = "", author: str = "") -> bytes: +def _create_epub_bytes(content: str, title: str = "", author: str = "", format_id: str = "fountain/core") -> bytes: """Build a complete EPUB 3 file in memory and return the bytes.""" # Extract title page fields from the Fountain content tp_fields = _parse_title_page(content) @@ -415,7 +478,7 @@ def _create_epub_bytes(content: str, title: str = "", author: str = "") -> bytes uid = str(uuid.uuid4()) # Parse screenplay body into XHTML - body_content, scenes = _parse_fountain_to_xhtml(content) + body_content, scenes = _parse_fountain_to_xhtml(content, format_id) # Build the EPUB ZIP buf = BytesIO() @@ -440,7 +503,7 @@ def _create_epub_bytes(content: str, title: str = "", author: str = "") -> bytes return buf.getvalue() -def export_epub(content: str, title: str = "", author: str = "", output_path: str = "", parent=None) -> str: +def export_epub(content: str, title: str = "", author: str = "", output_path: str = "", parent=None, format_id: str = "fountain/core") -> str: """Export Fountain content as an EPUB file. If *output_path* is empty, opens a file-save dialog. Returns the path @@ -464,23 +527,24 @@ def export_epub(content: str, title: str = "", author: str = "", output_path: st if not output_path.endswith(".epub"): output_path += ".epub" - data = _create_epub_bytes(content, title=title, author=author) + data = _create_epub_bytes(content, title=title, author=author, format_id=format_id) with open(output_path, "wb") as f: f.write(data) return output_path -def export_epub_to_path(content: str, path: str, title: str = "", author: str = "") -> bool: +def export_epub_to_path(content: str, path: str, title: str = "", author: str = "", format_id: str = "fountain/core") -> bool: """Export Fountain content as an EPUB to a specific path. Returns True on success, False on failure. """ try: os.makedirs(os.path.dirname(path) or ".", exist_ok=True) - data = _create_epub_bytes(content, title=title, author=author) + data = _create_epub_bytes(content, title=title, author=author, format_id=format_id) with open(path, "wb") as f: f.write(data) return True except Exception: + logging.getLogger("ecrit.export.epub").warning("EPUB export to %s failed", path, exc_info=True) return False diff --git a/ecrit/export/odt_export.py b/ecrit/export/odt_export.py index bd901a1..512d080 100644 --- a/ecrit/export/odt_export.py +++ b/ecrit/export/odt_export.py @@ -1,6 +1,7 @@ """ODT export — generate Open Document Text from Fountain script.""" import json +import logging import os import zipfile from io import BytesIO @@ -8,6 +9,8 @@ from PySide6.QtWidgets import QFileDialog +logger = logging.getLogger("ecrit.export.odt") + CONTENT_XML_HEAD = """ + + + + + + + + + + + + + + + + @@ -79,14 +100,19 @@ "Note": "Note", "Lyric": "Dialogue", "BlankLine": "Action", + "PageHeader": "PageHeader", + "PanelHeader": "PanelHeader", + "Sfx": "Sfx", + "Caption": "Caption", } -def _build_content_xml(script_content: str) -> str: +def _build_content_xml(script_content: str, format_id: str = "fountain/core") -> str: try: import ecrit_core - parsed = json.loads(ecrit_core.parse_fountain(script_content)) + parsed = json.loads(ecrit_core.parse_fountain(script_content, format_id)) except Exception: + logger.debug("Fountain parse unavailable, inserting raw text", exc_info=True) return CONTENT_XML_HEAD + f"{escape(script_content)}" + CONTENT_XML_TAIL parts = [CONTENT_XML_HEAD] @@ -103,22 +129,35 @@ def _build_content_xml(script_content: str) -> str: parts.append(f'') continue - parts.append(f'{escape(text)}') + display = text + if kind == "Sfx": + num = elem.get("number", "") + display = f"{num}. SFX: {text}" if num else f"SFX: {text}" + elif kind == "Caption": + num = elem.get("number", "") + subtype = elem.get("subtype", "CAPTION") + display = f"{num}. {subtype}: {text}" if num else f"{subtype}: {text}" + + parts.append(f'{escape(display)}') parts.append(CONTENT_XML_TAIL) return "\n".join(parts) -def _create_odt_bytes(script_content: str) -> bytes: +def _create_odt_bytes(script_content: str, format_id: str = "fountain/core") -> bytes: buf = BytesIO() with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: - zf.writestr("mimetype", "application/vnd.oasis.opendocument.text") + zf.writestr( + zipfile.ZipInfo("mimetype", date_time=(2020, 1, 1, 0, 0, 0)), + "application/vnd.oasis.opendocument.text", + compress_type=zipfile.ZIP_STORED, + ) zf.writestr("META-INF/manifest.xml", MANIFEST_XML) - zf.writestr("content.xml", _build_content_xml(script_content)) + zf.writestr("content.xml", _build_content_xml(script_content, format_id)) return buf.getvalue() -def export_odt(script_content: str, title: str = "Untitled", parent=None) -> str: +def export_odt(script_content: str, title: str = "Untitled", parent=None, format_id: str = "fountain/core") -> str: default_name = f"{title}.odt" path, _ = QFileDialog.getSaveFileName( parent, "Export ODT", @@ -131,14 +170,14 @@ def export_odt(script_content: str, title: str = "Untitled", parent=None) -> str if not path.endswith(".odt"): path += ".odt" - data = _create_odt_bytes(script_content) + data = _create_odt_bytes(script_content, format_id) with open(path, 'wb') as f: f.write(data) return path -def export_odt_to_path(script_content: str, path: str): +def export_odt_to_path(script_content: str, path: str, format_id: str = "fountain/core"): os.makedirs(os.path.dirname(path) or ".", exist_ok=True) - data = _create_odt_bytes(script_content) + data = _create_odt_bytes(script_content, format_id) with open(path, 'wb') as f: f.write(data) diff --git a/ecrit/export/pdf_export.py b/ecrit/export/pdf_export.py index 208e153..db10ea3 100644 --- a/ecrit/export/pdf_export.py +++ b/ecrit/export/pdf_export.py @@ -1,6 +1,7 @@ """PDF export — paginate Fountain script to PDF using Qt's print engine.""" import json +import logging from PySide6.QtWidgets import QFileDialog from PySide6.QtCore import QMarginsF, QSizeF, Qt from PySide6.QtGui import ( @@ -21,10 +22,14 @@ "Synopsis": {"italic": True}, "Note": {"italic": True}, "PageBreak": {"page_break": True}, + "PageHeader": {"bold": True, "upper": True, "align": Qt.AlignmentFlag.AlignLeft, "margin_top": 24}, + "PanelHeader": {"bold": True, "align": Qt.AlignmentFlag.AlignLeft, "margin_top": 18, "left_indent": 36}, + "Sfx": {"bold": True, "upper": True, "align": Qt.AlignmentFlag.AlignLeft, "margin_top": 6, "left_indent": 36}, + "Caption": {"align": Qt.AlignmentFlag.AlignLeft, "margin_top": 6, "left_indent": 36}, } -def _build_document(script_content: str, font_size: int = 12, include_title_page: bool = True, scene_numbers: bool = False) -> QTextDocument: +def _build_document(script_content: str, font_size: int = 12, include_title_page: bool = True, scene_numbers: bool = False, format_id: str = "fountain/core") -> QTextDocument: doc = QTextDocument() font = QFont("Courier Prime", font_size) doc.setDefaultFont(font) @@ -33,8 +38,9 @@ def _build_document(script_content: str, font_size: int = 12, include_title_page try: import ecrit_core - parsed = json.loads(ecrit_core.parse_fountain(script_content)) + parsed = json.loads(ecrit_core.parse_fountain(script_content, format_id)) except Exception: + logging.getLogger("ecrit.export.pdf").debug("Fountain parse unavailable, inserting raw text", exc_info=True) cursor.insertText(script_content) return doc @@ -52,16 +58,18 @@ def _build_document(script_content: str, font_size: int = 12, include_title_page cursor.setBlockFormat(block_fmt) cursor.insertText(title_page.get("title", ""), title_fmt) + normal_fmt = QTextCharFormat() + normal_fmt.setFont(QFont("Courier Prime", font_size)) + if title_page.get("credit"): cursor.insertBlock() - normal_fmt = QTextCharFormat() - normal_fmt.setFont(QFont("Courier Prime", 12)) block_fmt.setTopMargin(24) cursor.setBlockFormat(block_fmt) cursor.insertText(title_page["credit"], normal_fmt) if title_page.get("author"): cursor.insertBlock() + block_fmt.setTopMargin(24) cursor.setBlockFormat(block_fmt) cursor.insertText(title_page["author"], normal_fmt) @@ -107,6 +115,14 @@ def _build_document(script_content: str, font_size: int = 12, include_title_page if scene_numbers and kind == "SceneHeading": scene_num += 1 display_text = f"{scene_num}. {display_text}" + if kind == "Sfx": + num = elem.get("number", "") + display_text = f"{num}. SFX: {display_text}" if num else f"SFX: {display_text}" + elif kind == "Caption": + num = elem.get("number", "") + subtype = elem.get("subtype", "CAPTION") + prefix = f"{num}. {subtype}:" if num else f"{subtype}:" + display_text = f"{prefix} {display_text}" cursor.insertText(display_text, char_fmt) return doc @@ -119,6 +135,7 @@ def export_pdf( include_title_page: bool = True, scene_numbers: bool = False, parent=None, + format_id: str = "fountain/core", ) -> str: default_name = f"{title}.pdf" path, _ = QFileDialog.getSaveFileName( @@ -145,7 +162,7 @@ def export_pdf( layout = QPageLayout(page_size, QPageLayout.Orientation.Portrait, margins) printer.setPageLayout(layout) - doc = _build_document(script_content, include_title_page=include_title_page, scene_numbers=scene_numbers) + doc = _build_document(script_content, include_title_page=include_title_page, scene_numbers=scene_numbers, format_id=format_id) doc.setPageSize(QSizeF(printer.pageRect(QPrinter.Unit.Point).size())) doc.print_(printer) @@ -157,6 +174,7 @@ def export_pdf_to_path( path: str, paper: str = "USLetter", include_title_page: bool = True, + format_id: str = "fountain/core", ): import os os.makedirs(os.path.dirname(path) or ".", exist_ok=True) @@ -174,6 +192,6 @@ def export_pdf_to_path( layout = QPageLayout(page_size, QPageLayout.Orientation.Portrait, margins) printer.setPageLayout(layout) - doc = _build_document(script_content, include_title_page=include_title_page) + doc = _build_document(script_content, include_title_page=include_title_page, format_id=format_id) doc.setPageSize(QSizeF(printer.pageRect(QPrinter.Unit.Point).size())) doc.print_(printer) diff --git a/ecrit/main.py b/ecrit/main.py index 3f94aa3..5cbe495 100644 --- a/ecrit/main.py +++ b/ecrit/main.py @@ -5,6 +5,8 @@ from PySide6.QtCore import Qt, QTimer, Signal as QtSignal from PySide6.QtGui import QShortcut, QKeySequence +from ecrit.ui.icons import IconButton + from ecrit.ui.styles import theme from ecrit.ui.styles.stylesheet import generate from ecrit.ui.components.title_bar import TitleBar @@ -28,7 +30,6 @@ from ecrit.ui.overlays.sync_settings import SyncSettingsDialog from ecrit.ui.overlays.cloud_export import CloudExportDialog from ecrit.ui.overlays.share_review import ShareReviewDialog -from ecrit.ui.overlays.marketplace import MarketplaceDialog from ecrit.ui.overlays.collaboration import CollaborationDialog from ecrit.ui.overlays.companion import CompanionDialog from ecrit.screenplay.series_projects import SeriesProject @@ -61,7 +62,6 @@ def __init__(self): self.title_bar = TitleBar(show_phases=False) self.title_bar.settings_clicked.connect(self._open_settings) - self.title_bar.home_clicked.connect(self._go_dashboard) self.title_bar.cmd_chip.clicked.connect(self._show_command_palette) self.title_bar.close_requested.connect(self.close) self.title_bar.minimize_requested.connect(self.showMinimized) @@ -71,6 +71,15 @@ def __init__(self): self.stack = QStackedWidget() root.addWidget(self.stack, 1) + self._home_btn = IconButton("home", icon_size=20, parent=self) + self._home_btn.setObjectName("homeBtn") + self._home_btn.setFixedSize(40, 40) + self._home_btn.setToolTip("Dashboard") + self._home_btn.setAccessibleName("Return to dashboard") + self._home_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self._home_btn.clicked.connect(self._go_dashboard) + self._home_btn.hide() + self.dashboard = Dashboard() self.dashboard.open_project.connect(self._open_project) self.dashboard.new_project.connect(self._show_new_project) @@ -101,7 +110,6 @@ def __init__(self): self._editor_title_bar = TitleBar(show_phases=True) self._editor_title_bar.settings_clicked.connect(self._open_settings) - self._editor_title_bar.home_clicked.connect(self._go_dashboard) self._editor_title_bar.phase_changed.connect(self._on_phase_changed) self._editor_title_bar.cmd_chip.clicked.connect(self._show_command_palette) self._editor_title_bar.close_requested.connect(self.close) @@ -111,7 +119,7 @@ def __init__(self): self._stats_dialog = StatsDialog(self) self._shortcuts_dialog = ShortcutsDialog(self) self._settings_dialog = SettingsDialog(self) - self._settings_dialog.theme_changed.connect(self._apply_theme) + self._settings_dialog.theme_changed.connect(self._on_theme_changed_from_settings) self._settings_dialog.language_changed.connect(self._on_language_changed) self._settings_dialog.project_folder_changed.connect(self._on_project_folder_changed) self._settings_dialog.settings_applied.connect(self._on_settings_applied) @@ -153,9 +161,6 @@ def __init__(self): self._share_dialog = ShareReviewDialog(self) self._share_dialog.share_created.connect(self._on_share_created) - self._marketplace_dialog = MarketplaceDialog(self) - self._marketplace_dialog.plugin_installed.connect(self._on_plugin_installed) - self._marketplace_dialog.plugin_uninstalled.connect(self._on_plugin_uninstalled) self._collab_session = CollabSession(user_name=STATE.author_name or "Writer") self._active_collab_session = None @@ -196,6 +201,13 @@ def __init__(self): self._fullscreen_shortcut = QShortcut(QKeySequence("F11"), self) self._fullscreen_shortcut.activated.connect(self._toggle_fullscreen) + self._zoom_in_shortcut = QShortcut(QKeySequence("Ctrl+="), self) + self._zoom_in_shortcut.activated.connect(lambda: self._zoom_manuscript(1)) + self._zoom_out_shortcut = QShortcut(QKeySequence("Ctrl+-"), self) + self._zoom_out_shortcut.activated.connect(lambda: self._zoom_manuscript(-1)) + self._zoom_reset_shortcut = QShortcut(QKeySequence("Ctrl+0"), self) + self._zoom_reset_shortcut.activated.connect(lambda: self._zoom_manuscript(0)) + self._next_bookmark_shortcut = QShortcut(QKeySequence("Ctrl+]"), self) self._next_bookmark_shortcut.activated.connect(self._next_bookmark) @@ -216,16 +228,32 @@ def __init__(self): self._sprint_status_timer.timeout.connect(self._update_sprint_label) self._sprint_status_timer.start() + self._setup_tab_order() self._load_saved_preferences() self._apply_theme() self._go_dashboard() + def _setup_tab_order(self): + """Set logical tab order for keyboard navigation within title bar and status bar.""" + from PySide6.QtWidgets import QWidget + tb = self._editor_title_bar + sb = self.editor.status_bar + QWidget.setTabOrder(tb.settings_btn, tb.cmd_chip) + if tb.phase_tabs: + phases = list(tb.phase_tabs._buttons.values()) + QWidget.setTabOrder(tb.cmd_chip, phases[0]) + for i in range(len(phases) - 1): + QWidget.setTabOrder(phases[i], phases[i + 1]) + QWidget.setTabOrder(sb.mode_label, sb.focus_label) + def _load_saved_preferences(self): prefs = STATE.load_preferences() if not prefs: return STATE.author_name = prefs.get("author_name", STATE.author_name) STATE.author_email = prefs.get("author_email", STATE.author_email) + if "theme" in prefs: + theme.set_theme(prefs["theme"]) if "language" in prefs: STATE.language = prefs["language"] set_language(prefs["language"]) @@ -234,6 +262,10 @@ def _load_saved_preferences(self): font = QFont("Courier Prime", prefs["font_size"]) font.setStyleHint(QFont.StyleHint.Monospace) self.editor.manuscript.editor.setFont(font) + if "ui_font_size" in prefs: + app_font = QApplication.font() + app_font.setPointSize(prefs["ui_font_size"]) + QApplication.setFont(app_font) if "auto_save" in prefs: if prefs["auto_save"]: self.editor.manuscript.editor._save_timer.setInterval(1000) @@ -253,15 +285,22 @@ def _load_saved_preferences(self): def _apply_theme(self): t = theme.current() self.setStyleSheet(generate(t)) + self._editor_title_bar.set_wordmark_accent() + self.editor.manuscript.editor.highlighter._update_formats() + self.editor.manuscript.editor.highlighter.rehighlight() + self.editor.manuscript.editor.completer._apply_theme() def _go_dashboard(self): if self.stack.currentWidget() is self.editor and STATE.current_project_path: STATE.script_content = self.editor.manuscript.editor.toPlainText() STATE.save_script() + STATE.save_outline(self.editor.outline.canvas._nodes) + STATE.save_plan_documents(self.editor.plan.get_documents()) self._autosave_timer.stop() self._swap_title_bar(show_phases=False) self.title_bar.set_context("") self.stack.setCurrentWidget(self.dashboard) + self._home_btn.hide() self.dashboard.refresh() self._update_week_stats() @@ -269,6 +308,7 @@ def _show_new_project(self): self._swap_title_bar(show_phases=False) self.title_bar.set_context("New Project") self.stack.setCurrentWidget(self.new_project_wizard) + self._home_btn.hide() self.new_project_wizard.reset() def _open_project(self, path: str): @@ -280,6 +320,8 @@ def _open_project(self, path: str): title = data.get("meta", {}).get("title", "Untitled") self._editor_title_bar.set_context(title) self._editor_title_bar.set_wordmark_accent() + self._home_btn.show() + self._position_home_btn() dialect = data.get("meta", {}).get("format_id", "fountain/core") paper = data.get("meta", {}).get("paper", "US Letter") self.editor.status_bar.update_info(dialect=dialect) @@ -314,6 +356,11 @@ def _on_content_changed(self): saved = STATE.save_script() if saved: self._module_registry.call_hook(HOOK_AFTER_SAVE, STATE.script_content) + if self.editor.outline.canvas._nodes: + STATE.save_outline(self.editor.outline.canvas._nodes) + plan_docs = self.editor.plan.get_documents() + if plan_docs: + STATE.save_plan_documents(plan_docs) self._editor_title_bar.save_dot.set_saved(saved) if saved and STATE.current_project_path: self._auto_sync_if_enabled() @@ -327,14 +374,20 @@ def _on_scenes_renumbered(self): ) def _auto_sync_if_enabled(self): + if hasattr(self, "_sync_pending") and self._sync_pending: + return import threading from ecrit.sync.remote_sync import load_remote_config, push_to_remote config, _result = load_remote_config(STATE.current_project_path) if config and config.auto_sync and config.remote_url: + self._sync_pending = True path = STATE.current_project_path - threading.Thread( - target=push_to_remote, args=(path, config), daemon=True - ).start() + def _do_sync(): + try: + push_to_remote(path, config) + finally: + self._sync_pending = False + threading.Thread(target=_do_sync, daemon=True).start() def _auto_export_if_enabled(self): import tempfile @@ -354,8 +407,9 @@ def _auto_export_if_enabled(self): tmp.write(content) tmp_path = tmp.name exporter.upload_file(tmp_path, cfg.folder_path) - except Exception: - pass + except Exception as e: + import logging + logging.getLogger("ecrit.export").warning("Auto-export failed for %s: %s", cfg.provider, e) finally: if tmp_path: try: @@ -414,7 +468,6 @@ def _on_command(self, name: str): "Remote Sync": self._show_sync_settings, "Cloud Export": self._show_cloud_export, "Share for Review": self._show_share_review, - "Plugin Marketplace": self._show_marketplace, "Collaboration": self._show_collaboration, "Companion Sync": self._show_companion, "Change Language": self._show_language_settings, @@ -722,43 +775,68 @@ def _on_share_created(self, watermark: str): "path": path, }) - def _show_marketplace(self): - self._marketplace_dialog.exec() def _show_collaboration(self): if STATE.current_project_path: content = self.editor.manuscript.editor.toPlainText() title = self._editor_title_bar.context_label.text() or "Untitled" self._collab_session.project_title = title + self._collab_dialog._host_content = content + self._collab_dialog._host_title = title self._collab_dialog.exec() def _on_collab_started(self, session): - if STATE.current_project_path and session.role.value == "host": - content = self.editor.manuscript.editor.toPlainText() - session._crdt.set_text(content) + self._on_collab_left() self.editor.manuscript.presence_bar.setVisible(True) self._active_collab_session = session + self._collab_suppress_local = False self._collab_text_changed.connect(self._apply_collab_text) self._collab_participants_changed.connect(self._apply_collab_participants) self._collab_cursor_changed.connect(self._apply_collab_cursor) + self.editor.manuscript.editor.document().contentsChange.connect( + self._on_collab_local_edit + ) session.set_callbacks( on_text_change=lambda text: self._collab_text_changed.emit(text), on_participant_change=lambda: self._collab_participants_changed.emit(), on_cursor_change=lambda uid, pos, length: self._collab_cursor_changed.emit(uid, pos, length), ) + def _on_collab_local_edit(self, position: int, chars_removed: int, chars_added: int): + session = self._active_collab_session + if not session or self._collab_suppress_local: + return + if chars_removed > 0: + session.apply_local_delete(position, chars_removed) + if chars_added > 0: + doc = self.editor.manuscript.editor.document() + cursor = self.editor.manuscript.editor.textCursor() + cursor.setPosition(position) + cursor.movePosition(cursor.MoveOperation.Right, cursor.MoveMode.KeepAnchor, chars_added) + added_text = cursor.selectedText().replace("
", "\n") + if added_text: + session.apply_local_insert(position, added_text) + def _apply_collab_text(self, text: str): editor = self.editor.manuscript.editor if editor.toPlainText() != text: cursor_pos = editor.textCursor().position() - editor.blockSignals(True) - editor.setPlainText(text) + self._collab_suppress_local = True cursor = editor.textCursor() + cursor.beginEditBlock() + cursor.select(cursor.SelectionType.Document) + cursor.insertText(text) + cursor.endEditBlock() cursor.setPosition(min(cursor_pos, len(text))) editor.setTextCursor(cursor) - editor.blockSignals(False) + self._collab_suppress_local = False STATE.script_content = text - STATE.save_script() + if not hasattr(self, "_collab_save_timer"): + self._collab_save_timer = QTimer(self) + self._collab_save_timer.setSingleShot(True) + self._collab_save_timer.setInterval(2000) + self._collab_save_timer.timeout.connect(lambda: STATE.save_script()) + self._collab_save_timer.start() def _apply_collab_participants(self): session = self._active_collab_session @@ -787,6 +865,15 @@ def _on_collab_left(self): self._collab_cursor_changed.disconnect(self._apply_collab_cursor) except RuntimeError: pass + try: + self.editor.manuscript.editor.document().contentsChange.disconnect( + self._on_collab_local_edit + ) + except RuntimeError: + pass + if hasattr(self, "_collab_save_timer") and self._collab_save_timer.isActive(): + self._collab_save_timer.stop() + STATE.save_script() self._active_collab_session = None self.editor.status_bar.sprint_label.setText("") self.editor.manuscript.presence_bar.clear_participants() @@ -806,8 +893,6 @@ def _on_companion_sync(self): url = device_sync.start_transfer_server(path) self._companion_dialog.set_transfer_url(url) self._companion_dialog.set_sync_status(f"Bundle ready: {path}") - for device in device_sync._devices: - device_sync.mark_synced(device.device_id) def _on_wifi_transfer(self, path: str): if not STATE.current_project_path: @@ -847,22 +932,24 @@ def _export_script(self, kind: str): from ecrit.screenplay.module_system import HOOK_BEFORE_EXPORT, HOOK_AFTER_EXPORT self._module_registry.call_hook(HOOK_BEFORE_EXPORT, kind, content) export_opts = self.editor.deliver._export_opts + format_id = STATE.get_format_id() if kind == "pdf": from ecrit.export.pdf_export import export_pdf export_pdf( content, title=title, parent=self, include_title_page=export_opts.get("title_page", True), scene_numbers=export_opts.get("scene_numbers", True), + format_id=format_id, ) elif kind == "odt": from ecrit.export.odt_export import export_odt - export_odt(content, title=title, parent=self) + export_odt(content, title=title, parent=self, format_id=format_id) elif kind == "fountain": from ecrit.export.fountain_export import export_fountain export_fountain(content, title=title, parent=self) elif kind == "epub": from ecrit.export.epub_export import export_epub - export_epub(content, title=title, parent=self) + export_epub(content, title=title, parent=self, format_id=format_id) self._module_registry.call_hook(HOOK_AFTER_EXPORT, kind, content) def _import_script(self): @@ -901,6 +988,18 @@ def _swap_title_bar(self, show_phases: bool): old_bar.hide() self.title_bar.show() + def resizeEvent(self, event): + super().resizeEvent(event) + self._position_home_btn() + + def _position_home_btn(self): + margin = 20 + self._home_btn.move( + self.width() - self._home_btn.width() - margin, + self.height() - self._home_btn.height() - margin, + ) + self._home_btn.raise_() + def _toggle_maximize(self): if self.isMaximized(): self.showNormal() @@ -1062,7 +1161,7 @@ def _on_title_template_applied(self, fountain_text: str): content = editor.toPlainText() if content.startswith("Title:") or content.startswith("title:"): lines = content.split("\n") - end = 0 + end = len(lines) for i, line in enumerate(lines): if line.strip() == "" and i > 0: end = i + 1 @@ -1125,6 +1224,28 @@ def on_pick(item): def _toggle_theme(self): theme.toggle() self._apply_theme() + self._save_theme_preference() + + def _on_theme_changed_from_settings(self): + self._apply_theme() + self._save_theme_preference() + + def _save_theme_preference(self): + prefs = STATE.load_preferences() + prefs["theme"] = theme.current().name + STATE.save_preferences(prefs) + + def _zoom_manuscript(self, direction: int): + if self.stack.currentWidget() is not self.editor: + return + editor = self.editor.manuscript.editor + font = editor.font() + if direction == 0: + font.setPointSize(15) + else: + new_size = font.pointSize() + (2 * direction) + font.setPointSize(max(8, min(36, new_size))) + editor.setFont(font) def mousePressEvent(self, event): if event.position().y() < 44: @@ -1160,6 +1281,11 @@ def _on_settings_applied(self, settings: dict): font = QFont("Courier Prime", font_size) font.setStyleHint(QFont.StyleHint.Monospace) self.editor.manuscript.editor.setFont(font) + ui_font_size = settings.get("ui_font_size", 14) + app_font = QApplication.font() + app_font.setPointSize(ui_font_size) + QApplication.setFont(app_font) + self._apply_theme() word_target = settings.get("word_target", 2500) self.editor.status_bar.words_label.setText( f"0 / {word_target:,} today" @@ -1187,6 +1313,7 @@ def _on_settings_applied(self, settings: dict): "author_name": STATE.author_name, "author_email": STATE.author_email, "font_size": font_size, + "ui_font_size": ui_font_size, "word_target": word_target, "auto_save": auto_save, "typewriter": settings.get("typewriter", True), @@ -1196,13 +1323,6 @@ def _on_settings_applied(self, settings: dict): "autosave_enabled": autosave_enabled, }) - def _on_plugin_installed(self, plugin_id: str): - if hasattr(self._settings_dialog, '_module_registry') and self._settings_dialog._module_registry: - self._settings_dialog._refresh_modules_list() - - def _on_plugin_uninstalled(self, plugin_id: str): - if hasattr(self._settings_dialog, '_module_registry') and self._settings_dialog._module_registry: - self._settings_dialog._refresh_modules_list() def _on_sync_config_saved(self, config: dict): if not STATE.current_project_path: @@ -1230,13 +1350,26 @@ def _update_week_stats(self): self.dashboard.week_stats.set_stats( words=stats.get("word_count", 0), pages=stats.get("page_count", 0), + scenes=stats.get("scene_count", 0), + characters=stats.get("character_count", 0), ) def main(): + QApplication.setHighDpiScaleFactorRoundingPolicy( + Qt.HighDpiScaleFactorRoundingPolicy.PassThrough, + ) app = QApplication(sys.argv) app.setApplicationName("Écrit") + from PySide6.QtGui import QFont + font = QFont("Liberation Serif", 14) + font.setHintingPreference(QFont.HintingPreference.PreferNoHinting) + font.setStyleStrategy( + QFont.StyleStrategy.PreferAntialias + ) + app.setFont(font) + window = MainWindow() window.show() sys.exit(app.exec()) diff --git a/ecrit/screenplay/module_system.py b/ecrit/screenplay/module_system.py index 40a02bc..1863eea 100644 --- a/ecrit/screenplay/module_system.py +++ b/ecrit/screenplay/module_system.py @@ -5,10 +5,13 @@ import importlib import importlib.util import json +import logging import os from dataclasses import dataclass, field, asdict from typing import Any, Callable, Optional +logger = logging.getLogger("ecrit.modules") + @dataclass class ModuleManifest: @@ -154,7 +157,7 @@ def call_hook(self, hook_name: str, *args, **kwargs) -> list[Any]: try: results.append(callback(*args, **kwargs)) except Exception: - pass + logger.warning("Hook '%s' callback %s failed", hook_name, callback, exc_info=True) return results def _unhook_module(self, name: str) -> None: diff --git a/ecrit/screenplay/outline_sync.py b/ecrit/screenplay/outline_sync.py new file mode 100644 index 0000000..f1d0471 --- /dev/null +++ b/ecrit/screenplay/outline_sync.py @@ -0,0 +1,312 @@ +"""Bidirectional sync between outline nodes and Fountain script content.""" + +from __future__ import annotations + +import json +import logging +import re + +logger = logging.getLogger("ecrit.screenplay.outline_sync") + +try: + import ecrit_core +except ImportError: + ecrit_core = None + + +def outline_to_script(nodes: list[dict], format_id: str = "fountain/core") -> str: + """Generate a Fountain script skeleton from outline nodes. + + For comic formats, Page/Panel/Spread nodes produce comic Fountain syntax. + For screenplay formats, ActBreak/Scene nodes produce standard Fountain. + """ + is_comic = format_id.startswith("fountain+comic") + lines: list[str] = [] + + sorted_nodes = _topo_sort(nodes) + + for node in sorted_nodes: + kind = node.get("kind", "Scene") + label = node.get("label") or "" + synopsis = node.get("synopsis") or "" + + if is_comic: + if kind == "Page": + lines.append("") + lines.append(f"# {label.upper()}") + if synopsis: + lines.append(f"= {synopsis}") + lines.append("") + elif kind == "Spread": + lines.append("") + lines.append(f"# {label.upper()}") + if synopsis: + lines.append(f"= {synopsis}") + lines.append("") + elif kind == "Panel": + panel_label = label + if not panel_label.upper().startswith("PANEL"): + panel_label = f"PANEL {panel_label}" + lines.append(panel_label.upper()) + if synopsis: + lines.append("") + lines.append(synopsis) + lines.append("") + elif kind == "Note": + lines.append(f"[[{synopsis or label}]]") + lines.append("") + else: + if synopsis: + lines.append(synopsis) + lines.append("") + else: + if kind == "ActBreak": + lines.append("") + lines.append(f"# {label}") + if synopsis: + lines.append(f"= {synopsis}") + lines.append("") + elif kind == "Scene": + heading = label + if not any(heading.upper().startswith(p) for p in ("INT.", "EXT.", "INT/EXT", "EST.", "I/E.")): + heading = f"INT. {heading.upper()} - DAY" + lines.append(heading) + if synopsis: + lines.append(f"= {synopsis}") + lines.append("") + lines.append("") + elif kind == "Transition": + lines.append(f"{label.upper()} TO:") + lines.append("") + elif kind == "Note": + lines.append(f"[[{synopsis or label}]]") + lines.append("") + + return "\n".join(lines).strip() + "\n" + + +def script_to_outline(script: str, format_id: str = "fountain/core") -> list[dict]: + """Parse a Fountain script and generate outline nodes from its structure. + + For comic formats, extracts Page/Panel/Spread structure. + For screenplay formats, extracts Scene/ActBreak structure. + """ + is_comic = format_id.startswith("fountain+comic") + elements = _parse_elements(script, format_id) + + nodes: list[dict] = [] + node_id = 0 + prev_id: str | None = None + + x_start = 40 + y_start = 40 + row_height = 130 + col_width = 220 + current_row = -1 + col_in_row = 0 + + if is_comic: + for elem in elements: + etype = elem.get("type", "") + + if etype == "PageHeader": + current_row += 1 + col_in_row = 0 + kind = "Page" + text = elem.get("text", "") + if "SPREAD" in text.upper(): + kind = "Spread" + nid = f"sync_{node_id}" + node_id += 1 + node = { + "id": nid, + "x": x_start, + "y": y_start + current_row * row_height, + "kind": kind, + "label": text, + "synopsis": "", + "connections": [], + } + if prev_id is not None: + _connect(nodes, prev_id, nid) + nodes.append(node) + prev_id = nid + + elif etype == "PanelHeader": + col_in_row += 1 + nid = f"sync_{node_id}" + node_id += 1 + node = { + "id": nid, + "x": x_start + col_in_row * col_width, + "y": y_start + max(current_row, 0) * row_height, + "kind": "Panel", + "label": elem.get("text", ""), + "synopsis": "", + "connections": [], + } + if prev_id is not None: + _connect(nodes, prev_id, nid) + nodes.append(node) + prev_id = nid + + elif etype in ("Action", "Dialogue", "Caption", "Sfx"): + if nodes: + last = nodes[-1] + text = elem.get("text", "") + cur = last.get("synopsis") or "" + if len(cur) < 200: + if cur: + last["synopsis"] = cur + f" / {text[:60]}" + else: + last["synopsis"] = text[:80] + else: + for elem in elements: + etype = elem.get("type", "") + + if etype == "Section": + current_row += 1 + col_in_row = 0 + nid = f"sync_{node_id}" + node_id += 1 + node = { + "id": nid, + "x": x_start, + "y": y_start + current_row * row_height, + "kind": "ActBreak", + "label": elem.get("text", ""), + "synopsis": "", + "connections": [], + } + if prev_id is not None: + _connect(nodes, prev_id, nid) + nodes.append(node) + prev_id = nid + + elif etype == "SceneHeading": + col_in_row += 1 + nid = f"sync_{node_id}" + node_id += 1 + node = { + "id": nid, + "x": x_start + col_in_row * col_width, + "y": y_start + max(current_row, 0) * row_height, + "kind": "Scene", + "label": elem.get("text", ""), + "synopsis": "", + "connections": [], + } + if prev_id is not None: + _connect(nodes, prev_id, nid) + nodes.append(node) + prev_id = nid + + elif etype == "Transition": + col_in_row += 1 + nid = f"sync_{node_id}" + node_id += 1 + node = { + "id": nid, + "x": x_start + col_in_row * col_width, + "y": y_start + max(current_row, 0) * row_height, + "kind": "Transition", + "label": elem.get("text", ""), + "synopsis": "", + "connections": [], + } + if prev_id is not None: + _connect(nodes, prev_id, nid) + nodes.append(node) + prev_id = nid + + elif etype == "Synopsis": + if nodes: + nodes[-1]["synopsis"] = elem.get("text", "") + + elif etype in ("Action", "Dialogue"): + if nodes and not nodes[-1].get("synopsis"): + nodes[-1]["synopsis"] = elem.get("text", "")[:80] + + return nodes + + +def _connect(nodes: list[dict], from_id: int, to_id: int): + for n in nodes: + if n["id"] == from_id: + n["connections"].append(to_id) + return + + +def _parse_elements(script: str, format_id: str) -> list[dict]: + if ecrit_core: + try: + doc = json.loads(ecrit_core.parse_fountain(script, format_id)) + return doc.get("elements", []) + except Exception: + logger.debug("ecrit_core parse failed, using fallback", exc_info=True) + return _parse_elements_fallback(script) + + +def _parse_elements_fallback(script: str) -> list[dict]: + elements = [] + for line in script.split("\n"): + stripped = line.strip() + if not stripped: + continue + if stripped.startswith("#"): + text = stripped.lstrip("#").strip() + if re.match(r"(?i)^PAGE\s", text): + elements.append({"type": "PageHeader", "text": text}) + elif re.match(r"(?i)^SPREAD[\s(]", text): + elements.append({"type": "PageHeader", "text": text}) + else: + elements.append({"type": "Section", "text": text}) + elif re.match(r"(?i)^\.?PANEL\s", stripped): + elements.append({"type": "PanelHeader", "text": stripped}) + elif stripped.startswith(("INT.", "EXT.", "INT/EXT", "EST.", "I/E.")): + elements.append({"type": "SceneHeading", "text": stripped}) + elif stripped.startswith("="): + elements.append({"type": "Synopsis", "text": stripped[1:].strip()}) + elif stripped.endswith("TO:") and stripped.isupper(): + elements.append({"type": "Transition", "text": stripped}) + else: + elements.append({"type": "Action", "text": stripped}) + return elements + + +def _topo_sort(nodes: list[dict]) -> list[dict]: + """Sort nodes following connection order. Falls back to position-based if no connections.""" + if not nodes: + return [] + + id_map = {n["id"]: n for n in nodes} + has_incoming = set() + for n in nodes: + for cid in n.get("connections", []): + has_incoming.add(cid) + + roots = [n for n in nodes if n["id"] not in has_incoming] + if not roots: + return sorted(nodes, key=lambda n: (n.get("y", 0), n.get("x", 0))) + + visited: set = set() + result = [] + + for root in sorted(roots, key=lambda n: (n.get("y", 0), n.get("x", 0))): + stack = [root] + while stack: + node = stack.pop() + nid = node["id"] + if nid in visited: + continue + visited.add(nid) + result.append(node) + for cid in reversed(node.get("connections", [])): + if cid in id_map and cid not in visited: + stack.append(id_map[cid]) + + for n in nodes: + if n["id"] not in visited: + result.append(n) + + return result diff --git a/ecrit/screenplay/structure_templates.py b/ecrit/screenplay/structure_templates.py index 7279d6c..6a564dc 100644 --- a/ecrit/screenplay/structure_templates.py +++ b/ecrit/screenplay/structure_templates.py @@ -32,6 +32,16 @@ class StructureTemplate: beats: list[StructureBeat] = field(default_factory=list) +@dataclass(frozen=True, slots=True) +class ComicBeat: + """A beat in a comic structure template — tied to a page and optional panel.""" + + kind: str # "Page", "Panel", "Spread", "Note" + label: str + synopsis: str = "" + page: int = 1 + + # --------------------------------------------------------------------------- # Built-in templates # --------------------------------------------------------------------------- @@ -476,6 +486,129 @@ class StructureTemplate: ) +# --------------------------------------------------------------------------- +# Comic structure templates +# --------------------------------------------------------------------------- + +@dataclass(frozen=True, slots=True) +class ComicTemplate: + """A comic book structure template using Page/Panel/Spread nodes.""" + + name: str + description: str + beats: list[ComicBeat] = field(default_factory=list) + + +_COMIC_22_PAGE = ComicTemplate( + name="22-Page Issue", + description="Standard monthly comic book issue layout (DC/Marvel format).", + beats=[ + ComicBeat("Page", "PAGE 1", "Splash page — hook the reader", 1), + ComicBeat("Panel", "Panel 1", "Opening image / establishing shot", 1), + ComicBeat("Page", "PAGE 2", "Setup — introduce protagonist", 2), + ComicBeat("Panel", "Panel 1", "Character introduction", 2), + ComicBeat("Panel", "Panel 2", "Status quo / world-building", 2), + ComicBeat("Panel", "Panel 3", "Hint at conflict", 2), + ComicBeat("Page", "PAGE 3", "Setup continued", 3), + ComicBeat("Panel", "Panel 1", "Supporting cast / relationships", 3), + ComicBeat("Panel", "Panel 2", "Deepen the world", 3), + ComicBeat("Page", "PAGES 4-5", "Inciting incident", 4), + ComicBeat("Spread", "SPREAD (4-5)", "Double-page spread — the event that starts the story", 4), + ComicBeat("Page", "PAGE 6", "Reaction / stakes established", 6), + ComicBeat("Panel", "Panel 1", "Protagonist reacts to inciting incident", 6), + ComicBeat("Panel", "Panel 2", "Stakes become clear", 6), + ComicBeat("Page", "PAGE 7", "Complication", 7), + ComicBeat("Panel", "Panel 1", "First obstacle or antagonist appearance", 7), + ComicBeat("Page", "PAGE 8", "Rising action", 8), + ComicBeat("Panel", "Panel 1", "Protagonist commits to goal", 8), + ComicBeat("Page", "PAGE 9", "B-plot introduction", 9), + ComicBeat("Panel", "Panel 1", "Secondary storyline begins", 9), + ComicBeat("Page", "PAGE 10", "Escalation", 10), + ComicBeat("Panel", "Panel 1", "Tensions rise / new info", 10), + ComicBeat("Page", "PAGE 11", "Midpoint turn", 11), + ComicBeat("Panel", "Panel 1", "Major revelation or reversal", 11), + ComicBeat("Page", "PAGES 12-13", "Midpoint action sequence", 12), + ComicBeat("Spread", "SPREAD (12-13)", "Big action beat at the halfway mark", 12), + ComicBeat("Page", "PAGE 14", "Aftermath", 14), + ComicBeat("Panel", "Panel 1", "Consequences of midpoint", 14), + ComicBeat("Page", "PAGE 15", "Regrouping", 15), + ComicBeat("Panel", "Panel 1", "Protagonist reassesses", 15), + ComicBeat("Page", "PAGE 16", "Rising tension", 16), + ComicBeat("Panel", "Panel 1", "Antagonist gains ground", 16), + ComicBeat("Page", "PAGE 17", "All is lost moment", 17), + ComicBeat("Panel", "Panel 1", "Lowest point for protagonist", 17), + ComicBeat("Page", "PAGE 18", "Turning point", 18), + ComicBeat("Panel", "Panel 1", "New resolve / plan emerges", 18), + ComicBeat("Page", "PAGE 19", "Climax begins", 19), + ComicBeat("Panel", "Panel 1", "Final confrontation starts", 19), + ComicBeat("Page", "PAGES 20-21", "Climax", 20), + ComicBeat("Spread", "SPREAD (20-21)", "Double-page climactic moment", 20), + ComicBeat("Page", "PAGE 22", "Resolution / cliffhanger", 22), + ComicBeat("Panel", "Panel 1", "Wrap up or hook for next issue", 22), + ], +) + +_COMIC_SHORT = ComicTemplate( + name="5-Page Short", + description="Short comic story or anthology entry.", + beats=[ + ComicBeat("Page", "PAGE 1", "Hook — establish character and situation", 1), + ComicBeat("Panel", "Panel 1", "Opening image", 1), + ComicBeat("Panel", "Panel 2", "Character introduction", 1), + ComicBeat("Panel", "Panel 3", "Inciting incident", 1), + ComicBeat("Page", "PAGE 2", "Complication — raise the stakes", 2), + ComicBeat("Panel", "Panel 1", "Protagonist pursues goal", 2), + ComicBeat("Panel", "Panel 2", "First obstacle", 2), + ComicBeat("Page", "PAGE 3", "Escalation — midpoint turn", 3), + ComicBeat("Panel", "Panel 1", "Major complication or twist", 3), + ComicBeat("Panel", "Panel 2", "Emotional peak", 3), + ComicBeat("Page", "PAGE 4", "Climax — confrontation", 4), + ComicBeat("Panel", "Panel 1", "The decisive moment", 4), + ComicBeat("Panel", "Panel 2", "Action / resolution of conflict", 4), + ComicBeat("Page", "PAGE 5", "Resolution — landing", 5), + ComicBeat("Panel", "Panel 1", "Aftermath", 5), + ComicBeat("Panel", "Panel 2", "Closing image / punchline", 5), + ], +) + +_COMIC_GN_CHAPTER = ComicTemplate( + name="Graphic Novel Chapter", + description="A 10-page chapter for a longer graphic novel narrative.", + beats=[ + ComicBeat("Page", "PAGE 1", "Chapter opening — mood and setting", 1), + ComicBeat("Panel", "Panel 1", "Establishing shot", 1), + ComicBeat("Panel", "Panel 2", "Character entry", 1), + ComicBeat("Page", "PAGE 2", "Character development", 2), + ComicBeat("Panel", "Panel 1", "Dialogue-heavy scene", 2), + ComicBeat("Panel", "Panel 2", "Reveal personality / motivation", 2), + ComicBeat("Page", "PAGE 3", "The question — what drives this chapter", 3), + ComicBeat("Panel", "Panel 1", "Central conflict introduced", 3), + ComicBeat("Page", "PAGE 4", "Exploration", 4), + ComicBeat("Panel", "Panel 1", "Investigation / journey begins", 4), + ComicBeat("Panel", "Panel 2", "Discovery or clue", 4), + ComicBeat("Page", "PAGE 5", "Complications", 5), + ComicBeat("Panel", "Panel 1", "Things go sideways", 5), + ComicBeat("Page", "PAGES 6-7", "Turning point", 6), + ComicBeat("Spread", "SPREAD (6-7)", "The chapter's key visual moment", 6), + ComicBeat("Page", "PAGE 8", "Consequences", 8), + ComicBeat("Panel", "Panel 1", "React and regroup", 8), + ComicBeat("Page", "PAGE 9", "Resolution attempt", 9), + ComicBeat("Panel", "Panel 1", "Character takes action", 9), + ComicBeat("Panel", "Panel 2", "Outcome — success or failure", 9), + ComicBeat("Page", "PAGE 10", "Chapter close — bridge to next", 10), + ComicBeat("Panel", "Panel 1", "Emotional beat / reflection", 10), + ComicBeat("Panel", "Panel 2", "Cliffhanger or transition", 10), + ], +) + + +COMIC_TEMPLATES: dict[str, ComicTemplate] = { + "comic_22_page": _COMIC_22_PAGE, + "comic_short": _COMIC_SHORT, + "comic_gn_chapter": _COMIC_GN_CHAPTER, +} + + # --------------------------------------------------------------------------- # Template registry # --------------------------------------------------------------------------- @@ -529,7 +662,7 @@ def generate_outline_nodes(template: StructureTemplate) -> list[dict]: nodes: list[dict] = [] node_id = 0 - prev_id: int | None = None + prev_id: str | None = None # Layout constants x_start = 40 @@ -541,22 +674,19 @@ def generate_outline_nodes(template: StructureTemplate) -> list[dict]: beats_in_act = acts[act_num] # Act break node - act_node_id = node_id + act_node_str = f"tpl_{node_id}" node_id += 1 act_x = x_start act_y = y_start + act_index * row_height - connections: list[int] = [] if prev_id is not None: - connections = [act_node_id] - # Add connection from the previous node to this act break for n in nodes: if n["id"] == prev_id: - n["connections"].append(act_node_id) + n["connections"].append(act_node_str) break act_node: dict = { - "id": act_node_id, + "id": act_node_str, "x": act_x, "y": act_y, "kind": "ActBreak", @@ -565,17 +695,17 @@ def generate_outline_nodes(template: StructureTemplate) -> list[dict]: "connections": [], } nodes.append(act_node) - prev_id = act_node_id + prev_id = act_node_str # Beat nodes for beat_index, beat in enumerate(beats_in_act): - beat_node_id = node_id + beat_node_str = f"tpl_{node_id}" node_id += 1 beat_x = x_start + (beat_index + 1) * col_width beat_y = act_y beat_node: dict = { - "id": beat_node_id, + "id": beat_node_str, "x": beat_x, "y": beat_y, "kind": "Scene", @@ -584,13 +714,77 @@ def generate_outline_nodes(template: StructureTemplate) -> list[dict]: "connections": [], } - # Connect previous node to this beat for n in nodes: if n["id"] == prev_id: - n["connections"].append(beat_node_id) + n["connections"].append(beat_node_str) break nodes.append(beat_node) - prev_id = beat_node_id + prev_id = beat_node_str + + return nodes + + +def generate_comic_outline_nodes(template: ComicTemplate) -> list[dict]: + """Convert a comic template into outline graph nodes using Page/Panel/Spread kinds. + + Layout: each page is a horizontal row. A Page or Spread node starts the row, + then Panel nodes are spaced to the right. Consecutive nodes are connected. + """ + if not template.beats: + return [] + + nodes: list[dict] = [] + node_id = 0 + prev_id: str | None = None + + x_start = 40 + y_start = 40 + row_height = 130 + col_width = 220 + + pages_seen: dict[int, int] = {} + current_row = -1 + col_in_row = 0 + + for beat in template.beats: + is_page_level = beat.kind in ("Page", "Spread") + + if is_page_level: + if beat.page not in pages_seen: + current_row += 1 + pages_seen[beat.page] = current_row + col_in_row = 0 + else: + current_row = pages_seen[beat.page] + col_in_row += 1 + else: + col_in_row += 1 + + row = pages_seen.get(beat.page, current_row) + bx = x_start + col_in_row * col_width + by = y_start + row * row_height + + bid = f"tpl_{node_id}" + node_id += 1 + + bnode: dict = { + "id": bid, + "x": bx, + "y": by, + "kind": beat.kind, + "label": beat.label, + "synopsis": beat.synopsis, + "connections": [], + } + + if prev_id is not None: + for n in nodes: + if n["id"] == prev_id: + n["connections"].append(bid) + break + + nodes.append(bnode) + prev_id = bid return nodes diff --git a/ecrit/stores/app_state.py b/ecrit/stores/app_state.py index 8772e13..b541571 100644 --- a/ecrit/stores/app_state.py +++ b/ecrit/stores/app_state.py @@ -1,12 +1,15 @@ """Central app state management.""" import json +import logging import re from dataclasses import dataclass, field from typing import Optional, Callable from pathlib import Path from datetime import datetime, timezone +logger = logging.getLogger("ecrit.state") + try: import ecrit_core except ImportError: @@ -45,6 +48,7 @@ def load_projects(self): data = ecrit_core.list_projects(self.project_folder) self.projects = json.loads(data) except Exception: + logger.debug("ecrit_core.list_projects failed, using Python fallback", exc_info=True) self.projects = self._load_projects_py() else: self.projects = self._load_projects_py() @@ -55,17 +59,25 @@ def _load_projects_py(self) -> list: if not folder.exists(): return [] projects = [] - for item in sorted(folder.iterdir(), key=lambda p: p.stat().st_mtime if p.is_dir() else 0, reverse=True): + entries = [] + for item in folder.iterdir(): + if item.is_dir(): + try: + entries.append((item, item.stat().st_mtime)) + except OSError: + pass + entries.sort(key=lambda e: e[1], reverse=True) + for item, mtime_ts in entries: meta_file = item / "meta.json" - if item.is_dir() and meta_file.exists(): + if meta_file.exists(): try: meta = json.loads(meta_file.read_text(encoding="utf-8")) meta["path"] = str(item) - mtime = datetime.fromtimestamp(item.stat().st_mtime, tz=timezone.utc) + mtime = datetime.fromtimestamp(mtime_ts, tz=timezone.utc) meta["modified_at"] = mtime.isoformat() projects.append(meta) - except Exception: - pass + except (json.JSONDecodeError, OSError) as exc: + logger.warning("Skipping project %s: %s", item.name, exc) return projects def open_project(self, path: str): @@ -77,7 +89,7 @@ def open_project(self, path: str): self.notify() return data except Exception: - pass + logger.debug("ecrit_core.open_project failed, using Python fallback", exc_info=True) return self._open_project_py(path) def _open_project_py(self, path: str): @@ -87,19 +99,39 @@ def _open_project_py(self, path: str): return None try: meta = json.loads(meta_file.read_text(encoding="utf-8")) - except Exception: + except (json.JSONDecodeError, OSError) as exc: + logger.warning("Cannot read project meta %s: %s", meta_file, exc) return None script_file = project_dir / "script.fountain" script = "" if script_file.exists(): try: script = script_file.read_text(encoding="utf-8") - except Exception: - pass + except OSError as exc: + logger.warning("Cannot read script file %s: %s", script_file, exc) + outline_nodes = [] + outline_file = project_dir / "outline.json" + if outline_file.exists(): + try: + outline_nodes = json.loads(outline_file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + logger.warning("Cannot read outline %s: %s", outline_file, exc) + plan_documents = [] + plan_file = project_dir / "plan_documents.json" + if plan_file.exists(): + try: + plan_documents = json.loads(plan_file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + logger.warning("Cannot read plan documents %s: %s", plan_file, exc) self.current_project_path = path self.script_content = script self.notify() - return {"meta": meta, "script": script} + return { + "meta": meta, + "script": script, + "outline_nodes": outline_nodes, + "plan_documents": plan_documents, + } def save_script(self) -> bool: if ecrit_core and self.current_project_path: @@ -107,13 +139,14 @@ def save_script(self) -> bool: ecrit_core.save_script(self.current_project_path, self.script_content) return True except Exception: - pass + logger.debug("ecrit_core.save_script failed, using Python fallback", exc_info=True) if self.current_project_path: script_file = Path(self.current_project_path) / "script.fountain" try: script_file.write_text(self.script_content, encoding="utf-8") return True - except Exception: + except OSError as exc: + logger.warning("Failed to save script to %s: %s", script_file, exc) return False return False @@ -128,7 +161,7 @@ def create_project(self, title: str, author: str, format_id: str, paper: str, ki self.load_projects() return meta except Exception: - pass + logger.debug("ecrit_core.create_project failed, using Python fallback", exc_info=True) return self._create_project_py(title, author, format_id, paper, kind) def _create_project_py(self, title: str, author: str, format_id: str, paper: str, kind: str = "Single"): @@ -162,12 +195,48 @@ def _create_project_py(self, title: str, author: str, format_id: str, paper: str self.load_projects() return meta + def save_outline(self, nodes: list) -> bool: + if not self.current_project_path: + return False + try: + path = Path(self.current_project_path) / "outline.json" + path.write_text(json.dumps(nodes, indent=2), encoding="utf-8") + return True + except OSError as exc: + logger.warning("Failed to save outline: %s", exc) + return False + + def save_plan_documents(self, documents: list) -> bool: + if not self.current_project_path: + return False + try: + path = Path(self.current_project_path) / "plan_documents.json" + path.write_text(json.dumps(documents, indent=2), encoding="utf-8") + return True + except OSError as exc: + logger.warning("Failed to save plan documents: %s", exc) + return False + + def get_format_id(self) -> str: + if not self.current_project_path: + return "fountain/core" + meta_path = Path(self.current_project_path) / "project.json" + if not meta_path.exists(): + meta_path = Path(self.current_project_path) / "meta.json" + if meta_path.exists(): + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + return meta.get("format_id", "fountain/core") + except (json.JSONDecodeError, OSError): + pass + return "fountain/core" + def parse_script(self) -> dict: if ecrit_core and self.script_content: try: - return json.loads(ecrit_core.parse_fountain(self.script_content)) + return json.loads(ecrit_core.parse_fountain(self.script_content, self.get_format_id())) except Exception: - pass + logger.debug("ecrit_core.parse_fountain failed, using Python fallback", exc_info=True) return self._parse_script_py() def _parse_script_py(self) -> dict: @@ -189,9 +258,9 @@ def _parse_script_py(self) -> dict: def get_stats(self) -> dict: if ecrit_core and self.script_content: try: - return json.loads(ecrit_core.get_script_stats(self.script_content)) + return json.loads(ecrit_core.get_script_stats(self.script_content, self.get_format_id())) except Exception: - pass + logger.debug("ecrit_core.get_script_stats failed, using Python fallback", exc_info=True) return self._get_stats_py() def _get_stats_py(self) -> dict: @@ -296,12 +365,13 @@ def save_preferences(self, prefs: dict) -> bool: if path.exists(): try: existing = json.loads(path.read_text(encoding="utf-8")) - except Exception: - pass + except (json.JSONDecodeError, OSError): + logger.debug("Could not read existing preferences, starting fresh") existing.update(prefs) path.write_text(json.dumps(existing, indent=2), encoding="utf-8") return True - except Exception: + except OSError as exc: + logger.warning("Failed to save preferences: %s", exc) return False def load_preferences(self) -> dict: @@ -309,8 +379,8 @@ def load_preferences(self) -> dict: path = self._prefs_path() if path.exists(): return json.loads(path.read_text(encoding="utf-8")) - except Exception: - pass + except (json.JSONDecodeError, OSError) as exc: + logger.warning("Failed to load preferences: %s", exc) return {} STATE = AppState() diff --git a/ecrit/sync/cloud_export.py b/ecrit/sync/cloud_export.py index a8def0e..91c8eea 100644 --- a/ecrit/sync/cloud_export.py +++ b/ecrit/sync/cloud_export.py @@ -116,138 +116,42 @@ def is_authenticated(self) -> bool: # Concrete stub implementations # --------------------------------------------------------------------------- -class GoogleDriveExporter(CloudExporter): - PROVIDER_NAME = "Google Drive" - AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" +class _StubExporter(CloudExporter): + """Base for providers whose SDK is not yet integrated.""" def authenticate(self) -> bool: - if self.config.auth_token: - self._authenticated = True - return True - self._authenticated = False return False def upload_file(self, local_path: str, remote_folder: str) -> ExportResult: - if not self._authenticated: - return ExportResult(False, "Not authenticated with Google Drive") - filename = os.path.basename(local_path) - url = f"https://drive.google.com/file/d/stub-id/{filename}" - return ExportResult(True, f"Uploaded {filename} to Google Drive", url) + return ExportResult(False, f"{self.PROVIDER_NAME} integration is not yet available") def list_files(self, remote_folder: str) -> list[dict]: - if not self._authenticated: - return [] - return [ - {"name": "example_script.fountain", "id": "stub-1", "size": 24000}, - {"name": "example_script.pdf", "id": "stub-2", "size": 180000}, - ] + return [] -class ICloudExporter(CloudExporter): - PROVIDER_NAME = "iCloud Drive" - AUTH_URL = "https://appleid.apple.com/auth/authorize" - - def authenticate(self) -> bool: - if self.config.auth_token: - self._authenticated = True - return True - self._authenticated = False - return False +class GoogleDriveExporter(_StubExporter): + PROVIDER_NAME = "Google Drive" + AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" - def upload_file(self, local_path: str, remote_folder: str) -> ExportResult: - if not self._authenticated: - return ExportResult(False, "Not authenticated with iCloud Drive") - filename = os.path.basename(local_path) - return ExportResult( - True, - f"Uploaded {filename} to iCloud Drive", - f"icloud://stub/{remote_folder}/{filename}", - ) - def list_files(self, remote_folder: str) -> list[dict]: - if not self._authenticated: - return [] - return [ - {"name": "example_script.fountain", "type": "file", "size": 24000}, - ] +class ICloudExporter(_StubExporter): + PROVIDER_NAME = "iCloud Drive" + AUTH_URL = "https://appleid.apple.com/auth/authorize" -class DropboxExporter(CloudExporter): +class DropboxExporter(_StubExporter): PROVIDER_NAME = "Dropbox" AUTH_URL = "https://www.dropbox.com/oauth2/authorize" - def authenticate(self) -> bool: - if self.config.auth_token: - self._authenticated = True - return True - self._authenticated = False - return False - - def upload_file(self, local_path: str, remote_folder: str) -> ExportResult: - if not self._authenticated: - return ExportResult(False, "Not authenticated with Dropbox") - filename = os.path.basename(local_path) - url = f"https://www.dropbox.com/home{remote_folder}/{filename}" - return ExportResult(True, f"Uploaded {filename} to Dropbox", url) - def list_files(self, remote_folder: str) -> list[dict]: - if not self._authenticated: - return [] - return [ - {"name": "example_script.fountain", "path": f"{remote_folder}/example_script.fountain", "size": 24000}, - ] - - -class OneDriveExporter(CloudExporter): +class OneDriveExporter(_StubExporter): PROVIDER_NAME = "OneDrive" AUTH_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" - def authenticate(self) -> bool: - if self.config.auth_token: - self._authenticated = True - return True - self._authenticated = False - return False - - def upload_file(self, local_path: str, remote_folder: str) -> ExportResult: - if not self._authenticated: - return ExportResult(False, "Not authenticated with OneDrive") - filename = os.path.basename(local_path) - url = f"https://onedrive.live.com/stub/{remote_folder}/{filename}" - return ExportResult(True, f"Uploaded {filename} to OneDrive", url) - - def list_files(self, remote_folder: str) -> list[dict]: - if not self._authenticated: - return [] - return [ - {"name": "example_script.fountain", "id": "stub-onedrive-1", "size": 24000}, - ] - -class NextcloudExporter(CloudExporter): +class NextcloudExporter(_StubExporter): PROVIDER_NAME = "Nextcloud" - AUTH_URL = "https://cloud.example.com/index.php/apps/oauth2/authorize" - - def authenticate(self) -> bool: - if self.config.auth_token: - self._authenticated = True - return True - self._authenticated = False - return False - - def upload_file(self, local_path: str, remote_folder: str) -> ExportResult: - if not self._authenticated: - return ExportResult(False, "Not authenticated with Nextcloud") - filename = os.path.basename(local_path) - url = f"https://cloud.example.com/remote.php/dav/files/user{remote_folder}/{filename}" - return ExportResult(True, f"Uploaded {filename} to Nextcloud", url) - - def list_files(self, remote_folder: str) -> list[dict]: - if not self._authenticated: - return [] - return [ - {"name": "example_script.fountain", "href": f"{remote_folder}/example_script.fountain", "size": 24000}, - ] + AUTH_URL = "" # --------------------------------------------------------------------------- @@ -366,6 +270,9 @@ def load_cloud_configs(project_path: str) -> list[CloudConfig]: path = _cloud_config_path(project_path) if not os.path.isfile(path): return [] - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - return [CloudConfig.from_dict(entry) for entry in data] + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return [CloudConfig.from_dict(entry) for entry in data] + except (json.JSONDecodeError, OSError, KeyError, TypeError): + return [] diff --git a/ecrit/sync/remote_sync.py b/ecrit/sync/remote_sync.py index 40074ab..1372a98 100644 --- a/ecrit/sync/remote_sync.py +++ b/ecrit/sync/remote_sync.py @@ -311,7 +311,7 @@ def _list_conflict_files(project_path: Path) -> List[str]: proc = _run_git(["diff", "--name-only", "--diff-filter=U"], cwd=project_path) if proc.returncode == 0 and proc.stdout.strip(): return proc.stdout.strip().splitlines() - except Exception: + except (subprocess.TimeoutExpired, OSError): pass return [] diff --git a/ecrit/ui/components/presence_indicators.py b/ecrit/ui/components/presence_indicators.py index 3caf470..98311ea 100644 --- a/ecrit/ui/components/presence_indicators.py +++ b/ecrit/ui/components/presence_indicators.py @@ -4,8 +4,6 @@ from PySide6.QtCore import Qt from PySide6.QtGui import QColor -from ecrit.ui.styles import theme - class PresenceBadge(QFrame): def __init__(self, user_name: str, color: str, parent=None): diff --git a/ecrit/ui/components/status_bar.py b/ecrit/ui/components/status_bar.py index 4a711ca..14f3134 100644 --- a/ecrit/ui/components/status_bar.py +++ b/ecrit/ui/components/status_bar.py @@ -3,6 +3,8 @@ from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QPushButton from PySide6.QtCore import Qt, Signal +from ecrit.ui.icons import IconButton + class StatusBar(QWidget): find_clicked = Signal() @@ -27,24 +29,34 @@ def __init__(self, parent=None): layout.setSpacing(12) self.page_label = QLabel("Page 1 of 1") + self.page_label.setAccessibleName("Page indicator") + self.page_label.setFocusPolicy(Qt.FocusPolicy.NoFocus) layout.addWidget(self.page_label) self.scene_label = QLabel() self.scene_label.setStyleSheet("font-family: 'Courier Prime', monospace;") + self.scene_label.setAccessibleName("Current scene heading") + self.scene_label.setFocusPolicy(Qt.FocusPolicy.NoFocus) layout.addWidget(self.scene_label) layout.addStretch() self.words_label = QLabel("0 / 2,500 today") + self.words_label.setAccessibleName("Daily word count") + self.words_label.setFocusPolicy(Qt.FocusPolicy.NoFocus) layout.addWidget(self.words_label) self.sprint_label = QLabel() + self.sprint_label.setAccessibleName("Sprint timer") + self.sprint_label.setFocusPolicy(Qt.FocusPolicy.NoFocus) layout.addWidget(self.sprint_label) self.mode_label = QPushButton("typewriter") self.mode_label.setObjectName("iconBtn") self.mode_label.setFixedHeight(22) self.mode_label.setToolTip("Toggle typewriter scrolling") + self.mode_label.setAccessibleName("Typewriter scrolling mode") + self.mode_label.setAccessibleDescription("Keep cursor centered while typing") self.mode_label.setCursor(Qt.CursorShape.PointingHandCursor) self.mode_label.clicked.connect(self._toggle_typewriter) layout.addWidget(self.mode_label) @@ -53,6 +65,8 @@ def __init__(self, parent=None): self.focus_label.setObjectName("iconBtn") self.focus_label.setFixedHeight(22) self.focus_label.setToolTip("Toggle focus mode (dim inactive paragraphs)") + self.focus_label.setAccessibleName("Focus mode") + self.focus_label.setAccessibleDescription("Dim inactive paragraphs to reduce distractions") self.focus_label.setCursor(Qt.CursorShape.PointingHandCursor) self.focus_label.clicked.connect(self._toggle_focus) layout.addWidget(self.focus_label) @@ -61,6 +75,14 @@ def __init__(self, parent=None): sep.setFixedWidth(8) layout.addWidget(sep) + _a11y_names = { + "Find": "Find and replace", + "Stats": "Statistics", + "KB": "Keyboard shortcuts", + "Theme": "Toggle theme", + "Read": "Reading mode", + "Sprint": "Sprint timer", + } buttons = [ ("Find", self.find_clicked), ("Stats", self.stats_clicked), @@ -73,7 +95,8 @@ def __init__(self, parent=None): btn = QPushButton(label) btn.setObjectName("iconBtn") btn.setFixedSize(QLabel(label).sizeHint().width() + 12, 22) - btn.setToolTip(label) + btn.setToolTip(_a11y_names.get(label, label)) + btn.setAccessibleName(_a11y_names.get(label, label)) btn.clicked.connect(signal.emit) layout.addWidget(btn) @@ -85,7 +108,7 @@ def __init__(self, parent=None): self.dialect_label.setStyleSheet("font-family: ui-monospace, Menlo, monospace; font-size: 11px;") layout.addWidget(self.dialect_label) - self.home_btn = QPushButton("⌂") + self.home_btn = IconButton("home", icon_size=16) self.home_btn.setObjectName("homeBtn") self.home_btn.setFixedSize(34, 22) self.home_btn.setToolTip("Dashboard") diff --git a/ecrit/ui/components/title_bar.py b/ecrit/ui/components/title_bar.py index b2f65a4..b917948 100644 --- a/ecrit/ui/components/title_bar.py +++ b/ecrit/ui/components/title_bar.py @@ -7,6 +7,7 @@ from PySide6.QtGui import QPainter, QColor, QPen from ecrit.ui.styles import theme +from ecrit.ui.icons import icon as heroicon, IconButton class WindowButton(QWidget): @@ -62,22 +63,25 @@ def __init__(self, parent=None): super().__init__(parent) self.setFixedSize(68, 44) layout = QHBoxLayout(self) - layout.setContentsMargins(12, 0, 0, 0) + layout.setContentsMargins(0, 0, 12, 0) layout.setSpacing(8) - self._close = WindowButton("close") - self._close.clicked.connect(self.close_clicked.emit) - layout.addWidget(self._close) + layout.addStretch() self._minimize = WindowButton("minimize") + self._minimize.setAccessibleName("Minimize window") self._minimize.clicked.connect(self.minimize_clicked.emit) layout.addWidget(self._minimize) self._maximize = WindowButton("maximize") + self._maximize.setAccessibleName("Maximize window") self._maximize.clicked.connect(self.maximize_clicked.emit) layout.addWidget(self._maximize) - layout.addStretch() + self._close = WindowButton("close") + self._close.setAccessibleName("Close window") + self._close.clicked.connect(self.close_clicked.emit) + layout.addWidget(self._close) class PhaseTabBar(QWidget): @@ -97,6 +101,7 @@ def __init__(self, parent=None): btn = QPushButton(phase) btn.setProperty("active", phase == self._active) btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.setAccessibleName(f"{phase} phase") btn.clicked.connect(lambda checked=False, p=phase: self._on_click(p)) layout.addWidget(btn) self._buttons[phase] = btn @@ -136,7 +141,6 @@ def paintEvent(self, _event): class TitleBar(QWidget): settings_clicked = Signal() - home_clicked = Signal() phase_changed = Signal(str) close_requested = Signal() minimize_requested = Signal() @@ -148,29 +152,33 @@ def __init__(self, show_phases=False, parent=None): self.setFixedHeight(44) layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 12, 0) + layout.setContentsMargins(12, 0, 0, 0) layout.setSpacing(8) - self.window_controls = WindowControls() - self.window_controls.close_clicked.connect(self.close_requested.emit) - self.window_controls.minimize_clicked.connect(self.minimize_requested.emit) - self.window_controls.maximize_clicked.connect(self.maximize_requested.emit) - layout.addWidget(self.window_controls) + self.settings_btn = IconButton("cog-6-tooth") + self.settings_btn.setObjectName("iconBtn") + self.settings_btn.setFixedSize(28, 28) + self.settings_btn.setToolTip("Settings") + self.settings_btn.setAccessibleName("Settings") + self.settings_btn.setAccessibleDescription("Open application settings") + self.settings_btn.clicked.connect(self.settings_clicked.emit) + layout.addWidget(self.settings_btn) - self.home_btn = QPushButton("⌂") - self.home_btn.setObjectName("homeBtn") - self.home_btn.setFixedSize(28, 28) - self.home_btn.setToolTip("Dashboard") - self.home_btn.setCursor(Qt.CursorShape.PointingHandCursor) - self.home_btn.clicked.connect(self.home_clicked.emit) - layout.addWidget(self.home_btn) + self.save_dot = SaveIndicator() + self.save_dot.setAccessibleName("Save status") + self.save_dot.setAccessibleDescription("Green when saved, orange when unsaved") + self.save_dot.setFocusPolicy(Qt.FocusPolicy.NoFocus) + layout.addWidget(self.save_dot) self.wordmark = QLabel("Écrit") self.wordmark.setObjectName("wordmark") + self.wordmark.setAccessibleName("Écrit") + self.wordmark.setFocusPolicy(Qt.FocusPolicy.NoFocus) layout.addWidget(self.wordmark) self.context_label = QLabel() self.context_label.setObjectName("titleContext") + self.context_label.setFocusPolicy(Qt.FocusPolicy.NoFocus) layout.addWidget(self.context_label) layout.addStretch() @@ -183,27 +191,32 @@ def __init__(self, show_phases=False, parent=None): else: self.phase_tabs = None - self.cmd_chip = QPushButton("⌘K open anything") + self.cmd_chip = QPushButton(" open anything") self.cmd_chip.setObjectName("secondary") self.cmd_chip.setFixedHeight(28) + self.cmd_chip.setAccessibleName("Command palette") + self.cmd_chip.setAccessibleDescription("Search commands and navigate the app") layout.addWidget(self.cmd_chip) - self.save_dot = SaveIndicator() - layout.addWidget(self.save_dot) - - self.settings_btn = QPushButton("⚙") - self.settings_btn.setObjectName("iconBtn") - self.settings_btn.setFixedSize(28, 28) - self.settings_btn.setToolTip("Settings") - self.settings_btn.clicked.connect(self.settings_clicked.emit) - layout.addWidget(self.settings_btn) + self.window_controls = WindowControls() + self.window_controls.close_clicked.connect(self.close_requested.emit) + self.window_controls.minimize_clicked.connect(self.minimize_requested.emit) + self.window_controls.maximize_clicked.connect(self.maximize_requested.emit) + layout.addWidget(self.window_controls) def set_context(self, text: str): self.context_label.setText(text) + def refresh_icons(self): + t = theme.current() + color = t.neutral_300 if t.name == "nocturne" else t.neutral_700 + self.cmd_chip.setIcon(heroicon("magnifying-glass", color, 14)) + self.cmd_chip.setIconSize(QSize(14, 14)) + def set_wordmark_accent(self): from PySide6.QtCore import Qt t = theme.current() self.wordmark.setTextFormat(Qt.TextFormat.RichText) self.wordmark.setText(f"Écrit" f".") + self.refresh_icons() diff --git a/ecrit/ui/icons.py b/ecrit/ui/icons.py new file mode 100644 index 0000000..81f7bb1 --- /dev/null +++ b/ecrit/ui/icons.py @@ -0,0 +1,96 @@ +"""Heroicons (MIT, Tailwind Labs) — outline 24x24, rendered as QIcon for Écrit UI.""" + +from PySide6.QtCore import QByteArray, QEvent, QRectF, QSize +from PySide6.QtGui import QColor, QIcon, QPixmap, QPainter +from PySide6.QtSvg import QSvgRenderer +from PySide6.QtWidgets import QPushButton + +_TEMPLATE = ( + '{paths}' +) + +_PATHS: dict[str, str] = { + "x-mark": '', + "cog-6-tooth": ( + '' + '' + ), + "home": ( + '' + ), + "chevron-up": '', + "chevron-down": '', + "arrow-right": '', + "arrow-down": '', + "minus": '', + "plus": '', + "arrow-down-tray": ( + '' + ), + "magnifying-glass": ( + '' + ), + "command-line": ( + '' + ), + "document-arrow-down": ( + '' + ), +} + + +def icon(name: str, color: str = "#ffffff", size: int = 20) -> QIcon: + svg = _TEMPLATE.format(color=color, paths=_PATHS[name]) + renderer = QSvgRenderer(QByteArray(svg.encode())) + pixmap = QPixmap(size, size) + pixmap.fill(QColor(0, 0, 0, 0)) + painter = QPainter(pixmap) + renderer.render(painter, QRectF(0, 0, size, size)) + painter.end() + return QIcon(pixmap) + + +class IconButton(QPushButton): + """QPushButton that renders a Heroicon and auto-refreshes on theme change.""" + + def __init__(self, icon_name: str, icon_size: int = 18, parent=None): + super().__init__(parent) + self._icon_name = icon_name + self._icon_size = icon_size + self._refresh() + + def changeEvent(self, event): + if event.type() == QEvent.Type.StyleChange: + self._refresh() + super().changeEvent(event) + + def _refresh(self): + from ecrit.ui.styles import theme + t = theme.current() + color = t.neutral_300 if t.name == "nocturne" else t.neutral_600 + self.setIcon(icon(self._icon_name, color, self._icon_size)) + self.setIconSize(QSize(self._icon_size, self._icon_size)) diff --git a/ecrit/ui/overlays/analytics_dialog.py b/ecrit/ui/overlays/analytics_dialog.py index c09c040..33ea01c 100644 --- a/ecrit/ui/overlays/analytics_dialog.py +++ b/ecrit/ui/overlays/analytics_dialog.py @@ -17,14 +17,13 @@ def __init__(self, parent=None): self.setMinimumSize(800, 560) self.setModal(True) - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(24, 20, 24, 20) layout.setSpacing(12) header = QHBoxLayout() title = QLabel("Script Analytics") - title.setStyleSheet(f"font-size: 18px; font-weight: bold; color: {t.text};") + title.setStyleSheet("font-size: 18px; font-weight: bold;") header.addWidget(title) header.addStretch() layout.addLayout(header) diff --git a/ecrit/ui/overlays/annotations_panel.py b/ecrit/ui/overlays/annotations_panel.py index b5606bd..d2d57fc 100644 --- a/ecrit/ui/overlays/annotations_panel.py +++ b/ecrit/ui/overlays/annotations_panel.py @@ -7,7 +7,6 @@ from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QColor -from ecrit.ui.styles import theme from ecrit.screenplay.annotations import AnnotationManager, Annotation @@ -21,19 +20,18 @@ def __init__(self, annotation_manager: AnnotationManager, parent=None): self.setMinimumSize(420, 500) self.setModal(True) - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(16, 16, 16, 16) layout.setSpacing(10) header = QHBoxLayout() title = QLabel("Annotations") - title.setStyleSheet(f"font-size: 16px; font-weight: bold; color: {t.text};") + title.setStyleSheet("font-size: 16px; font-weight: bold;") header.addWidget(title) header.addStretch() self._count_label = QLabel() - self._count_label.setStyleSheet(f"color: {t.neutral_400}; font-size: 12px;") + self._count_label.setObjectName("countBadge") header.addWidget(self._count_label) layout.addLayout(header) diff --git a/ecrit/ui/overlays/autosave_browser.py b/ecrit/ui/overlays/autosave_browser.py index 0243ae3..1e2d01c 100644 --- a/ecrit/ui/overlays/autosave_browser.py +++ b/ecrit/ui/overlays/autosave_browser.py @@ -8,7 +8,7 @@ ) from PySide6.QtCore import Qt, Signal -from ecrit.ui.styles import theme +from ecrit.ui.icons import IconButton from ecrit.screenplay.autosave import AutosaveManager @@ -24,7 +24,6 @@ def __init__(self, manager: AutosaveManager, project_path: str, parent=None): self._manager = manager self._project_path = project_path - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(20, 16, 20, 16) layout.setSpacing(12) @@ -34,30 +33,34 @@ def __init__(self, manager: AutosaveManager, project_path: str, parent=None): title.setStyleSheet("font-size: 20px; font-weight: 500;") header.addWidget(title) header.addStretch() - close_btn = QPushButton("×") + close_btn = IconButton("x-mark") close_btn.setObjectName("iconBtn") close_btn.setFixedSize(28, 28) + close_btn.setAccessibleName("Close") close_btn.clicked.connect(self.close) header.addWidget(close_btn) layout.addLayout(header) info = QLabel(f"Snapshots saved every {manager._interval_minutes} min") - info.setStyleSheet(f"color: {t.neutral_500}; font-size: 12px;") + info.setObjectName("dashMutedSmall") layout.addWidget(info) self.snap_list = QListWidget() + self.snap_list.setAccessibleName("Autosave Snapshots") layout.addWidget(self.snap_list, 1) btn_row = QHBoxLayout() self.delete_btn = QPushButton("Delete") self.delete_btn.setObjectName("secondary") self.delete_btn.setFixedHeight(34) + self.delete_btn.setAccessibleName("Delete Snapshot") self.delete_btn.clicked.connect(self._delete_selected) btn_row.addWidget(self.delete_btn) btn_row.addStretch() self.restore_btn = QPushButton("Restore selected") self.restore_btn.setObjectName("primary") self.restore_btn.setFixedHeight(34) + self.restore_btn.setAccessibleName("Restore Selected Snapshot") self.restore_btn.clicked.connect(self._restore_selected) btn_row.addWidget(self.restore_btn) layout.addLayout(btn_row) diff --git a/ecrit/ui/overlays/character_cards_panel.py b/ecrit/ui/overlays/character_cards_panel.py index e453bcf..4d29032 100644 --- a/ecrit/ui/overlays/character_cards_panel.py +++ b/ecrit/ui/overlays/character_cards_panel.py @@ -8,7 +8,6 @@ from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QColor, QPixmap -from ecrit.ui.styles import theme from ecrit.screenplay.character_cards import ( CharacterCardManager, CharacterCard, CharacterBio, RELATIONSHIP_TYPES, ) @@ -24,19 +23,18 @@ def __init__(self, manager: CharacterCardManager, parent=None): self.setMinimumSize(800, 560) self.setModal(True) - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(24, 20, 24, 20) layout.setSpacing(12) header = QHBoxLayout() title = QLabel("Character Cards") - title.setStyleSheet(f"font-size: 18px; font-weight: bold; color: {t.text};") + title.setStyleSheet("font-size: 18px; font-weight: bold;") header.addWidget(title) header.addStretch() self._count_label = QLabel() - self._count_label.setStyleSheet(f"color: {t.neutral_400}; font-size: 13px;") + self._count_label.setObjectName("countBadge") header.addWidget(self._count_label) layout.addLayout(header) @@ -44,15 +42,18 @@ def __init__(self, manager: CharacterCardManager, parent=None): left = QVBoxLayout() self._card_list = QListWidget() self._card_list.setMaximumWidth(200) + self._card_list.setAccessibleName("Character Cards") self._card_list.currentItemChanged.connect(self._on_card_selected) left.addWidget(self._card_list) add_btn = QPushButton("+ New Card") + add_btn.setAccessibleName("New Card") add_btn.clicked.connect(self._add_card) left.addWidget(add_btn) auto_btn = QPushButton("Auto-populate") auto_btn.setToolTip("Scan script for character names") + auto_btn.setAccessibleName("Auto-populate from Script") auto_btn.clicked.connect(self._auto_populate) left.addWidget(auto_btn) @@ -73,15 +74,18 @@ def __init__(self, manager: CharacterCardManager, parent=None): btn_row = QHBoxLayout() save_btn = QPushButton("Save Changes") + save_btn.setAccessibleName("Save Changes") save_btn.clicked.connect(self._save_current) btn_row.addWidget(save_btn) del_btn = QPushButton("Delete Card") + del_btn.setAccessibleName("Delete Card") del_btn.clicked.connect(self._delete_current) btn_row.addWidget(del_btn) btn_row.addStretch() close_btn = QPushButton("Close") + close_btn.setAccessibleName("Close") close_btn.clicked.connect(self.close) btn_row.addWidget(close_btn) layout.addLayout(btn_row) @@ -108,15 +112,24 @@ def refresh(self): def _build_bio_tab(self): layout = QFormLayout(self._bio_tab) self._bio_full_name = QLineEdit() + self._bio_full_name.setAccessibleName("Full Name") self._bio_nickname = QLineEdit() + self._bio_nickname.setAccessibleName("Nickname") self._bio_age = QLineEdit() + self._bio_age.setAccessibleName("Age") self._bio_gender = QLineEdit() + self._bio_gender.setAccessibleName("Gender") self._bio_occupation = QLineEdit() + self._bio_occupation.setAccessibleName("Occupation") self._bio_physical = QLineEdit() + self._bio_physical.setAccessibleName("Physical") self._bio_backstory = QTextEdit() self._bio_backstory.setMaximumHeight(80) + self._bio_backstory.setAccessibleName("Backstory") self._bio_motivation = QLineEdit() + self._bio_motivation.setAccessibleName("Motivation") self._bio_speech = QLineEdit() + self._bio_speech.setAccessibleName("Speech Pattern") layout.addRow("Full Name:", self._bio_full_name) layout.addRow("Nickname:", self._bio_nickname) @@ -131,18 +144,22 @@ def _build_bio_tab(self): def _build_rel_tab(self): layout = QVBoxLayout(self._rel_tab) self._rel_list = QListWidget() + self._rel_list.setAccessibleName("Relationships") layout.addWidget(self._rel_list) add_row = QHBoxLayout() self._rel_target = QLineEdit() self._rel_target.setPlaceholderText("Character name...") + self._rel_target.setAccessibleName("Relationship Target") add_row.addWidget(self._rel_target) self._rel_type = QComboBox() + self._rel_type.setAccessibleName("Relationship Type") self._rel_type.addItems(RELATIONSHIP_TYPES) add_row.addWidget(self._rel_type) add_rel_btn = QPushButton("Add") + add_rel_btn.setAccessibleName("Add Relationship") add_rel_btn.clicked.connect(self._add_relationship) add_row.addWidget(add_rel_btn) layout.addLayout(add_row) @@ -150,12 +167,17 @@ def _build_rel_tab(self): def _build_arc_tab(self): layout = QFormLayout(self._arc_tab) self._arc_wants = QLineEdit() + self._arc_wants.setAccessibleName("Wants") self._arc_needs = QLineEdit() + self._arc_needs.setAccessibleName("Needs") self._arc_flaw = QLineEdit() + self._arc_flaw.setAccessibleName("Flaw") self._arc_summary = QTextEdit() self._arc_summary.setMaximumHeight(80) + self._arc_summary.setAccessibleName("Arc Summary") self._arc_notes = QTextEdit() self._arc_notes.setMaximumHeight(80) + self._arc_notes.setAccessibleName("Notes") layout.addRow("Wants:", self._arc_wants) layout.addRow("Needs:", self._arc_needs) diff --git a/ecrit/ui/overlays/character_sheet.py b/ecrit/ui/overlays/character_sheet.py index f6e3bf2..fdb2c58 100644 --- a/ecrit/ui/overlays/character_sheet.py +++ b/ecrit/ui/overlays/character_sheet.py @@ -7,30 +7,25 @@ from PySide6.QtCore import Qt, Signal from ecrit.ui.styles import theme +from ecrit.ui.icons import IconButton class MoodboardTile(QFrame): def __init__(self, parent=None): super().__init__(parent) self._image_path = "" - t = theme.current() + self.setObjectName("moodboardTile") self.setFixedSize(120, 120) self.setCursor(Qt.CursorShape.PointingHandCursor) - self._default_style = ( - f"background: {t.neutral_800 if t.name == 'nocturne' else t.neutral_200}; " - f"border: 2px dashed {t.neutral_700 if t.name == 'nocturne' else t.neutral_300}; " - f"border-radius: {t.radius_md}px;" - ) - self.setStyleSheet(self._default_style) layout = QVBoxLayout(self) layout.setAlignment(Qt.AlignmentFlag.AlignCenter) self._icon = QLabel("+") self._icon.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._icon.setStyleSheet(f"color: {t.neutral_500}; font-size: 24px; background: transparent;") + self._icon.setStyleSheet("font-size: 24px;") layout.addWidget(self._icon) self._label = QLabel("Click to add") self._label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._label.setStyleSheet(f"color: {t.neutral_500}; font-size: 11px; background: transparent;") + self._label.setStyleSheet("font-size: 11px;") layout.addWidget(self._label) def mousePressEvent(self, event): @@ -62,7 +57,6 @@ def __init__(self, parent=None): self.setModal(True) self._data = {} - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -79,9 +73,10 @@ def __init__(self, parent=None): header.addStretch() - close_btn = QPushButton("×") + close_btn = IconButton("x-mark") close_btn.setObjectName("iconBtn") close_btn.setFixedSize(28, 28) + close_btn.setAccessibleName("Close") close_btn.clicked.connect(self.close) header.addWidget(close_btn) layout.addLayout(header) @@ -110,6 +105,7 @@ def __init__(self, parent=None): left.addWidget(lbl) inp = QLineEdit() inp.setFixedHeight(32) + inp.setAccessibleName(label_text) left.addWidget(inp) setattr(self, attr_name, inp) @@ -124,11 +120,12 @@ def __init__(self, parent=None): left.addWidget(lbl) txt = QTextEdit() txt.setFixedHeight(60) + txt.setAccessibleName(label_text) left.addWidget(txt) setattr(self, attr_name, txt) self.scenes_label = QLabel() - self.scenes_label.setStyleSheet(f"color: {t.neutral_500}; font-size: 12px;") + self.scenes_label.setObjectName("dashMutedSmall") left.addWidget(self.scenes_label) left.addStretch() @@ -154,6 +151,7 @@ def __init__(self, parent=None): self.notes_input = QTextEdit() self.notes_input.setPlaceholderText("Character notes...") + self.notes_input.setAccessibleName("Notes") right.addWidget(self.notes_input, 1) right_widget = QWidget() @@ -170,6 +168,7 @@ def __init__(self, parent=None): done_btn = QPushButton("Done") done_btn.setObjectName("primary") done_btn.setFixedHeight(36) + done_btn.setAccessibleName("Done") done_btn.clicked.connect(self._on_done) footer.addWidget(done_btn) layout.addLayout(footer) diff --git a/ecrit/ui/overlays/cloud_export.py b/ecrit/ui/overlays/cloud_export.py index 42be117..5065dda 100644 --- a/ecrit/ui/overlays/cloud_export.py +++ b/ecrit/ui/overlays/cloud_export.py @@ -7,8 +7,8 @@ ) from PySide6.QtCore import Qt, Signal -from ecrit.ui.styles import theme from ecrit.sync.cloud_export import CloudProvider, CloudConfig +from ecrit.ui.icons import IconButton CLOUD_PROVIDERS = [ @@ -32,7 +32,6 @@ def __init__(self, parent=None): self.setMinimumSize(480, 440) self.setModal(True) - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -42,9 +41,10 @@ def __init__(self, parent=None): title.setStyleSheet("font-size: 20px; font-weight: 500;") header.addWidget(title) header.addStretch() - close_btn = QPushButton("×") + close_btn = IconButton("x-mark") close_btn.setObjectName("iconBtn") close_btn.setFixedSize(28, 28) + close_btn.setAccessibleName("Close") close_btn.clicked.connect(self.close) header.addWidget(close_btn) layout.addLayout(header) @@ -63,18 +63,20 @@ def __init__(self, parent=None): self.provider_combo = QComboBox() self.provider_combo.setFixedHeight(34) + self.provider_combo.setAccessibleName("Provider") for provider, name, desc in CLOUD_PROVIDERS: self.provider_combo.addItem(f"{name} — {desc}", provider.value) self.provider_combo.currentIndexChanged.connect(self._on_provider_changed) form.addWidget(self.provider_combo) self.auth_status = QLabel("Not authenticated") - self.auth_status.setStyleSheet(f"color: {t.neutral_500}; font-size: 12px;") + self.auth_status.setObjectName("dashMutedSmall") form.addWidget(self.auth_status) auth_btn = QPushButton("Authenticate...") auth_btn.setObjectName("secondary") auth_btn.setFixedHeight(34) + auth_btn.setAccessibleName("Authenticate") auth_btn.clicked.connect(self._authenticate) form.addWidget(auth_btn) @@ -90,6 +92,7 @@ def __init__(self, parent=None): self.folder_input.setFixedHeight(34) self.folder_input.setPlaceholderText("/Écrit/Exports") self.folder_input.setText("/Écrit/Exports") + self.folder_input.setAccessibleName("Remote Folder") form.addWidget(self.folder_input) format_label = QLabel("Export Format") @@ -98,6 +101,7 @@ def __init__(self, parent=None): self.format_combo = QComboBox() self.format_combo.setFixedHeight(34) + self.format_combo.setAccessibleName("Export Format") self.format_combo.addItem("PDF", "pdf") self.format_combo.addItem("ODT (LibreOffice)", "odt") self.format_combo.addItem("Fountain (.fountain)", "fountain") @@ -105,6 +109,7 @@ def __init__(self, parent=None): form.addWidget(self.format_combo) self.auto_export_check = QCheckBox("Auto-export on save") + self.auto_export_check.setAccessibleName("Auto-export on save") self.auto_export_check.toggled.connect(self._on_auto_export_toggled) form.addWidget(self.auto_export_check) @@ -112,13 +117,13 @@ def __init__(self, parent=None): divider2.setFrameShape(QFrame.Shape.HLine) form.addWidget(divider2) - history_label = QLabel("EXPORT HISTORY") - history_label.setObjectName("kicker") - form.addWidget(history_label) - - self.history_list = QListWidget() - self.history_list.setFixedHeight(100) - form.addWidget(self.history_list) + coming_soon = QLabel( + "Cloud export is not yet available. Provider integrations " + "are planned for a future release." + ) + coming_soon.setWordWrap(True) + coming_soon.setObjectName("kicker") + form.addWidget(coming_soon) form.addStretch() scroll.setWidget(content) @@ -131,59 +136,34 @@ def __init__(self, parent=None): cancel_btn = QPushButton("Close") cancel_btn.setObjectName("secondary") cancel_btn.setFixedHeight(36) + cancel_btn.setAccessibleName("Close") cancel_btn.clicked.connect(self.close) footer.addWidget(cancel_btn) - self.export_btn = QPushButton("Export Now") + self.export_btn = QPushButton("Coming Soon") self.export_btn.setObjectName("primary") self.export_btn.setFixedHeight(36) - self.export_btn.clicked.connect(self._do_export) + self.export_btn.setAccessibleName("Export") + self.export_btn.setEnabled(False) footer.addWidget(self.export_btn) layout.addLayout(footer) def _on_provider_changed(self, index: int): - provider = self.provider_combo.currentData() - if hasattr(self, "_config_map") and provider in self._config_map: - cfg = self._config_map[provider] - if cfg.get("authenticated"): - self.auth_status.setText("Authenticated") - else: - self.auth_status.setText("Not authenticated") - self.auto_export_check.setChecked(cfg.get("auto_export", False)) - else: - self.auth_status.setText("Not authenticated") + self.auth_status.setText("Not yet available") def _authenticate(self): - provider = self.provider_combo.currentData() - name = self.provider_combo.currentText().split(" —")[0] - self.auth_status.setText(f"Opening {name} authorization — check your browser") + self.auth_status.setText("Authentication is not yet available — coming in a future release.") def _on_auto_export_toggled(self, checked: bool): provider = self.provider_combo.currentData() self.auto_export_changed.emit(provider, checked) def _do_export(self): - provider = self.provider_combo.currentData() - fmt = self.format_combo.currentData() - self.export_requested.emit(provider, fmt) - self.history_list.insertItem(0, f"Exported as {fmt.upper()} to {self.provider_combo.currentText().split(' —')[0]}") + pass def set_configs(self, configs: list): - self._config_map = {} - for cfg in configs: - self._config_map[cfg.get("provider", "")] = cfg - provider = cfg.get("provider", "") - folder = cfg.get("folder", "") - if folder: - self.folder_input.setText(folder) - idx = self.provider_combo.findData(provider) - if idx >= 0: - self.provider_combo.setCurrentIndex(idx) - if cfg.get("authenticated"): - self.auth_status.setText("Authenticated") - auto = cfg.get("auto_export", False) - self.auto_export_check.setChecked(auto) + pass def add_history_entry(self, text: str): - self.history_list.insertItem(0, text) + pass diff --git a/ecrit/ui/overlays/collaboration.py b/ecrit/ui/overlays/collaboration.py index 6771faa..c8724c1 100644 --- a/ecrit/ui/overlays/collaboration.py +++ b/ecrit/ui/overlays/collaboration.py @@ -8,10 +8,10 @@ from PySide6.QtCore import Qt, Signal, QTimer from PySide6.QtGui import QGuiApplication -from ecrit.ui.styles import theme from ecrit.i18n import tr from ecrit.collab.session import CollabSession, SessionState, CollabRole from ecrit.collab.p2p import ConnectionToken +from ecrit.ui.icons import IconButton class CollaborationDialog(QDialog): @@ -26,7 +26,8 @@ def __init__(self, parent=None): self.setModal(True) self._session: CollabSession | None = None - t = theme.current() + self._host_content: str = "" + self._host_title: str = "" layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -39,12 +40,13 @@ def __init__(self, parent=None): header.addStretch() self.status_label = QLabel(tr("collab.disconnected")) - self.status_label.setStyleSheet(f"color: {t.neutral_500}; font-size: 13px;") + self.status_label.setObjectName("dashMuted") header.addWidget(self.status_label) - close_btn = QPushButton("×") + close_btn = IconButton("x-mark") close_btn.setObjectName("iconBtn") close_btn.setFixedSize(28, 28) + close_btn.setAccessibleName("Close") close_btn.clicked.connect(self.close) header.addWidget(close_btn) layout.addLayout(header) @@ -61,25 +63,28 @@ def __init__(self, parent=None): self.peers_list = QListWidget() self.peers_list.setFixedHeight(160) + self.peers_list.setAccessibleName("LAN Peers") l_layout.addWidget(self.peers_list) scan_row = QHBoxLayout() self.scan_btn = QPushButton(tr("collab.scanning")) self.scan_btn.setObjectName("secondary") self.scan_btn.setFixedHeight(34) + self.scan_btn.setAccessibleName("Scan for Peers") self.scan_btn.clicked.connect(self._toggle_scan) scan_row.addWidget(self.scan_btn) self.join_lan_btn = QPushButton(tr("collab.join_session")) self.join_lan_btn.setObjectName("primary") self.join_lan_btn.setFixedHeight(34) + self.join_lan_btn.setAccessibleName("Join LAN Session") self.join_lan_btn.setEnabled(False) self.join_lan_btn.clicked.connect(self._join_lan_peer) scan_row.addWidget(self.join_lan_btn) l_layout.addLayout(scan_row) self.peer_count_label = QLabel(tr("collab.no_peers")) - self.peer_count_label.setStyleSheet(f"color: {t.neutral_500}; font-size: 12px;") + self.peer_count_label.setObjectName("dashMutedSmall") l_layout.addWidget(self.peer_count_label) l_layout.addStretch() @@ -94,9 +99,7 @@ def __init__(self, parent=None): p_layout.addWidget(QLabel(tr("collab.remote_p2p"))) host_frame = QFrame() - host_frame.setStyleSheet( - f"QFrame {{ background: {t.neutral_100}; border-radius: 8px; padding: 12px; }}" - ) + host_frame.setObjectName("collabFrame") h_layout = QVBoxLayout(host_frame) h_layout.setSpacing(8) h_layout.addWidget(QLabel(tr("collab.host"))) @@ -104,23 +107,23 @@ def __init__(self, parent=None): self.token_display = QTextEdit() self.token_display.setReadOnly(True) self.token_display.setFixedHeight(60) - self.token_display.setStyleSheet( - f"font-family: ui-monospace, Menlo, monospace; font-size: 11px; " - f"background: {t.bg}; border: 1px solid {t.neutral_700}; border-radius: 4px;" - ) + self.token_display.setObjectName("collabTokenDisplay") self.token_display.setPlaceholderText(tr("collab.connection_token")) + self.token_display.setAccessibleName("Connection Token") h_layout.addWidget(self.token_display) host_btn_row = QHBoxLayout() self.start_btn = QPushButton(tr("collab.start_session")) self.start_btn.setObjectName("primary") self.start_btn.setFixedHeight(34) + self.start_btn.setAccessibleName("Start Session") self.start_btn.clicked.connect(self._start_host) host_btn_row.addWidget(self.start_btn) self.copy_token_btn = QPushButton(tr("collab.copy_token")) self.copy_token_btn.setObjectName("secondary") self.copy_token_btn.setFixedHeight(34) + self.copy_token_btn.setAccessibleName("Copy Token") self.copy_token_btn.setEnabled(False) self.copy_token_btn.clicked.connect(self._copy_token) host_btn_row.addWidget(self.copy_token_btn) @@ -128,9 +131,7 @@ def __init__(self, parent=None): p_layout.addWidget(host_frame) join_frame = QFrame() - join_frame.setStyleSheet( - f"QFrame {{ background: {t.neutral_100}; border-radius: 8px; padding: 12px; }}" - ) + join_frame.setObjectName("collabFrame") j_layout = QVBoxLayout(join_frame) j_layout.setSpacing(8) j_layout.addWidget(QLabel(tr("collab.guest"))) @@ -138,11 +139,13 @@ def __init__(self, parent=None): self.token_input = QLineEdit() self.token_input.setPlaceholderText(tr("collab.paste_token")) self.token_input.setFixedHeight(34) + self.token_input.setAccessibleName("Paste Token") j_layout.addWidget(self.token_input) self.join_btn = QPushButton(tr("collab.join_session")) self.join_btn.setObjectName("primary") self.join_btn.setFixedHeight(34) + self.join_btn.setAccessibleName("Join Session") self.join_btn.clicked.connect(self._join_with_token) j_layout.addWidget(self.join_btn) p_layout.addWidget(join_frame) @@ -157,13 +160,14 @@ def __init__(self, parent=None): self.leave_btn = QPushButton(tr("collab.leave_session")) self.leave_btn.setObjectName("secondary") self.leave_btn.setFixedHeight(36) + self.leave_btn.setAccessibleName("Leave Session") self.leave_btn.setEnabled(False) self.leave_btn.clicked.connect(self._leave_session) leave_row.addWidget(self.leave_btn) leave_row.addStretch() self.participants_label = QLabel("") - self.participants_label.setStyleSheet(f"color: {t.neutral_500}; font-size: 12px;") + self.participants_label.setObjectName("dashMutedSmall") leave_row.addWidget(self.participants_label) layout.addLayout(leave_row) @@ -223,7 +227,7 @@ def _join_lan_peer(self): def _start_host(self): if not self._session: return - token = self._session.host(content="", project_title="") + token = self._session.host(content=self._host_content, project_title=self._host_title) self._token = token.encode() self.token_display.setText(self._token) self.copy_token_btn.setEnabled(True) diff --git a/ecrit/ui/overlays/command_palette.py b/ecrit/ui/overlays/command_palette.py index ad26f94..ff40afc 100644 --- a/ecrit/ui/overlays/command_palette.py +++ b/ecrit/ui/overlays/command_palette.py @@ -7,7 +7,6 @@ from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QShortcut, QKeySequence -from ecrit.ui.styles import theme COMMANDS = [ @@ -41,7 +40,6 @@ ("Remote Sync", "tool", "Sync project to GitHub/GitLab/Codeberg"), ("Cloud Export", "export", "Export to Google Drive, Dropbox, etc."), ("Share for Review", "export", "Generate watermarked review link"), - ("Plugin Marketplace", "tool", "Browse and install plugins"), ("Collaboration", "tool", "Start or join a collaboration session"), ("Companion Sync", "tool", "Sync with Android companion app"), ("Change Language", "tool", "Change interface language"), @@ -75,11 +73,7 @@ def __init__(self, parent=None): self.setModal(True) self.setWindowFlags(Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint) - t = theme.current() - self.setStyleSheet( - f"background: {t.surface}; border: 1px solid {t.neutral_700}; " - f"border-radius: {t.radius_lg}px;" - ) + self.setObjectName("cmdPalette") layout = QVBoxLayout(self) layout.setContentsMargins(12, 12, 12, 8) @@ -88,15 +82,17 @@ def __init__(self, parent=None): self.search_input = QLineEdit() self.search_input.setPlaceholderText("Type a command...") self.search_input.setFixedHeight(38) + self.search_input.setAccessibleName("Command Search") self.search_input.textChanged.connect(self._filter) layout.addWidget(self.search_input) self.results = QListWidget() + self.results.setAccessibleName("Command Results") self.results.itemActivated.connect(self._on_select) layout.addWidget(self.results, 1) hint = QLabel("↑↓ navigate ⏎ select Esc close") - hint.setStyleSheet(f"color: {t.neutral_500}; font-size: 11px;") + hint.setObjectName("cmdPaletteHint") hint.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(hint) @@ -104,7 +100,6 @@ def __init__(self, parent=None): def _populate(self, commands): self.results.clear() - t = theme.current() for name, category, desc in commands: item = QListWidgetItem() item.setData(Qt.ItemDataRole.UserRole, name) diff --git a/ecrit/ui/overlays/companion.py b/ecrit/ui/overlays/companion.py index 1199439..7d72832 100644 --- a/ecrit/ui/overlays/companion.py +++ b/ecrit/ui/overlays/companion.py @@ -7,9 +7,9 @@ ) from PySide6.QtCore import Qt, Signal -from ecrit.ui.styles import theme from ecrit.i18n import tr from ecrit.companion.device_sync import DeviceSync, PairedDevice, SyncStatus +from ecrit.ui.icons import IconButton class CompanionDialog(QDialog): @@ -25,8 +25,6 @@ def __init__(self, parent=None): self._device_sync = DeviceSync() self._device_sync.set_status_callback(self._on_sync_status_change) - t = theme.current() - layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -36,9 +34,10 @@ def __init__(self, parent=None): title.setStyleSheet("font-size: 20px; font-weight: 500;") header.addWidget(title) header.addStretch() - close_btn = QPushButton("×") + close_btn = IconButton("x-mark") close_btn.setObjectName("iconBtn") close_btn.setFixedSize(28, 28) + close_btn.setAccessibleName("Close") close_btn.clicked.connect(self.close) header.addWidget(close_btn) layout.addLayout(header) @@ -54,17 +53,20 @@ def __init__(self, parent=None): d_layout.addWidget(QLabel(tr("companion.paired_devices"))) self.devices_list = QListWidget() self.devices_list.setFixedHeight(140) + self.devices_list.setAccessibleName("Paired Devices") d_layout.addWidget(self.devices_list) pair_row = QHBoxLayout() self.device_name_input = QLineEdit() self.device_name_input.setPlaceholderText(tr("companion.device_name")) self.device_name_input.setFixedHeight(34) + self.device_name_input.setAccessibleName("Device Name") pair_row.addWidget(self.device_name_input) pair_btn = QPushButton(tr("companion.pair_new")) pair_btn.setObjectName("primary") pair_btn.setFixedHeight(34) + pair_btn.setAccessibleName("Pair New Device") pair_btn.clicked.connect(self._pair_new_device) pair_row.addWidget(pair_btn) d_layout.addLayout(pair_row) @@ -72,6 +74,7 @@ def __init__(self, parent=None): unpair_btn = QPushButton("Unpair Selected") unpair_btn.setObjectName("secondary") unpair_btn.setFixedHeight(30) + unpair_btn.setAccessibleName("Unpair Selected") unpair_btn.clicked.connect(self._unpair_selected) d_layout.addWidget(unpair_btn) @@ -85,18 +88,20 @@ def __init__(self, parent=None): s_layout.setSpacing(12) self.sync_status_label = QLabel(tr("companion.never_synced")) - self.sync_status_label.setStyleSheet(f"color: {t.neutral_500}; font-size: 13px;") + self.sync_status_label.setObjectName("dashMuted") s_layout.addWidget(self.sync_status_label) sync_btn = QPushButton(tr("companion.full_sync")) sync_btn.setObjectName("primary") sync_btn.setFixedHeight(40) + sync_btn.setAccessibleName("Full Sync") sync_btn.clicked.connect(self.sync_bundle_requested.emit) s_layout.addWidget(sync_btn) reader_btn = QPushButton(tr("companion.reader_mode")) reader_btn.setObjectName("secondary") reader_btn.setFixedHeight(36) + reader_btn.setAccessibleName("Reader Mode Export") reader_btn.clicked.connect(self.reader_export_requested.emit) s_layout.addWidget(reader_btn) @@ -106,10 +111,7 @@ def __init__(self, parent=None): s_layout.addWidget(transfer_label) self.transfer_url_label = QLabel("") - self.transfer_url_label.setStyleSheet( - f"font-family: ui-monospace, Menlo, monospace; font-size: 12px; " - f"color: {t.accent_300};" - ) + self.transfer_url_label.setObjectName("dashMonoMuted") self.transfer_url_label.setWordWrap(True) self.transfer_url_label.setTextInteractionFlags( Qt.TextInteractionFlag.TextSelectableByMouse @@ -119,6 +121,7 @@ def __init__(self, parent=None): wifi_btn = QPushButton(tr("companion.wifi_transfer")) wifi_btn.setObjectName("secondary") wifi_btn.setFixedHeight(36) + wifi_btn.setAccessibleName("Wi-Fi Transfer") wifi_btn.clicked.connect(self._start_wifi_transfer) s_layout.addWidget(wifi_btn) diff --git a/ecrit/ui/overlays/compare_drafts.py b/ecrit/ui/overlays/compare_drafts.py index 6907dab..5a0f18c 100644 --- a/ecrit/ui/overlays/compare_drafts.py +++ b/ecrit/ui/overlays/compare_drafts.py @@ -8,6 +8,7 @@ from PySide6.QtCore import Qt, Signal from ecrit.ui.styles import theme +from ecrit.ui.icons import IconButton class CompareDrafts(QDialog): @@ -17,7 +18,6 @@ def __init__(self, parent=None): self.setMinimumSize(820, 560) self.setModal(True) - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(20, 16, 20, 16) layout.setSpacing(12) @@ -31,12 +31,14 @@ def __init__(self, parent=None): self.view_mode = QPushButton("Side by side") self.view_mode.setObjectName("secondary") self.view_mode.setFixedHeight(28) + self.view_mode.setAccessibleName("Toggle View Mode") self.view_mode.clicked.connect(self._toggle_view) header.addWidget(self.view_mode) - close_btn = QPushButton("×") + close_btn = IconButton("x-mark") close_btn.setObjectName("iconBtn") close_btn.setFixedSize(28, 28) + close_btn.setAccessibleName("Close") close_btn.clicked.connect(self.close) header.addWidget(close_btn) layout.addLayout(header) @@ -45,14 +47,17 @@ def __init__(self, parent=None): selectors.addWidget(QLabel("From:")) self.from_select = QComboBox() self.from_select.setFixedHeight(30) + self.from_select.setAccessibleName("From Snapshot") selectors.addWidget(self.from_select, 1) selectors.addWidget(QLabel("To:")) self.to_select = QComboBox() self.to_select.setFixedHeight(30) + self.to_select.setAccessibleName("To Snapshot") selectors.addWidget(self.to_select, 1) compare_btn = QPushButton("Compare") compare_btn.setObjectName("primary") compare_btn.setFixedHeight(30) + compare_btn.setAccessibleName("Compare") compare_btn.clicked.connect(self._run_compare) selectors.addWidget(compare_btn) layout.addLayout(selectors) @@ -63,26 +68,17 @@ def __init__(self, parent=None): self.left_view = QTextEdit() self.left_view.setReadOnly(True) - self.left_view.setStyleSheet( - f"font-family: 'Courier Prime', Courier, monospace; font-size: 13px; " - f"background: {t.surface}; border: 1px solid {t.divider}; border-radius: 6px;" - ) + self.left_view.setObjectName("diffView") self.splitter.addWidget(self.left_view) self.right_view = QTextEdit() self.right_view.setReadOnly(True) - self.right_view.setStyleSheet( - f"font-family: 'Courier Prime', Courier, monospace; font-size: 13px; " - f"background: {t.surface}; border: 1px solid {t.divider}; border-radius: 6px;" - ) + self.right_view.setObjectName("diffView") self.splitter.addWidget(self.right_view) self.inline_view = QTextEdit() self.inline_view.setReadOnly(True) - self.inline_view.setStyleSheet( - f"font-family: 'Courier Prime', Courier, monospace; font-size: 13px; " - f"background: {t.surface}; border: 1px solid {t.divider}; border-radius: 6px;" - ) + self.inline_view.setObjectName("diffView") self.inline_view.hide() layout.addWidget(self.splitter, 1) @@ -90,7 +86,7 @@ def __init__(self, parent=None): stats = QHBoxLayout() self.stats_label = QLabel() - self.stats_label.setStyleSheet(f"color: {t.neutral_500}; font-size: 12px;") + self.stats_label.setObjectName("dashMutedSmall") stats.addWidget(self.stats_label) stats.addStretch() layout.addLayout(stats) diff --git a/ecrit/ui/overlays/find_replace.py b/ecrit/ui/overlays/find_replace.py index 61c2889..31ff73e 100644 --- a/ecrit/ui/overlays/find_replace.py +++ b/ecrit/ui/overlays/find_replace.py @@ -6,8 +6,7 @@ ) from PySide6.QtCore import Qt, Signal -from ecrit.ui.styles import theme - +from ecrit.ui.icons import IconButton class FindReplaceBar(QFrame): find_next = Signal(str, bool, bool) @@ -33,29 +32,33 @@ def __init__(self, parent=None): self.find_input = QLineEdit() self.find_input.setPlaceholderText("Search text...") + self.find_input.setAccessibleName("Find") self.find_input.returnPressed.connect(self._on_find_next) find_row.addWidget(self.find_input, 1) self.match_label = QLabel("0 / 0") - self.match_label.setStyleSheet(f"color: {theme.current().neutral_500}; font-size: 12px;") + self.match_label.setObjectName("dashMuted") self.match_label.setFixedWidth(60) find_row.addWidget(self.match_label) - prev_btn = QPushButton("↑") + prev_btn = IconButton("chevron-up", icon_size=16) prev_btn.setObjectName("secondary") prev_btn.setFixedSize(28, 28) + prev_btn.setAccessibleName("Find Previous") prev_btn.clicked.connect(self._on_find_prev) find_row.addWidget(prev_btn) - next_btn = QPushButton("↓") + next_btn = IconButton("chevron-down", icon_size=16) next_btn.setObjectName("secondary") next_btn.setFixedSize(28, 28) + next_btn.setAccessibleName("Find Next") next_btn.clicked.connect(self._on_find_next) find_row.addWidget(next_btn) - close_btn = QPushButton("×") + close_btn = IconButton("x-mark") close_btn.setObjectName("iconBtn") close_btn.setFixedSize(24, 24) + close_btn.setAccessibleName("Close") close_btn.clicked.connect(self.toggle) find_row.addWidget(close_btn) @@ -68,17 +71,20 @@ def __init__(self, parent=None): self.replace_input = QLineEdit() self.replace_input.setPlaceholderText("Replace with...") + self.replace_input.setAccessibleName("Replace") replace_row.addWidget(self.replace_input, 1) r_one = QPushButton("Replace") r_one.setObjectName("secondary") r_one.setFixedHeight(28) + r_one.setAccessibleName("Replace") r_one.clicked.connect(self._on_replace_one) replace_row.addWidget(r_one) r_all = QPushButton("All") r_all.setObjectName("secondary") r_all.setFixedHeight(28) + r_all.setAccessibleName("Replace All") r_all.clicked.connect(self._on_replace_all) replace_row.addWidget(r_all) @@ -86,8 +92,10 @@ def __init__(self, parent=None): options = QHBoxLayout() self.case_check = QCheckBox("Match case") + self.case_check.setAccessibleName("Match case") options.addWidget(self.case_check) self.regex_check = QCheckBox("Regex") + self.regex_check.setAccessibleName("Regex") options.addWidget(self.regex_check) options.addStretch() layout.addLayout(options) diff --git a/ecrit/ui/overlays/logline_builder.py b/ecrit/ui/overlays/logline_builder.py index 76711d2..16e793d 100644 --- a/ecrit/ui/overlays/logline_builder.py +++ b/ecrit/ui/overlays/logline_builder.py @@ -7,6 +7,7 @@ from PySide6.QtCore import Qt, Signal from ecrit.ui.styles import theme +from ecrit.ui.icons import IconButton from ecrit.screenplay.logline_builder import ( LOGLINE_TEMPLATES, build_logline, @@ -33,8 +34,6 @@ def __init__(self, parent=None): self._field_inputs: dict[str, QLineEdit] = {} self._current_key: str = "" - t = theme.current() - layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -45,9 +44,10 @@ def __init__(self, parent=None): title.setStyleSheet("font-size: 20px; font-weight: 500;") header.addWidget(title) header.addStretch() - close_btn = QPushButton("×") + close_btn = IconButton("x-mark") close_btn.setObjectName("iconBtn") close_btn.setFixedSize(28, 28) + close_btn.setAccessibleName("Close") close_btn.clicked.connect(self.close) header.addWidget(close_btn) layout.addLayout(header) @@ -63,6 +63,7 @@ def __init__(self, parent=None): self._template_combo = QComboBox() self._template_combo.setFixedHeight(34) + self._template_combo.setAccessibleName("Template") for key, tmpl in LOGLINE_TEMPLATES.items(): self._template_combo.addItem(tmpl.name, key) self._template_combo.currentIndexChanged.connect(self._on_template_changed) @@ -70,10 +71,8 @@ def __init__(self, parent=None): self._example_label = QLabel() self._example_label.setWordWrap(True) - self._example_label.setStyleSheet( - f"color: {t.neutral_500}; font-size: 12px; font-style: italic; " - f"padding: 4px 0;" - ) + self._example_label.setObjectName("dashMutedSmall") + self._example_label.setStyleSheet("font-style: italic; padding: 4px 0;") selector_area.addWidget(self._example_label) layout.addLayout(selector_area) @@ -94,9 +93,6 @@ def __init__(self, parent=None): # --- Divider --- divider = QFrame() divider.setFrameShape(QFrame.Shape.HLine) - divider.setStyleSheet( - f"color: {t.neutral_700 if t.name == 'nocturne' else t.divider};" - ) layout.addWidget(divider) # --- Preview area --- @@ -111,19 +107,12 @@ def __init__(self, parent=None): self._preview = QTextEdit() self._preview.setReadOnly(True) self._preview.setFixedHeight(72) - self._preview.setStyleSheet( - f"background: {t.neutral_800 if t.name == 'nocturne' else t.neutral_200}; " - f"border: 1px solid {t.neutral_700 if t.name == 'nocturne' else t.neutral_300}; " - f"border-radius: {t.radius_sm}px; " - f"padding: 8px; font-size: 13px;" - ) + self._preview.setObjectName("loglinePreview") preview_area.addWidget(self._preview) # --- Validation indicator --- self._validation_label = QLabel() - self._validation_label.setStyleSheet( - f"color: {t.neutral_500}; font-size: 12px; padding: 2px 0;" - ) + self._validation_label.setObjectName("dashMutedSmall") preview_area.addWidget(self._validation_label) layout.addLayout(preview_area) @@ -136,12 +125,14 @@ def __init__(self, parent=None): cancel_btn = QPushButton("Close") cancel_btn.setObjectName("secondary") cancel_btn.setFixedHeight(36) + cancel_btn.setAccessibleName("Close") cancel_btn.clicked.connect(self.close) footer.addWidget(cancel_btn) self._apply_btn = QPushButton("Apply") self._apply_btn.setObjectName("primary") self._apply_btn.setFixedHeight(36) + self._apply_btn.setAccessibleName("Apply Logline") self._apply_btn.clicked.connect(self._on_apply) footer.addWidget(self._apply_btn) @@ -186,6 +177,7 @@ def _on_template_changed(self, index: int) -> None: inp = QLineEdit() inp.setFixedHeight(32) inp.setPlaceholderText(f"Enter {_field_display_name(field_name).lower()}...") + inp.setAccessibleName(_field_display_name(field_name)) inp.textChanged.connect(self._update_preview) self._fields_layout.addWidget(inp) self._field_inputs[field_name] = inp diff --git a/ecrit/ui/overlays/marketplace.py b/ecrit/ui/overlays/marketplace.py index ca61414..f2eb23d 100644 --- a/ecrit/ui/overlays/marketplace.py +++ b/ecrit/ui/overlays/marketplace.py @@ -7,8 +7,8 @@ ) from PySide6.QtCore import Qt, Signal -from ecrit.ui.styles import theme from ecrit.i18n import tr +from ecrit.ui.icons import IconButton from ecrit.plugins.marketplace import Marketplace, PluginListing, PluginCategory @@ -24,7 +24,6 @@ def __init__(self, parent=None): self._marketplace = Marketplace() - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -34,9 +33,10 @@ def __init__(self, parent=None): title.setStyleSheet("font-size: 20px; font-weight: 500;") header.addWidget(title) header.addStretch() - close_btn = QPushButton("×") + close_btn = IconButton("x-mark") close_btn.setObjectName("iconBtn") close_btn.setFixedSize(28, 28) + close_btn.setAccessibleName("Close") close_btn.clicked.connect(self.close) header.addWidget(close_btn) layout.addLayout(header) @@ -46,10 +46,12 @@ def __init__(self, parent=None): self.search_input = QLineEdit() self.search_input.setPlaceholderText(tr("marketplace.search")) self.search_input.setFixedHeight(34) + self.search_input.setAccessibleName("Search Plugins") self.search_input.textChanged.connect(self._on_search) search_row.addWidget(self.search_input) self.category_combo = QComboBox() + self.category_combo.setAccessibleName("Plugin Category") self.category_combo.addItem(tr("marketplace.categories"), "") for cat in PluginCategory: self.category_combo.addItem(cat.value.capitalize(), cat.value) @@ -65,6 +67,7 @@ def __init__(self, parent=None): i_layout = QVBoxLayout(self.installed_tab) i_layout.setContentsMargins(16, 12, 16, 12) self.installed_list = QListWidget() + self.installed_list.setAccessibleName("Installed Plugins") self.installed_list.currentRowChanged.connect(self._on_installed_selected) i_layout.addWidget(self.installed_list) @@ -72,6 +75,7 @@ def __init__(self, parent=None): self.uninstall_btn = QPushButton(tr("marketplace.uninstall")) self.uninstall_btn.setObjectName("secondary") self.uninstall_btn.setFixedHeight(34) + self.uninstall_btn.setAccessibleName("Uninstall Plugin") self.uninstall_btn.setEnabled(False) self.uninstall_btn.clicked.connect(self._uninstall_selected) i_btn_row.addWidget(self.uninstall_btn) @@ -83,6 +87,7 @@ def __init__(self, parent=None): a_layout = QVBoxLayout(self.available_tab) a_layout.setContentsMargins(16, 12, 16, 12) self.available_list = QListWidget() + self.available_list.setAccessibleName("Available Plugins") self.available_list.currentRowChanged.connect(self._on_available_selected) a_layout.addWidget(self.available_list) @@ -90,6 +95,7 @@ def __init__(self, parent=None): self.install_btn = QPushButton(tr("marketplace.install")) self.install_btn.setObjectName("primary") self.install_btn.setFixedHeight(34) + self.install_btn.setAccessibleName("Install Plugin") self.install_btn.setEnabled(False) self.install_btn.clicked.connect(self._install_from_directory) a_btn_row.addWidget(self.install_btn) @@ -100,17 +106,15 @@ def __init__(self, parent=None): layout.addWidget(tabs, 1) details = QFrame() + details.setObjectName("detailsPane") details.setFixedHeight(100) - details.setStyleSheet( - f"QFrame {{ background: {t.neutral_100}; border-top: 1px solid {t.neutral_700}; }}" - ) d_layout = QVBoxLayout(details) d_layout.setContentsMargins(24, 8, 24, 8) self.detail_name = QLabel("—") self.detail_name.setStyleSheet("font-weight: 500; font-size: 14px;") d_layout.addWidget(self.detail_name) self.detail_info = QLabel("") - self.detail_info.setStyleSheet(f"color: {t.neutral_500}; font-size: 12px;") + self.detail_info.setObjectName("dashMuted") d_layout.addWidget(self.detail_info) self.detail_desc = QLabel("") self.detail_desc.setWordWrap(True) diff --git a/ecrit/ui/overlays/orphan_finder_dialog.py b/ecrit/ui/overlays/orphan_finder_dialog.py index b169dab..bd9ace7 100644 --- a/ecrit/ui/overlays/orphan_finder_dialog.py +++ b/ecrit/ui/overlays/orphan_finder_dialog.py @@ -7,7 +7,6 @@ from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QColor -from ecrit.ui.styles import theme class OrphanFinderDialog(QDialog): @@ -19,19 +18,18 @@ def __init__(self, parent=None): self.setMinimumSize(750, 480) self.setModal(True) - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(24, 20, 24, 20) layout.setSpacing(12) header = QHBoxLayout() title = QLabel("Orphan / Widow Finder") - title.setStyleSheet(f"font-size: 18px; font-weight: bold; color: {t.text};") + title.setStyleSheet("font-size: 18px; font-weight: bold;") header.addWidget(title) header.addStretch() self._summary_label = QLabel() - self._summary_label.setStyleSheet(f"color: {t.neutral_400}; font-size: 13px;") + self._summary_label.setObjectName("countBadge") header.addWidget(self._summary_label) layout.addLayout(header) diff --git a/ecrit/ui/overlays/reading_mode.py b/ecrit/ui/overlays/reading_mode.py index b7a2513..bec3bd5 100644 --- a/ecrit/ui/overlays/reading_mode.py +++ b/ecrit/ui/overlays/reading_mode.py @@ -7,7 +7,6 @@ from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QFont, QShortcut, QKeySequence -from ecrit.ui.styles import theme class ReadingMode(QWidget): @@ -15,8 +14,7 @@ class ReadingMode(QWidget): def __init__(self, parent=None): super().__init__(parent) - t = theme.current() - self.setStyleSheet(f"background: {t.bg};") + self.setObjectName("readingMode") layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -26,7 +24,7 @@ def __init__(self, parent=None): top_bar.addStretch() self.page_label = QLabel() - self.page_label.setStyleSheet(f"color: {t.neutral_500}; font-size: 12px;") + self.page_label.setObjectName("dashMutedSmall") top_bar.addWidget(self.page_label) top_bar.addStretch() @@ -34,6 +32,7 @@ def __init__(self, parent=None): exit_btn = QPushButton("Exit Reading Mode") exit_btn.setObjectName("secondary") exit_btn.setFixedHeight(28) + exit_btn.setAccessibleName("Exit Reading Mode") exit_btn.clicked.connect(self.exit_requested.emit) top_bar.addWidget(exit_btn) layout.addLayout(top_bar) @@ -50,16 +49,12 @@ def __init__(self, parent=None): font.setStyleHint(QFont.StyleHint.Monospace) self.text_view.setFont(font) self.text_view.setMaximumWidth(680) - self.text_view.setStyleSheet( - f"background: {t.surface}; color: {t.text}; border: none; " - f"font-family: 'Courier Prime', Courier, monospace; font-size: 15px; padding: 40px;" - ) center_layout.addWidget(self.text_view) layout.addWidget(center, 1) hint = QLabel("Press Esc to exit reading mode") hint.setAlignment(Qt.AlignmentFlag.AlignCenter) - hint.setStyleSheet(f"color: {t.neutral_600}; font-size: 12px; padding: 8px;") + hint.setObjectName("dashMutedSmall") layout.addWidget(hint) def set_content(self, text: str, page: int = 1, total_pages: int = 1): diff --git a/ecrit/ui/overlays/reports.py b/ecrit/ui/overlays/reports.py index 6143b90..bae4d88 100644 --- a/ecrit/ui/overlays/reports.py +++ b/ecrit/ui/overlays/reports.py @@ -6,7 +6,7 @@ ) from PySide6.QtCore import Qt -from ecrit.ui.styles import theme +from ecrit.ui.icons import IconButton from ecrit.screenplay.production_reports import ( generate_scene_report, generate_cast_report, @@ -40,7 +40,6 @@ def __init__(self, parent=None): self.setMinimumSize(700, 500) self.setModal(True) - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(24, 20, 24, 20) layout.setSpacing(16) @@ -51,9 +50,10 @@ def __init__(self, parent=None): title.setStyleSheet("font-size: 20px; font-weight: 500;") header.addWidget(title) header.addStretch() - close_btn = QPushButton("×") + close_btn = IconButton("x-mark") close_btn.setObjectName("iconBtn") close_btn.setFixedSize(28, 28) + close_btn.setAccessibleName("Close") close_btn.clicked.connect(self.close) header.addWidget(close_btn) layout.addLayout(header) diff --git a/ecrit/ui/overlays/scratchpad.py b/ecrit/ui/overlays/scratchpad.py index b0bcc3c..0db0d9b 100644 --- a/ecrit/ui/overlays/scratchpad.py +++ b/ecrit/ui/overlays/scratchpad.py @@ -5,7 +5,7 @@ ) from PySide6.QtCore import Qt, Signal -from ecrit.ui.styles import theme +from ecrit.ui.icons import IconButton class Scratchpad(QFrame): @@ -17,7 +17,6 @@ def __init__(self, parent=None): self.setObjectName("rail") self.setFixedWidth(300) - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(12, 12, 12, 12) layout.setSpacing(8) @@ -28,37 +27,37 @@ def __init__(self, parent=None): header.addWidget(title) header.addStretch() - close_btn = QPushButton("×") + close_btn = IconButton("x-mark") close_btn.setObjectName("iconBtn") close_btn.setFixedSize(22, 22) + close_btn.setAccessibleName("Close") close_btn.clicked.connect(self.closed.emit) header.addWidget(close_btn) layout.addLayout(header) hint = QLabel("Cut text lands here. Drag back to the script when ready.") hint.setWordWrap(True) - hint.setStyleSheet(f"color: {t.neutral_500}; font-size: 12px;") + hint.setObjectName("dashMutedSmall") layout.addWidget(hint) self.text_area = QTextEdit() self.text_area.setPlaceholderText("Nothing here yet...") - self.text_area.setStyleSheet( - f"font-family: 'Courier Prime', Courier, monospace; font-size: 13px; " - f"background: transparent; border: 1px solid {t.divider}; border-radius: 6px; " - f"padding: 8px;" - ) + self.text_area.setObjectName("scratchpadEditor") + self.text_area.setAccessibleName("Scratchpad Editor") layout.addWidget(self.text_area, 1) buttons = QHBoxLayout() paste_btn = QPushButton("Paste to script") paste_btn.setObjectName("primary") paste_btn.setFixedHeight(30) + paste_btn.setAccessibleName("Paste to Script") paste_btn.clicked.connect(self._on_paste) buttons.addWidget(paste_btn) clear_btn = QPushButton("Clear") clear_btn.setObjectName("secondary") clear_btn.setFixedHeight(30) + clear_btn.setAccessibleName("Clear Scratchpad") clear_btn.clicked.connect(self._on_clear) buttons.addWidget(clear_btn) layout.addLayout(buttons) diff --git a/ecrit/ui/overlays/series_panel.py b/ecrit/ui/overlays/series_panel.py index ea529ab..d6dbee4 100644 --- a/ecrit/ui/overlays/series_panel.py +++ b/ecrit/ui/overlays/series_panel.py @@ -8,7 +8,7 @@ ) from PySide6.QtCore import Qt, Signal -from ecrit.ui.styles import theme +from ecrit.ui.icons import IconButton from ecrit.screenplay.series_projects import ( SeriesProject, SeriesSeason, Episode, BibleEntry, SeriesBible, ) @@ -28,7 +28,6 @@ def __init__(self, parent=None): self._project: SeriesProject | None = None - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -38,9 +37,10 @@ def __init__(self, parent=None): title.setStyleSheet("font-size: 20px; font-weight: 500;") header.addWidget(title) header.addStretch() - close_btn = QPushButton("×") + close_btn = IconButton("x-mark") close_btn.setObjectName("iconBtn") close_btn.setFixedSize(28, 28) + close_btn.setAccessibleName("Close") close_btn.clicked.connect(self.close) header.addWidget(close_btn) layout.addLayout(header) @@ -58,6 +58,7 @@ def __init__(self, parent=None): close_btn2 = QPushButton("Close") close_btn2.setObjectName("secondary") close_btn2.setFixedHeight(36) + close_btn2.setAccessibleName("Close") close_btn2.clicked.connect(self.close) footer.addWidget(close_btn2) layout.addLayout(footer) @@ -78,12 +79,14 @@ def _build_episodes_tab(self): self.season_combo = QComboBox() self.season_combo.setFixedHeight(32) self.season_combo.setMinimumWidth(160) + self.season_combo.setAccessibleName("Season") self.season_combo.currentIndexChanged.connect(self._on_season_changed) top_row.addWidget(self.season_combo) add_season_btn = QPushButton("+ Season") add_season_btn.setObjectName("secondary") add_season_btn.setFixedHeight(32) + add_season_btn.setAccessibleName("Add Season") add_season_btn.clicked.connect(self._add_season) top_row.addWidget(add_season_btn) @@ -92,6 +95,7 @@ def _build_episodes_tab(self): add_ep_btn = QPushButton("+ Episode") add_ep_btn.setObjectName("primary") add_ep_btn.setFixedHeight(32) + add_ep_btn.setAccessibleName("Add Episode") add_ep_btn.clicked.connect(self._add_episode) top_row.addWidget(add_ep_btn) @@ -123,6 +127,7 @@ def _build_bible_tab(self): self.bible_category_combo = QComboBox() self.bible_category_combo.setFixedHeight(32) + self.bible_category_combo.setAccessibleName("Category") for cat in ["all", "character", "location", "prop", "theme", "backstory"]: self.bible_category_combo.addItem(cat.title(), cat) self.bible_category_combo.currentIndexChanged.connect(self._refresh_bible) @@ -134,12 +139,14 @@ def _build_bible_tab(self): self.bible_search.setPlaceholderText("Search bible...") self.bible_search.setFixedHeight(32) self.bible_search.setFixedWidth(200) + self.bible_search.setAccessibleName("Search Bible") self.bible_search.textChanged.connect(self._refresh_bible) top_row.addWidget(self.bible_search) add_entry_btn = QPushButton("+ Entry") add_entry_btn.setObjectName("primary") add_entry_btn.setFixedHeight(32) + add_entry_btn.setAccessibleName("Add Bible Entry") add_entry_btn.clicked.connect(self._add_bible_entry) top_row.addWidget(add_entry_btn) @@ -173,6 +180,7 @@ def _build_info_tab(self): layout.addWidget(lbl) inp = QLineEdit() inp.setFixedHeight(32) + inp.setAccessibleName(label_text) setattr(self, attr, inp) layout.addWidget(inp) @@ -181,6 +189,7 @@ def _build_info_tab(self): layout.addWidget(lbl) self.logline_input = QTextEdit() self.logline_input.setFixedHeight(72) + self.logline_input.setAccessibleName("Logline") layout.addWidget(self.logline_input) layout.addStretch() @@ -188,6 +197,7 @@ def _build_info_tab(self): save_btn = QPushButton("Save Info") save_btn.setObjectName("primary") save_btn.setFixedHeight(36) + save_btn.setAccessibleName("Save Info") save_btn.clicked.connect(self._save_info) layout.addWidget(save_btn) diff --git a/ecrit/ui/overlays/settings.py b/ecrit/ui/overlays/settings.py index d3fbfbf..def9f91 100644 --- a/ecrit/ui/overlays/settings.py +++ b/ecrit/ui/overlays/settings.py @@ -3,23 +3,19 @@ from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QLineEdit, QCheckBox, QComboBox, QTabWidget, QWidget, - QFrame, QFileDialog, QListWidget, QListWidgetItem, QGridLayout, - QSpinBox + QFrame, QFileDialog, QListWidget, QListWidgetItem, QSpinBox ) from PySide6.QtCore import Qt, Signal from ecrit.ui.styles import theme +from ecrit.ui.icons import IconButton from ecrit.i18n import tr, set_language, get_language, available_languages class _FormGroup(QFrame): def __init__(self, label: str, parent=None): super().__init__(parent) - t = theme.current() - self.setStyleSheet( - f"QFrame {{ background: {t.neutral_100 if t.name == 'organic' else t.neutral_900}; " - f"border-radius: 8px; padding: 0; }}" - ) + self.setObjectName("formGroup") self._layout = QVBoxLayout(self) self._layout.setContentsMargins(16, 12, 16, 12) self._layout.setSpacing(10) @@ -57,17 +53,16 @@ def __init__(self, parent=None): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) - t = theme.current() - header = QHBoxLayout() header.setContentsMargins(24, 20, 24, 12) title = QLabel("Settings") title.setStyleSheet("font-size: 20px; font-weight: 500;") header.addWidget(title) header.addStretch() - close_btn = QPushButton("×") + close_btn = IconButton("x-mark") close_btn.setObjectName("iconBtn") close_btn.setFixedSize(28, 28) + close_btn.setAccessibleName("Close") close_btn.clicked.connect(self.close) header.addWidget(close_btn) layout.addLayout(header) @@ -84,11 +79,13 @@ def __init__(self, parent=None): self.author_input = QLineEdit() self.author_input.setPlaceholderText("Your name") self.author_input.setFixedHeight(34) + self.author_input.setAccessibleName("Author Name") identity_group.add_row("Author Name", self.author_input) self.email_input = QLineEdit() self.email_input.setPlaceholderText("you@example.com") self.email_input.setFixedHeight(34) + self.email_input.setAccessibleName("Author Email") identity_group.add_row("Author Email", self.email_input) g_layout.addWidget(identity_group) @@ -96,6 +93,7 @@ def __init__(self, parent=None): self.language_combo = QComboBox() self.language_combo.setFixedHeight(34) self.language_combo.setMinimumWidth(180) + self.language_combo.setAccessibleName("Language") for code, display_name in available_languages(): self.language_combo.addItem(display_name, code) current = get_language() @@ -113,14 +111,12 @@ def __init__(self, parent=None): folder_layout.setContentsMargins(0, 0, 0, 0) folder_layout.setSpacing(8) self.folder_label = QLabel() - self.folder_label.setStyleSheet( - f"font-family: ui-monospace, Menlo, monospace; font-size: 12px; " - f"color: {t.neutral_500}; background: transparent;" - ) + self.folder_label.setObjectName("dashMonoMuted") folder_layout.addWidget(self.folder_label, 1) browse_btn = QPushButton("Browse") browse_btn.setObjectName("secondary") browse_btn.setFixedSize(80, 30) + browse_btn.setAccessibleName("Browse Project Folder") browse_btn.clicked.connect(self._browse_folder) folder_layout.addWidget(browse_btn) storage_group.add_row("Project Folder", folder_widget) @@ -138,15 +134,18 @@ def __init__(self, parent=None): behavior_group = _FormGroup("Behavior") self.typewriter_check = QCheckBox("Typewriter scrolling") self.typewriter_check.setStyleSheet("background: transparent;") + self.typewriter_check.setAccessibleName("Typewriter scrolling") self.typewriter_check.setChecked(True) behavior_group.add_widget(self.typewriter_check) self.line_numbers_check = QCheckBox("Show line numbers") self.line_numbers_check.setStyleSheet("background: transparent;") + self.line_numbers_check.setAccessibleName("Show line numbers") behavior_group.add_widget(self.line_numbers_check) self.auto_save_check = QCheckBox("Auto-save on pause (1s debounce)") self.auto_save_check.setStyleSheet("background: transparent;") + self.auto_save_check.setAccessibleName("Auto-save on pause") self.auto_save_check.setChecked(True) behavior_group.add_widget(self.auto_save_check) e_layout.addWidget(behavior_group) @@ -154,6 +153,7 @@ def __init__(self, parent=None): autosave_group = _FormGroup("Autosave Snapshots") self.autosave_enabled_check = QCheckBox("Enable periodic autosave snapshots") self.autosave_enabled_check.setStyleSheet("background: transparent;") + self.autosave_enabled_check.setAccessibleName("Enable periodic autosave snapshots") self.autosave_enabled_check.setChecked(True) autosave_group.add_widget(self.autosave_enabled_check) @@ -163,6 +163,7 @@ def __init__(self, parent=None): self.autosave_interval_spin.setSuffix(" min") self.autosave_interval_spin.setFixedHeight(34) self.autosave_interval_spin.setFixedWidth(100) + self.autosave_interval_spin.setAccessibleName("Snapshot interval") autosave_group.add_row("Snapshot interval", self.autosave_interval_spin) e_layout.addWidget(autosave_group) @@ -170,14 +171,25 @@ def __init__(self, parent=None): self.font_size = QComboBox() self.font_size.setFixedHeight(34) self.font_size.setMinimumWidth(100) + self.font_size.setAccessibleName("Script Font Size") for s in ["12", "13", "14", "15", "16", "18"]: self.font_size.addItem(f"{s}pt", int(s)) self.font_size.setCurrentText("15pt") typography_group.add_row("Script Font Size", self.font_size) + self.ui_font_size = QComboBox() + self.ui_font_size.setFixedHeight(34) + self.ui_font_size.setMinimumWidth(100) + self.ui_font_size.setAccessibleName("Interface font size") + for s in ["12", "13", "14", "15", "16", "18"]: + self.ui_font_size.addItem(f"{s}px", int(s)) + self.ui_font_size.setCurrentText("14px") + typography_group.add_row("Interface Font Size", self.ui_font_size) + self.word_target = QLineEdit("2500") self.word_target.setFixedHeight(34) self.word_target.setFixedWidth(100) + self.word_target.setAccessibleName("Daily Word Target") typography_group.add_row("Daily Word Target", self.word_target) e_layout.addWidget(typography_group) @@ -193,15 +205,18 @@ def __init__(self, parent=None): appearance_group = _FormGroup("Appearance") theme_row = QHBoxLayout() theme_row.setSpacing(12) + current_theme = theme.current().name self.dark_btn = QPushButton("Nocturne (Dark)") - self.dark_btn.setObjectName("primary" if t.name == "nocturne" else "secondary") + self.dark_btn.setObjectName("primary" if current_theme == "nocturne" else "secondary") self.dark_btn.setFixedHeight(44) + self.dark_btn.setAccessibleName("Nocturne Dark Theme") self.dark_btn.clicked.connect(lambda: self._set_theme("nocturne")) theme_row.addWidget(self.dark_btn) self.light_btn = QPushButton("Organic (Light)") - self.light_btn.setObjectName("primary" if t.name == "organic" else "secondary") + self.light_btn.setObjectName("primary" if current_theme == "organic" else "secondary") self.light_btn.setFixedHeight(44) + self.light_btn.setAccessibleName("Organic Light Theme") self.light_btn.clicked.connect(lambda: self._set_theme("organic")) theme_row.addWidget(self.light_btn) appearance_group._layout.addLayout(theme_row) @@ -222,7 +237,7 @@ def __init__(self, parent=None): about_group.add_widget(app_name) ver = QLabel("v26.9.1 — Cross-platform Screenplay Editor") - ver.setStyleSheet(f"color: {t.neutral_500}; font-size: 13px; background: transparent;") + ver.setObjectName("dashMuted") about_group.add_widget(ver) desc = QLabel( @@ -246,6 +261,7 @@ def __init__(self, parent=None): modules_group = _FormGroup("Loaded Modules") self.modules_list = QListWidget() self.modules_list.setFixedHeight(140) + self.modules_list.setAccessibleName("Loaded Modules") self.modules_list.currentRowChanged.connect(self._on_module_selected) modules_group.add_widget(self.modules_list) @@ -253,6 +269,7 @@ def __init__(self, parent=None): self.toggle_module_btn = QPushButton("Enable/Disable") self.toggle_module_btn.setObjectName("secondary") self.toggle_module_btn.setFixedHeight(34) + self.toggle_module_btn.setAccessibleName("Enable or Disable Module") self.toggle_module_btn.setEnabled(False) self.toggle_module_btn.clicked.connect(self._toggle_module) mod_btn_row.addWidget(self.toggle_module_btn) @@ -260,6 +277,7 @@ def __init__(self, parent=None): self.scan_dir_btn = QPushButton("Scan Directory") self.scan_dir_btn.setObjectName("secondary") self.scan_dir_btn.setFixedHeight(34) + self.scan_dir_btn.setAccessibleName("Scan Directory") self.scan_dir_btn.clicked.connect(self._scan_modules_directory) mod_btn_row.addWidget(self.scan_dir_btn) mod_btn_row.addStretch() @@ -273,11 +291,11 @@ def __init__(self, parent=None): details_group.add_widget(self.mod_detail_name) self.mod_detail_version = QLabel("") - self.mod_detail_version.setStyleSheet(f"color: {t.neutral_500}; font-size: 12px; background: transparent;") + self.mod_detail_version.setObjectName("dashMutedSmall") details_group.add_widget(self.mod_detail_version) self.mod_detail_author = QLabel("") - self.mod_detail_author.setStyleSheet(f"color: {t.neutral_500}; font-size: 12px; background: transparent;") + self.mod_detail_author.setObjectName("dashMutedSmall") details_group.add_widget(self.mod_detail_author) self.mod_detail_desc = QLabel("") @@ -286,15 +304,12 @@ def __init__(self, parent=None): details_group.add_widget(self.mod_detail_desc) self.mod_detail_type = QLabel("") - self.mod_detail_type.setStyleSheet(f"color: {t.neutral_500}; font-size: 12px; background: transparent;") + self.mod_detail_type.setObjectName("dashMutedSmall") details_group.add_widget(self.mod_detail_type) self.mod_detail_path = QLabel("") self.mod_detail_path.setWordWrap(True) - self.mod_detail_path.setStyleSheet( - f"font-family: ui-monospace, Menlo, monospace; font-size: 11px; " - f"color: {t.neutral_500}; background: transparent;" - ) + self.mod_detail_path.setObjectName("dashMonoMuted") details_group.add_widget(self.mod_detail_path) self.mod_detail_error = QLabel("") @@ -340,7 +355,7 @@ def _refresh_modules_list(self): status = "Enabled" if mod.enabled else "Disabled" text = f"{mod.manifest.name} v{mod.manifest.version} [{mod.manifest.module_type}] ({status})" item = QListWidgetItem(text) - item.setData(Qt.UserRole, mod.manifest.name) + item.setData(Qt.ItemDataRole.UserRole, mod.manifest.name) self.modules_list.addItem(item) def _on_module_selected(self, row): @@ -351,7 +366,7 @@ def _on_module_selected(self, row): item = self.modules_list.item(row) if not item: return - name = item.data(Qt.UserRole) + name = item.data(Qt.ItemDataRole.UserRole) mod = self._module_registry.get_module(name) if not mod: return @@ -384,7 +399,7 @@ def _toggle_module(self): item = self.modules_list.currentItem() if not item: return - name = item.data(Qt.UserRole) + name = item.data(Qt.ItemDataRole.UserRole) mod = self._module_registry.get_module(name) if not mod: return @@ -428,6 +443,14 @@ def load_state(self): self.autosave_interval_spin.setValue(prefs["autosave_interval"]) if "autosave_enabled" in prefs: self.autosave_enabled_check.setChecked(prefs["autosave_enabled"]) + if "ui_font_size" in prefs: + idx = self.ui_font_size.findData(prefs["ui_font_size"]) + if idx >= 0: + self.ui_font_size.setCurrentIndex(idx) + if "font_size" in prefs: + idx = self.font_size.findData(prefs["font_size"]) + if idx >= 0: + self.font_size.setCurrentIndex(idx) def closeEvent(self, event): try: @@ -438,6 +461,7 @@ def closeEvent(self, event): "author_name": self.author_input.text(), "author_email": self.email_input.text(), "font_size": self.font_size.currentData() or 15, + "ui_font_size": self.ui_font_size.currentData() or 14, "word_target": word_target, "typewriter": self.typewriter_check.isChecked(), "line_numbers": self.line_numbers_check.isChecked(), diff --git a/ecrit/ui/overlays/share_review.py b/ecrit/ui/overlays/share_review.py index 02d3c82..8506665 100644 --- a/ecrit/ui/overlays/share_review.py +++ b/ecrit/ui/overlays/share_review.py @@ -7,7 +7,7 @@ ) from PySide6.QtCore import Qt, Signal -from ecrit.ui.styles import theme +from ecrit.ui.icons import IconButton class ShareReviewDialog(QDialog): @@ -23,7 +23,6 @@ def __init__(self, parent=None): self._shares: list[dict] = [] - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -33,9 +32,10 @@ def __init__(self, parent=None): title.setStyleSheet("font-size: 20px; font-weight: 500;") header.addWidget(title) header.addStretch() - close_btn = QPushButton("×") + close_btn = IconButton("x-mark") close_btn.setObjectName("iconBtn") close_btn.setFixedSize(28, 28) + close_btn.setAccessibleName("Close") close_btn.clicked.connect(self.close) header.addWidget(close_btn) layout.addLayout(header) @@ -53,7 +53,7 @@ def __init__(self, parent=None): "Includes a watermark overlay for confidentiality." ) desc.setWordWrap(True) - desc.setStyleSheet(f"color: {t.neutral_500}; font-size: 13px;") + desc.setObjectName("dashMuted") form.addWidget(desc) watermark_label = QLabel("Watermark Text") @@ -64,13 +64,16 @@ def __init__(self, parent=None): self.watermark_input.setFixedHeight(34) self.watermark_input.setText("CONFIDENTIAL — FOR REVIEW ONLY") self.watermark_input.setPlaceholderText("e.g. CONFIDENTIAL — FOR REVIEW ONLY") + self.watermark_input.setAccessibleName("Watermark Text") form.addWidget(self.watermark_input) self.include_title_page = QCheckBox("Include title page") + self.include_title_page.setAccessibleName("Include title page") self.include_title_page.setChecked(True) form.addWidget(self.include_title_page) self.include_page_numbers = QCheckBox("Include page numbers") + self.include_page_numbers.setAccessibleName("Include page numbers") self.include_page_numbers.setChecked(True) form.addWidget(self.include_page_numbers) @@ -84,6 +87,7 @@ def __init__(self, parent=None): self.shares_list = QListWidget() self.shares_list.setFixedHeight(120) + self.shares_list.setAccessibleName("Generated Shares") form.addWidget(self.shares_list) btn_row = QHBoxLayout() @@ -92,12 +96,14 @@ def __init__(self, parent=None): copy_btn = QPushButton("Copy Path") copy_btn.setObjectName("secondary") copy_btn.setFixedHeight(30) + copy_btn.setAccessibleName("Copy Path") copy_btn.clicked.connect(self._copy_selected_path) btn_row.addWidget(copy_btn) delete_btn = QPushButton("Delete") delete_btn.setObjectName("secondary") delete_btn.setFixedHeight(30) + delete_btn.setAccessibleName("Delete Share") delete_btn.clicked.connect(self._delete_selected) btn_row.addWidget(delete_btn) @@ -115,12 +121,14 @@ def __init__(self, parent=None): cancel_btn = QPushButton("Close") cancel_btn.setObjectName("secondary") cancel_btn.setFixedHeight(36) + cancel_btn.setAccessibleName("Close") cancel_btn.clicked.connect(self.close) footer.addWidget(cancel_btn) self.generate_btn = QPushButton("Generate Review Link") self.generate_btn.setObjectName("primary") self.generate_btn.setFixedHeight(36) + self.generate_btn.setAccessibleName("Generate Review Link") self.generate_btn.clicked.connect(self._generate) footer.addWidget(self.generate_btn) diff --git a/ecrit/ui/overlays/shortcuts.py b/ecrit/ui/overlays/shortcuts.py index 3d24e14..62612e3 100644 --- a/ecrit/ui/overlays/shortcuts.py +++ b/ecrit/ui/overlays/shortcuts.py @@ -6,7 +6,8 @@ ) from PySide6.QtCore import Qt -from ecrit.ui.styles import theme +from ecrit.ui.icons import IconButton + SHORTCUT_GROUPS = [ ("File", [ @@ -52,7 +53,6 @@ def __init__(self, parent=None): self.setMinimumSize(480, 520) self.setModal(True) - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(24, 20, 24, 20) layout.setSpacing(16) @@ -62,9 +62,10 @@ def __init__(self, parent=None): title.setStyleSheet("font-size: 20px; font-weight: 500;") header.addWidget(title) header.addStretch() - close_btn = QPushButton("×") + close_btn = IconButton("x-mark") close_btn.setObjectName("iconBtn") close_btn.setFixedSize(28, 28) + close_btn.setAccessibleName("Close") close_btn.clicked.connect(self.close) header.addWidget(close_btn) layout.addLayout(header) @@ -86,11 +87,7 @@ def __init__(self, parent=None): grid.setSpacing(6) for i, (key, desc) in enumerate(shortcuts): key_label = QLabel(key) - key_label.setStyleSheet( - f"font-family: ui-monospace, Menlo, monospace; font-size: 12px; " - f"background: {t.neutral_800 if t.name == 'nocturne' else t.neutral_200}; " - f"padding: 3px 8px; border-radius: 4px;" - ) + key_label.setObjectName("kbdKey") key_label.setFixedWidth(140) grid.addWidget(key_label, i, 0) diff --git a/ecrit/ui/overlays/snapshots.py b/ecrit/ui/overlays/snapshots.py index 54dc9c8..3b03db9 100644 --- a/ecrit/ui/overlays/snapshots.py +++ b/ecrit/ui/overlays/snapshots.py @@ -10,7 +10,7 @@ ) from PySide6.QtCore import Qt, Signal -from ecrit.ui.styles import theme +from ecrit.ui.icons import IconButton def _run_git(project_path: str, *args) -> str: @@ -23,7 +23,7 @@ def _run_git(project_path: str, *args) -> str: timeout=10, ) return result.stdout.strip() - except Exception: + except (subprocess.TimeoutExpired, OSError): return "" @@ -103,7 +103,6 @@ def __init__(self, parent=None): self._project_path = "" - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(20, 16, 20, 16) layout.setSpacing(12) @@ -113,9 +112,10 @@ def __init__(self, parent=None): title.setStyleSheet("font-size: 20px; font-weight: 500;") header.addWidget(title) header.addStretch() - close_btn = QPushButton("×") + close_btn = IconButton("x-mark") close_btn.setObjectName("iconBtn") close_btn.setFixedSize(28, 28) + close_btn.setAccessibleName("Close") close_btn.clicked.connect(self.close) header.addWidget(close_btn) layout.addLayout(header) @@ -124,16 +124,19 @@ def __init__(self, parent=None): self.message_input = QLineEdit() self.message_input.setPlaceholderText("Snapshot message (optional)") self.message_input.setFixedHeight(34) + self.message_input.setAccessibleName("Snapshot Message") create_row.addWidget(self.message_input, 1) snap_btn = QPushButton("Snapshot") snap_btn.setObjectName("primary") snap_btn.setFixedHeight(34) + snap_btn.setAccessibleName("Create Snapshot") snap_btn.clicked.connect(self._create_snapshot) create_row.addWidget(snap_btn) layout.addLayout(create_row) self.snap_list = QListWidget() + self.snap_list.setAccessibleName("Snapshots") layout.addWidget(self.snap_list, 1) restore_row = QHBoxLayout() @@ -141,6 +144,7 @@ def __init__(self, parent=None): self.restore_btn = QPushButton("Restore selected") self.restore_btn.setObjectName("secondary") self.restore_btn.setFixedHeight(34) + self.restore_btn.setAccessibleName("Restore Selected Snapshot") self.restore_btn.clicked.connect(self._restore_snapshot) restore_row.addWidget(self.restore_btn) layout.addLayout(restore_row) @@ -151,7 +155,6 @@ def set_project(self, project_path: str): def _refresh(self): self.snap_list.clear() - t = theme.current() snapshots = list_snapshots(self._project_path) for s in snapshots: item = QListWidgetItem(f"{s['date']} — {s['label']}") diff --git a/ecrit/ui/overlays/spellcheck_dialog.py b/ecrit/ui/overlays/spellcheck_dialog.py index 26b9019..958dc76 100644 --- a/ecrit/ui/overlays/spellcheck_dialog.py +++ b/ecrit/ui/overlays/spellcheck_dialog.py @@ -7,7 +7,6 @@ from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QColor -from ecrit.ui.styles import theme class SpellCheckDialog(QDialog): @@ -20,19 +19,18 @@ def __init__(self, parent=None): self.setMinimumSize(700, 460) self.setModal(True) - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(24, 20, 24, 20) layout.setSpacing(12) header = QHBoxLayout() title = QLabel("Spell Check") - title.setStyleSheet(f"font-size: 18px; font-weight: bold; color: {t.text};") + title.setStyleSheet("font-size: 18px; font-weight: bold;") header.addWidget(title) header.addStretch() self._count_label = QLabel() - self._count_label.setStyleSheet(f"color: {t.neutral_400}; font-size: 13px;") + self._count_label.setObjectName("countBadge") header.addWidget(self._count_label) layout.addLayout(header) diff --git a/ecrit/ui/overlays/sprint_timer.py b/ecrit/ui/overlays/sprint_timer.py index 79f86db..625ae8c 100644 --- a/ecrit/ui/overlays/sprint_timer.py +++ b/ecrit/ui/overlays/sprint_timer.py @@ -5,7 +5,6 @@ ) from PySide6.QtCore import Qt, Signal, QTimer -from ecrit.ui.styles import theme class SprintTimerWidget(QFrame): @@ -28,7 +27,6 @@ def __init__(self, parent=None): self._timer.setInterval(1000) self._timer.timeout.connect(self._tick) - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(16, 14, 16, 14) layout.setSpacing(12) @@ -49,6 +47,7 @@ def __init__(self, parent=None): btn = QPushButton(f"{mins}m") btn.setObjectName("secondary") btn.setFixedHeight(30) + btn.setAccessibleName(f"{mins} minutes") btn.clicked.connect(lambda checked=False, m=mins: self._set_duration(m)) presets.addWidget(btn) layout.addLayout(presets) @@ -57,12 +56,14 @@ def __init__(self, parent=None): self.start_btn = QPushButton("Start") self.start_btn.setObjectName("primary") self.start_btn.setFixedHeight(34) + self.start_btn.setAccessibleName("Start") self.start_btn.clicked.connect(self._toggle) controls.addWidget(self.start_btn) self.reset_btn = QPushButton("Reset") self.reset_btn.setObjectName("secondary") self.reset_btn.setFixedHeight(34) + self.reset_btn.setAccessibleName("Reset") self.reset_btn.clicked.connect(self._reset) controls.addWidget(self.reset_btn) layout.addLayout(controls) diff --git a/ecrit/ui/overlays/statistics.py b/ecrit/ui/overlays/statistics.py index 365d93d..fcd15cd 100644 --- a/ecrit/ui/overlays/statistics.py +++ b/ecrit/ui/overlays/statistics.py @@ -7,7 +7,7 @@ ) from PySide6.QtCore import Qt -from ecrit.ui.styles import theme +from ecrit.ui.icons import IconButton class StatRow(QFrame): @@ -16,9 +16,8 @@ def __init__(self, label: str, value: str, parent=None): layout = QHBoxLayout(self) layout.setContentsMargins(0, 4, 0, 4) - t = theme.current() lbl = QLabel(label) - lbl.setStyleSheet(f"color: {t.neutral_500}; font-size: 13px;") + lbl.setObjectName("dashMuted") layout.addWidget(lbl) layout.addStretch() @@ -39,7 +38,6 @@ def __init__(self, parent=None): self.setMinimumSize(520, 480) self.setModal(True) - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(24, 20, 24, 20) layout.setSpacing(16) @@ -49,9 +47,10 @@ def __init__(self, parent=None): title.setStyleSheet("font-size: 20px; font-weight: 500;") header.addWidget(title) header.addStretch() - close_btn = QPushButton("×") + close_btn = IconButton("x-mark") close_btn.setObjectName("iconBtn") close_btn.setFixedSize(28, 28) + close_btn.setAccessibleName("Close") close_btn.clicked.connect(self.close) header.addWidget(close_btn) layout.addLayout(header) diff --git a/ecrit/ui/overlays/sync_settings.py b/ecrit/ui/overlays/sync_settings.py index 8e210fd..626123e 100644 --- a/ecrit/ui/overlays/sync_settings.py +++ b/ecrit/ui/overlays/sync_settings.py @@ -7,7 +7,7 @@ ) from PySide6.QtCore import Qt, Signal -from ecrit.ui.styles import theme +from ecrit.ui.icons import IconButton PROVIDERS = [ @@ -31,7 +31,6 @@ def __init__(self, parent=None): self._config: dict = {} - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -41,9 +40,10 @@ def __init__(self, parent=None): title.setStyleSheet("font-size: 20px; font-weight: 500;") header.addWidget(title) header.addStretch() - close_btn = QPushButton("×") + close_btn = IconButton("x-mark") close_btn.setObjectName("iconBtn") close_btn.setFixedSize(28, 28) + close_btn.setAccessibleName("Close") close_btn.clicked.connect(self.close) header.addWidget(close_btn) layout.addLayout(header) @@ -63,6 +63,7 @@ def __init__(self, parent=None): self.provider_combo = QComboBox() self.provider_combo.setFixedHeight(34) + self.provider_combo.setAccessibleName("Provider") for key, name, url in PROVIDERS: self.provider_combo.addItem(f"{name} ({url})", key) form.addWidget(self.provider_combo) @@ -74,6 +75,7 @@ def __init__(self, parent=None): self.repo_url_input = QLineEdit() self.repo_url_input.setFixedHeight(34) self.repo_url_input.setPlaceholderText("https://github.com/user/project.git") + self.repo_url_input.setAccessibleName("Repository URL") form.addWidget(self.repo_url_input) user_label = QLabel("Username") @@ -83,6 +85,7 @@ def __init__(self, parent=None): self.username_input = QLineEdit() self.username_input.setFixedHeight(34) self.username_input.setPlaceholderText("Your username") + self.username_input.setAccessibleName("Username") form.addWidget(self.username_input) token_label = QLabel("Personal Access Token") @@ -93,6 +96,7 @@ def __init__(self, parent=None): self.token_input.setFixedHeight(34) self.token_input.setEchoMode(QLineEdit.EchoMode.Password) self.token_input.setPlaceholderText("ghp_... or glpat-...") + self.token_input.setAccessibleName("Personal Access Token") form.addWidget(self.token_input) branch_label = QLabel("Branch") @@ -102,14 +106,15 @@ def __init__(self, parent=None): self.branch_input = QLineEdit() self.branch_input.setFixedHeight(34) self.branch_input.setText("main") + self.branch_input.setAccessibleName("Branch") form.addWidget(self.branch_input) self.auto_sync_check = QCheckBox("Auto-sync on save") + self.auto_sync_check.setAccessibleName("Auto-sync on save") form.addWidget(self.auto_sync_check) divider = QFrame() divider.setFrameShape(QFrame.Shape.HLine) - divider.setStyleSheet(f"color: {t.neutral_700 if t.name == 'nocturne' else t.divider};") form.addWidget(divider) status_label = QLabel("STATUS") @@ -118,11 +123,11 @@ def __init__(self, parent=None): self.status_display = QLabel("Not configured") self.status_display.setWordWrap(True) - self.status_display.setStyleSheet(f"color: {t.neutral_500}; font-size: 13px; padding: 4px 0;") + self.status_display.setObjectName("dashMuted") form.addWidget(self.status_display) self.last_sync_label = QLabel("") - self.last_sync_label.setStyleSheet(f"color: {t.neutral_500}; font-size: 12px;") + self.last_sync_label.setObjectName("dashMutedSmall") form.addWidget(self.last_sync_label) form.addStretch() @@ -135,6 +140,7 @@ def __init__(self, parent=None): save_btn = QPushButton("Save Config") save_btn.setObjectName("secondary") save_btn.setFixedHeight(36) + save_btn.setAccessibleName("Save Config") save_btn.clicked.connect(self._save_config) footer.addWidget(save_btn) @@ -143,12 +149,14 @@ def __init__(self, parent=None): pull_btn = QPushButton("Pull") pull_btn.setObjectName("secondary") pull_btn.setFixedHeight(36) + pull_btn.setAccessibleName("Pull") pull_btn.clicked.connect(lambda: self.sync_requested.emit("pull")) footer.addWidget(pull_btn) push_btn = QPushButton("Push") push_btn.setObjectName("primary") push_btn.setFixedHeight(36) + push_btn.setAccessibleName("Push") push_btn.clicked.connect(lambda: self.sync_requested.emit("push")) footer.addWidget(push_btn) diff --git a/ecrit/ui/overlays/title_template_dialog.py b/ecrit/ui/overlays/title_template_dialog.py index 31165c7..23630c9 100644 --- a/ecrit/ui/overlays/title_template_dialog.py +++ b/ecrit/ui/overlays/title_template_dialog.py @@ -7,7 +7,6 @@ ) from PySide6.QtCore import Qt, Signal -from ecrit.ui.styles import theme from ecrit.screenplay.title_templates import TitleTemplateManager @@ -22,13 +21,12 @@ def __init__(self, manager: TitleTemplateManager, parent=None): self.setMinimumSize(660, 520) self.setModal(True) - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(24, 20, 24, 20) layout.setSpacing(12) header = QLabel("Title Page Template") - header.setStyleSheet(f"font-size: 18px; font-weight: bold; color: {t.text};") + header.setStyleSheet("font-size: 18px; font-weight: bold;") layout.addWidget(header) body = QHBoxLayout() @@ -41,7 +39,7 @@ def __init__(self, manager: TitleTemplateManager, parent=None): right = QVBoxLayout() self._desc_label = QLabel() self._desc_label.setWordWrap(True) - self._desc_label.setStyleSheet(f"color: {t.neutral_400}; font-size: 12px;") + self._desc_label.setObjectName("countBadge") right.addWidget(self._desc_label) self._form_layout = QFormLayout() @@ -50,7 +48,7 @@ def __init__(self, manager: TitleTemplateManager, parent=None): self._preview = QTextEdit() self._preview.setReadOnly(True) self._preview.setMaximumHeight(120) - self._preview.setStyleSheet(f"font-family: 'Courier Prime', monospace; font-size: 12px; color: {t.text};") + self._preview.setStyleSheet("font-family: 'Courier Prime', monospace; font-size: 12px;") right.addWidget(self._preview) right.addStretch() body.addLayout(right, 1) diff --git a/ecrit/ui/screens/dashboard.py b/ecrit/ui/screens/dashboard.py index d714fa1..9adf156 100644 --- a/ecrit/ui/screens/dashboard.py +++ b/ecrit/ui/screens/dashboard.py @@ -7,7 +7,7 @@ from PySide6.QtCore import Qt, Signal from datetime import datetime -from ecrit.ui.styles import theme +from ecrit.ui.icons import IconButton class ContinueWritingCard(QFrame): @@ -25,29 +25,26 @@ def __init__(self, parent=None): top.addWidget(kicker) top.addStretch() self.sync_label = QLabel() - self.sync_label.setStyleSheet(f"color: {theme.current().neutral_500}; font-size: 12px; background: transparent;") + self.sync_label.setObjectName("dashMuted") top.addWidget(self.sync_label) layout.addLayout(top) self.title_label = QLabel() - self.title_label.setStyleSheet("font-size: 26px; font-weight: 500; background: transparent;") + self.title_label.setObjectName("dashHeroTitle") layout.addWidget(self.title_label) self.meta_label = QLabel() - self.meta_label.setStyleSheet(f"color: {theme.current().neutral_500}; font-size: 13px; background: transparent;") + self.meta_label.setObjectName("dashMuted") layout.addWidget(self.meta_label) self.excerpt = QLabel() self.excerpt.setWordWrap(True) - self.excerpt.setStyleSheet( - f"font-family: 'Courier Prime', monospace; font-size: 13px; " - f"color: {theme.current().neutral_400}; background: transparent; padding: 12px 0;" - ) + self.excerpt.setObjectName("dashExcerpt") layout.addWidget(self.excerpt) bottom = QHBoxLayout() self.position_label = QLabel() - self.position_label.setStyleSheet(f"color: {theme.current().neutral_500}; font-size: 13px; background: transparent;") + self.position_label.setObjectName("dashMuted") bottom.addWidget(self.position_label) bottom.addStretch() open_btn = QPushButton("Open") @@ -74,29 +71,31 @@ def __init__(self, parent=None): layout.addWidget(kicker) self.stats = {} + self._stat_labels = [] grid = QGridLayout() grid.setSpacing(8) for i, (key, label) in enumerate([ ("words", "Words written"), - ("sessions", "Sessions"), + ("scenes", "Scenes"), ("pages", "Pages"), - ("streak", "Streak"), + ("characters", "Characters"), ]): val = QLabel("0") val.setAlignment(Qt.AlignmentFlag.AlignRight) - val.setStyleSheet("font-family: ui-monospace, Menlo, monospace; font-size: 20px; background: transparent;") + val.setObjectName("dashStatValue") lbl = QLabel(label) - lbl.setStyleSheet(f"color: {theme.current().neutral_500}; font-size: 12px; background: transparent;") + lbl.setObjectName("dashMuted") grid.addWidget(lbl, i, 0) grid.addWidget(val, i, 1) self.stats[key] = val + self._stat_labels.append(lbl) layout.addLayout(grid) - def set_stats(self, words=0, sessions=0, pages=0, streak="0 days"): + def set_stats(self, words=0, scenes=0, pages=0, characters=0): self.stats["words"].setText(f"{words:,}") - self.stats["sessions"].setText(str(sessions)) + self.stats["scenes"].setText(str(scenes)) self.stats["pages"].setText(str(pages)) - self.stats["streak"].setText(str(streak)) + self.stats["characters"].setText(str(characters)) class ProjectCard(QFrame): @@ -111,8 +110,6 @@ def __init__(self, project_data: dict, parent=None): layout = QVBoxLayout(self) layout.setSpacing(4) - t = theme.current() - top = QHBoxLayout() fmt = project_data.get("format_id", "") category = _format_category(fmt) @@ -121,19 +118,19 @@ def __init__(self, project_data: dict, parent=None): tag.setObjectName("tagNeutral") top.addWidget(tag) fmt_label = QLabel(fmt) - fmt_label.setStyleSheet(f"font-family: ui-monospace, Menlo, monospace; font-size: 11px; color: {t.neutral_500}; background: transparent;") + fmt_label.setObjectName("dashMonoMuted") top.addWidget(fmt_label) top.addStretch() layout.addLayout(top) title = QLabel(project_data.get("title", "Untitled")) - title.setStyleSheet("font-size: 15px; font-weight: 500; background: transparent;") + title.setObjectName("dashCardTitle") layout.addWidget(title) modified = project_data.get("modified_at", "") if modified: mod_label = QLabel(f"edited {_relative_time(modified)}") - mod_label.setStyleSheet(f"color: {t.neutral_500}; font-size: 12.5px; background: transparent;") + mod_label.setObjectName("dashMutedSmall") layout.addWidget(mod_label) def mousePressEvent(self, _event): @@ -151,7 +148,6 @@ def __init__(self, parent=None): self._build_ui() def _build_ui(self): - t = theme.current() scroll = QScrollArea(self) scroll.setWidgetResizable(True) scroll.setFrameShape(QFrame.Shape.NoFrame) @@ -172,18 +168,20 @@ def _build_ui(self): date_str = now.strftime("%A, %d %B") header = QHBoxLayout() - greeting_label = QLabel(f"{greeting}.") - greeting_label.setStyleSheet("font-size: 28px; font-weight: 500;") - header.addWidget(greeting_label) - date_label = QLabel(date_str) - date_label.setStyleSheet(f"color: {t.neutral_500}; font-size: 14px; padding-top: 8px;") - header.addWidget(date_label) + self.greeting_label = QLabel(f"{greeting}.") + self.greeting_label.setStyleSheet("font-size: 28px; font-weight: 500;") + header.addWidget(self.greeting_label) + self.date_label = QLabel(date_str) + self.date_label.setObjectName("dashMuted") + self.date_label.setStyleSheet("font-size: 14px; padding-top: 8px;") + header.addWidget(self.date_label) header.addStretch() - settings_btn = QPushButton("⚙") + settings_btn = IconButton("cog-6-tooth", icon_size=20) settings_btn.setObjectName("iconBtn") settings_btn.setFixedSize(36, 36) settings_btn.setToolTip("Settings") + settings_btn.setAccessibleName("Settings") settings_btn.clicked.connect(self.open_settings.emit) header.addWidget(settings_btn) @@ -203,12 +201,15 @@ def _build_ui(self): new_btn = QPushButton("+ New project") new_btn.setObjectName("primary") new_btn.setFixedHeight(44) + new_btn.setAccessibleName("Create new project") new_btn.clicked.connect(self.new_project.emit) right_stack.addWidget(new_btn) - import_btn = QPushButton("↓ Import script") + import_btn = IconButton("arrow-down-tray", icon_size=16) + import_btn.setText(" Import script") import_btn.setObjectName("secondary") import_btn.setFixedHeight(44) + import_btn.setAccessibleName("Import existing screenplay file") import_btn.clicked.connect(self.import_script.emit) right_stack.addWidget(import_btn) @@ -226,7 +227,7 @@ def _build_ui(self): projects_title.setStyleSheet("font-size: 16px; font-weight: 500;") projects_header.addWidget(projects_title) self.count_label = QLabel("0 open") - self.count_label.setStyleSheet(f"color: {t.neutral_500}; font-size: 13px;") + self.count_label.setObjectName("dashMuted") projects_header.addWidget(self.count_label) projects_header.addStretch() inner_layout.addLayout(projects_header) @@ -251,7 +252,12 @@ def _on_open_recent(self): def refresh(self): from ecrit.stores.app_state import STATE - t = theme.current() + + now = datetime.now() + hour = now.hour + greeting = "Good morning" if hour < 12 else "Good afternoon" if hour < 18 else "Good evening" + self.greeting_label.setText(f"{greeting}.") + self.date_label.setText(now.strftime("%A, %d %B")) STATE.load_projects() projects = STATE.projects @@ -321,5 +327,5 @@ def _relative_time(iso_str: str) -> str: return f"{hours}h ago" mins = diff.seconds // 60 return f"{mins}m ago" - except Exception: + except (ValueError, TypeError): return "" diff --git a/ecrit/ui/screens/editor.py b/ecrit/ui/screens/editor.py index 48d276c..a2f888d 100644 --- a/ecrit/ui/screens/editor.py +++ b/ecrit/ui/screens/editor.py @@ -4,7 +4,7 @@ QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame, QSplitter, QListWidget, QListWidgetItem, QPlainTextEdit, QSizePolicy, QTextEdit, QScrollArea, - QComboBox, QMenu, + QComboBox, QMenu, QInputDialog, ) from PySide6.QtCore import Qt, Signal, QTimer from PySide6.QtGui import ( @@ -13,6 +13,7 @@ ) from ecrit.ui.styles import theme +from ecrit.ui.icons import IconButton from ecrit.ui.components.status_bar import StatusBar from ecrit.ui.overlays.find_replace import FindReplaceBar from ecrit.ui.overlays.reading_mode import ReadingMode @@ -34,10 +35,17 @@ class FountainHighlighter(QSyntaxHighlighter): - def __init__(self, document: QTextDocument): + def __init__(self, document: QTextDocument, format_id: str = "fountain/core"): super().__init__(document) + self._format_id = format_id + self._comic_mode = format_id.startswith("fountain+comic") self._update_formats() + def set_format_id(self, format_id: str): + self._format_id = format_id + self._comic_mode = format_id.startswith("fountain+comic") + self.rehighlight() + def _update_formats(self): t = theme.current() @@ -63,11 +71,44 @@ def _update_formats(self): self.section_fmt.setFontWeight(QFont.Weight.Bold) self.section_fmt.setForeground(QColor(t.accent_300)) + self.page_header_fmt = QTextCharFormat() + self.page_header_fmt.setFontWeight(QFont.Weight.Bold) + self.page_header_fmt.setFontCapitalization(QFont.Capitalization.AllUppercase) + self.page_header_fmt.setForeground(QColor(t.accent_300)) + + self.panel_header_fmt = QTextCharFormat() + self.panel_header_fmt.setFontWeight(QFont.Weight.Bold) + self.panel_header_fmt.setForeground(QColor(t.accent_500)) + + self.sfx_fmt = QTextCharFormat() + self.sfx_fmt.setFontWeight(QFont.Weight.Bold) + self.sfx_fmt.setFontCapitalization(QFont.Capitalization.AllUppercase) + + self.caption_fmt = QTextCharFormat() + self.caption_fmt.setForeground(QColor(t.neutral_300)) + def highlightBlock(self, text: str): stripped = text.strip() if not stripped: return + upper_stripped = stripped.upper() + + if self._comic_mode: + if stripped.startswith("#") and "PAGE" in upper_stripped: + self.setFormat(0, len(text), self.page_header_fmt) + return + if upper_stripped.startswith("PANEL ") or upper_stripped.startswith(".PANEL "): + self.setFormat(0, len(text), self.panel_header_fmt) + return + if upper_stripped.startswith("SFX:"): + self.setFormat(0, len(text), self.sfx_fmt) + return + import re + if re.match(r"(?i)^(CAP|CAPTION|BANNER|VOICE\s*OVER|VO|NARRATION|INTERNAL|EDITORIAL|TIME[- ]PLACE):", stripped): + self.setFormat(0, len(text), self.caption_fmt) + return + if stripped.startswith(("INT.", "EXT.", "EST.", "INT./EXT.", "I/E.")) or stripped.startswith("."): self.setFormat(0, len(text), self.scene_fmt) elif stripped.startswith("#"): @@ -263,6 +304,8 @@ class ScriptEditor(QPlainTextEdit): def __init__(self, parent=None): super().__init__(parent) self.setObjectName("scriptEditor") + self.setAccessibleName("Screenplay editor") + self.setAccessibleDescription("Main screenplay text editor using Fountain format") font = QFont("Courier Prime", 15) font.setStyleHint(QFont.StyleHint.Monospace) @@ -270,7 +313,8 @@ def __init__(self, parent=None): self.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth) self.setTabStopDistance(40) - self.highlighter = FountainHighlighter(self.document()) + self._format_id = "fountain/core" + self.highlighter = FountainHighlighter(self.document(), self._format_id) self.completer = ScriptCompleter(self) self._typewriter = True @@ -291,6 +335,10 @@ def __init__(self, parent=None): self._update_line_number_area_width() + def set_format_id(self, format_id: str): + self._format_id = format_id + self.highlighter.set_format_id(format_id) + def set_line_numbers_visible(self, visible: bool): self._show_line_numbers = visible self._update_line_number_area_width() @@ -465,36 +513,102 @@ def keyPressEvent(self, event): self.completer.update_popup() def _cycle_element_type(self): + """Cycle current line through Fountain element types. + In comic mode the cycle includes Page, Panel, Caption, and SFX. + """ cursor = self.textCursor() - cursor.select(QTextCursor.SelectionType.BlockUnderCursor) - line = cursor.selectedText().strip() - if not line: + cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock) + cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor) + line = cursor.selectedText() + stripped = line.strip() + if not stripped: return - prefixes = ["INT. ", "EXT. ", ""] - transitions = ["CUT TO:", "FADE OUT.", "SMASH CUT TO:", ""] - - if line.startswith(("INT.", "EXT.", "INT/EXT")): - for i, pfx in enumerate(prefixes): - if line.startswith(pfx.strip()) and pfx: - next_pfx = prefixes[(i + 1) % len(prefixes)] - bare = line.split(".", 1)[1].strip() if "." in line else line - cursor.removeSelectedText() - cursor.insertText(f"{next_pfx}{bare}" if next_pfx else bare) - return - elif line.startswith("(") and line.endswith(")"): - cursor.removeSelectedText() - cursor.insertText(line[1:-1] if len(line) > 2 else line) - elif line.endswith(("TO:", "OUT.")): - for i, tr in enumerate(transitions): - if line == tr: - next_tr = transitions[(i + 1) % len(transitions)] - cursor.removeSelectedText() - cursor.insertText(next_tr if next_tr else line) - return + current = self._detect_element_type(stripped) + if self._format_id.startswith("fountain+comic"): + cycle = [ + "page_header", "panel_header", "action", "character", + "dialogue", "parenthetical", "caption", "sfx", + ] else: - cursor.removeSelectedText() - cursor.insertText(f"({line})") + cycle = ["scene_heading", "action", "character", "dialogue", "parenthetical", "transition"] + idx = cycle.index(current) if current in cycle else 0 + next_type = cycle[(idx + 1) % len(cycle)] + + bare = self._strip_element_formatting(stripped, current) + formatted = self._apply_element_formatting(bare, next_type) + + cursor.removeSelectedText() + cursor.insertText(formatted) + + def _detect_element_type(self, line: str) -> str: + import re + upper = line.upper() + if self._format_id.startswith("fountain+comic"): + if line.startswith("#") and "PAGE" in upper: + return "page_header" + if upper.startswith("PANEL ") or upper.startswith(".PANEL "): + return "panel_header" + if upper.startswith("SFX:"): + return "sfx" + if re.match(r"(?i)^(CAP|CAPTION|BANNER|VOICE\s*OVER|VO|NARRATION|INTERNAL|EDITORIAL|TIME[- ]PLACE):", line): + return "caption" + if line.startswith(("INT.", "INT ", "EXT.", "EXT ", "INT/EXT", "I/E ")): + return "scene_heading" + if line.startswith("(") and line.endswith(")"): + return "parenthetical" + if line.endswith(("TO:", "OUT.", "IN:")) and line == line.upper(): + return "transition" + if line == line.upper() and len(line) < 60 and not line.startswith(("INT", "EXT")): + return "character" + if line and line[0].islower(): + return "dialogue" + return "action" + + def _strip_element_formatting(self, line: str, elem_type: str) -> str: + if elem_type == "scene_heading": + for pfx in ("INT/EXT.", "INT/EXT ", "I/E ", "INT.", "INT ", "EXT.", "EXT "): + if line.startswith(pfx): + return line[len(pfx):].strip() + return line + if elem_type == "parenthetical": + return line[1:-1].strip() if len(line) > 2 else line + if elem_type == "page_header": + import re + m = re.match(r"(?i)^#\s*PAGE\s+(.*)", line) + return m.group(1).strip() if m else line + if elem_type == "panel_header": + import re + m = re.match(r"(?i)^\.?PANEL\s+(.*)", line) + return m.group(1).strip() if m else line + if elem_type == "sfx": + return line.split(":", 1)[1].strip() if ":" in line else line + if elem_type == "caption": + return line.split(":", 1)[1].strip() if ":" in line else line + return line + + def _apply_element_formatting(self, bare: str, elem_type: str) -> str: + if elem_type == "scene_heading": + return f"INT. {bare.upper()}" if bare else "INT. " + if elem_type == "action": + return bare[0].upper() + bare[1:] if bare else bare + if elem_type == "character": + return bare.upper() + if elem_type == "dialogue": + return bare[0].lower() + bare[1:] if bare else bare + if elem_type == "parenthetical": + return f"({bare.lower()})" if bare else "()" + if elem_type == "transition": + return f"{bare.upper()}:" if not bare.endswith(":") else bare.upper() + if elem_type == "page_header": + return f"# PAGE {bare.upper()}" if bare else "# PAGE " + if elem_type == "panel_header": + return f"PANEL {bare}" if bare else "PANEL " + if elem_type == "sfx": + return f"SFX: {bare.upper()}" if bare else "SFX: " + if elem_type == "caption": + return f"CAP: {bare}" if bare else "CAP: " + return bare class SceneNavigator(QFrame): @@ -823,12 +937,24 @@ def _add_document(self): self._doc_content[name] = "" self.doc_list.setCurrentRow(self.doc_list.count() - 1) + def get_documents(self) -> list: + if self._current_doc in self._doc_content: + self._doc_content[self._current_doc] = self.editor.toPlainText() + return [ + {"name": name, "content": content} + for name, content in self._doc_content.items() + ] + class OutlinePhase(QWidget): """Outline phase: node graph with structure template support.""" + generate_script_requested = Signal(str) + import_script_requested = Signal() + def __init__(self, parent=None): super().__init__(parent) + self._format_id = "fountain/core" layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -839,32 +965,48 @@ def __init__(self, parent=None): title.setStyleSheet("font-size: 20px; font-weight: 500;") toolbar.addWidget(title) - layout_h = QPushButton("→") + layout_h = IconButton("arrow-right", icon_size=16) layout_h.setObjectName("secondary") layout_h.setFixedSize(28, 28) layout_h.setToolTip("Horizontal layout") + layout_h.setAccessibleName("Horizontal layout") layout_h.clicked.connect(lambda: self._relayout("horizontal")) toolbar.addWidget(layout_h) - layout_v = QPushButton("↓") + layout_v = IconButton("arrow-down", icon_size=16) layout_v.setObjectName("secondary") layout_v.setFixedSize(28, 28) layout_v.setToolTip("Vertical layout") + layout_v.setAccessibleName("Vertical layout") layout_v.clicked.connect(lambda: self._relayout("vertical")) toolbar.addWidget(layout_v) toolbar.addStretch() self.template_combo = QComboBox() - self.template_combo.addItem("— Structure Template —", "") - for key, display_name in list_templates(): - self.template_combo.addItem(display_name, key) + self.template_combo.setAccessibleName("Structure template") + self._populate_templates() self.template_combo.setFixedHeight(30) self.template_combo.currentIndexChanged.connect(self._on_template_selected) toolbar.addWidget(self.template_combo) + self.generate_btn = QPushButton("Generate Script") + self.generate_btn.setObjectName("secondary") + self.generate_btn.setAccessibleName("Generate script from outline") + self.generate_btn.setToolTip("Generate a Fountain script skeleton from this outline") + self.generate_btn.clicked.connect(self._on_generate_script) + toolbar.addWidget(self.generate_btn) + + self.import_btn = QPushButton("Import Script") + self.import_btn.setObjectName("secondary") + self.import_btn.setAccessibleName("Import outline from manuscript") + self.import_btn.setToolTip("Build outline nodes from the current manuscript") + self.import_btn.clicked.connect(self.import_script_requested.emit) + toolbar.addWidget(self.import_btn) + add_btn = QPushButton("+ Add node") add_btn.setObjectName("primary") + add_btn.setAccessibleName("Add outline node") add_btn.clicked.connect(self._add_node) toolbar.addWidget(add_btn) layout.addLayout(toolbar) @@ -875,36 +1017,66 @@ def __init__(self, parent=None): zoom_bar = QHBoxLayout() zoom_bar.setContentsMargins(20, 0, 20, 12) zoom_bar.addStretch() - self.zoom_out = QPushButton("−") + self.zoom_out = IconButton("minus", icon_size=16) self.zoom_out.setObjectName("secondary") self.zoom_out.setFixedSize(28, 28) + self.zoom_out.setAccessibleName("Zoom out") self.zoom_out.clicked.connect(lambda: self._zoom(-0.1)) zoom_bar.addWidget(self.zoom_out) self.zoom_label = QLabel("100%") + self.zoom_label.setAccessibleName("Zoom level") zoom_bar.addWidget(self.zoom_label) - self.zoom_in = QPushButton("+") + self.zoom_in = IconButton("plus", icon_size=16) self.zoom_in.setObjectName("secondary") self.zoom_in.setFixedSize(28, 28) + self.zoom_in.setAccessibleName("Zoom in") self.zoom_in.clicked.connect(lambda: self._zoom(0.1)) zoom_bar.addWidget(self.zoom_in) fit_btn = QPushButton("Fit") fit_btn.setObjectName("secondary") + fit_btn.setAccessibleName("Reset zoom to fit") fit_btn.clicked.connect(self._fit_view) zoom_bar.addWidget(fit_btn) zoom_bar.addStretch() info = QLabel("Right-click to add nodes") - info.setStyleSheet(f"color: {theme.current().neutral_500}; font-size: 12px;") + info.setObjectName("dashMutedSmall") zoom_bar.addWidget(info) layout.addLayout(zoom_bar) + def _populate_templates(self): + self.template_combo.clear() + is_comic = self._format_id.startswith("fountain+comic") + if is_comic: + from ecrit.screenplay.structure_templates import COMIC_TEMPLATES + self.template_combo.addItem("— Comic Template —", "") + for key, tpl in COMIC_TEMPLATES.items(): + self.template_combo.addItem(tpl.name, key) + else: + self.template_combo.addItem("— Structure Template —", "") + for key, display_name in list_templates(): + self.template_combo.addItem(display_name, key) + + def set_format_id(self, format_id: str): + self._format_id = format_id + self.canvas._comic_mode = format_id.startswith("fountain+comic") + self._populate_templates() + def _on_template_selected(self, index: int): key = self.template_combo.itemData(index) if not key: return - tpl = TEMPLATES.get(key) - if tpl: - nodes = generate_outline_nodes(tpl) - self.canvas.set_nodes(nodes) + is_comic = self._format_id.startswith("fountain+comic") + if is_comic: + from ecrit.screenplay.structure_templates import COMIC_TEMPLATES, generate_comic_outline_nodes + tpl = COMIC_TEMPLATES.get(key) + if tpl: + nodes = generate_comic_outline_nodes(tpl) + self.canvas.set_nodes(nodes) + else: + tpl = TEMPLATES.get(key) + if tpl: + nodes = generate_outline_nodes(tpl) + self.canvas.set_nodes(nodes) self.template_combo.setCurrentIndex(0) def apply_template(self, key: str): @@ -914,12 +1086,21 @@ def apply_template(self, key: str): nodes = generate_outline_nodes(tpl) self.canvas.set_nodes(nodes) + def _on_generate_script(self): + from ecrit.screenplay.outline_sync import outline_to_script + script = outline_to_script(self.canvas._nodes, self._format_id) + self.generate_script_requested.emit(script) + def _add_node(self): nodes = list(self.canvas._nodes) new_id = f"node_{len(nodes)+1}" x = 40 + (len(nodes) % 4) * 210 y = 40 + (len(nodes) // 4) * 120 - nodes.append({"id": new_id, "kind": "Scene", "label": f"Scene {len(nodes)+1}", "synopsis": "", "x": x, "y": y, "connections": []}) + if self._format_id.startswith("fountain+comic"): + panel_count = sum(1 for n in nodes if n.get("kind") == "Panel") + 1 + nodes.append({"id": new_id, "kind": "Panel", "label": f"Panel {panel_count}", "synopsis": "", "x": x, "y": y, "connections": []}) + else: + nodes.append({"id": new_id, "kind": "Scene", "label": f"Scene {len(nodes)+1}", "synopsis": "", "x": x, "y": y, "connections": []}) self.canvas.set_nodes(nodes) def _zoom(self, delta: float): @@ -947,23 +1128,52 @@ def _relayout(self, direction: str): class OutlineCanvas(QWidget): - """Pannable, zoomable node graph canvas.""" + """Pannable, zoomable node graph canvas with draggable, editable, linkable nodes.""" + + NODE_SIZES = { + "ActBreak": (220, 44), + "Scene": (190, 110), + "Transition": (110, 36), + "Note": (160, 80), + "Page": (240, 48), + "Panel": (190, 100), + "Spread": (300, 48), + } def __init__(self, parent=None): super().__init__(parent) self.setMinimumSize(400, 300) + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self._nodes = [] self._zoom = 1.0 self._pan_x = 0.0 self._pan_y = 0.0 - self._dragging = False + self._panning = False + self._dragging_node = None + self._drag_offset_x = 0.0 + self._drag_offset_y = 0.0 self._last_pos = None self._dot_spacing = 22 + self._selected_node = None + self._linking_from = None + self._comic_mode = False def set_nodes(self, nodes: list): self._nodes = nodes self.update() + def _to_canvas(self, pos): + return (pos.x() - self._pan_x) / self._zoom, (pos.y() - self._pan_y) / self._zoom + + def _node_at(self, cx, cy): + for node in reversed(self._nodes): + nx, ny = node.get("x", 0), node.get("y", 0) + kind = node.get("kind", "Scene") + w, h = self.NODE_SIZES.get(kind, (190, 92)) + if nx <= cx <= nx + w and ny <= cy <= ny + h: + return node + return None + def paintEvent(self, _event): p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) @@ -985,129 +1195,328 @@ def paintEvent(self, _event): p.translate(self._pan_x, self._pan_y) p.scale(self._zoom, self._zoom) - for node in self._nodes: - self._draw_node(p, node, t) - for node in self._nodes: for conn_id in node.get("connections", []): target = next((n for n in self._nodes if n["id"] == conn_id), None) if target: self._draw_connector(p, node, target, t) + if self._linking_from: + p.setPen(QPen(QColor(t.accent), 2, Qt.PenStyle.DashLine)) + src = self._linking_from + sx = src.get("x", 0) + self.NODE_SIZES.get(src.get("kind", "Scene"), (190, 92))[0] // 2 + sy = src.get("y", 0) + self.NODE_SIZES.get(src.get("kind", "Scene"), (190, 92))[1] // 2 + if self._last_pos: + mx, my = self._to_canvas(self._last_pos) + p.drawLine(int(sx), int(sy), int(mx), int(my)) + + for node in self._nodes: + self._draw_node(p, node, t, selected=(node is self._selected_node)) + p.restore() p.end() - def _draw_node(self, p: QPainter, node: dict, t): + def _draw_node(self, p: QPainter, node: dict, t, selected=False): x, y = node.get("x", 0), node.get("y", 0) kind = node.get("kind", "Scene") + if selected: + nw, nh = self.NODE_SIZES.get(kind, (190, 92)) + p.setPen(QPen(QColor(t.accent), 2)) + p.setBrush(Qt.BrushStyle.NoBrush) + p.drawRoundedRect(int(x) - 3, int(y) - 3, nw + 6, nh + 6, 8, 8) + if kind == "ActBreak": w, h = 220, 44 p.setBrush(QColor(t.accent_900)) p.setPen(QPen(QColor(t.accent_700), 1)) p.drawRoundedRect(int(x), int(y), w, h, 6, 6) p.setPen(QPen(QColor(t.text), 1)) + font = p.font() + font.setPointSize(11) + font.setBold(True) + p.setFont(font) p.drawText(int(x) + 12, int(y) + 28, node.get("label", "")) + font.setBold(False) + p.setFont(font) elif kind == "Scene": - w, h = 190, 92 + w, h = 190, 110 p.setBrush(QColor(t.surface)) p.setPen(QPen(QColor(t.neutral_800), 1)) p.drawRoundedRect(int(x), int(y), w, h, 6, 6) p.setPen(QPen(QColor(t.text), 1)) font = p.font() - font.setPointSize(10) + font.setPointSize(11) font.setBold(True) p.setFont(font) - p.drawText(int(x) + 8, int(y) + 20, node.get("label", "")) + p.drawText(int(x) + 10, int(y) + 22, node.get("label", "")) font.setBold(False) font.setPointSize(9) p.setFont(font) p.setPen(QPen(QColor(t.neutral_400), 1)) synopsis = node.get("synopsis", "") if synopsis: - p.drawText(int(x) + 8, int(y) + 40, int(w) - 16, 40, Qt.TextFlag.TextWordWrap, synopsis[:80]) + p.drawText(int(x) + 10, int(y) + 38, int(w) - 20, 60, Qt.TextFlag.TextWordWrap, synopsis[:120]) elif kind == "Transition": w, h = 110, 36 p.setBrush(QColor(t.surface)) p.setPen(QPen(QColor(t.neutral_700), 1)) p.drawRoundedRect(int(x), int(y), w, h, 18, 18) p.setPen(QPen(QColor(t.neutral_400), 1)) + font = p.font() + font.setPointSize(9) + p.setFont(font) p.drawText(int(x) + 12, int(y) + 23, node.get("label", "")) elif kind == "Note": - w, h = 160, 60 + w, h = 160, 80 pen = QPen(QColor(t.neutral_600), 1, Qt.PenStyle.DashLine) p.setPen(pen) p.setBrush(QColor(t.bg)) p.drawRoundedRect(int(x), int(y), w, h, 6, 6) p.setPen(QPen(QColor(t.neutral_400), 1)) font = p.font() + font.setPointSize(9) font.setItalic(True) p.setFont(font) - p.drawText(int(x) + 8, int(y) + 30, node.get("label", "")) + text = node.get("label", "") + p.drawText(int(x) + 8, int(y) + 14, int(w) - 16, int(h) - 18, Qt.TextFlag.TextWordWrap, text[:160]) font.setItalic(False) p.setFont(font) + elif kind == "Page": + w, h = 240, 48 + p.setBrush(QColor(t.accent_900)) + p.setPen(QPen(QColor(t.accent), 2)) + p.drawRoundedRect(int(x), int(y), w, h, 6, 6) + p.setPen(QPen(QColor(t.text), 1)) + font = p.font() + font.setPointSize(12) + font.setBold(True) + p.setFont(font) + p.drawText(int(x) + 14, int(y) + 30, node.get("label", "")) + font.setBold(False) + p.setFont(font) + elif kind == "Panel": + w, h = 190, 100 + p.setBrush(QColor(t.surface)) + p.setPen(QPen(QColor(t.accent_700), 1)) + p.drawRoundedRect(int(x), int(y), w, h, 6, 6) + p.setPen(QPen(QColor(t.accent), 1)) + font = p.font() + font.setPointSize(10) + font.setBold(True) + p.setFont(font) + p.drawText(int(x) + 10, int(y) + 20, node.get("label", "")) + font.setBold(False) + font.setPointSize(9) + p.setFont(font) + p.setPen(QPen(QColor(t.neutral_400), 1)) + synopsis = node.get("synopsis", "") + if synopsis: + p.drawText(int(x) + 10, int(y) + 34, int(w) - 20, 56, Qt.TextFlag.TextWordWrap, synopsis[:120]) + elif kind == "Spread": + w, h = 300, 48 + p.setBrush(QColor(t.accent_900)) + p.setPen(QPen(QColor(t.accent), 2)) + p.drawRoundedRect(int(x), int(y), w, h, 6, 6) + inner_x = int(x) + w // 2 + p.setPen(QPen(QColor(t.accent_700), 1, Qt.PenStyle.DashLine)) + p.drawLine(inner_x, int(y) + 6, inner_x, int(y) + h - 6) + p.setPen(QPen(QColor(t.text), 1)) + font = p.font() + font.setPointSize(12) + font.setBold(True) + p.setFont(font) + p.drawText(int(x) + 14, int(y) + 30, node.get("label", "")) + font.setBold(False) + p.setFont(font) def _draw_connector(self, p: QPainter, src: dict, dst: dict, t): - x1 = src.get("x", 0) + 190 - y1 = src.get("y", 0) + 46 + sk = src.get("kind", "Scene") + dk = dst.get("kind", "Scene") + sw, sh = self.NODE_SIZES.get(sk, (190, 92)) + dw, dh = self.NODE_SIZES.get(dk, (190, 92)) + x1 = src.get("x", 0) + sw + y1 = src.get("y", 0) + sh // 2 x2 = dst.get("x", 0) - y2 = dst.get("y", 0) + 46 + y2 = dst.get("y", 0) + dh // 2 p.setPen(QPen(QColor(t.neutral_600), 1.5)) p.drawLine(int(x1), int(y1), int(x2), int(y2)) + ax, ay = int(x2), int(y2) + p.setBrush(QColor(t.neutral_600)) + p.setPen(Qt.PenStyle.NoPen) + p.drawEllipse(ax - 4, ay - 4, 8, 8) def wheelEvent(self, event): delta = event.angleDelta().y() factor = 1.1 if delta > 0 else 0.9 - new_zoom = max(0.5, min(1.6, self._zoom * factor)) + new_zoom = max(0.3, min(2.5, self._zoom * factor)) self._zoom = new_zoom self.update() def mousePressEvent(self, event): + cx, cy = self._to_canvas(event.position()) + hit = self._node_at(cx, cy) + if event.button() == Qt.MouseButton.LeftButton: - self._dragging = True - self._last_pos = event.position() - self.setCursor(Qt.CursorShape.ClosedHandCursor) + if self._linking_from: + if hit and hit is not self._linking_from: + conns = self._linking_from.setdefault("connections", []) + if hit["id"] not in conns: + conns.append(hit["id"]) + self._linking_from = None + self.setCursor(Qt.CursorShape.OpenHandCursor) + self.update() + return + + if hit: + self._dragging_node = hit + self._drag_offset_x = cx - hit.get("x", 0) + self._drag_offset_y = cy - hit.get("y", 0) + self._selected_node = hit + self.setCursor(Qt.CursorShape.ClosedHandCursor) + else: + self._selected_node = None + self._panning = True + self._last_pos = event.position() + self.setCursor(Qt.CursorShape.ClosedHandCursor) + self.update() + elif event.button() == Qt.MouseButton.RightButton: - self._show_context_menu(event.position(), event.globalPosition().toPoint()) + if hit: + self._show_node_context_menu(hit, event.globalPosition().toPoint()) + else: + self._show_context_menu(event.position(), event.globalPosition().toPoint()) + + def mouseDoubleClickEvent(self, event): + if event.button() != Qt.MouseButton.LeftButton: + return + cx, cy = self._to_canvas(event.position()) + hit = self._node_at(cx, cy) + if hit: + self._edit_node(hit) def mouseReleaseEvent(self, event): - self._dragging = False + self._dragging_node = None + self._panning = False self.setCursor(Qt.CursorShape.OpenHandCursor) def mouseMoveEvent(self, event): - if self._dragging and self._last_pos: + if self._linking_from: + self._last_pos = event.position() + self.update() + return + if self._dragging_node: + cx, cy = self._to_canvas(event.position()) + self._dragging_node["x"] = cx - self._drag_offset_x + self._dragging_node["y"] = cy - self._drag_offset_y + self.update() + elif self._panning and self._last_pos: delta = event.position() - self._last_pos self._pan_x += delta.x() self._pan_y += delta.y() self._last_pos = event.position() self.update() - def _show_context_menu(self, local_pos, global_pos): - canvas_x = (local_pos.x() - self._pan_x) / self._zoom - canvas_y = (local_pos.y() - self._pan_y) / self._zoom + def keyPressEvent(self, event): + if event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace) and self._selected_node: + node_id = self._selected_node["id"] + self._nodes.remove(self._selected_node) + for n in self._nodes: + conns = n.get("connections", []) + if node_id in conns: + conns.remove(node_id) + self._selected_node = None + self.update() + else: + super().keyPressEvent(event) + def _edit_node(self, node): + kind = node.get("kind", "Scene") + if kind in ("Scene", "Note", "Panel"): + label, ok = QInputDialog.getText(self, f"Edit {kind}", "Title:", text=node.get("label", "")) + if ok and label: + node["label"] = label + synopsis, ok2 = QInputDialog.getMultiLineText( + self, f"Edit {kind}", "Synopsis / Notes:", node.get("synopsis", ""), + ) + if ok2: + node["synopsis"] = synopsis + else: + label, ok = QInputDialog.getText(self, f"Edit {kind}", "Label:", text=node.get("label", "")) + if ok and label: + node["label"] = label + self.update() + + def _show_node_context_menu(self, node, global_pos): menu = QMenu(self) - add_scene = menu.addAction("Add Scene") - add_act = menu.addAction("Add Act Break") - add_note = menu.addAction("Add Note") - add_transition = menu.addAction("Add Transition") + edit_action = menu.addAction("Edit...") + link_action = menu.addAction("Link to...") + menu.addSeparator() + delete_action = menu.addAction("Delete") action = menu.exec(global_pos) - if action == add_scene: - self._add_node_at(canvas_x, canvas_y, "Scene", f"Scene {len(self._nodes) + 1}") - elif action == add_act: - self._add_node_at(canvas_x, canvas_y, "ActBreak", f"Act {len(self._nodes) + 1}") - elif action == add_note: - self._add_node_at(canvas_x, canvas_y, "Note", "Note") - elif action == add_transition: - self._add_node_at(canvas_x, canvas_y, "Transition", "Transition") + if action == edit_action: + self._edit_node(node) + elif action == link_action: + self._linking_from = node + self.setCursor(Qt.CursorShape.CrossCursor) + elif action == delete_action: + node_id = node["id"] + self._nodes.remove(node) + for n in self._nodes: + conns = n.get("connections", []) + if node_id in conns: + conns.remove(node_id) + if self._selected_node is node: + self._selected_node = None + self.update() + + def _show_context_menu(self, local_pos, global_pos): + canvas_x, canvas_y = self._to_canvas(local_pos) + + menu = QMenu(self) + if self._comic_mode: + add_page = menu.addAction("Add Page") + add_panel = menu.addAction("Add Panel") + add_spread = menu.addAction("Add Spread") + add_note = menu.addAction("Add Note") + action = menu.exec(global_pos) + page_count = sum(1 for n in self._nodes if n.get("kind") == "Page") + spread_count = sum(1 for n in self._nodes if n.get("kind") == "Spread") + next_page = page_count + spread_count * 2 + 1 + panel_count = sum(1 for n in self._nodes if n.get("kind") == "Panel") + 1 + if action == add_page: + self._add_node_at(canvas_x, canvas_y, "Page", f"PAGE {next_page}") + elif action == add_panel: + self._add_node_at(canvas_x, canvas_y, "Panel", f"Panel {panel_count}") + elif action == add_spread: + self._add_node_at(canvas_x, canvas_y, "Spread", f"SPREAD (Pages {next_page}-{next_page + 1})") + elif action == add_note: + self._add_node_at(canvas_x, canvas_y, "Note", "Note") + else: + add_scene = menu.addAction("Add Scene") + add_act = menu.addAction("Add Act Break") + add_note = menu.addAction("Add Note") + add_transition = menu.addAction("Add Transition") + action = menu.exec(global_pos) + if action == add_scene: + self._add_node_at(canvas_x, canvas_y, "Scene", f"Scene {len(self._nodes) + 1}") + elif action == add_act: + self._add_node_at(canvas_x, canvas_y, "ActBreak", f"Act {len(self._nodes) + 1}") + elif action == add_note: + self._add_node_at(canvas_x, canvas_y, "Note", "Note") + elif action == add_transition: + self._add_node_at(canvas_x, canvas_y, "Transition", "Transition") def _add_node_at(self, x: float, y: float, kind: str, label: str): - new_id = f"node_{len(self._nodes) + 1}" - self._nodes.append({ + import time + new_id = f"node_{int(time.time() * 1000)}_{len(self._nodes)}" + node = { "id": new_id, "kind": kind, "label": label, "synopsis": "", "x": x, "y": y, "connections": [], - }) + } + self._nodes.append(node) + self._selected_node = node self.update() @@ -1142,7 +1551,6 @@ def __init__(self, bookmark_manager: BookmarkManager, parent=None): def refresh(self): self.bookmark_list.clear() - t = theme.current() for bm in self._manager.get_all(): item = QListWidgetItem(f"● {bm.name} L{bm.line}") item.setData(Qt.ItemDataRole.UserRole, bm.line) @@ -1199,8 +1607,8 @@ def __init__(self, parent=None): center_layout.addWidget(self.presence_bar) self.page_frame = QFrame() - self.page_frame.setStyleSheet(f"background: {theme.current().surface}; border-radius: 4px;") - self.page_frame.setMaximumWidth(680) + self.page_frame.setObjectName("pageFrame") + self.page_frame.setMaximumWidth(816) self.page_frame.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) page_layout = QVBoxLayout(self.page_frame) page_layout.setContentsMargins(0, 0, 0, 0) @@ -1229,7 +1637,7 @@ def __init__(self, parent=None): splitter.setStretchFactor(2, 1) splitter.setStretchFactor(3, 0) splitter.setStretchFactor(4, 0) - splitter.setSizes([216, 1, 600, 1, 264]) + splitter.setSizes([216, 1, 816, 1, 264]) layout.addWidget(splitter) @@ -1266,7 +1674,7 @@ def __init__(self, parent=None): header.addWidget(title) header.addStretch() self.count_label = QLabel("0 flags") - self.count_label.setStyleSheet(f"color: {theme.current().neutral_500}; font-size: 12px;") + self.count_label.setObjectName("dashMutedSmall") header.addWidget(self.count_label) rail_layout.addLayout(header) @@ -1295,7 +1703,7 @@ def _on_issue_selected(self, row): if row < 0 or row >= len(self._issues): return line_num = self._issues[row]["line"] - block = self.script_view.document().findBlockByLineNumber(line_num - 1) + block = self.script_view.document().findBlockByNumber(line_num - 1) if block.isValid(): cursor = self.script_view.textCursor() cursor.setPosition(block.position()) @@ -1323,7 +1731,8 @@ def _check_script(text: str) -> list[dict]: issues.append({"line": i, "message": "Unclosed parenthetical"}) if stripped.endswith(")") and not stripped.startswith("(") and stripped.count("(") == 0: issues.append({"line": i, "message": "Orphan closing parenthesis"}) - if stripped.isupper() and len(stripped) > 1 and stripped[0].isalpha(): + if (stripped.isupper() and len(stripped) > 1 and stripped[0].isalpha() + and not re.search(r'[.!?]$', re.sub(r'\([^)]*\)\s*$', '', stripped))): if i < len(lines): next_line = lines[i].strip() if i < len(lines) else "" if not next_line: @@ -1377,16 +1786,13 @@ def __init__(self, parent=None): center_layout.addLayout(top) self.format_label = QLabel("Paginated against fountain/core · US Letter · Courier Prime 12pt") - self.format_label.setStyleSheet(f"color: {theme.current().neutral_500}; font-size: 13px;") + self.format_label.setObjectName("dashMuted") center_layout.addWidget(self.format_label) self.preview_area = QPlainTextEdit() self.preview_area.setReadOnly(True) self.preview_area.setFont(QFont("Courier Prime", 12)) - self.preview_area.setStyleSheet( - f"background: {theme.current().surface}; border-radius: 8px; " - f"min-height: 400px; color: {theme.current().text}; padding: 24px;" - ) + self.preview_area.setObjectName("deliverPreview") self.preview_area.setPlaceholderText("Script preview will appear when a project is loaded") center_layout.addWidget(self.preview_area, 1) center_layout.addStretch() @@ -1459,9 +1865,7 @@ def __init__(self, parent=None): rail_layout.addLayout(rev_btn_row) self.revision_history_label = QLabel("No revisions yet") - self.revision_history_label.setStyleSheet( - f"color: {theme.current().neutral_500}; font-size: 12px;" - ) + self.revision_history_label.setObjectName("dashMutedSmall") self.revision_history_label.setWordWrap(True) rail_layout.addWidget(self.revision_history_label) @@ -1479,9 +1883,7 @@ def __init__(self, parent=None): self.contest_result_label = QLabel() self.contest_result_label.setWordWrap(True) - self.contest_result_label.setStyleSheet( - f"color: {theme.current().neutral_500}; font-size: 12px;" - ) + self.contest_result_label.setObjectName("dashMutedSmall") rail_layout.addWidget(self.contest_result_label) rail_layout.addStretch() @@ -1570,15 +1972,13 @@ def _update_preview(self): if not script: return if self._view_mode == "cover": - lines = script.split("\n") - title_page = [] - for line in lines: - stripped = line.strip() - if stripped.startswith(("Title:", "Credit:", "Author:", "Source:", "Draft date:", "Contact:", "Copyright:")): - title_page.append(stripped) - elif stripped.startswith(("INT.", "EXT.", "EST.")) or (title_page and not stripped and len(title_page) > 1): - break - self.preview_area.setPlainText("\n".join(title_page) if title_page else "No title page metadata found") + from ecrit.export.epub_export import _parse_title_page + fields = _parse_title_page(script) + if fields: + title_page = [f"{k.title()}: {v}" for k, v in fields.items()] + self.preview_area.setPlainText("\n".join(title_page)) + else: + self.preview_area.setPlainText("No title page metadata found") else: self.preview_area.setPlainText(script) @@ -1626,6 +2026,7 @@ def __init__(self, parent=None): super().__init__(parent) self._current_phase = "Manuscript" self._total_pages = 1 + self._format_id = "fountain/core" self._build_ui() def _build_ui(self): @@ -1699,6 +2100,8 @@ def _build_ui(self): self.manuscript.editor.text_cut.connect(self.scratchpad.add_text) self.manuscript.editor.cursor_info_changed.connect(self._on_cursor_info_changed) self.manuscript.scene_nav.scene_selected.connect(self._on_scene_selected) + self.outline.generate_script_requested.connect(self._on_generate_from_outline) + self.outline.import_script_requested.connect(self._on_import_to_outline) def switch_phase(self, phase: str): self._current_phase = phase @@ -1758,6 +2161,10 @@ def _on_scene_selected(self, row: int): def load_project(self, project_data: dict): script = project_data.get("script", "") + format_id = project_data.get("meta", {}).get("format_id", "fountain/core") + self._format_id = format_id + self.manuscript.editor.set_format_id(format_id) + self.outline.set_format_id(format_id) self.manuscript.editor.setPlainText(script) self.manuscript.scene_nav.set_script(script) @@ -1777,12 +2184,45 @@ def load_project(self, project_data: dict): dialect=project_data.get("meta", {}).get("format_id", "fountain/core"), ) - plans = project_data.get("plan_documents", []) - if plans: - self.plan.editor.setPlainText(plans[0].get("content", "")) + outline_nodes = project_data.get("outline_nodes", []) + if outline_nodes: + self.outline.canvas.set_nodes(outline_nodes) + else: + self.outline.canvas.set_nodes([]) + + plan_documents = project_data.get("plan_documents", []) + if plan_documents: + self.plan._doc_content.clear() + self.plan.doc_list.clear() + for doc in plan_documents: + name = doc.get("name", "Untitled") + content = doc.get("content", "") + self.plan.doc_list.addItem(name) + self.plan._doc_content[name] = content + self.plan.doc_list.setCurrentRow(0) + else: + self.plan._doc_content = { + "Logline": "", "Synopsis": "", + "One-page pitch": "", "Treatment": "", + } + self.plan.doc_list.clear() + for name in self.plan._doc_content: + self.plan.doc_list.addItem(name) + self.plan.doc_list.setCurrentRow(0) self.proofread.script_view.setPlainText(script) + def _on_generate_from_outline(self, script: str): + self.manuscript.editor.setPlainText(script) + self.switch_phase("Manuscript") + + def _on_import_to_outline(self): + from ecrit.screenplay.outline_sync import script_to_outline + script = self.manuscript.editor.toPlainText() + format_id = getattr(self, "_format_id", "fountain/core") + nodes = script_to_outline(script, format_id) + self.outline.canvas.set_nodes(nodes) + def _toggle_find(self): self.find_bar.toggle() @@ -1791,6 +2231,7 @@ def _on_find_closed(self): def _find_in_direction(self, text, case_sensitive, regex, backward=False): if not text: + self.find_bar.set_match_count(0, 0) return editor = self.manuscript.editor if regex: @@ -1801,6 +2242,7 @@ def _find_in_direction(self, text, case_sensitive, regex, backward=False): qre = QRegularExpression(text) qre.setPatternOptions(opts) if not qre.isValid(): + self.find_bar.set_match_count(0, 0) return flags = QTextDocument.FindFlag.FindBackward if backward else QTextDocument.FindFlag(0) found = editor.find(qre, flags) @@ -1821,6 +2263,55 @@ def _find_in_direction(self, text, case_sensitive, regex, backward=False): cursor.movePosition(wrap_to) editor.setTextCursor(cursor) editor.find(text, flags) + self._update_match_count(text, case_sensitive, regex) + + def _update_match_count(self, text, case_sensitive, regex): + if not text: + self.find_bar.set_match_count(0, 0) + return + content = self.manuscript.editor.toPlainText() + if regex: + import re + flags = 0 if case_sensitive else re.IGNORECASE + try: + matches = list(re.finditer(text, content, flags)) + except re.error: + self.find_bar.set_match_count(0, 0) + return + else: + if case_sensitive: + search_content = content + search_text = text + else: + search_content = content.lower() + search_text = text.lower() + matches = [] + start = 0 + while True: + idx = search_content.find(search_text, start) + if idx == -1: + break + matches.append(idx) + start = idx + 1 + total = len(matches) + if total == 0: + self.find_bar.set_match_count(0, 0) + return + cursor_pos = self.manuscript.editor.textCursor().position() + current = 1 + for i, m in enumerate(matches): + if isinstance(m, int): + pos = m + match_len = len(text) + else: + pos = m.start() + match_len = m.end() - m.start() + if pos >= cursor_pos - match_len: + current = i + 1 + break + else: + current = total + self.find_bar.set_match_count(current, total) def _do_find(self, text, case_sensitive, regex): self._find_in_direction(text, case_sensitive, regex, backward=False) @@ -1829,19 +2320,65 @@ def _do_find_prev(self, text, case_sensitive, regex): self._find_in_direction(text, case_sensitive, regex, backward=True) def _do_replace(self, find_text, replace_text): + case_sensitive = self.find_bar.case_check.isChecked() + use_regex = self.find_bar.regex_check.isChecked() editor = self.manuscript.editor cursor = editor.textCursor() - if cursor.hasSelection() and cursor.selectedText() == find_text: - cursor.insertText(replace_text) - self._do_find(find_text, False, False) + if cursor.hasSelection(): + selected = cursor.selectedText().replace("
", "\n") + if use_regex: + import re + flags = 0 if case_sensitive else re.IGNORECASE + try: + if re.fullmatch(find_text, selected, flags): + new_text = re.sub(find_text, replace_text, selected, flags=flags) + cursor.insertText(new_text) + except re.error: + pass + else: + match = selected == find_text if case_sensitive else selected.lower() == find_text.lower() + if match: + cursor.insertText(replace_text) + self._do_find(find_text, case_sensitive, use_regex) def _do_replace_all(self, find_text, replace_text): if not find_text: return + case_sensitive = self.find_bar.case_check.isChecked() + use_regex = self.find_bar.regex_check.isChecked() editor = self.manuscript.editor - content = editor.toPlainText() - count = content.count(find_text) - if count > 0: - new_content = content.replace(find_text, replace_text) - editor.setPlainText(new_content) - self.find_bar.set_match_count(0, 0) + from PySide6.QtGui import QTextDocument, QTextCursor + doc = editor.document() + flags = QTextDocument.FindFlag(0) + if case_sensitive: + flags |= QTextDocument.FindFlag.FindCaseSensitively + search_pattern = find_text + if use_regex: + import re + try: + re.compile(find_text) + except re.error: + return + from PySide6.QtCore import QRegularExpression + qre = QRegularExpression(find_text) + if not case_sensitive: + qre.setPatternOptions(QRegularExpression.PatternOption.CaseInsensitiveOption) + search_pattern = qre + cursor = editor.textCursor() + cursor.beginEditBlock() + search_cursor = QTextCursor(doc) + while True: + found = doc.find(search_pattern, search_cursor, flags) + if found.isNull() or not found.hasSelection(): + break + if use_regex: + import re + re_flags = 0 if case_sensitive else re.IGNORECASE + matched = found.selectedText().replace("
", "\n") + replacement = re.sub(find_text, replace_text, matched, count=1, flags=re_flags) + else: + replacement = replace_text + found.insertText(replacement) + search_cursor = found + cursor.endEditBlock() + self.find_bar.set_match_count(0, 0) diff --git a/ecrit/ui/screens/new_project.py b/ecrit/ui/screens/new_project.py index ca0f157..2637be6 100644 --- a/ecrit/ui/screens/new_project.py +++ b/ecrit/ui/screens/new_project.py @@ -13,7 +13,7 @@ import json _DIALECTS = json.loads(ecrit_core.get_dialects()) _CATEGORIES = json.loads(ecrit_core.get_format_categories()) -except Exception: +except (ImportError, ValueError, TypeError, OSError): _DIALECTS = [] _CATEGORIES = {} @@ -21,7 +21,7 @@ ("Feature Film", ["fountain/core", "fountain+shooting", "fountain+studio47", "fountain+a4"]), ("Television", ["fountain+multicam", "fountain+bbc-screen", "fountain+bbc-scene"]), ("Audio & Radio", ["fountain+radio-scene", "fountain+radio-cue", "fountain+audio-us"]), - ("Comics", ["fountain+comic-full", "fountain+comic-plot", "fountain+comic-lean", "fountain+comic-gn"]), + ("Comics & Graphic Novels", ["fountain+comic-dc", "fountain+comic-dh", "fountain+comic-indie"]), ("Stage", ["fountain+stage-us", "fountain+stage-uk"]), ("Interactive", ["fountain+branch", "fountain+barks"]), ] @@ -37,10 +37,9 @@ "fountain+radio-scene": "Radio Scene", "fountain+radio-cue": "Radio Cue", "fountain+audio-us": "Audio US", - "fountain+comic-full": "Comic Full", - "fountain+comic-plot": "Comic Plot", - "fountain+comic-lean": "Comic Lean", - "fountain+comic-gn": "Graphic Novel", + "fountain+comic-dc": "DC / Marvel", + "fountain+comic-dh": "Dark Horse", + "fountain+comic-indie": "Image / Indie", "fountain+stage-us": "Stage US", "fountain+stage-uk": "Stage UK", "fountain+branch": "Branch", @@ -61,18 +60,13 @@ def __init__(self, format_id: str, parent=None): layout = QVBoxLayout(self) layout.setSpacing(4) - t = theme.current() - label = FORMAT_LABELS.get(format_id, format_id) name = QLabel(label) name.setStyleSheet("font-size: 14px; font-weight: 500; background: transparent;") layout.addWidget(name) fid = QLabel(format_id) - fid.setStyleSheet( - f"font-family: ui-monospace, Menlo, monospace; font-size: 11px; " - f"color: {t.neutral_500}; background: transparent;" - ) + fid.setObjectName("dashMonoMuted") layout.addWidget(fid) def set_selected(self, selected: bool): @@ -102,8 +96,6 @@ def __init__(self, parent=None): self._build_ui() def _build_ui(self): - t = theme.current() - scroll = QScrollArea(self) scroll.setWidgetResizable(True) scroll.setFrameShape(QFrame.Shape.NoFrame) @@ -123,7 +115,7 @@ def _build_ui(self): inner_layout.addWidget(title) subtitle = QLabel("Set up your screenplay, then start writing.") - subtitle.setStyleSheet(f"color: {t.neutral_500}; font-size: 14px;") + subtitle.setObjectName("dashMuted") inner_layout.addWidget(subtitle) inner_layout.addSpacing(8) diff --git a/ecrit/ui/styles/stylesheet.py b/ecrit/ui/styles/stylesheet.py index 75a8156..335ae0f 100644 --- a/ecrit/ui/styles/stylesheet.py +++ b/ecrit/ui/styles/stylesheet.py @@ -14,11 +14,16 @@ def generate(t: ThemeTokens) -> str: background-color: {t.bg}; color: {t.text}; font-family: 'Liberation Serif', 'Tinos', 'Times New Roman', serif; - font-size: 13px; + font-size: 14px; }} QWidget:focus {{ outline: none; }} +QPushButton:focus, QComboBox:focus, QCheckBox:focus, QSpinBox:focus, +QListWidget:focus, QTabBar:focus {{ + border: 2px solid {t.accent}; + border-radius: {t.radius_sm}px; +}} /* ═══ Title Bar ═══ */ #titleBar {{ @@ -31,12 +36,12 @@ def generate(t: ThemeTokens) -> str: background: transparent; }} #wordmark {{ - font-size: 16px; + font-size: 18px; font-weight: 500; color: {t.text}; }} #titleContext {{ - font-size: 13px; + font-size: 14px; color: {t.neutral_500}; }} @@ -45,8 +50,8 @@ def generate(t: ThemeTokens) -> str: background: transparent; border: none; color: {t.neutral_500}; - font-size: 13px; - padding: 8px 12px; + font-size: 14px; + padding: 8px 14px; font-weight: 500; }} #phaseTabs QPushButton:hover {{ @@ -165,21 +170,21 @@ def generate(t: ThemeTokens) -> str: QLabel#tagNeutral {{ background: {t.neutral_800 if t.name == "nocturne" else t.neutral_100}; color: {t.neutral_100 if t.name == "nocturne" else t.neutral_800}; - font-size: 11px; + font-size: 12px; padding: 3px 10px; border-radius: {int(t.radius_md * 0.75)}px; }} QLabel#tagAccent {{ background: {t.accent_800 if t.name == "nocturne" else t.accent_100}; color: {t.accent_100 if t.name == "nocturne" else t.accent_800}; - font-size: 11px; + font-size: 12px; padding: 3px 10px; border-radius: {int(t.radius_md * 0.75)}px; }} /* ═══ Kicker Labels ═══ */ QLabel#kicker {{ - font-size: 12px; + font-size: 13px; letter-spacing: 1px; color: {t.neutral_500}; text-transform: uppercase; @@ -192,13 +197,13 @@ def generate(t: ThemeTokens) -> str: border-top: 1px solid {t.divider}; min-height: 28px; max-height: 28px; - font-size: 12px; + font-size: 13px; color: {t.neutral_500}; }} #statusBar QLabel {{ background: transparent; color: {t.neutral_500}; - font-size: 12px; + font-size: 13px; }} /* ═══ Rails (side panels) ═══ */ @@ -218,7 +223,7 @@ def generate(t: ThemeTokens) -> str: border: none; outline: none; font-family: 'Courier Prime', Courier, monospace; - font-size: 12px; + font-size: 13px; }} QListWidget::item {{ padding: 6px 12px; @@ -249,7 +254,7 @@ def generate(t: ThemeTokens) -> str: background: transparent; color: {t.neutral_500}; padding: 10px 16px; - font-size: 13.5px; + font-size: 14px; border: none; border-radius: {t.radius_sm}px; }} @@ -314,7 +319,7 @@ def generate(t: ThemeTokens) -> str: border: 1px solid {t.neutral_700}; border-radius: {t.radius_sm}px; padding: 4px 8px; - font-size: 12px; + font-size: 13px; }} /* ═══ Menu ═══ */ @@ -338,17 +343,17 @@ def generate(t: ThemeTokens) -> str: margin: 4px 8px; }} -/* ═══ Home Button ═══ */ +/* ═══ Home Button (floating, bottom-right) ═══ */ #homeBtn {{ background: {t.surface}; border: 1px solid {t.neutral_800 if t.name == "nocturne" else t.divider}; - border-radius: {t.radius_sm}px; - width: 34px; - height: 34px; + border-radius: {t.radius_md}px; + font-size: 18px; padding: 0; }} #homeBtn:hover {{ border-color: {t.accent}; + background: {t.accent_900 if t.name == "nocturne" else t.accent_100}; }} /* ═══ Script Editor ═══ */ @@ -357,7 +362,7 @@ def generate(t: ThemeTokens) -> str: color: {t.text}; border: none; font-family: 'Courier Prime', Courier, monospace; - font-size: 15px; + font-size: 16px; line-height: 1.6; padding: 40px; selection-background-color: rgba({_hex_to_rgb_str(t.accent)},0.25); @@ -369,10 +374,168 @@ def generate(t: ThemeTokens) -> str: color: {t.text}; border: none; font-family: 'Liberation Serif', 'Tinos', 'Times New Roman', serif; - font-size: 16px; + font-size: 17px; padding: 20px; selection-background-color: rgba({_hex_to_rgb_str(t.accent)},0.25); }} + +/* ═══ Dashboard ═══ */ +#dashMuted {{ + color: {t.neutral_500}; + font-size: 13px; + background: transparent; +}} +#dashHeroTitle {{ + font-size: 26px; + font-weight: 500; + background: transparent; +}} +#dashExcerpt {{ + font-family: 'Courier Prime', Courier, monospace; + font-size: 13px; + color: {t.neutral_400}; + background: transparent; + padding: 12px 0; +}} +#dashStatValue {{ + font-family: ui-monospace, Menlo, monospace; + font-size: 20px; + background: transparent; +}} +#dashMonoMuted {{ + font-family: ui-monospace, Menlo, monospace; + font-size: 11px; + color: {t.neutral_500}; + background: transparent; +}} +#dashCardTitle {{ + font-size: 15px; + font-weight: 500; + background: transparent; +}} +#dashMutedSmall {{ + color: {t.neutral_500}; + font-size: 12.5px; + background: transparent; +}} + +/* ═══ Command Palette ═══ */ +#cmdPalette {{ + background: {t.surface}; + border: 1px solid {t.neutral_700}; + border-radius: {t.radius_lg}px; +}} +#cmdPaletteHint {{ + color: {t.neutral_500}; + font-size: 11px; + background: transparent; +}} + +/* ═══ Marketplace Details ═══ */ +#detailsPane {{ + background: {t.surface}; + border-top: 1px solid {t.neutral_700}; +}} + +/* ═══ Editor Frames ═══ */ +#pageFrame {{ + background: {t.surface}; + border-radius: 4px; +}} +#deliverPreview {{ + background: {t.surface}; + border-radius: 8px; + min-height: 400px; + color: {t.text}; + padding: 24px; + border: none; +}} + +/* ═══ Collaboration ═══ */ +#collabFrame {{ + background: {t.surface}; + border-radius: 8px; + padding: 12px; +}} +#collabTokenDisplay {{ + font-family: ui-monospace, Menlo, monospace; + font-size: 11px; + background: {t.bg}; + border: 1px solid {t.neutral_700}; + border-radius: 4px; +}} + +/* ═══ Form Groups (Settings) ═══ */ +#formGroup {{ + background: {t.neutral_100 if t.name == "organic" else t.neutral_900}; + border-radius: 8px; +}} +#formGroup QLabel {{ + background: transparent; +}} + +/* ═══ Keyboard Shortcut Keys ═══ */ +#kbdKey {{ + font-family: ui-monospace, Menlo, monospace; + font-size: 12px; + background: {t.neutral_200 if t.name == "organic" else t.neutral_800}; + padding: 3px 8px; + border-radius: 4px; +}} + +/* ═══ Count Badge ═══ */ +#countBadge {{ + color: {t.neutral_400}; + font-size: 13px; + background: transparent; +}} + +/* ═══ Logline Builder ═══ */ +#loglinePreview {{ + background: {t.neutral_200 if t.name == "organic" else t.neutral_800}; + border: 1px solid {t.neutral_300 if t.name == "organic" else t.neutral_700}; + border-radius: {t.radius_sm}px; + padding: 8px; + font-size: 13px; +}} + +/* ═══ Scratchpad ═══ */ +#scratchpadEditor {{ + font-family: 'Courier Prime', Courier, monospace; + font-size: 13px; + background: transparent; + border: 1px solid {t.divider}; + border-radius: 6px; + padding: 8px; +}} + +/* ═══ Draft Comparison ═══ */ +#diffView {{ + font-family: 'Courier Prime', Courier, monospace; + font-size: 13px; + background: {t.surface}; + border: 1px solid {t.divider}; + border-radius: 6px; +}} + +/* ═══ Moodboard Tiles ═══ */ +#moodboardTile {{ + background: {t.neutral_200 if t.name == "organic" else t.neutral_800}; + border: 2px dashed {t.neutral_300 if t.name == "organic" else t.neutral_700}; + border-radius: {t.radius_md}px; +}} +#moodboardTile QLabel {{ + color: {t.neutral_500}; + background: transparent; +}} + +/* ═══ Reading Mode ═══ */ +#readingMode {{ + background: {t.bg}; +}} +#readingMode QLabel {{ + background: transparent; +}} """ diff --git a/ecrit/ui/styles/theme.py b/ecrit/ui/styles/theme.py index ecf9457..dde65d4 100644 --- a/ecrit/ui/styles/theme.py +++ b/ecrit/ui/styles/theme.py @@ -154,9 +154,12 @@ def current() -> ThemeTokens: return _current_theme -def set_theme(theme: ThemeTokens): +def set_theme(theme): global _current_theme - _current_theme = theme + if isinstance(theme, str): + _current_theme = NOCTURNE if theme == "nocturne" else ORGANIC + else: + _current_theme = theme def toggle(): diff --git a/src-tauri/src/fountain/dialect.rs b/src-tauri/src/fountain/dialect.rs index e6b58d8..80b233f 100644 --- a/src-tauri/src/fountain/dialect.rs +++ b/src-tauri/src/fountain/dialect.rs @@ -110,40 +110,47 @@ pub fn builtin_dialects() -> Vec { extra_elements: vec![], }, Dialect { - id: "fountain+comic-full".into(), - name: "Full Script — DC House".into(), - description: "Full script comic format with page/panel descriptions.".into(), + id: "fountain+comic-dc".into(), + name: "DC / Marvel House Style".into(), + description: "Industry-standard full script — PAGE/PANEL hierarchy, numbered lettering elements, balloon-type parentheticals. Used by DC, Marvel, and most mainstream publishers.".into(), paper: PaperSize::USLetter, extra_elements: vec![ - ExtraElement { name: "Panel".into(), tag: "panel".into() }, + ExtraElement { name: "Page".into(), tag: "page_header".into() }, + ExtraElement { name: "Panel".into(), tag: "panel_header".into() }, ExtraElement { name: "Caption".into(), tag: "caption".into() }, ExtraElement { name: "SFX".into(), tag: "sfx".into() }, + ExtraElement { name: "Banner".into(), tag: "banner".into() }, + ExtraElement { name: "Voice Over".into(), tag: "voice_over".into() }, ], }, Dialect { - id: "fountain+comic-plot".into(), - name: "Marvel Method".into(), - description: "Plot-first comic format for artist collaboration.".into(), + id: "fountain+comic-dh".into(), + name: "Dark Horse Style".into(), + description: "Dark Horse house style — heavier panel descriptions, numbered captions and SFX, editorial notes supported. Matches Dark Horse submission guidelines.".into(), paper: PaperSize::USLetter, extra_elements: vec![ - ExtraElement { name: "Panel".into(), tag: "panel".into() }, + ExtraElement { name: "Page".into(), tag: "page_header".into() }, + ExtraElement { name: "Panel".into(), tag: "panel_header".into() }, + ExtraElement { name: "Caption".into(), tag: "caption".into() }, + ExtraElement { name: "SFX".into(), tag: "sfx".into() }, + ExtraElement { name: "Banner".into(), tag: "banner".into() }, + ExtraElement { name: "Editorial".into(), tag: "editorial".into() }, ], }, Dialect { - id: "fountain+comic-lean".into(), - name: "Panel-per-line".into(), - description: "Minimal comic format, one panel per line.".into(), - paper: PaperSize::USLetter, - extra_elements: vec![], - }, - Dialect { - id: "fountain+comic-gn".into(), - name: "Graphic Novel".into(), - description: "Long-form graphic novel script format.".into(), + id: "fountain+comic-indie".into(), + name: "Image / Indie Style".into(), + description: "Flexible indie format used at Image, BOOM!, and creator-owned books — PAGE/PANEL structure with per-page lettering numbers and all caption subtypes.".into(), paper: PaperSize::USLetter, extra_elements: vec![ - ExtraElement { name: "Panel".into(), tag: "panel".into() }, + ExtraElement { name: "Page".into(), tag: "page_header".into() }, + ExtraElement { name: "Panel".into(), tag: "panel_header".into() }, ExtraElement { name: "Caption".into(), tag: "caption".into() }, + ExtraElement { name: "SFX".into(), tag: "sfx".into() }, + ExtraElement { name: "Banner".into(), tag: "banner".into() }, + ExtraElement { name: "Voice Over".into(), tag: "voice_over".into() }, + ExtraElement { name: "Internal".into(), tag: "internal".into() }, + ExtraElement { name: "Narration".into(), tag: "narration".into() }, ], }, Dialect { @@ -227,12 +234,11 @@ pub fn format_categories() -> Vec { }, FormatCategory { name: "Comics & Graphic Novels".into(), - description: "Full script, Marvel method, panel-per-line, and graphic novel formats.".into(), + description: "Full-script comic formats with PAGE/PANEL hierarchy, per-page lettering numbers, balloon types, and SFX.".into(), formats: vec![ - "fountain+comic-full".into(), - "fountain+comic-plot".into(), - "fountain+comic-lean".into(), - "fountain+comic-gn".into(), + "fountain+comic-dc".into(), + "fountain+comic-dh".into(), + "fountain+comic-indie".into(), ], }, FormatCategory { diff --git a/src-tauri/src/fountain/elements.rs b/src-tauri/src/fountain/elements.rs index 0cde0ba..36a5b24 100644 --- a/src-tauri/src/fountain/elements.rs +++ b/src-tauri/src/fountain/elements.rs @@ -3,11 +3,10 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "type")] pub enum Element { - TitlePage { pairs: Vec }, SceneHeading { text: String, scene_number: Option, forced: bool }, Action { text: String, centered: bool, forced: bool }, Character { name: String, extension: Option, dual: bool, forced: bool }, - Dialogue { text: String }, + Dialogue { text: String, #[serde(skip_serializing_if = "Option::is_none")] number: Option }, Parenthetical { text: String }, Transition { text: String, forced: bool }, Lyric { text: String }, @@ -17,12 +16,10 @@ pub enum Element { Boneyard { text: String }, PageBreak, BlankLine, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct TitlePagePair { - pub key: String, - pub value: String, + PageHeader { text: String, page_number: Option }, + PanelHeader { text: String, panel_number: Option }, + Sfx { text: String, number: Option }, + Caption { text: String, subtype: String, number: Option }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -70,4 +67,8 @@ pub struct ScriptStats { pub action_percentage: f32, pub characters: Vec, pub scenes: Vec, + pub comic_page_count: u32, + pub panel_count: u32, + pub sfx_count: u32, + pub caption_count: u32, } diff --git a/src-tauri/src/fountain/parser.rs b/src-tauri/src/fountain/parser.rs index 47d55d6..1cb8bc5 100644 --- a/src-tauri/src/fountain/parser.rs +++ b/src-tauri/src/fountain/parser.rs @@ -16,6 +16,14 @@ pub struct FountainParser { note_re: Regex, boneyard_re: Regex, lyric_re: Regex, + page_header_re: Regex, + panel_header_re: Regex, + sfx_re: Regex, + caption_re: Regex, +} + +fn is_comic_format(format_id: &str) -> bool { + format_id.starts_with("fountain+comic") } impl FountainParser { @@ -35,10 +43,19 @@ impl FountainParser { note_re: Regex::new(r"\[\[([^\]]*)\]\]").unwrap(), boneyard_re: Regex::new(r"/\*[\s\S]*?\*/").unwrap(), lyric_re: Regex::new(r"^~(.+)$").unwrap(), + page_header_re: Regex::new(r"(?i)^#\s*PAGE\s+(.+)$").unwrap(), + panel_header_re: Regex::new(r"(?i)^\.?PANEL\s+(.+)$").unwrap(), + sfx_re: Regex::new(r"(?i)^SFX:\s*(.+)$").unwrap(), + caption_re: Regex::new(r"(?i)^(CAP|CAPTION|BANNER|VOICE\s*OVER|VO|NARRATION|INTERNAL|EDITORIAL|TIME[- ]PLACE):\s*(.+)$").unwrap(), } } pub fn parse(&self, input: &str) -> FountainDocument { + self.parse_with_format(input, "fountain/core") + } + + pub fn parse_with_format(&self, input: &str, format_id: &str) -> FountainDocument { + let comic_mode = is_comic_format(format_id); let cleaned = self.boneyard_re.replace_all(input, "").to_string(); let lines: Vec<&str> = cleaned.lines().collect(); let mut elements = Vec::new(); @@ -50,6 +67,10 @@ impl FountainParser { i = consumed; } + let mut comic_page_num: u32 = 0; + let mut comic_panel_num: u32 = 0; + let mut lettering_num: u32 = 0; + while i < lines.len() { let line = lines[i].trim_end(); @@ -65,7 +86,70 @@ impl FountainParser { continue; } + if comic_mode { + if let Some(caps) = self.page_header_re.captures(line) { + comic_page_num += 1; + comic_panel_num = 0; + lettering_num = 0; + let text = caps[1].trim().to_string(); + elements.push(Element::PageHeader { + text: format!("PAGE {}", text), + page_number: Some(comic_page_num), + }); + i += 1; + continue; + } + + if let Some(caps) = self.panel_header_re.captures(line) { + comic_panel_num += 1; + let text = caps[1].trim().to_string(); + elements.push(Element::PanelHeader { + text: format!("PANEL {}", text), + panel_number: Some(comic_panel_num), + }); + i += 1; + continue; + } + + if let Some(caps) = self.sfx_re.captures(line) { + lettering_num += 1; + let text = caps[1].trim().to_string(); + elements.push(Element::Sfx { + text, + number: Some(lettering_num), + }); + i += 1; + continue; + } + + if let Some(caps) = self.caption_re.captures(line) { + lettering_num += 1; + let subtype_raw = caps[1].to_string(); + let text = caps[2].trim().to_string(); + let subtype = normalize_caption_subtype(&subtype_raw); + elements.push(Element::Caption { + text, + subtype, + number: Some(lettering_num), + }); + i += 1; + continue; + } + } + if let Some(caps) = self.section_re.captures(line) { + if comic_mode && caps[2].trim().to_uppercase().starts_with("PAGE") { + comic_page_num += 1; + comic_panel_num = 0; + lettering_num = 0; + let text = caps[2].trim().to_string(); + elements.push(Element::PageHeader { + text, + page_number: Some(comic_page_num), + }); + i += 1; + continue; + } let level = caps[1].len() as u8; let text = caps[2].trim().to_string(); elements.push(Element::Section { level, text }); @@ -135,7 +219,7 @@ impl FountainParser { continue; } - if let Some((char_elem, dialogue_elements, consumed)) = self.try_dialogue_block(&lines, i, &elements) { + if let Some((char_elem, dialogue_elements, consumed)) = self.try_dialogue_block(&lines, i, &elements, comic_mode, &mut lettering_num) { elements.push(char_elem); for de in dialogue_elements { elements.push(de); @@ -244,6 +328,8 @@ impl FountainParser { lines: &[&str], start: usize, prev_elements: &[Element], + comic_mode: bool, + lettering_num: &mut u32, ) -> Option<(Element, Vec, usize)> { let line = lines[start].trim_end(); @@ -272,6 +358,13 @@ impl FountainParser { return None; } + let balloon_num = if comic_mode { + *lettering_num += 1; + Some(*lettering_num) + } else { + None + }; + let char_elem = Element::Character { name, extension, dual, forced }; let mut dialogue_elems = Vec::new(); @@ -284,7 +377,7 @@ impl FountainParser { if trimmed.starts_with('(') && trimmed.ends_with(')') { dialogue_elems.push(Element::Parenthetical { text: trimmed.to_string() }); } else { - dialogue_elems.push(Element::Dialogue { text: dline.to_string() }); + dialogue_elems.push(Element::Dialogue { text: dline.to_string(), number: balloon_num }); } i += 1; } @@ -318,6 +411,11 @@ impl FountainParser { let mut lines_on_page = 0u32; let lines_per_page = 55u32; + let mut comic_page_count = 0u32; + let mut panel_count = 0u32; + let mut sfx_count = 0u32; + let mut caption_count = 0u32; + for elem in &doc.elements { match elem { Element::SceneHeading { text, scene_number, .. } => { @@ -354,7 +452,7 @@ impl FountainParser { } lines_on_page += 1; } - Element::Dialogue { text } => { + Element::Dialogue { text, .. } => { let wc = text.split_whitespace().count() as u32; total_words += wc; dialogue_words += wc; @@ -388,6 +486,28 @@ impl FountainParser { page += 1; lines_on_page = 0; } + Element::PageHeader { .. } => { + comic_page_count += 1; + lines_on_page += 2; + } + Element::PanelHeader { .. } => { + panel_count += 1; + lines_on_page += 2; + } + Element::Sfx { text, .. } => { + sfx_count += 1; + let wc = text.split_whitespace().count() as u32; + total_words += wc; + action_words += wc; + lines_on_page += 1; + } + Element::Caption { text, .. } => { + caption_count += 1; + let wc = text.split_whitespace().count() as u32; + total_words += wc; + dialogue_words += wc; + lines_on_page += 1; + } _ => {} } @@ -410,6 +530,23 @@ impl FountainParser { action_percentage: (action_words as f32 / total_content) * 100.0, characters: char_list, scenes, + comic_page_count, + panel_count, + sfx_count, + caption_count, } } } + +fn normalize_caption_subtype(raw: &str) -> String { + let collapsed: String = raw.split_whitespace().collect::>().join(" ").to_uppercase(); + match collapsed.as_str() { + "CAP" | "CAPTION" => "CAPTION".to_string(), + "BANNER" | "TIME-PLACE" | "TIME PLACE" => "BANNER".to_string(), + "VO" | "VOICE OVER" | "VOICEOVER" => "VOICE OVER".to_string(), + "NARRATION" => "NARRATION".to_string(), + "INTERNAL" => "INTERNAL".to_string(), + "EDITORIAL" => "EDITORIAL".to_string(), + _ => "CAPTION".to_string(), + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b030943..f84f643 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,28 +7,30 @@ use fountain::dialect; use project::manager::ProjectManager; #[pyfunction] -fn parse_fountain(text: &str) -> PyResult { +#[pyo3(signature = (text, format_id=None))] +fn parse_fountain(text: &str, format_id: Option<&str>) -> PyResult { let parser = FountainParser::new(); - let doc = parser.parse(text); - Ok(serde_json::to_string(&doc).unwrap()) + let doc = parser.parse_with_format(text, format_id.unwrap_or("fountain/core")); + serde_json::to_string(&doc).map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) } #[pyfunction] -fn get_script_stats(text: &str) -> PyResult { +#[pyo3(signature = (text, format_id=None))] +fn get_script_stats(text: &str, format_id: Option<&str>) -> PyResult { let parser = FountainParser::new(); - let doc = parser.parse(text); + let doc = parser.parse_with_format(text, format_id.unwrap_or("fountain/core")); let stats = parser.compute_stats(&doc); - Ok(serde_json::to_string(&stats).unwrap()) + serde_json::to_string(&stats).map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) } #[pyfunction] fn get_dialects() -> PyResult { - Ok(serde_json::to_string(&dialect::builtin_dialects()).unwrap()) + serde_json::to_string(&dialect::builtin_dialects()).map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) } #[pyfunction] fn get_format_categories() -> PyResult { - Ok(serde_json::to_string(&dialect::format_categories()).unwrap()) + serde_json::to_string(&dialect::format_categories()).map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) } #[pyfunction] diff --git a/src-tauri/src/project/manager.rs b/src-tauri/src/project/manager.rs index 86127d1..c5ef488 100644 --- a/src-tauri/src/project/manager.rs +++ b/src-tauri/src/project/manager.rs @@ -28,13 +28,13 @@ impl ProjectManager { fs::create_dir_all(&project_dir).map_err(|e| e.to_string())?; fs::create_dir_all(project_dir.join("plan")).map_err(|e| e.to_string())?; - let sample_heading = match format_id { - "fountain+comic-full" | "fountain+comic-plot" | "fountain+comic-lean" | "fountain+comic-gn" => { - format!("Title: {}\nCredit: Written by\nAuthor: {}\n\n# PAGE ONE\n\n.PANEL 1\n\nEstablishing shot.\n", title, author) - } - _ => { - format!("Title: {}\nCredit: Written by\nAuthor: {}\n\nINT. LOCATION — DAY\n\nAction goes here.\n", title, author) - } + let sample_heading = if format_id.starts_with("fountain+comic") { + format!( + "Title: {}\nCredit: Written by\nAuthor: {}\n\n# PAGE ONE\n\nPANEL 1\n\nEstablishing shot. Wide angle on the city skyline.\n\nCAP: The city never sleeps.\n\nCHARACTER\n(burst)\nDialogue goes here!\n\nSFX: KRAAAK\n\n# PAGE TWO\n\nPANEL 1\n\nAction description for the next panel.\n", + title, author + ) + } else { + format!("Title: {}\nCredit: Written by\nAuthor: {}\n\nINT. LOCATION — DAY\n\nAction goes here.\n", title, author) }; fs::write(project_dir.join("script.fountain"), &sample_heading).map_err(|e| e.to_string())?; diff --git a/tests/test_adversarial.py b/tests/test_adversarial.py index 2499876..69cc37d 100644 --- a/tests/test_adversarial.py +++ b/tests/test_adversarial.py @@ -1087,6 +1087,7 @@ def test_pending_ops_cleared_on_sync_response(self): session._pending_ops = [Operation(0, 0, "old")] session._crdt = TextCRDT() session._p2p = None + session._lock = __import__("threading").Lock() session._on_text_change = None session._on_participant_change = None session._on_cursor_change = None diff --git a/tests/test_cloud_export.py b/tests/test_cloud_export.py new file mode 100644 index 0000000..f08f5ed --- /dev/null +++ b/tests/test_cloud_export.py @@ -0,0 +1,176 @@ +"""Tests for cloud export stubs and config persistence.""" + +import base64 +import json +import os +import pytest + +from ecrit.sync.cloud_export import ( + CloudProvider, CloudConfig, ExportResult, + GoogleDriveExporter, ICloudExporter, DropboxExporter, + OneDriveExporter, NextcloudExporter, + get_exporter, export_script, + save_cloud_configs, load_cloud_configs, +) + + +class TestCloudConfig: + def test_to_dict_roundtrip(self): + config = CloudConfig( + provider=CloudProvider.GOOGLE_DRIVE, + auth_token="secret-token", + folder_path="/scripts", + auto_export=True, + last_export_time="2026-01-01T00:00:00", + ) + d = config.to_dict() + restored = CloudConfig.from_dict(d) + assert restored.provider == CloudProvider.GOOGLE_DRIVE + assert restored.auth_token == "secret-token" + assert restored.folder_path == "/scripts" + assert restored.auto_export is True + assert restored.last_export_time == "2026-01-01T00:00:00" + + def test_to_dict_base64_encodes_token(self): + config = CloudConfig( + provider=CloudProvider.DROPBOX, auth_token="my-token", folder_path="/", + ) + d = config.to_dict() + assert "auth_token" not in d + decoded = base64.b64decode(d["auth_token_b64"]).decode("utf-8") + assert decoded == "my-token" + + def test_from_dict_empty_token(self): + config = CloudConfig.from_dict({ + "provider": "dropbox", "auth_token_b64": "", "folder_path": "/", + }) + assert config.auth_token == "" + + def test_from_dict_defaults(self): + config = CloudConfig.from_dict({"provider": "icloud"}) + assert config.folder_path == "/" + assert config.auto_export is False + + +class TestStubExporters: + @pytest.mark.parametrize("cls,name", [ + (GoogleDriveExporter, "Google Drive"), + (ICloudExporter, "iCloud Drive"), + (DropboxExporter, "Dropbox"), + (OneDriveExporter, "OneDrive"), + (NextcloudExporter, "Nextcloud"), + ]) + def test_authenticate_returns_false(self, cls, name): + config = CloudConfig( + provider=CloudProvider.GOOGLE_DRIVE, auth_token="tok", folder_path="/", + ) + exporter = cls(config) + assert exporter.authenticate() is False + assert exporter.PROVIDER_NAME == name + + @pytest.mark.parametrize("cls", [ + GoogleDriveExporter, ICloudExporter, DropboxExporter, + OneDriveExporter, NextcloudExporter, + ]) + def test_upload_returns_failure(self, cls): + config = CloudConfig( + provider=CloudProvider.GOOGLE_DRIVE, auth_token="", folder_path="/", + ) + exporter = cls(config) + result = exporter.upload_file("/fake/path.fountain", "/remote") + assert result.success is False + assert "not yet available" in result.message + + @pytest.mark.parametrize("cls", [ + GoogleDriveExporter, ICloudExporter, DropboxExporter, + OneDriveExporter, NextcloudExporter, + ]) + def test_list_files_returns_empty(self, cls): + config = CloudConfig( + provider=CloudProvider.GOOGLE_DRIVE, auth_token="", folder_path="/", + ) + exporter = cls(config) + assert exporter.list_files("/") == [] + + def test_is_authenticated_default_false(self): + config = CloudConfig( + provider=CloudProvider.DROPBOX, auth_token="", folder_path="/", + ) + exporter = DropboxExporter(config) + assert exporter.is_authenticated() is False + + +class TestGetExporter: + def test_returns_correct_type(self): + exporter = get_exporter(CloudProvider.GOOGLE_DRIVE) + assert isinstance(exporter, GoogleDriveExporter) + + def test_with_config(self): + config = CloudConfig( + provider=CloudProvider.DROPBOX, auth_token="t", folder_path="/x", + ) + exporter = get_exporter(CloudProvider.DROPBOX, config) + assert exporter.config.folder_path == "/x" + + def test_unsupported_provider_raises(self): + with pytest.raises(ValueError, match="Unsupported"): + get_exporter("not_a_provider") + + +class TestExportScript: + def test_no_config_returns_failure(self, tmp_path): + result = export_script( + str(tmp_path), CloudProvider.GOOGLE_DRIVE, + "INT. OFFICE", "script", + ) + assert result.success is False + assert "No configuration" in result.message + + def test_auth_failure_returns_failure(self, tmp_path): + ecrit_dir = tmp_path / ".ecrit" + ecrit_dir.mkdir() + config = CloudConfig( + provider=CloudProvider.DROPBOX, auth_token="bad", folder_path="/", + ) + save_cloud_configs(str(tmp_path), [config]) + result = export_script( + str(tmp_path), CloudProvider.DROPBOX, + "INT. OFFICE", "script", + ) + assert result.success is False + assert "Authentication failed" in result.message + + +class TestConfigPersistence: + def test_save_and_load(self, tmp_path): + configs = [ + CloudConfig( + provider=CloudProvider.GOOGLE_DRIVE, + auth_token="tok1", folder_path="/a", + ), + CloudConfig( + provider=CloudProvider.DROPBOX, + auth_token="tok2", folder_path="/b", + auto_export=True, + ), + ] + save_cloud_configs(str(tmp_path), configs) + loaded = load_cloud_configs(str(tmp_path)) + assert len(loaded) == 2 + assert loaded[0].provider == CloudProvider.GOOGLE_DRIVE + assert loaded[0].auth_token == "tok1" + assert loaded[1].auto_export is True + + def test_load_nonexistent(self, tmp_path): + assert load_cloud_configs(str(tmp_path / "nope")) == [] + + def test_load_corrupt_json(self, tmp_path): + ecrit_dir = tmp_path / ".ecrit" + ecrit_dir.mkdir() + (ecrit_dir / "cloud.json").write_text("not valid json{{{") + assert load_cloud_configs(str(tmp_path)) == [] + + def test_save_creates_directory(self, tmp_path): + project = tmp_path / "deep" / "project" + save_cloud_configs(str(project), []) + assert os.path.isfile(os.path.join(str(project), ".ecrit", "cloud.json")) diff --git a/tests/test_collab.py b/tests/test_collab.py index e9aa8ad..424dfbd 100644 --- a/tests/test_collab.py +++ b/tests/test_collab.py @@ -172,6 +172,18 @@ def test_transform_insert_in_deleted_range(self): transformed = crdt.transform(op1, op2) assert transformed.position == 3 + def test_transform_delete_expands_over_concurrent_insert(self): + """A delete expands to cover the gap created by a concurrent insert so all originally-targeted chars are removed.""" + crdt = TextCRDT(user_id="u1") + # op1: delete 5 chars starting at pos 2 (chars 2-6) + op1 = Operation(op_type=OperationType.DELETE, position=2, length=5) + # op2: insert "XY" at pos 4 (inside op1's range) + op2 = Operation(op_type=OperationType.INSERT, position=4, text="XY") + transformed = crdt.transform(op1, op2) + # Delete grows by insert length to skip over the inserted text + assert transformed.length == 7 + assert transformed.position == 2 + def test_transform_preserves_other_fields(self): crdt = TextCRDT(user_id="u1") op1 = Operation( diff --git a/tests/test_collab_network.py b/tests/test_collab_network.py new file mode 100644 index 0000000..4325d85 --- /dev/null +++ b/tests/test_collab_network.py @@ -0,0 +1,210 @@ +"""Tests for collaboration networking: LAN discovery and P2P connection.""" + +import json +import time +import pytest +from unittest.mock import MagicMock, patch + +from ecrit.collab.lan import LANPeer, LANDiscovery, SERVICE_ID, DISCOVERY_PORT +from ecrit.collab.p2p import ConnectionToken, P2PConnection + + +class TestLANPeer: + def test_is_stale_fresh(self): + peer = LANPeer( + user_id="u1", user_name="Alice", + ip_address="192.168.1.10", port=8741, + last_seen=time.time(), + ) + assert not peer.is_stale() + + def test_is_stale_old(self): + peer = LANPeer( + user_id="u1", user_name="Alice", + ip_address="192.168.1.10", port=8741, + last_seen=time.time() - 20.0, + ) + assert peer.is_stale(timeout=10.0) + + def test_session_id_default(self): + peer = LANPeer( + user_id="u1", user_name="A", + ip_address="127.0.0.1", port=8741, + ) + assert peer.session_id == "" + + def test_project_title_default(self): + peer = LANPeer( + user_id="u1", user_name="A", + ip_address="127.0.0.1", port=8741, + ) + assert peer.project_title == "" + + +class TestLANDiscovery: + def test_init(self): + disc = LANDiscovery("u1", "Alice", port=9000) + assert disc.user_id == "u1" + assert disc.user_name == "Alice" + assert disc.port == 9000 + + def test_set_project_title(self): + disc = LANDiscovery("u1", "Alice") + disc.set_project_title("My Script") + assert disc._project_title == "My Script" + + def test_set_session_id(self): + disc = LANDiscovery("u1", "Alice") + disc.set_session_id("sess-123") + assert disc._session_id == "sess-123" + + def test_make_announce(self): + disc = LANDiscovery("u1", "Alice", port=8741) + disc.set_project_title("Test Project") + disc.set_session_id("sess-abc") + data = json.loads(disc._make_announce().decode("utf-8")) + assert data["service"] == SERVICE_ID + assert data["user_id"] == "u1" + assert data["user_name"] == "Alice" + assert data["port"] == 8741 + assert data["project_title"] == "Test Project" + assert data["session_id"] == "sess-abc" + + def test_get_peers_empty(self): + disc = LANDiscovery("u1", "Alice") + assert disc.get_peers() == [] + + def test_get_peers_prunes_stale(self): + disc = LANDiscovery("u1", "Alice") + disc._peers["old"] = LANPeer( + user_id="old", user_name="Old", + ip_address="1.2.3.4", port=8741, + last_seen=time.time() - 100, + ) + peers = disc.get_peers() + assert peers == [] + assert "old" not in disc._peers + + def test_stop_clears_peers(self): + disc = LANDiscovery("u1", "Alice") + disc._peers["p1"] = LANPeer( + user_id="p1", user_name="P", + ip_address="1.2.3.4", port=8741, + last_seen=time.time(), + ) + disc.stop() + assert disc._peers == {} + assert disc._running is False + + def test_callbacks_set(self): + disc = LANDiscovery("u1", "Alice") + found_cb = MagicMock() + lost_cb = MagicMock() + disc.set_callbacks(on_peer_found=found_cb, on_peer_lost=lost_cb) + assert disc._on_peer_found is found_cb + assert disc._on_peer_lost is lost_cb + + def test_peer_lost_callback_on_stale(self): + disc = LANDiscovery("u1", "Alice") + lost_cb = MagicMock() + disc.set_callbacks(on_peer_lost=lost_cb) + disc._peers["stale"] = LANPeer( + user_id="stale", user_name="S", + ip_address="1.2.3.4", port=8741, + last_seen=time.time() - 100, + ) + disc.get_peers() + lost_cb.assert_called_once_with("stale") + + +class TestConnectionToken: + def test_encode_decode_roundtrip(self): + token = ConnectionToken( + host="192.168.1.5", port=8741, + session_id="abc123", user_name="Bob", + project_title="Screenplay", + ) + encoded = token.encode() + decoded = ConnectionToken.decode(encoded) + assert decoded is not None + assert decoded.host == "192.168.1.5" + assert decoded.port == 8741 + assert decoded.session_id == "abc123" + assert decoded.user_name == "Bob" + assert decoded.project_title == "Screenplay" + + def test_decode_invalid_token(self): + assert ConnectionToken.decode("not-valid-base64!!!") is None + + def test_decode_empty(self): + assert ConnectionToken.decode("") is None + + def test_created_at_auto_set(self): + token = ConnectionToken(host="h", port=1, session_id="s", user_name="u") + assert token.created_at > 0 + + +class TestP2PConnection: + def test_init(self): + p2p = P2PConnection("u1", "Alice") + assert p2p.user_id == "u1" + assert p2p.user_name == "Alice" + assert p2p._port == 0 + assert not p2p._running + + def test_not_connected_initially(self): + p2p = P2PConnection("u1", "Alice") + assert not p2p.is_connected() + assert p2p.get_peer_count() == 0 + assert p2p.get_client_ids() == [] + + def test_send_no_peer(self): + p2p = P2PConnection("u1", "Alice") + assert p2p.send("nobody", "hello") is False + + def test_set_callbacks(self): + p2p = P2PConnection("u1", "Alice") + on_msg = MagicMock() + on_conn = MagicMock() + on_disc = MagicMock() + p2p.set_callbacks(on_message=on_msg, on_connect=on_conn, on_disconnect=on_disc) + assert p2p._on_message is on_msg + assert p2p._on_connect is on_conn + assert p2p._on_disconnect is on_disc + + def test_close_when_not_running(self): + p2p = P2PConnection("u1", "Alice") + p2p.close() + assert not p2p._running + + def test_host_and_close(self): + p2p = P2PConnection("u1", "Alice") + token = p2p.host_session(port=0, project_title="Test") + assert p2p._running + assert p2p._port > 0 + assert token.port == p2p._port + assert token.project_title == "Test" + assert len(p2p._session_id) > 0 + p2p.close() + assert not p2p._running + + def test_host_creates_valid_token(self): + p2p = P2PConnection("u1", "Alice") + token = p2p.host_session() + encoded = token.encode() + decoded = ConnectionToken.decode(encoded) + assert decoded is not None + assert decoded.port == p2p._port + p2p.close() + + def test_broadcast_empty(self): + p2p = P2PConnection("u1", "Alice") + p2p.broadcast("hello") + + def test_join_refused(self): + p2p = P2PConnection("u1", "Alice") + token = ConnectionToken( + host="127.0.0.1", port=1, + session_id="nope", user_name="Host", + ) + assert p2p.join_session(token) is False diff --git a/tests/test_comic_fountain.py b/tests/test_comic_fountain.py new file mode 100644 index 0000000..76c8525 --- /dev/null +++ b/tests/test_comic_fountain.py @@ -0,0 +1,477 @@ +"""Tests for comic book Fountain extensions — parser, dialects, stats, and exports.""" + +import json +import pytest + +ecrit_core = pytest.importorskip("ecrit_core") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def parse_comic(text: str) -> dict: + return json.loads(ecrit_core.parse_fountain(text, "fountain+comic-dc")) + +def parse_standard(text: str) -> dict: + return json.loads(ecrit_core.parse_fountain(text)) + +def stats_comic(text: str) -> dict: + return json.loads(ecrit_core.get_script_stats(text, "fountain+comic-dc")) + +def elements_of_type(doc: dict, etype: str) -> list[dict]: + return [e for e in doc["elements"] if e["type"] == etype] + + +# --------------------------------------------------------------------------- +# Comic element parsing +# --------------------------------------------------------------------------- + +class TestPageHeader: + def test_section_page_header(self): + doc = parse_comic("# PAGE ONE\n") + pages = elements_of_type(doc, "PageHeader") + assert len(pages) == 1 + assert pages[0]["text"] == "PAGE ONE" + assert pages[0]["page_number"] == 1 + + def test_multiple_pages(self): + doc = parse_comic("# PAGE ONE\n\nAction.\n\n# PAGE TWO\n\nMore action.\n\n# PAGE THREE\n") + pages = elements_of_type(doc, "PageHeader") + assert len(pages) == 3 + assert pages[0]["page_number"] == 1 + assert pages[1]["page_number"] == 2 + assert pages[2]["page_number"] == 3 + + def test_page_case_insensitive(self): + doc = parse_comic("# page one\n") + pages = elements_of_type(doc, "PageHeader") + assert len(pages) == 1 + + def test_page_with_number(self): + doc = parse_comic("# PAGE 7\n") + pages = elements_of_type(doc, "PageHeader") + assert pages[0]["text"] == "PAGE 7" + + def test_non_page_section_preserved(self): + doc = parse_comic("# ACT ONE\n") + sections = elements_of_type(doc, "Section") + assert len(sections) == 1 + assert sections[0]["text"] == "ACT ONE" + + def test_standard_mode_no_page_header(self): + doc = parse_standard("# PAGE ONE\n") + pages = elements_of_type(doc, "PageHeader") + assert len(pages) == 0 + sections = elements_of_type(doc, "Section") + assert len(sections) == 1 + + +class TestPanelHeader: + def test_basic_panel(self): + doc = parse_comic("# PAGE ONE\n\nPANEL 1\n") + panels = elements_of_type(doc, "PanelHeader") + assert len(panels) == 1 + assert panels[0]["text"] == "PANEL 1" + assert panels[0]["panel_number"] == 1 + + def test_forced_panel(self): + doc = parse_comic("# PAGE ONE\n\n.PANEL 3\n") + panels = elements_of_type(doc, "PanelHeader") + assert len(panels) == 1 + + def test_panel_numbering_resets_on_new_page(self): + doc = parse_comic("# PAGE ONE\n\nPANEL 1\n\nPANEL 2\n\n# PAGE TWO\n\nPANEL 1\n") + panels = elements_of_type(doc, "PanelHeader") + assert len(panels) == 3 + assert panels[0]["panel_number"] == 1 + assert panels[1]["panel_number"] == 2 + assert panels[2]["panel_number"] == 1 + + def test_standard_mode_no_panel(self): + doc = parse_standard("PANEL 1\n") + panels = elements_of_type(doc, "PanelHeader") + assert len(panels) == 0 + + +class TestSfx: + def test_basic_sfx(self): + doc = parse_comic("# PAGE ONE\n\nPANEL 1\n\nSFX: KRAKOOM\n") + sfx = elements_of_type(doc, "Sfx") + assert len(sfx) == 1 + assert sfx[0]["text"] == "KRAKOOM" + assert sfx[0]["number"] == 1 + + def test_sfx_case_insensitive(self): + doc = parse_comic("# PAGE ONE\n\nsfx: boom\n") + sfx = elements_of_type(doc, "Sfx") + assert len(sfx) == 1 + assert sfx[0]["text"] == "boom" + + def test_sfx_numbering_shared_with_dialogue(self): + doc = parse_comic( + "# PAGE ONE\n\nPANEL 1\n\n" + "BATMAN\nHello.\n\n" + "SFX: CRASH\n\n" + "CAP: A caption.\n" + ) + sfx = elements_of_type(doc, "Sfx") + caps = elements_of_type(doc, "Caption") + assert sfx[0]["number"] == 2 + assert caps[0]["number"] == 3 + + def test_sfx_numbering_resets_on_new_page(self): + doc = parse_comic( + "# PAGE ONE\n\nSFX: BOOM\n\n" + "# PAGE TWO\n\nSFX: CRASH\n" + ) + sfx = elements_of_type(doc, "Sfx") + assert sfx[0]["number"] == 1 + assert sfx[1]["number"] == 1 + + def test_standard_mode_no_sfx(self): + doc = parse_standard("SFX: BOOM\n") + sfx = elements_of_type(doc, "Sfx") + assert len(sfx) == 0 + + +class TestCaption: + def test_basic_caption(self): + doc = parse_comic("# PAGE ONE\n\nCAP: The city never sleeps.\n") + caps = elements_of_type(doc, "Caption") + assert len(caps) == 1 + assert caps[0]["text"] == "The city never sleeps." + assert caps[0]["subtype"] == "CAPTION" + assert caps[0]["number"] == 1 + + def test_caption_long_form(self): + doc = parse_comic("# PAGE ONE\n\nCAPTION: Longer form.\n") + caps = elements_of_type(doc, "Caption") + assert caps[0]["subtype"] == "CAPTION" + + def test_banner_subtype(self): + doc = parse_comic("# PAGE ONE\n\nBANNER: Gotham City. Midnight.\n") + caps = elements_of_type(doc, "Caption") + assert caps[0]["subtype"] == "BANNER" + + def test_voice_over_subtype(self): + doc = parse_comic("# PAGE ONE\n\nVOICE OVER: Narrating here.\n") + caps = elements_of_type(doc, "Caption") + assert caps[0]["subtype"] == "VOICE OVER" + + def test_vo_subtype(self): + doc = parse_comic("# PAGE ONE\n\nVO: Short form.\n") + caps = elements_of_type(doc, "Caption") + assert caps[0]["subtype"] == "VOICE OVER" + + def test_narration_subtype(self): + doc = parse_comic("# PAGE ONE\n\nNARRATION: The hero speaks.\n") + caps = elements_of_type(doc, "Caption") + assert caps[0]["subtype"] == "NARRATION" + + def test_internal_subtype(self): + doc = parse_comic("# PAGE ONE\n\nINTERNAL: Thought bubble.\n") + caps = elements_of_type(doc, "Caption") + assert caps[0]["subtype"] == "INTERNAL" + + def test_editorial_subtype(self): + doc = parse_comic("# PAGE ONE\n\nEDITORIAL: See issue #42.\n") + caps = elements_of_type(doc, "Caption") + assert caps[0]["subtype"] == "EDITORIAL" + + def test_time_place_subtype(self): + doc = parse_comic("# PAGE ONE\n\nTIME-PLACE: New York, 1942.\n") + caps = elements_of_type(doc, "Caption") + assert caps[0]["subtype"] == "BANNER" + + +class TestLetteringNumbering: + def test_per_page_numbering(self): + script = ( + "# PAGE ONE\n\n" + "PANEL 1\n\n" + "BATMAN\nFirst line.\n\n" + "CAP: Caption one.\n\n" + "SFX: BOOM\n\n" + "# PAGE TWO\n\n" + "PANEL 1\n\n" + "ROBIN\nSecond page first.\n\n" + "CAP: Caption two page.\n" + ) + doc = parse_comic(script) + sfx = elements_of_type(doc, "Sfx") + caps = elements_of_type(doc, "Caption") + + assert sfx[0]["number"] == 3 + assert caps[0]["number"] == 2 + assert caps[1]["number"] == 2 + + def test_dialogue_increments_lettering(self): + script = ( + "# PAGE ONE\n\n" + "PANEL 1\n\n" + "BATMAN\nLine one.\n\n" + "JOKER\nLine two.\n\n" + "SFX: HAHA\n" + ) + doc = parse_comic(script) + sfx = elements_of_type(doc, "Sfx") + assert sfx[0]["number"] == 3 + + +class TestBalloonParentheticals: + def test_balloon_types_in_dialogue(self): + script = ( + "# PAGE ONE\n\n" + "PANEL 1\n\n" + "BATMAN\n(whisper)\nI am the night.\n\n" + "JOKER\n(burst)\nHA HA HA!\n" + ) + doc = parse_comic(script) + parens = elements_of_type(doc, "Parenthetical") + assert len(parens) == 2 + assert parens[0]["text"] == "(whisper)" + assert parens[1]["text"] == "(burst)" + + +# --------------------------------------------------------------------------- +# Dialect registry +# --------------------------------------------------------------------------- + +class TestComicDialects: + def test_dialects_contain_new_ids(self): + dialects = json.loads(ecrit_core.get_dialects()) + ids = [d["id"] for d in dialects] + assert "fountain+comic-dc" in ids + assert "fountain+comic-dh" in ids + assert "fountain+comic-indie" in ids + + def test_old_ids_removed(self): + dialects = json.loads(ecrit_core.get_dialects()) + ids = [d["id"] for d in dialects] + assert "fountain+comic-full" not in ids + assert "fountain+comic-plot" not in ids + assert "fountain+comic-lean" not in ids + assert "fountain+comic-gn" not in ids + + def test_categories_updated(self): + categories = json.loads(ecrit_core.get_format_categories()) + comic_cat = next(c for c in categories if "Comics" in c["name"]) + assert "fountain+comic-dc" in comic_cat["formats"] + assert "fountain+comic-dh" in comic_cat["formats"] + assert "fountain+comic-indie" in comic_cat["formats"] + assert len(comic_cat["formats"]) == 3 + + def test_dc_dialect_has_extra_elements(self): + dialects = json.loads(ecrit_core.get_dialects()) + dc = next(d for d in dialects if d["id"] == "fountain+comic-dc") + tags = [e["tag"] for e in dc["extra_elements"]] + assert "page_header" in tags + assert "panel_header" in tags + assert "caption" in tags + assert "sfx" in tags + + def test_all_comic_formats_activate_parsing(self): + script = "# PAGE ONE\n\nPANEL 1\n\nSFX: BOOM\n" + for fmt in ["fountain+comic-dc", "fountain+comic-dh", "fountain+comic-indie"]: + doc = json.loads(ecrit_core.parse_fountain(script, fmt)) + pages = elements_of_type(doc, "PageHeader") + assert len(pages) == 1, f"PageHeader not parsed for {fmt}" + sfx = elements_of_type(doc, "Sfx") + assert len(sfx) == 1, f"Sfx not parsed for {fmt}" + + +# --------------------------------------------------------------------------- +# Stats +# --------------------------------------------------------------------------- + +class TestComicStats: + def test_comic_page_count(self): + script = "# PAGE ONE\n\n# PAGE TWO\n\n# PAGE THREE\n" + stats = stats_comic(script) + assert stats["comic_page_count"] == 3 + + def test_panel_count(self): + script = "# PAGE ONE\n\nPANEL 1\n\nPANEL 2\n\nPANEL 3\n" + stats = stats_comic(script) + assert stats["panel_count"] == 3 + + def test_sfx_count(self): + script = "# PAGE ONE\n\nSFX: BOOM\n\nSFX: CRASH\n" + stats = stats_comic(script) + assert stats["sfx_count"] == 2 + + def test_caption_count(self): + script = "# PAGE ONE\n\nCAP: One.\n\nBANNER: Two.\n\nVO: Three.\n" + stats = stats_comic(script) + assert stats["caption_count"] == 3 + + def test_caption_words_count_as_dialogue(self): + script = "# PAGE ONE\n\nCAP: Three words here.\n" + stats = stats_comic(script) + assert stats["word_count"] >= 3 + assert stats["dialogue_percentage"] > 0 + + def test_sfx_words_count_as_action(self): + script = "# PAGE ONE\n\nSFX: KRAKKA BOOM BOOM\n" + stats = stats_comic(script) + assert stats["action_percentage"] > 0 + + def test_standard_mode_zero_comic_stats(self): + script = "INT. OFFICE - DAY\n\nAction here.\n" + stats = json.loads(ecrit_core.get_script_stats(script)) + assert stats["comic_page_count"] == 0 + assert stats["panel_count"] == 0 + assert stats["sfx_count"] == 0 + assert stats["caption_count"] == 0 + + +# --------------------------------------------------------------------------- +# Backward compatibility +# --------------------------------------------------------------------------- + +class TestBackwardCompatibility: + def test_parse_fountain_no_format_id(self): + doc = json.loads(ecrit_core.parse_fountain("INT. OFFICE - DAY\n\nAction.\n")) + headings = elements_of_type(doc, "SceneHeading") + assert len(headings) == 1 + + def test_get_script_stats_no_format_id(self): + stats = json.loads(ecrit_core.get_script_stats("INT. OFFICE - DAY\n\nAction.\n")) + assert stats["scene_count"] == 1 + + def test_comic_syntax_in_standard_mode_becomes_action(self): + doc = parse_standard("INT. OFFICE - DAY\n\nSFX: BOOM\n") + actions = elements_of_type(doc, "Action") + assert any("SFX" in a["text"] for a in actions) + + def test_standard_elements_still_work_in_comic_mode(self): + script = ( + "# PAGE ONE\n\n" + "PANEL 1\n\n" + "INT. OFFICE - DAY\n\n" + "Action description.\n\n" + "BATMAN\nHello.\n" + ) + doc = parse_comic(script) + headings = elements_of_type(doc, "SceneHeading") + actions = elements_of_type(doc, "Action") + chars = elements_of_type(doc, "Character") + assert len(headings) == 1 + assert len(actions) >= 1 + assert len(chars) == 1 + + +# --------------------------------------------------------------------------- +# Full script integration test +# --------------------------------------------------------------------------- + +class TestFullComicScript: + SCRIPT = """\ +Title: The Dark Knight Returns +Credit: Written by +Author: Test Writer + +# PAGE ONE + +PANEL 1 + +Wide establishing shot of GOTHAM CITY at night. Rain hammers the skyline. +Lightning cracks the sky in the distance. + +CAP: The city remembers what it was. + +PANEL 2 + +Close on BATMAN perched on a gargoyle, cape whipping in the wind. + +BATMAN +(whisper) +Tonight we end this. + +SFX: KRAKOOM + +PANEL 3 + +Below, CATWOMAN lands silently on a fire escape. + +CATWOMAN +(off) +You always say that. + +BANNER: Gotham City. 3:47 AM. + +# PAGE TWO + +PANEL 1 + +Interior of the Batcave. Alfred stands by the computer array. + +ALFRED +Master Wayne, the signal— + +PANEL 2 + +Batman drops from above into the cave. + +BATMAN +I saw it. + +CAP: He always sees it. + +SFX: THWIP + +VOICE OVER: Some nights you wonder if it's worth it. +""" + + def test_page_count(self): + stats = stats_comic(self.SCRIPT) + assert stats["comic_page_count"] == 2 + + def test_panel_count(self): + stats = stats_comic(self.SCRIPT) + assert stats["panel_count"] == 5 + + def test_sfx_count(self): + stats = stats_comic(self.SCRIPT) + assert stats["sfx_count"] == 2 + + def test_caption_count(self): + stats = stats_comic(self.SCRIPT) + assert stats["caption_count"] == 4 + + def test_character_count(self): + stats = stats_comic(self.SCRIPT) + names = [c["name"] for c in stats["characters"]] + assert "BATMAN" in names + assert "CATWOMAN" in names + assert "ALFRED" in names + + def test_all_element_types_present(self): + doc = parse_comic(self.SCRIPT) + types = set(e["type"] for e in doc["elements"]) + assert "PageHeader" in types + assert "PanelHeader" in types + assert "Sfx" in types + assert "Caption" in types + assert "Character" in types + assert "Dialogue" in types + assert "Parenthetical" in types + assert "Action" in types + + def test_lettering_numbers_reset_on_page_two(self): + doc = parse_comic(self.SCRIPT) + page2_elements = [] + on_page2 = False + for e in doc["elements"]: + if e["type"] == "PageHeader" and "TWO" in e.get("text", ""): + on_page2 = True + continue + if on_page2 and e["type"] in ("Sfx", "Caption", "Character"): + page2_elements.append(e) + + sfx_on_p2 = [e for e in page2_elements if e["type"] == "Sfx"] + cap_on_p2 = [e for e in page2_elements if e["type"] == "Caption"] + assert len(sfx_on_p2) == 1 + assert sfx_on_p2[0]["number"] is not None + assert len(cap_on_p2) == 2 diff --git a/tests/test_comic_outline.py b/tests/test_comic_outline.py new file mode 100644 index 0000000..47c6731 --- /dev/null +++ b/tests/test_comic_outline.py @@ -0,0 +1,378 @@ +"""Tests for comic-specific outline nodes, templates, and outline-manuscript sync.""" + +import json +import pytest + +from ecrit.screenplay.structure_templates import ( + COMIC_TEMPLATES, ComicTemplate, ComicBeat, + generate_comic_outline_nodes, +) +from ecrit.screenplay.outline_sync import ( + outline_to_script, script_to_outline, +) + +try: + import ecrit_core + HAS_CORE = True +except ImportError: + HAS_CORE = False + + +# --------------------------------------------------------------------------- +# Comic structure templates +# --------------------------------------------------------------------------- + +class TestComicTemplates: + def test_22_page_template_exists(self): + assert "comic_22_page" in COMIC_TEMPLATES + + def test_short_template_exists(self): + assert "comic_short" in COMIC_TEMPLATES + + def test_gn_chapter_template_exists(self): + assert "comic_gn_chapter" in COMIC_TEMPLATES + + def test_22_page_has_pages_and_panels(self): + tpl = COMIC_TEMPLATES["comic_22_page"] + kinds = {b.kind for b in tpl.beats} + assert "Page" in kinds + assert "Panel" in kinds + assert "Spread" in kinds + + def test_short_has_5_pages(self): + tpl = COMIC_TEMPLATES["comic_short"] + pages = [b for b in tpl.beats if b.kind == "Page"] + assert len(pages) == 5 + + def test_gn_chapter_has_spread(self): + tpl = COMIC_TEMPLATES["comic_gn_chapter"] + spreads = [b for b in tpl.beats if b.kind == "Spread"] + assert len(spreads) >= 1 + + def test_all_templates_have_names(self): + for key, tpl in COMIC_TEMPLATES.items(): + assert tpl.name, f"Template {key} has no name" + assert tpl.description, f"Template {key} has no description" + + +class TestGenerateComicOutlineNodes: + def test_generates_nodes(self): + tpl = COMIC_TEMPLATES["comic_short"] + nodes = generate_comic_outline_nodes(tpl) + assert len(nodes) == len(tpl.beats) + + def test_node_kinds_match_beats(self): + tpl = COMIC_TEMPLATES["comic_short"] + nodes = generate_comic_outline_nodes(tpl) + for node, beat in zip(nodes, tpl.beats): + assert node["kind"] == beat.kind + + def test_nodes_have_connections(self): + tpl = COMIC_TEMPLATES["comic_short"] + nodes = generate_comic_outline_nodes(tpl) + connected = sum(1 for n in nodes if n["connections"]) + assert connected >= len(nodes) - 1 + + def test_page_nodes_at_row_start(self): + tpl = COMIC_TEMPLATES["comic_short"] + nodes = generate_comic_outline_nodes(tpl) + page_nodes = [n for n in nodes if n["kind"] == "Page"] + for pn in page_nodes: + assert pn["x"] == 40 + + def test_panel_nodes_offset_from_page(self): + tpl = COMIC_TEMPLATES["comic_short"] + nodes = generate_comic_outline_nodes(tpl) + panel_nodes = [n for n in nodes if n["kind"] == "Panel"] + for pn in panel_nodes: + assert pn["x"] > 40 + + def test_empty_template_returns_empty(self): + tpl = ComicTemplate(name="Empty", description="Nothing", beats=[]) + assert generate_comic_outline_nodes(tpl) == [] + + def test_spread_node_created(self): + tpl = COMIC_TEMPLATES["comic_22_page"] + nodes = generate_comic_outline_nodes(tpl) + spreads = [n for n in nodes if n["kind"] == "Spread"] + assert len(spreads) >= 1 + + def test_all_nodes_have_required_fields(self): + tpl = COMIC_TEMPLATES["comic_short"] + nodes = generate_comic_outline_nodes(tpl) + for n in nodes: + assert "id" in n + assert "x" in n + assert "y" in n + assert "kind" in n + assert "label" in n + assert "connections" in n + + +# --------------------------------------------------------------------------- +# Outline → Script (comic mode) +# --------------------------------------------------------------------------- + +class TestOutlineToScriptComic: + def test_page_becomes_section_header(self): + nodes = [ + {"id": 0, "kind": "Page", "label": "PAGE 1", "synopsis": "Hook", "x": 0, "y": 0, "connections": []}, + ] + script = outline_to_script(nodes, "fountain+comic-dc") + assert "# PAGE 1" in script + + def test_panel_becomes_panel_header(self): + nodes = [ + {"id": 0, "kind": "Page", "label": "PAGE 1", "synopsis": "", "x": 0, "y": 0, "connections": [1]}, + {"id": 1, "kind": "Panel", "label": "Panel 1", "synopsis": "Action here", "x": 220, "y": 0, "connections": []}, + ] + script = outline_to_script(nodes, "fountain+comic-dc") + assert "PANEL 1" in script + assert "Action here" in script + + def test_spread_becomes_section_header(self): + nodes = [ + {"id": 0, "kind": "Spread", "label": "SPREAD (4-5)", "synopsis": "Big moment", "x": 0, "y": 0, "connections": []}, + ] + script = outline_to_script(nodes, "fountain+comic-dc") + assert "# SPREAD (4-5)" in script + + def test_note_becomes_fountain_note(self): + nodes = [ + {"id": 0, "kind": "Note", "label": "Remember the cape", "synopsis": "", "x": 0, "y": 0, "connections": []}, + ] + script = outline_to_script(nodes, "fountain+comic-dc") + assert "[[Remember the cape]]" in script + + def test_full_page_structure(self): + nodes = [ + {"id": 0, "kind": "Page", "label": "PAGE 1", "synopsis": "Splash", "x": 0, "y": 0, "connections": [1]}, + {"id": 1, "kind": "Panel", "label": "Panel 1", "synopsis": "Hero lands", "x": 220, "y": 0, "connections": [2]}, + {"id": 2, "kind": "Panel", "label": "Panel 2", "synopsis": "Villain reacts", "x": 440, "y": 0, "connections": []}, + ] + script = outline_to_script(nodes, "fountain+comic-dc") + assert "# PAGE 1" in script + assert "PANEL 1" in script + assert "PANEL 2" in script + lines = script.split("\n") + page_idx = next(i for i, l in enumerate(lines) if "PAGE 1" in l) + panel1_idx = next(i for i, l in enumerate(lines) if "PANEL 1" in l) + panel2_idx = next(i for i, l in enumerate(lines) if "PANEL 2" in l) + assert page_idx < panel1_idx < panel2_idx + + +# --------------------------------------------------------------------------- +# Outline → Script (screenplay mode) +# --------------------------------------------------------------------------- + +class TestOutlineToScriptScreenplay: + def test_act_break_becomes_section(self): + nodes = [ + {"id": 0, "kind": "ActBreak", "label": "Act 1", "synopsis": "", "x": 0, "y": 0, "connections": []}, + ] + script = outline_to_script(nodes, "fountain/core") + assert "# Act 1" in script + + def test_scene_becomes_heading(self): + nodes = [ + {"id": 0, "kind": "Scene", "label": "INT. OFFICE - DAY", "synopsis": "", "x": 0, "y": 0, "connections": []}, + ] + script = outline_to_script(nodes, "fountain/core") + assert "INT. OFFICE - DAY" in script + + def test_scene_without_prefix_gets_int(self): + nodes = [ + {"id": 0, "kind": "Scene", "label": "The Chase", "synopsis": "", "x": 0, "y": 0, "connections": []}, + ] + script = outline_to_script(nodes, "fountain/core") + assert "INT. THE CHASE - DAY" in script + + def test_transition_produces_to(self): + nodes = [ + {"id": 0, "kind": "Transition", "label": "CUT", "synopsis": "", "x": 0, "y": 0, "connections": []}, + ] + script = outline_to_script(nodes, "fountain/core") + assert "CUT TO:" in script + + def test_synopsis_preserved(self): + nodes = [ + {"id": 0, "kind": "Scene", "label": "INT. BAR - NIGHT", "synopsis": "A tense meeting.", "x": 0, "y": 0, "connections": []}, + ] + script = outline_to_script(nodes, "fountain/core") + assert "= A tense meeting." in script + + +# --------------------------------------------------------------------------- +# Script → Outline (comic mode) +# --------------------------------------------------------------------------- + +class TestScriptToOutlineComic: + @pytest.fixture() + def comic_script(self): + return ( + "# PAGE ONE\n\n" + "PANEL 1\n\n" + "Hero leaps from rooftop.\n\n" + "PANEL 2\n\n" + "Villain looks up.\n\n" + "# PAGE TWO\n\n" + "PANEL 1\n\n" + "The confrontation.\n\n" + ) + + def test_extracts_pages(self, comic_script): + nodes = script_to_outline(comic_script, "fountain+comic-dc") + pages = [n for n in nodes if n["kind"] == "Page"] + assert len(pages) == 2 + + def test_extracts_panels(self, comic_script): + nodes = script_to_outline(comic_script, "fountain+comic-dc") + panels = [n for n in nodes if n["kind"] == "Panel"] + assert len(panels) == 3 + + def test_panels_get_synopsis_from_content(self, comic_script): + nodes = script_to_outline(comic_script, "fountain+comic-dc") + panels = [n for n in nodes if n["kind"] == "Panel"] + assert any("Hero leaps" in p.get("synopsis", "") for p in panels) + + def test_page_nodes_at_row_start(self, comic_script): + nodes = script_to_outline(comic_script, "fountain+comic-dc") + pages = [n for n in nodes if n["kind"] == "Page"] + for p in pages: + assert p["x"] == 40 + + def test_nodes_connected(self, comic_script): + nodes = script_to_outline(comic_script, "fountain+comic-dc") + connected_count = sum(1 for n in nodes if n["connections"]) + assert connected_count >= len(nodes) - 1 + + +# --------------------------------------------------------------------------- +# Script → Outline (screenplay mode) +# --------------------------------------------------------------------------- + +class TestScriptToOutlineScreenplay: + @pytest.fixture() + def screenplay(self): + return ( + "# Act 1\n\n" + "INT. COFFEE SHOP - DAY\n\n" + "= The hero meets a stranger.\n\n" + "They talk.\n\n" + "INT. STREET - NIGHT\n\n" + "A chase begins.\n\n" + ) + + def test_extracts_act(self, screenplay): + nodes = script_to_outline(screenplay, "fountain/core") + acts = [n for n in nodes if n["kind"] == "ActBreak"] + assert len(acts) == 1 + + def test_extracts_scenes(self, screenplay): + nodes = script_to_outline(screenplay, "fountain/core") + scenes = [n for n in nodes if n["kind"] == "Scene"] + assert len(scenes) == 2 + + def test_synopsis_from_marker(self, screenplay): + nodes = script_to_outline(screenplay, "fountain/core") + scenes = [n for n in nodes if n["kind"] == "Scene"] + assert any("stranger" in s.get("synopsis", "") for s in scenes) + + def test_nodes_connected(self, screenplay): + nodes = script_to_outline(screenplay, "fountain/core") + connected_count = sum(1 for n in nodes if n["connections"]) + assert connected_count >= len(nodes) - 1 + + +# --------------------------------------------------------------------------- +# Round-trip: outline → script → outline +# --------------------------------------------------------------------------- + +class TestRoundTrip: + def test_comic_round_trip_preserves_page_count(self): + tpl = COMIC_TEMPLATES["comic_short"] + nodes = generate_comic_outline_nodes(tpl) + script = outline_to_script(nodes, "fountain+comic-dc") + nodes2 = script_to_outline(script, "fountain+comic-dc") + pages_orig = sum(1 for n in nodes if n["kind"] == "Page") + pages_rt = sum(1 for n in nodes2 if n["kind"] == "Page") + assert pages_rt == pages_orig + + def test_comic_round_trip_preserves_panel_count(self): + tpl = COMIC_TEMPLATES["comic_short"] + nodes = generate_comic_outline_nodes(tpl) + script = outline_to_script(nodes, "fountain+comic-dc") + nodes2 = script_to_outline(script, "fountain+comic-dc") + panels_orig = sum(1 for n in nodes if n["kind"] == "Panel") + panels_rt = sum(1 for n in nodes2 if n["kind"] == "Panel") + assert panels_rt == panels_orig + + def test_screenplay_round_trip_preserves_scene_count(self): + nodes = [ + {"id": 0, "kind": "ActBreak", "label": "Act 1", "synopsis": "", "x": 0, "y": 0, "connections": [1]}, + {"id": 1, "kind": "Scene", "label": "INT. OFFICE - DAY", "synopsis": "Meeting.", "x": 220, "y": 0, "connections": [2]}, + {"id": 2, "kind": "Scene", "label": "EXT. PARK - NIGHT", "synopsis": "Discovery.", "x": 440, "y": 0, "connections": []}, + ] + script = outline_to_script(nodes, "fountain/core") + nodes2 = script_to_outline(script, "fountain/core") + scenes_orig = sum(1 for n in nodes if n["kind"] == "Scene") + scenes_rt = sum(1 for n in nodes2 if n["kind"] == "Scene") + assert scenes_rt == scenes_orig + + +# --------------------------------------------------------------------------- +# Canvas node types +# --------------------------------------------------------------------------- + +class TestCanvasNodeTypes: + def test_node_sizes_include_comic_types(self): + from ecrit.ui.screens.editor import OutlineCanvas + assert "Page" in OutlineCanvas.NODE_SIZES + assert "Panel" in OutlineCanvas.NODE_SIZES + assert "Spread" in OutlineCanvas.NODE_SIZES + + def test_page_wider_than_scene(self): + from ecrit.ui.screens.editor import OutlineCanvas + page_w = OutlineCanvas.NODE_SIZES["Page"][0] + scene_w = OutlineCanvas.NODE_SIZES["Scene"][0] + assert page_w > scene_w + + def test_spread_widest(self): + from ecrit.ui.screens.editor import OutlineCanvas + spread_w = OutlineCanvas.NODE_SIZES["Spread"][0] + for kind, (w, h) in OutlineCanvas.NODE_SIZES.items(): + if kind != "Spread": + assert spread_w >= w + + +# --------------------------------------------------------------------------- +# Outline phase format awareness +# --------------------------------------------------------------------------- + +class TestOutlinePhaseFormatAware: + def test_outline_phase_has_set_format_id(self): + from ecrit.ui.screens.editor import OutlinePhase + phase = OutlinePhase() + phase.set_format_id("fountain+comic-dc") + assert phase._format_id == "fountain+comic-dc" + assert phase.canvas._comic_mode is True + + def test_outline_phase_standard_mode(self): + from ecrit.ui.screens.editor import OutlinePhase + phase = OutlinePhase() + phase.set_format_id("fountain/core") + assert phase.canvas._comic_mode is False + + def test_template_combo_changes_with_format(self): + from ecrit.ui.screens.editor import OutlinePhase + phase = OutlinePhase() + phase.set_format_id("fountain+comic-dc") + items = [phase.template_combo.itemText(i) for i in range(phase.template_combo.count())] + assert any("Page" in item or "Issue" in item or "Short" in item or "Chapter" in item for item in items) + + def test_template_combo_screenplay_mode(self): + from ecrit.ui.screens.editor import OutlinePhase + phase = OutlinePhase() + phase.set_format_id("fountain/core") + items = [phase.template_combo.itemText(i) for i in range(phase.template_combo.count())] + assert any("Three-Act" in item for item in items) diff --git a/tests/test_companion.py b/tests/test_companion.py new file mode 100644 index 0000000..02ba260 --- /dev/null +++ b/tests/test_companion.py @@ -0,0 +1,162 @@ +"""Tests for companion modules: sync_bundle, reader_export, device_sync.""" + +import json +import os +import zipfile +import pytest + +from ecrit.companion.sync_bundle import SyncBundle, create_sync_bundle, load_sync_bundle +from ecrit.companion.reader_export import ReaderBundle, create_reader_bundle, _fountain_to_html + + +class TestSyncBundle: + def test_to_dict_roundtrip(self): + bundle = SyncBundle( + project_title="Test Script", + script_content="INT. OFFICE - DAY", + format_id="fountain/core", + ) + d = bundle.to_dict() + restored = SyncBundle.from_dict(d) + assert restored.project_title == "Test Script" + assert restored.script_content == "INT. OFFICE - DAY" + assert restored.format_id == "fountain/core" + + def test_created_at_auto_set(self): + bundle = SyncBundle(project_title="X", script_content="Y") + assert bundle.created_at != "" + + def test_from_dict_defaults(self): + bundle = SyncBundle.from_dict({}) + assert bundle.project_title == "Untitled" + assert bundle.script_content == "" + + def test_create_and_load_bundle(self, tmp_path): + project = tmp_path / "MyProject" + project.mkdir() + meta = {"title": "My Script", "format_id": "fountain/core"} + (project / "meta.json").write_text(json.dumps(meta)) + + content = "INT. LIVING ROOM - NIGHT\n\nCharacters speak." + filepath = create_sync_bundle( + str(project), content, title="My Script", output_dir=str(tmp_path), + ) + assert os.path.exists(filepath) + assert filepath.endswith(".ecrit-sync") + + loaded = load_sync_bundle(filepath) + assert loaded is not None + assert loaded.project_title == "My Script" + assert loaded.script_content == content + assert loaded.format_id == "fountain/core" + + def test_create_bundle_no_meta(self, tmp_path): + project = tmp_path / "NoMeta" + project.mkdir() + filepath = create_sync_bundle(str(project), "content", output_dir=str(tmp_path)) + loaded = load_sync_bundle(filepath) + assert loaded is not None + assert loaded.format_id == "fountain/core" + + def test_load_nonexistent(self, tmp_path): + assert load_sync_bundle(str(tmp_path / "nope.zip")) is None + + def test_load_corrupt_zip(self, tmp_path): + bad = tmp_path / "bad.ecrit-sync" + bad.write_text("not a zip") + assert load_sync_bundle(str(bad)) is None + + def test_bundle_contains_script(self, tmp_path): + project = tmp_path / "P" + project.mkdir() + content = "EXT. PARK - DAY" + filepath = create_sync_bundle(str(project), content, output_dir=str(tmp_path)) + with zipfile.ZipFile(filepath, "r") as zf: + names = zf.namelist() + assert "bundle.json" in names + assert "script.fountain" in names + script = zf.read("script.fountain").decode("utf-8") + assert script == content + + +class TestReaderExport: + def test_fountain_to_html_scene_heading(self): + html = _fountain_to_html("INT. OFFICE - DAY", "Test") + assert 'class="scene"' in html + assert "OFFICE" in html + + def test_fountain_to_html_character(self): + html = _fountain_to_html("JOHN\nHello there.", "Test") + assert 'class="character"' in html + assert "JOHN" in html + + def test_fountain_to_html_transition(self): + html = _fountain_to_html("CUT TO:", "Test") + assert 'class="transition"' in html + + def test_fountain_to_html_parenthetical(self): + html = _fountain_to_html("(whispering)", "Test") + assert 'class="parenthetical"' in html + + def test_fountain_to_html_escapes_xss(self): + html = _fountain_to_html("", "Test") + assert "