From 7e79ba03a6c05aebde0c66d3908c9e7222220b6a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 08:44:43 +0000 Subject: [PATCH 01/22] Fix UI polish: antialiasing, fonts, home button, Tab cycling, graph nodes, page width - Enable high-DPI scaling and font antialiasing on QApplication for crisp rendering - Increase font sizes across the entire stylesheet (+1-2px per element) - Move home button from title bar to floating bottom-right corner - Hide home button on dashboard (where it's redundant) - Rewrite Tab cycling to properly rotate through all six Fountain element types (scene heading, action, character, dialogue, parenthetical, transition) - Widen manuscript page frame from 680px to 816px to match standard script width - Make outline graph nodes fully interactive: drag to move, double-click to edit label/synopsis, right-click for context menu with edit/link/delete, keyboard delete, visual selection highlight, and connection creation mode with crosshair Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01NoacdztkMbQ5BREp9rVqNw --- ecrit/main.py | 39 +++- ecrit/ui/components/title_bar.py | 8 - ecrit/ui/screens/editor.py | 304 +++++++++++++++++++++++++------ ecrit/ui/styles/stylesheet.py | 38 ++-- 4 files changed, 302 insertions(+), 87 deletions(-) diff --git a/ecrit/main.py b/ecrit/main.py index 3f94aa3..9891b48 100644 --- a/ecrit/main.py +++ b/ecrit/main.py @@ -1,7 +1,7 @@ """Écrit — main application entry point.""" import sys -from PySide6.QtWidgets import QApplication, QMainWindow, QStackedWidget, QVBoxLayout, QWidget +from PySide6.QtWidgets import QApplication, QMainWindow, QPushButton, QStackedWidget, QVBoxLayout, QWidget from PySide6.QtCore import Qt, QTimer, Signal as QtSignal from PySide6.QtGui import QShortcut, QKeySequence @@ -61,7 +61,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 +70,14 @@ def __init__(self): self.stack = QStackedWidget() root.addWidget(self.stack, 1) + self._home_btn = QPushButton("⌂", self) + self._home_btn.setObjectName("homeBtn") + self._home_btn.setFixedSize(40, 40) + self._home_btn.setToolTip("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 +108,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) @@ -262,6 +268,7 @@ def _go_dashboard(self): 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 +276,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 +288,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) @@ -901,6 +911,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() @@ -1234,9 +1256,20 @@ def _update_week_stats(self): 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/ui/components/title_bar.py b/ecrit/ui/components/title_bar.py index b2f65a4..c8b13ec 100644 --- a/ecrit/ui/components/title_bar.py +++ b/ecrit/ui/components/title_bar.py @@ -157,14 +157,6 @@ def __init__(self, show_phases=False, parent=None): self.window_controls.maximize_clicked.connect(self.maximize_requested.emit) layout.addWidget(self.window_controls) - 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.wordmark = QLabel("Écrit") self.wordmark.setObjectName("wordmark") layout.addWidget(self.wordmark) diff --git a/ecrit/ui/screens/editor.py b/ecrit/ui/screens/editor.py index 48d276c..95c76e1 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 ( @@ -465,36 +465,67 @@ def keyPressEvent(self, event): self.completer.update_popup() def _cycle_element_type(self): + """Cycle current line through Fountain element types: + Scene Heading → Action → Character → Dialogue → Parenthetical → Transition → back. + """ 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 - else: - cursor.removeSelectedText() - cursor.insertText(f"({line})") + current = self._detect_element_type(stripped) + 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: + 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" + 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 == "transition": + return line + if elem_type == "character": + return 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() + return bare class SceneNavigator(QFrame): @@ -947,23 +978,48 @@ 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), + } 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 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,106 +1041,237 @@ 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) 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 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"): + 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) + 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 == 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 = (local_pos.x() - self._pan_x) / self._zoom - canvas_y = (local_pos.y() - self._pan_y) / self._zoom + canvas_x, canvas_y = self._to_canvas(local_pos) menu = QMenu(self) add_scene = menu.addAction("Add Scene") @@ -1103,11 +1290,14 @@ def _show_context_menu(self, local_pos, global_pos): 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() @@ -1200,7 +1390,7 @@ def __init__(self, parent=None): self.page_frame = QFrame() self.page_frame.setStyleSheet(f"background: {theme.current().surface}; border-radius: 4px;") - self.page_frame.setMaximumWidth(680) + 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 +1419,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) diff --git a/ecrit/ui/styles/stylesheet.py b/ecrit/ui/styles/stylesheet.py index 75a8156..6dc95e5 100644 --- a/ecrit/ui/styles/stylesheet.py +++ b/ecrit/ui/styles/stylesheet.py @@ -14,7 +14,7 @@ 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; @@ -31,12 +31,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 +45,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 +165,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 +192,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 +218,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 +249,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 +314,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 +338,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 +357,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,7 +369,7 @@ 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); }} From d5c68dc047f7f30a54fab6dd589a9ec6d01a0488 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 15:07:28 +0000 Subject: [PATCH 02/22] Fix data persistence, dashboard theming, and dishonest cloud export UI - Persist outline nodes to outline.json and plan documents to plan_documents.json so they survive project reload and phase switches - Replace hardcoded inline theme colors in dashboard with objectName-based stylesheet rules that respond to theme changes (Nocturne/Organic) - Make cloud export dialog honest: disable export button with "Coming Soon" label and explain provider integrations are planned for a future release instead of returning fake success URLs - Update tests to match new cloud export behavior Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01NoacdztkMbQ5BREp9rVqNw --- ecrit/main.py | 4 +++ ecrit/stores/app_state.py | 41 ++++++++++++++++++++++- ecrit/ui/overlays/cloud_export.py | 55 ++++++++----------------------- ecrit/ui/screens/dashboard.py | 50 ++++++++++++++-------------- ecrit/ui/screens/editor.py | 36 ++++++++++++++++++-- ecrit/ui/styles/stylesheet.py | 40 ++++++++++++++++++++++ tests/test_editor_widgets.py | 5 ++- tests/test_new_features.py | 24 +++++--------- 8 files changed, 168 insertions(+), 87 deletions(-) diff --git a/ecrit/main.py b/ecrit/main.py index 9891b48..511f327 100644 --- a/ecrit/main.py +++ b/ecrit/main.py @@ -264,6 +264,8 @@ 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("") @@ -324,6 +326,8 @@ def _on_content_changed(self): saved = STATE.save_script() if saved: self._module_registry.call_hook(HOOK_AFTER_SAVE, STATE.script_content) + STATE.save_outline(self.editor.outline.canvas._nodes) + STATE.save_plan_documents(self.editor.plan.get_documents()) self._editor_title_bar.save_dot.set_saved(saved) if saved and STATE.current_project_path: self._auto_sync_if_enabled() diff --git a/ecrit/stores/app_state.py b/ecrit/stores/app_state.py index 8772e13..ae5cc61 100644 --- a/ecrit/stores/app_state.py +++ b/ecrit/stores/app_state.py @@ -96,10 +96,29 @@ def _open_project_py(self, path: str): script = script_file.read_text(encoding="utf-8") except Exception: pass + 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 Exception: + pass + 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 Exception: + pass 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: @@ -162,6 +181,26 @@ 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 Exception: + 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 Exception: + return False + def parse_script(self) -> dict: if ecrit_core and self.script_content: try: diff --git a/ecrit/ui/overlays/cloud_export.py b/ecrit/ui/overlays/cloud_export.py index 42be117..4665e81 100644 --- a/ecrit/ui/overlays/cloud_export.py +++ b/ecrit/ui/overlays/cloud_export.py @@ -112,13 +112,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) @@ -134,56 +134,29 @@ def __init__(self, parent=None): 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.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/screens/dashboard.py b/ecrit/ui/screens/dashboard.py index d714fa1..5c194c7 100644 --- a/ecrit/ui/screens/dashboard.py +++ b/ecrit/ui/screens/dashboard.py @@ -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,6 +71,7 @@ def __init__(self, parent=None): layout.addWidget(kicker) self.stats = {} + self._stat_labels = [] grid = QGridLayout() grid.setSpacing(8) for i, (key, label) in enumerate([ @@ -84,12 +82,13 @@ def __init__(self, parent=None): ]): 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"): @@ -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,12 +168,13 @@ 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("⚙") @@ -226,7 +223,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 +248,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 diff --git a/ecrit/ui/screens/editor.py b/ecrit/ui/screens/editor.py index 95c76e1..ed7f968 100644 --- a/ecrit/ui/screens/editor.py +++ b/ecrit/ui/screens/editor.py @@ -854,6 +854,14 @@ 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.""" @@ -1967,9 +1975,31 @@ 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) diff --git a/ecrit/ui/styles/stylesheet.py b/ecrit/ui/styles/stylesheet.py index 6dc95e5..08a228c 100644 --- a/ecrit/ui/styles/stylesheet.py +++ b/ecrit/ui/styles/stylesheet.py @@ -373,6 +373,46 @@ def generate(t: ThemeTokens) -> str: 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; +}} """ diff --git a/tests/test_editor_widgets.py b/tests/test_editor_widgets.py index 1ff2a8e..f5e7910 100644 --- a/tests/test_editor_widgets.py +++ b/tests/test_editor_widgets.py @@ -584,13 +584,12 @@ def test_auto_export_changed_signal(self, qapp): assert len(received) == 1 assert received[0][1] is True - def test_provider_changed_restores_auth_status(self, qapp): + def test_provider_changed_shows_not_available(self, qapp): from ecrit.ui.overlays.cloud_export import CloudExportDialog dialog = CloudExportDialog() - dialog.set_configs([{"provider": "google_drive", "authenticated": True, "auto_export": True}]) dialog.provider_combo.setCurrentIndex(1) dialog.provider_combo.setCurrentIndex(0) - assert dialog.auth_status.text() == "Authenticated" + assert dialog.auth_status.text() == "Not yet available" class TestDeliverPhaseInit: diff --git a/tests/test_new_features.py b/tests/test_new_features.py index 756847f..9824681 100644 --- a/tests/test_new_features.py +++ b/tests/test_new_features.py @@ -376,19 +376,17 @@ def test_format_combo(self, qapp): assert "pdf" in formats assert "fountain" in formats - def test_export_requested_signal(self, qapp): + def test_export_button_disabled(self, qapp): from ecrit.ui.overlays.cloud_export import CloudExportDialog dialog = CloudExportDialog() - received = [] - dialog.export_requested.connect(lambda p, f: received.append((p, f))) - dialog._do_export() - assert len(received) == 1 + assert not dialog.export_btn.isEnabled() + assert dialog.export_btn.text() == "Coming Soon" - def test_history_entry(self, qapp): + def test_authenticate_shows_not_available(self, qapp): from ecrit.ui.overlays.cloud_export import CloudExportDialog dialog = CloudExportDialog() - dialog.add_history_entry("Test export") - assert dialog.history_list.count() == 1 + dialog._authenticate() + assert "not yet available" in dialog.auth_status.text().lower() # --------------------------------------------------------------------------- @@ -619,15 +617,11 @@ def test_sync_empty_config(self, qapp): assert config["remote_url"] == "" assert config["token"] == "" - def test_cloud_export_all_formats(self, qapp): + def test_cloud_export_button_disabled_coming_soon(self, qapp): from ecrit.ui.overlays.cloud_export import CloudExportDialog dialog = CloudExportDialog() - received = [] - dialog.export_requested.connect(lambda p, f: received.append(f)) - for i in range(dialog.format_combo.count()): - dialog.format_combo.setCurrentIndex(i) - dialog._do_export() - assert len(received) == dialog.format_combo.count() + assert not dialog.export_btn.isEnabled() + assert dialog.format_combo.count() == 4 def test_share_review_html_large_script(self): from ecrit.export.share_review import generate_review_html From 78ee8c04d2eb4d7ffe8407a8761315e44dae96f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 15:16:13 +0000 Subject: [PATCH 03/22] Fix Find & Replace, theme responsiveness, and dashboard stats - Find & Replace now updates match count on every find operation and shows current/total (e.g., "3 / 12") - Replace operations now respect case sensitivity and regex checkboxes instead of hardcoding case-sensitive literal matching - Convert ~20 inline theme.current() calls in overlays and editor phases to objectName-based stylesheet rules so they update on theme switch - WeekStatsCard now shows Scenes and Characters (real data from parser) instead of Sessions and Streak (which had no backing data) - Remove unused theme imports from find_replace, command_palette, marketplace, and collaboration overlays Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01NoacdztkMbQ5BREp9rVqNw --- ecrit/main.py | 2 + ecrit/ui/overlays/collaboration.py | 21 ++---- ecrit/ui/overlays/command_palette.py | 10 +-- ecrit/ui/overlays/find_replace.py | 5 +- ecrit/ui/overlays/marketplace.py | 8 +- ecrit/ui/screens/dashboard.py | 10 +-- ecrit/ui/screens/editor.py | 109 +++++++++++++++++++++------ ecrit/ui/styles/stylesheet.py | 46 +++++++++++ 8 files changed, 152 insertions(+), 59 deletions(-) diff --git a/ecrit/main.py b/ecrit/main.py index 511f327..96eb2de 100644 --- a/ecrit/main.py +++ b/ecrit/main.py @@ -1256,6 +1256,8 @@ 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), ) diff --git a/ecrit/ui/overlays/collaboration.py b/ecrit/ui/overlays/collaboration.py index 6771faa..775f548 100644 --- a/ecrit/ui/overlays/collaboration.py +++ b/ecrit/ui/overlays/collaboration.py @@ -8,7 +8,6 @@ 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 @@ -26,7 +25,6 @@ def __init__(self, parent=None): self.setModal(True) self._session: CollabSession | None = None - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -39,7 +37,7 @@ 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("×") @@ -79,7 +77,7 @@ def __init__(self, parent=None): 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 +92,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,10 +100,7 @@ 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")) h_layout.addWidget(self.token_display) @@ -128,9 +121,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"))) @@ -163,7 +154,7 @@ def __init__(self, parent=None): 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) diff --git a/ecrit/ui/overlays/command_palette.py b/ecrit/ui/overlays/command_palette.py index ad26f94..461bf8d 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 = [ @@ -75,11 +74,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) @@ -96,7 +91,7 @@ def __init__(self, parent=None): 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 +99,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/find_replace.py b/ecrit/ui/overlays/find_replace.py index 61c2889..eb652d5 100644 --- a/ecrit/ui/overlays/find_replace.py +++ b/ecrit/ui/overlays/find_replace.py @@ -6,9 +6,6 @@ ) from PySide6.QtCore import Qt, Signal -from ecrit.ui.styles import theme - - class FindReplaceBar(QFrame): find_next = Signal(str, bool, bool) find_prev = Signal(str, bool, bool) @@ -37,7 +34,7 @@ def __init__(self, parent=None): 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) diff --git a/ecrit/ui/overlays/marketplace.py b/ecrit/ui/overlays/marketplace.py index ca61414..ba11a1a 100644 --- a/ecrit/ui/overlays/marketplace.py +++ b/ecrit/ui/overlays/marketplace.py @@ -7,7 +7,6 @@ ) from PySide6.QtCore import Qt, Signal -from ecrit.ui.styles import theme from ecrit.i18n import tr from ecrit.plugins.marketplace import Marketplace, PluginListing, PluginCategory @@ -24,7 +23,6 @@ def __init__(self, parent=None): self._marketplace = Marketplace() - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -100,17 +98,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/screens/dashboard.py b/ecrit/ui/screens/dashboard.py index 5c194c7..d2027de 100644 --- a/ecrit/ui/screens/dashboard.py +++ b/ecrit/ui/screens/dashboard.py @@ -76,9 +76,9 @@ def __init__(self, parent=None): 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) @@ -91,11 +91,11 @@ def __init__(self, parent=None): 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): diff --git a/ecrit/ui/screens/editor.py b/ecrit/ui/screens/editor.py index ed7f968..f95f567 100644 --- a/ecrit/ui/screens/editor.py +++ b/ecrit/ui/screens/editor.py @@ -932,7 +932,7 @@ def __init__(self, parent=None): 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) @@ -1397,7 +1397,7 @@ 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.setObjectName("pageFrame") self.page_frame.setMaximumWidth(816) self.page_frame.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) page_layout = QVBoxLayout(self.page_frame) @@ -1464,7 +1464,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) @@ -1575,16 +1575,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() @@ -1657,9 +1654,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) @@ -1677,9 +1672,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() @@ -2011,6 +2004,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: @@ -2021,6 +2015,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) @@ -2041,6 +2036,50 @@ 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): + pos = m if isinstance(m, int) else m.start() + if pos >= cursor_pos - len(text): + 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) @@ -2049,19 +2088,47 @@ 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() + 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) + if use_regex: + import re + flags = 0 if case_sensitive else re.IGNORECASE + try: + new_content = re.sub(find_text, replace_text, content, flags=flags) + except re.error: + return + else: + if case_sensitive: + new_content = content.replace(find_text, replace_text) + else: + import re + new_content = re.sub(re.escape(find_text), replace_text, content, flags=re.IGNORECASE) + if new_content != content: editor.setPlainText(new_content) - self.find_bar.set_match_count(0, 0) + self.find_bar.set_match_count(0, 0) diff --git a/ecrit/ui/styles/stylesheet.py b/ecrit/ui/styles/stylesheet.py index 08a228c..f066e5d 100644 --- a/ecrit/ui/styles/stylesheet.py +++ b/ecrit/ui/styles/stylesheet.py @@ -413,6 +413,52 @@ def generate(t: ThemeTokens) -> str: 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; +}} """ From a8c50dc33ac0253e13051aee4d7c67f9f2f49d3e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 15:17:31 +0000 Subject: [PATCH 04/22] Remove stale theme.current() from cloud export dialog Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01NoacdztkMbQ5BREp9rVqNw --- ecrit/ui/overlays/cloud_export.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ecrit/ui/overlays/cloud_export.py b/ecrit/ui/overlays/cloud_export.py index 4665e81..b2ba638 100644 --- a/ecrit/ui/overlays/cloud_export.py +++ b/ecrit/ui/overlays/cloud_export.py @@ -7,7 +7,6 @@ ) from PySide6.QtCore import Qt, Signal -from ecrit.ui.styles import theme from ecrit.sync.cloud_export import CloudProvider, CloudConfig @@ -32,7 +31,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) @@ -69,7 +67,7 @@ def __init__(self, parent=None): 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...") From 3214a56a3382bf195a06adc7c9a1842b36d150df Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 15:31:50 +0000 Subject: [PATCH 05/22] Convert construction-time theme.current() to objectName CSS styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ~40 inline setStyleSheet() calls that baked theme colors at widget construction time with objectName-based CSS rules in the global stylesheet. This fixes theme toggle responsiveness — widgets now pick up new colors when the stylesheet is regenerated on theme change. Added 8 new CSS rule groups to stylesheet.py: formGroup, kbdKey, countBadge, loglinePreview, scratchpadEditor, diffView, moodboardTile, and readingMode. Converted 25 files across overlays and screens. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01NoacdztkMbQ5BREp9rVqNw --- ecrit/ui/overlays/analytics_dialog.py | 3 +- ecrit/ui/overlays/annotations_panel.py | 6 +- ecrit/ui/overlays/autosave_browser.py | 4 +- ecrit/ui/overlays/character_cards_panel.py | 6 +- ecrit/ui/overlays/character_sheet.py | 15 ++--- ecrit/ui/overlays/companion.py | 10 +-- ecrit/ui/overlays/compare_drafts.py | 18 ++---- ecrit/ui/overlays/logline_builder.py | 22 ++----- ecrit/ui/overlays/orphan_finder_dialog.py | 6 +- ecrit/ui/overlays/reading_mode.py | 12 +--- ecrit/ui/overlays/reports.py | 2 - ecrit/ui/overlays/scratchpad.py | 10 +-- ecrit/ui/overlays/series_panel.py | 2 - ecrit/ui/overlays/settings.py | 31 +++------- ecrit/ui/overlays/share_review.py | 4 +- ecrit/ui/overlays/shortcuts.py | 8 +-- ecrit/ui/overlays/snapshots.py | 3 - ecrit/ui/overlays/spellcheck_dialog.py | 6 +- ecrit/ui/overlays/sprint_timer.py | 2 - ecrit/ui/overlays/statistics.py | 5 +- ecrit/ui/overlays/sync_settings.py | 7 +-- ecrit/ui/overlays/title_template_dialog.py | 8 +-- ecrit/ui/screens/editor.py | 1 - ecrit/ui/screens/new_project.py | 11 +--- ecrit/ui/styles/stylesheet.py | 72 ++++++++++++++++++++++ 25 files changed, 121 insertions(+), 153 deletions(-) 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..c0ec737 100644 --- a/ecrit/ui/overlays/autosave_browser.py +++ b/ecrit/ui/overlays/autosave_browser.py @@ -8,7 +8,6 @@ ) from PySide6.QtCore import Qt, Signal -from ecrit.ui.styles import theme from ecrit.screenplay.autosave import AutosaveManager @@ -24,7 +23,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) @@ -42,7 +40,7 @@ def __init__(self, manager: AutosaveManager, project_path: str, parent=None): 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() diff --git a/ecrit/ui/overlays/character_cards_panel.py b/ecrit/ui/overlays/character_cards_panel.py index e453bcf..de63785 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) diff --git a/ecrit/ui/overlays/character_sheet.py b/ecrit/ui/overlays/character_sheet.py index f6e3bf2..544c57c 100644 --- a/ecrit/ui/overlays/character_sheet.py +++ b/ecrit/ui/overlays/character_sheet.py @@ -13,24 +13,18 @@ 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 +56,6 @@ def __init__(self, parent=None): self.setModal(True) self._data = {} - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -128,7 +121,7 @@ def __init__(self, parent=None): 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() diff --git a/ecrit/ui/overlays/companion.py b/ecrit/ui/overlays/companion.py index 1199439..916311b 100644 --- a/ecrit/ui/overlays/companion.py +++ b/ecrit/ui/overlays/companion.py @@ -7,7 +7,6 @@ ) 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 @@ -25,8 +24,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) @@ -85,7 +82,7 @@ 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")) @@ -106,10 +103,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 diff --git a/ecrit/ui/overlays/compare_drafts.py b/ecrit/ui/overlays/compare_drafts.py index 6907dab..db71efe 100644 --- a/ecrit/ui/overlays/compare_drafts.py +++ b/ecrit/ui/overlays/compare_drafts.py @@ -17,7 +17,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) @@ -63,26 +62,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 +80,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/logline_builder.py b/ecrit/ui/overlays/logline_builder.py index 76711d2..cc2d92f 100644 --- a/ecrit/ui/overlays/logline_builder.py +++ b/ecrit/ui/overlays/logline_builder.py @@ -33,8 +33,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) @@ -70,10 +68,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 +90,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 +104,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) 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..f0193fd 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() @@ -50,16 +48,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..eacfbd2 100644 --- a/ecrit/ui/overlays/reports.py +++ b/ecrit/ui/overlays/reports.py @@ -6,7 +6,6 @@ ) from PySide6.QtCore import Qt -from ecrit.ui.styles import theme from ecrit.screenplay.production_reports import ( generate_scene_report, generate_cast_report, @@ -40,7 +39,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) diff --git a/ecrit/ui/overlays/scratchpad.py b/ecrit/ui/overlays/scratchpad.py index b0bcc3c..2c4bcbc 100644 --- a/ecrit/ui/overlays/scratchpad.py +++ b/ecrit/ui/overlays/scratchpad.py @@ -5,7 +5,6 @@ ) from PySide6.QtCore import Qt, Signal -from ecrit.ui.styles import theme class Scratchpad(QFrame): @@ -17,7 +16,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) @@ -37,16 +35,12 @@ def __init__(self, parent=None): 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") layout.addWidget(self.text_area, 1) buttons = QHBoxLayout() diff --git a/ecrit/ui/overlays/series_panel.py b/ecrit/ui/overlays/series_panel.py index ea529ab..90b6424 100644 --- a/ecrit/ui/overlays/series_panel.py +++ b/ecrit/ui/overlays/series_panel.py @@ -8,7 +8,6 @@ ) from PySide6.QtCore import Qt, Signal -from ecrit.ui.styles import theme from ecrit.screenplay.series_projects import ( SeriesProject, SeriesSeason, Episode, BibleEntry, SeriesBible, ) @@ -28,7 +27,6 @@ def __init__(self, parent=None): self._project: SeriesProject | None = None - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) diff --git a/ecrit/ui/overlays/settings.py b/ecrit/ui/overlays/settings.py index d3fbfbf..6bb13cd 100644 --- a/ecrit/ui/overlays/settings.py +++ b/ecrit/ui/overlays/settings.py @@ -15,11 +15,7 @@ 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,8 +53,6 @@ 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") @@ -113,10 +107,7 @@ 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") @@ -193,14 +184,15 @@ 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.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.clicked.connect(lambda: self._set_theme("organic")) theme_row.addWidget(self.light_btn) @@ -222,7 +214,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( @@ -273,11 +265,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 +278,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("") diff --git a/ecrit/ui/overlays/share_review.py b/ecrit/ui/overlays/share_review.py index 02d3c82..c40ef41 100644 --- a/ecrit/ui/overlays/share_review.py +++ b/ecrit/ui/overlays/share_review.py @@ -7,7 +7,6 @@ ) from PySide6.QtCore import Qt, Signal -from ecrit.ui.styles import theme class ShareReviewDialog(QDialog): @@ -23,7 +22,6 @@ def __init__(self, parent=None): self._shares: list[dict] = [] - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -53,7 +51,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") diff --git a/ecrit/ui/overlays/shortcuts.py b/ecrit/ui/overlays/shortcuts.py index 3d24e14..c0eb31b 100644 --- a/ecrit/ui/overlays/shortcuts.py +++ b/ecrit/ui/overlays/shortcuts.py @@ -6,7 +6,6 @@ ) from PySide6.QtCore import Qt -from ecrit.ui.styles import theme SHORTCUT_GROUPS = [ ("File", [ @@ -52,7 +51,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) @@ -86,11 +84,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..958d69f 100644 --- a/ecrit/ui/overlays/snapshots.py +++ b/ecrit/ui/overlays/snapshots.py @@ -10,7 +10,6 @@ ) from PySide6.QtCore import Qt, Signal -from ecrit.ui.styles import theme def _run_git(project_path: str, *args) -> str: @@ -103,7 +102,6 @@ def __init__(self, parent=None): self._project_path = "" - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(20, 16, 20, 16) layout.setSpacing(12) @@ -151,7 +149,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..ce44a95 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) diff --git a/ecrit/ui/overlays/statistics.py b/ecrit/ui/overlays/statistics.py index 365d93d..e364f9c 100644 --- a/ecrit/ui/overlays/statistics.py +++ b/ecrit/ui/overlays/statistics.py @@ -7,7 +7,6 @@ ) from PySide6.QtCore import Qt -from ecrit.ui.styles import theme class StatRow(QFrame): @@ -16,9 +15,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 +37,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) diff --git a/ecrit/ui/overlays/sync_settings.py b/ecrit/ui/overlays/sync_settings.py index 8e210fd..f3c7550 100644 --- a/ecrit/ui/overlays/sync_settings.py +++ b/ecrit/ui/overlays/sync_settings.py @@ -7,7 +7,6 @@ ) from PySide6.QtCore import Qt, Signal -from ecrit.ui.styles import theme PROVIDERS = [ @@ -31,7 +30,6 @@ def __init__(self, parent=None): self._config: dict = {} - t = theme.current() layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -109,7 +107,6 @@ def __init__(self, parent=None): 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 +115,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() 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/editor.py b/ecrit/ui/screens/editor.py index f95f567..29f10fe 100644 --- a/ecrit/ui/screens/editor.py +++ b/ecrit/ui/screens/editor.py @@ -1340,7 +1340,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) diff --git a/ecrit/ui/screens/new_project.py b/ecrit/ui/screens/new_project.py index ca0f157..235e097 100644 --- a/ecrit/ui/screens/new_project.py +++ b/ecrit/ui/screens/new_project.py @@ -61,18 +61,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 +97,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 +116,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 f066e5d..c2ce881 100644 --- a/ecrit/ui/styles/stylesheet.py +++ b/ecrit/ui/styles/stylesheet.py @@ -459,6 +459,78 @@ def generate(t: ThemeTokens) -> str: 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; +}} """ From 46d61703cb026e5c32527d49961010e6fa5d29d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 15:37:10 +0000 Subject: [PATCH 06/22] Fix theme toggle for highlighter, completer, and wordmark Wire FountainHighlighter._update_formats(), ScriptCompleter._apply_theme(), and TitleBar.set_wordmark_accent() into the main _apply_theme() method so these components refresh their colors on theme toggle. Also remove unused theme imports from dashboard.py and presence_indicators.py. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01NoacdztkMbQ5BREp9rVqNw --- ecrit/main.py | 4 ++++ ecrit/ui/components/presence_indicators.py | 2 -- ecrit/ui/screens/dashboard.py | 2 -- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ecrit/main.py b/ecrit/main.py index 96eb2de..9cb545b 100644 --- a/ecrit/main.py +++ b/ecrit/main.py @@ -259,6 +259,10 @@ 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: 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/screens/dashboard.py b/ecrit/ui/screens/dashboard.py index d2027de..f03117d 100644 --- a/ecrit/ui/screens/dashboard.py +++ b/ecrit/ui/screens/dashboard.py @@ -7,8 +7,6 @@ from PySide6.QtCore import Qt, Signal from datetime import datetime -from ecrit.ui.styles import theme - class ContinueWritingCard(QFrame): open_clicked = Signal() From 37b33db59c0f72a115bcebf8772345987f6b4df5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 21:01:55 +0000 Subject: [PATCH 07/22] Fix collaboration bugs: OT transform, relay crash, LAN port, session security - Fix CollabMessage.operation() receiving dict instead of Operation, causing AttributeError when relaying ops to other peers - Rewrite OT transform to correctly handle overlapping DELETE ranges, INSERT splitting a DELETE range, and DELETE straddling an INSERT point - Propagate actual P2P server port to LAN discovery so peers connect to the right address instead of hardcoded default 8741 - Increase P2P session ID entropy from 48 bits to 128 bits - Remove premature mark_synced for companion devices before actual download Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01NoacdztkMbQ5BREp9rVqNw --- ecrit/collab/crdt.py | 48 +++++++++++++++++++++++++++++++++++++++-- ecrit/collab/p2p.py | 2 +- ecrit/collab/session.py | 3 ++- ecrit/main.py | 2 -- 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/ecrit/collab/crdt.py b/ecrit/collab/crdt.py index b6db8b5..d62da76 100644 --- a/ecrit/collab/crdt.py +++ b/ecrit/collab/crdt.py @@ -95,10 +95,22 @@ 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: + insert_len = len(op2.text) + if op1.op_type == OperationType.DELETE and op1.position < op2.position < op1.position + op1.length: + # op2 insert splits op1's delete range — expand length to skip inserted text + return Operation( + op_type=op1.op_type, + 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: return Operation( op_type=op1.op_type, - position=op1.position + len(op2.text), + position=op1.position + insert_len, text=op1.text, length=op1.length, user_id=op1.user_id, @@ -106,7 +118,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 +130,37 @@ 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 + return Operation( + op_type=op1.op_type, + position=max(op1.position, op2.position) - min(op1.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) if op1.position < op2.position else 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/p2p.py b/ecrit/collab/p2p.py index 2477236..41dc77d 100644 --- a/ecrit/collab/p2p.py +++ b/ecrit/collab/p2p.py @@ -85,7 +85,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) diff --git a/ecrit/collab/session.py b/ecrit/collab/session.py index 77c15eb..dc84661 100644 --- a/ecrit/collab/session.py +++ b/ecrit/collab/session.py @@ -102,6 +102,7 @@ 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.start() self._set_state(SessionState.CONNECTED) @@ -231,7 +232,7 @@ def _handle_message(self, peer_id: str, raw: str) -> None: 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: if pid != peer_id: diff --git a/ecrit/main.py b/ecrit/main.py index 9cb545b..3d4e332 100644 --- a/ecrit/main.py +++ b/ecrit/main.py @@ -824,8 +824,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: From 5fb81f3a7e19deb05db97f43e6a744d99bb8270c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 21:14:09 +0000 Subject: [PATCH 08/22] Flip title bar layout and replace Unicode icons with Heroicons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move window controls (close/minimize/maximize) to the right side and settings button to the left side of the title bar. Replace all Unicode symbol icons (×, ⚙, ⌂, arrows) across the app with Heroicons (MIT, Tailwind Labs) rendered as QIcon via QSvgRenderer. Add IconButton widget that auto-refreshes icon color on theme change. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01NoacdztkMbQ5BREp9rVqNw --- ecrit/main.py | 4 +- ecrit/ui/components/status_bar.py | 4 +- ecrit/ui/components/title_bar.py | 50 ++++++++------ ecrit/ui/icons.py | 96 +++++++++++++++++++++++++++ ecrit/ui/overlays/autosave_browser.py | 3 +- ecrit/ui/overlays/character_sheet.py | 3 +- ecrit/ui/overlays/cloud_export.py | 3 +- ecrit/ui/overlays/collaboration.py | 3 +- ecrit/ui/overlays/companion.py | 3 +- ecrit/ui/overlays/compare_drafts.py | 3 +- ecrit/ui/overlays/find_replace.py | 8 ++- ecrit/ui/overlays/logline_builder.py | 3 +- ecrit/ui/overlays/marketplace.py | 3 +- ecrit/ui/overlays/reports.py | 3 +- ecrit/ui/overlays/scratchpad.py | 3 +- ecrit/ui/overlays/series_panel.py | 3 +- ecrit/ui/overlays/settings.py | 3 +- ecrit/ui/overlays/share_review.py | 3 +- ecrit/ui/overlays/shortcuts.py | 4 +- ecrit/ui/overlays/snapshots.py | 3 +- ecrit/ui/overlays/statistics.py | 3 +- ecrit/ui/overlays/sync_settings.py | 3 +- ecrit/ui/screens/dashboard.py | 7 +- ecrit/ui/screens/editor.py | 9 +-- 24 files changed, 181 insertions(+), 49 deletions(-) create mode 100644 ecrit/ui/icons.py diff --git a/ecrit/main.py b/ecrit/main.py index 3d4e332..5f2d360 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 @@ -70,7 +72,7 @@ def __init__(self): self.stack = QStackedWidget() root.addWidget(self.stack, 1) - self._home_btn = QPushButton("⌂", self) + 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") diff --git a/ecrit/ui/components/status_bar.py b/ecrit/ui/components/status_bar.py index 4a711ca..7fd289a 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() @@ -85,7 +87,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 c8b13ec..8903d31 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,12 +63,10 @@ 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.clicked.connect(self.minimize_clicked.emit) @@ -77,7 +76,9 @@ def __init__(self, parent=None): self._maximize.clicked.connect(self.maximize_clicked.emit) layout.addWidget(self._maximize) - layout.addStretch() + self._close = WindowButton("close") + self._close.clicked.connect(self.close_clicked.emit) + layout.addWidget(self._close) class PhaseTabBar(QWidget): @@ -148,14 +149,18 @@ 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.clicked.connect(self.settings_clicked.emit) + layout.addWidget(self.settings_btn) + + self.save_dot = SaveIndicator() + layout.addWidget(self.save_dot) self.wordmark = QLabel("Écrit") self.wordmark.setObjectName("wordmark") @@ -175,27 +180,30 @@ 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) 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/autosave_browser.py b/ecrit/ui/overlays/autosave_browser.py index c0ec737..0d79678 100644 --- a/ecrit/ui/overlays/autosave_browser.py +++ b/ecrit/ui/overlays/autosave_browser.py @@ -8,6 +8,7 @@ ) from PySide6.QtCore import Qt, Signal +from ecrit.ui.icons import IconButton from ecrit.screenplay.autosave import AutosaveManager @@ -32,7 +33,7 @@ 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.clicked.connect(self.close) diff --git a/ecrit/ui/overlays/character_sheet.py b/ecrit/ui/overlays/character_sheet.py index 544c57c..52c7ed5 100644 --- a/ecrit/ui/overlays/character_sheet.py +++ b/ecrit/ui/overlays/character_sheet.py @@ -7,6 +7,7 @@ from PySide6.QtCore import Qt, Signal from ecrit.ui.styles import theme +from ecrit.ui.icons import IconButton class MoodboardTile(QFrame): @@ -72,7 +73,7 @@ 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.clicked.connect(self.close) diff --git a/ecrit/ui/overlays/cloud_export.py b/ecrit/ui/overlays/cloud_export.py index b2ba638..82e0657 100644 --- a/ecrit/ui/overlays/cloud_export.py +++ b/ecrit/ui/overlays/cloud_export.py @@ -8,6 +8,7 @@ from PySide6.QtCore import Qt, Signal from ecrit.sync.cloud_export import CloudProvider, CloudConfig +from ecrit.ui.icons import IconButton CLOUD_PROVIDERS = [ @@ -40,7 +41,7 @@ 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.clicked.connect(self.close) diff --git a/ecrit/ui/overlays/collaboration.py b/ecrit/ui/overlays/collaboration.py index 775f548..67e6678 100644 --- a/ecrit/ui/overlays/collaboration.py +++ b/ecrit/ui/overlays/collaboration.py @@ -11,6 +11,7 @@ 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): @@ -40,7 +41,7 @@ def __init__(self, parent=None): 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.clicked.connect(self.close) diff --git a/ecrit/ui/overlays/companion.py b/ecrit/ui/overlays/companion.py index 916311b..cb48be0 100644 --- a/ecrit/ui/overlays/companion.py +++ b/ecrit/ui/overlays/companion.py @@ -9,6 +9,7 @@ from ecrit.i18n import tr from ecrit.companion.device_sync import DeviceSync, PairedDevice, SyncStatus +from ecrit.ui.icons import IconButton class CompanionDialog(QDialog): @@ -33,7 +34,7 @@ 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.clicked.connect(self.close) diff --git a/ecrit/ui/overlays/compare_drafts.py b/ecrit/ui/overlays/compare_drafts.py index db71efe..fc778b9 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): @@ -33,7 +34,7 @@ def __init__(self, parent=None): 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.clicked.connect(self.close) diff --git a/ecrit/ui/overlays/find_replace.py b/ecrit/ui/overlays/find_replace.py index eb652d5..8d6b136 100644 --- a/ecrit/ui/overlays/find_replace.py +++ b/ecrit/ui/overlays/find_replace.py @@ -6,6 +6,8 @@ ) from PySide6.QtCore import Qt, Signal +from ecrit.ui.icons import IconButton + class FindReplaceBar(QFrame): find_next = Signal(str, bool, bool) find_prev = Signal(str, bool, bool) @@ -38,19 +40,19 @@ def __init__(self, parent=None): 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.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.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.clicked.connect(self.toggle) diff --git a/ecrit/ui/overlays/logline_builder.py b/ecrit/ui/overlays/logline_builder.py index cc2d92f..ed40d9f 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, @@ -43,7 +44,7 @@ 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.clicked.connect(self.close) diff --git a/ecrit/ui/overlays/marketplace.py b/ecrit/ui/overlays/marketplace.py index ba11a1a..7f1f0d4 100644 --- a/ecrit/ui/overlays/marketplace.py +++ b/ecrit/ui/overlays/marketplace.py @@ -8,6 +8,7 @@ from PySide6.QtCore import Qt, Signal from ecrit.i18n import tr +from ecrit.ui.icons import IconButton from ecrit.plugins.marketplace import Marketplace, PluginListing, PluginCategory @@ -32,7 +33,7 @@ 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.clicked.connect(self.close) diff --git a/ecrit/ui/overlays/reports.py b/ecrit/ui/overlays/reports.py index eacfbd2..b2c3adf 100644 --- a/ecrit/ui/overlays/reports.py +++ b/ecrit/ui/overlays/reports.py @@ -6,6 +6,7 @@ ) from PySide6.QtCore import Qt +from ecrit.ui.icons import IconButton from ecrit.screenplay.production_reports import ( generate_scene_report, generate_cast_report, @@ -49,7 +50,7 @@ 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.clicked.connect(self.close) diff --git a/ecrit/ui/overlays/scratchpad.py b/ecrit/ui/overlays/scratchpad.py index 2c4bcbc..a39c815 100644 --- a/ecrit/ui/overlays/scratchpad.py +++ b/ecrit/ui/overlays/scratchpad.py @@ -5,6 +5,7 @@ ) from PySide6.QtCore import Qt, Signal +from ecrit.ui.icons import IconButton class Scratchpad(QFrame): @@ -26,7 +27,7 @@ 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.clicked.connect(self.closed.emit) diff --git a/ecrit/ui/overlays/series_panel.py b/ecrit/ui/overlays/series_panel.py index 90b6424..5c2ce6c 100644 --- a/ecrit/ui/overlays/series_panel.py +++ b/ecrit/ui/overlays/series_panel.py @@ -8,6 +8,7 @@ ) from PySide6.QtCore import Qt, Signal +from ecrit.ui.icons import IconButton from ecrit.screenplay.series_projects import ( SeriesProject, SeriesSeason, Episode, BibleEntry, SeriesBible, ) @@ -36,7 +37,7 @@ 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.clicked.connect(self.close) diff --git a/ecrit/ui/overlays/settings.py b/ecrit/ui/overlays/settings.py index 6bb13cd..4339b3a 100644 --- a/ecrit/ui/overlays/settings.py +++ b/ecrit/ui/overlays/settings.py @@ -9,6 +9,7 @@ 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 @@ -59,7 +60,7 @@ 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.clicked.connect(self.close) diff --git a/ecrit/ui/overlays/share_review.py b/ecrit/ui/overlays/share_review.py index c40ef41..dfbecf3 100644 --- a/ecrit/ui/overlays/share_review.py +++ b/ecrit/ui/overlays/share_review.py @@ -7,6 +7,7 @@ ) from PySide6.QtCore import Qt, Signal +from ecrit.ui.icons import IconButton class ShareReviewDialog(QDialog): @@ -31,7 +32,7 @@ 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.clicked.connect(self.close) diff --git a/ecrit/ui/overlays/shortcuts.py b/ecrit/ui/overlays/shortcuts.py index c0eb31b..88c7b48 100644 --- a/ecrit/ui/overlays/shortcuts.py +++ b/ecrit/ui/overlays/shortcuts.py @@ -6,6 +6,8 @@ ) from PySide6.QtCore import Qt +from ecrit.ui.icons import IconButton + SHORTCUT_GROUPS = [ ("File", [ @@ -60,7 +62,7 @@ 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.clicked.connect(self.close) diff --git a/ecrit/ui/overlays/snapshots.py b/ecrit/ui/overlays/snapshots.py index 958d69f..420ecd5 100644 --- a/ecrit/ui/overlays/snapshots.py +++ b/ecrit/ui/overlays/snapshots.py @@ -10,6 +10,7 @@ ) from PySide6.QtCore import Qt, Signal +from ecrit.ui.icons import IconButton def _run_git(project_path: str, *args) -> str: @@ -111,7 +112,7 @@ 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.clicked.connect(self.close) diff --git a/ecrit/ui/overlays/statistics.py b/ecrit/ui/overlays/statistics.py index e364f9c..eeba29f 100644 --- a/ecrit/ui/overlays/statistics.py +++ b/ecrit/ui/overlays/statistics.py @@ -7,6 +7,7 @@ ) from PySide6.QtCore import Qt +from ecrit.ui.icons import IconButton class StatRow(QFrame): @@ -46,7 +47,7 @@ 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.clicked.connect(self.close) diff --git a/ecrit/ui/overlays/sync_settings.py b/ecrit/ui/overlays/sync_settings.py index f3c7550..7888888 100644 --- a/ecrit/ui/overlays/sync_settings.py +++ b/ecrit/ui/overlays/sync_settings.py @@ -7,6 +7,7 @@ ) from PySide6.QtCore import Qt, Signal +from ecrit.ui.icons import IconButton PROVIDERS = [ @@ -39,7 +40,7 @@ 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.clicked.connect(self.close) diff --git a/ecrit/ui/screens/dashboard.py b/ecrit/ui/screens/dashboard.py index f03117d..3465425 100644 --- a/ecrit/ui/screens/dashboard.py +++ b/ecrit/ui/screens/dashboard.py @@ -7,6 +7,8 @@ from PySide6.QtCore import Qt, Signal from datetime import datetime +from ecrit.ui.icons import IconButton + class ContinueWritingCard(QFrame): open_clicked = Signal() @@ -175,7 +177,7 @@ def _build_ui(self): 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") @@ -201,7 +203,8 @@ def _build_ui(self): 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.clicked.connect(self.import_script.emit) diff --git a/ecrit/ui/screens/editor.py b/ecrit/ui/screens/editor.py index 29f10fe..a5417cd 100644 --- a/ecrit/ui/screens/editor.py +++ b/ecrit/ui/screens/editor.py @@ -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 @@ -878,14 +879,14 @@ 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.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") @@ -914,14 +915,14 @@ 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.clicked.connect(lambda: self._zoom(-0.1)) zoom_bar.addWidget(self.zoom_out) self.zoom_label = QLabel("100%") 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.clicked.connect(lambda: self._zoom(0.1)) From cbd4ae1e0fc746c6c623a7a101770a116fedd468 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 21:35:43 +0000 Subject: [PATCH 09/22] Add comprehensive accessibility support and text size controls - Add setAccessibleName() to all interactive widgets across 29 files (title bar, status bar, dashboard, editor, and all 22 overlays) - Add keyboard focus indicators (2px accent border) in stylesheet - Wire Ctrl+=/-/0 zoom shortcuts for manuscript editor (8-36pt range) - Add UI font size control (12-18px) in Settings > Editor tab - Persist theme and font size preferences across sessions - Set NoFocus policy on decorative labels to prevent keyboard traps - Add logical tab order for title bar and status bar widgets - Fix theme.set_theme() to accept both strings and ThemeTokens objects Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01NoacdztkMbQ5BREp9rVqNw --- ecrit/main.py | 58 +++++++++++++++++++++- ecrit/ui/components/status_bar.py | 23 ++++++++- ecrit/ui/components/title_bar.py | 14 ++++++ ecrit/ui/overlays/autosave_browser.py | 4 ++ ecrit/ui/overlays/character_cards_panel.py | 24 +++++++++ ecrit/ui/overlays/character_sheet.py | 5 ++ ecrit/ui/overlays/cloud_export.py | 8 +++ ecrit/ui/overlays/collaboration.py | 10 ++++ ecrit/ui/overlays/command_palette.py | 2 + ecrit/ui/overlays/companion.py | 8 +++ ecrit/ui/overlays/compare_drafts.py | 5 ++ ecrit/ui/overlays/find_replace.py | 9 ++++ ecrit/ui/overlays/logline_builder.py | 5 ++ ecrit/ui/overlays/marketplace.py | 7 +++ ecrit/ui/overlays/reading_mode.py | 1 + ecrit/ui/overlays/reports.py | 1 + ecrit/ui/overlays/scratchpad.py | 4 ++ ecrit/ui/overlays/series_panel.py | 11 ++++ ecrit/ui/overlays/settings.py | 35 +++++++++++++ ecrit/ui/overlays/share_review.py | 9 ++++ ecrit/ui/overlays/shortcuts.py | 1 + ecrit/ui/overlays/snapshots.py | 5 ++ ecrit/ui/overlays/sprint_timer.py | 3 ++ ecrit/ui/overlays/statistics.py | 1 + ecrit/ui/overlays/sync_settings.py | 10 ++++ ecrit/ui/screens/dashboard.py | 3 ++ ecrit/ui/screens/editor.py | 10 ++++ ecrit/ui/styles/stylesheet.py | 5 ++ ecrit/ui/styles/theme.py | 7 ++- 29 files changed, 284 insertions(+), 4 deletions(-) diff --git a/ecrit/main.py b/ecrit/main.py index 5f2d360..a038216 100644 --- a/ecrit/main.py +++ b/ecrit/main.py @@ -76,6 +76,7 @@ def __init__(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() @@ -119,7 +120,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) @@ -204,6 +205,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) @@ -224,16 +232,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"]) @@ -242,6 +266,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) @@ -1155,6 +1183,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: @@ -1190,6 +1240,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" @@ -1217,6 +1272,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), diff --git a/ecrit/ui/components/status_bar.py b/ecrit/ui/components/status_bar.py index 7fd289a..14f3134 100644 --- a/ecrit/ui/components/status_bar.py +++ b/ecrit/ui/components/status_bar.py @@ -29,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) @@ -55,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) @@ -63,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), @@ -75,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) diff --git a/ecrit/ui/components/title_bar.py b/ecrit/ui/components/title_bar.py index 8903d31..26c83ae 100644 --- a/ecrit/ui/components/title_bar.py +++ b/ecrit/ui/components/title_bar.py @@ -69,14 +69,17 @@ def __init__(self, parent=None): 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) self._close = WindowButton("close") + self._close.setAccessibleName("Close window") self._close.clicked.connect(self.close_clicked.emit) layout.addWidget(self._close) @@ -98,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 @@ -156,18 +160,26 @@ def __init__(self, show_phases=False, parent=None): 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.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,6 +195,8 @@ def __init__(self, show_phases=False, parent=None): 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.window_controls = WindowControls() diff --git a/ecrit/ui/overlays/autosave_browser.py b/ecrit/ui/overlays/autosave_browser.py index 0d79678..1e2d01c 100644 --- a/ecrit/ui/overlays/autosave_browser.py +++ b/ecrit/ui/overlays/autosave_browser.py @@ -36,6 +36,7 @@ def __init__(self, manager: AutosaveManager, project_path: str, parent=None): 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,18 +46,21 @@ def __init__(self, manager: AutosaveManager, project_path: str, parent=None): 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 de63785..4d29032 100644 --- a/ecrit/ui/overlays/character_cards_panel.py +++ b/ecrit/ui/overlays/character_cards_panel.py @@ -42,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) @@ -71,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) @@ -106,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) @@ -129,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) @@ -148,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 52c7ed5..fdb2c58 100644 --- a/ecrit/ui/overlays/character_sheet.py +++ b/ecrit/ui/overlays/character_sheet.py @@ -76,6 +76,7 @@ def __init__(self, parent=None): 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) @@ -104,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) @@ -118,6 +120,7 @@ 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) @@ -148,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() @@ -164,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 82e0657..5065dda 100644 --- a/ecrit/ui/overlays/cloud_export.py +++ b/ecrit/ui/overlays/cloud_export.py @@ -44,6 +44,7 @@ def __init__(self, parent=None): 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) @@ -62,6 +63,7 @@ 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) @@ -74,6 +76,7 @@ def __init__(self, parent=None): 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) @@ -89,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") @@ -97,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") @@ -104,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) @@ -130,12 +136,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.export_btn = QPushButton("Coming Soon") self.export_btn.setObjectName("primary") self.export_btn.setFixedHeight(36) + self.export_btn.setAccessibleName("Export") self.export_btn.setEnabled(False) footer.addWidget(self.export_btn) diff --git a/ecrit/ui/overlays/collaboration.py b/ecrit/ui/overlays/collaboration.py index 67e6678..ff05d9f 100644 --- a/ecrit/ui/overlays/collaboration.py +++ b/ecrit/ui/overlays/collaboration.py @@ -44,6 +44,7 @@ def __init__(self, parent=None): 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) @@ -60,18 +61,21 @@ 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) @@ -103,18 +107,21 @@ def __init__(self, parent=None): self.token_display.setFixedHeight(60) 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) @@ -130,11 +137,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) @@ -149,6 +158,7 @@ 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) diff --git a/ecrit/ui/overlays/command_palette.py b/ecrit/ui/overlays/command_palette.py index 461bf8d..616224f 100644 --- a/ecrit/ui/overlays/command_palette.py +++ b/ecrit/ui/overlays/command_palette.py @@ -83,10 +83,12 @@ 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) diff --git a/ecrit/ui/overlays/companion.py b/ecrit/ui/overlays/companion.py index cb48be0..7d72832 100644 --- a/ecrit/ui/overlays/companion.py +++ b/ecrit/ui/overlays/companion.py @@ -37,6 +37,7 @@ def __init__(self, parent=None): 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) @@ -52,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) @@ -70,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) @@ -89,12 +94,14 @@ def __init__(self, parent=None): 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) @@ -114,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 fc778b9..5a0f18c 100644 --- a/ecrit/ui/overlays/compare_drafts.py +++ b/ecrit/ui/overlays/compare_drafts.py @@ -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 = 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) diff --git a/ecrit/ui/overlays/find_replace.py b/ecrit/ui/overlays/find_replace.py index 8d6b136..31ff73e 100644 --- a/ecrit/ui/overlays/find_replace.py +++ b/ecrit/ui/overlays/find_replace.py @@ -32,6 +32,7 @@ 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) @@ -43,18 +44,21 @@ def __init__(self, parent=None): 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 = 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 = 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) @@ -67,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) @@ -85,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 ed40d9f..16e793d 100644 --- a/ecrit/ui/overlays/logline_builder.py +++ b/ecrit/ui/overlays/logline_builder.py @@ -47,6 +47,7 @@ def __init__(self, parent=None): 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) @@ -62,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) @@ -123,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) @@ -173,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 7f1f0d4..f2eb23d 100644 --- a/ecrit/ui/overlays/marketplace.py +++ b/ecrit/ui/overlays/marketplace.py @@ -36,6 +36,7 @@ def __init__(self, parent=None): 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,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) @@ -64,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) @@ -71,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) @@ -82,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) @@ -89,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) diff --git a/ecrit/ui/overlays/reading_mode.py b/ecrit/ui/overlays/reading_mode.py index f0193fd..bec3bd5 100644 --- a/ecrit/ui/overlays/reading_mode.py +++ b/ecrit/ui/overlays/reading_mode.py @@ -32,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) diff --git a/ecrit/ui/overlays/reports.py b/ecrit/ui/overlays/reports.py index b2c3adf..bae4d88 100644 --- a/ecrit/ui/overlays/reports.py +++ b/ecrit/ui/overlays/reports.py @@ -53,6 +53,7 @@ def __init__(self, parent=None): 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 a39c815..0db0d9b 100644 --- a/ecrit/ui/overlays/scratchpad.py +++ b/ecrit/ui/overlays/scratchpad.py @@ -30,6 +30,7 @@ def __init__(self, parent=None): 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) @@ -42,18 +43,21 @@ def __init__(self, parent=None): self.text_area = QTextEdit() self.text_area.setPlaceholderText("Nothing here yet...") 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 5c2ce6c..d6dbee4 100644 --- a/ecrit/ui/overlays/series_panel.py +++ b/ecrit/ui/overlays/series_panel.py @@ -40,6 +40,7 @@ def __init__(self, parent=None): 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) @@ -57,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) @@ -77,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) @@ -91,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) @@ -122,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) @@ -133,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) @@ -172,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) @@ -180,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() @@ -187,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 4339b3a..ed8bc38 100644 --- a/ecrit/ui/overlays/settings.py +++ b/ecrit/ui/overlays/settings.py @@ -63,6 +63,7 @@ def __init__(self, parent=None): 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) @@ -79,11 +80,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) @@ -91,6 +94,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,6 +117,7 @@ def __init__(self, parent=None): 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) @@ -130,15 +135,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) @@ -146,6 +154,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) @@ -155,6 +164,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) @@ -162,14 +172,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) @@ -189,12 +210,14 @@ def __init__(self, parent=None): self.dark_btn = QPushButton("Nocturne (Dark)") 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 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) @@ -239,6 +262,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) @@ -246,6 +270,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) @@ -253,6 +278,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() @@ -418,6 +444,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: @@ -428,6 +462,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 dfbecf3..8506665 100644 --- a/ecrit/ui/overlays/share_review.py +++ b/ecrit/ui/overlays/share_review.py @@ -35,6 +35,7 @@ def __init__(self, parent=None): 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,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) @@ -83,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() @@ -91,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) @@ -114,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 88c7b48..62612e3 100644 --- a/ecrit/ui/overlays/shortcuts.py +++ b/ecrit/ui/overlays/shortcuts.py @@ -65,6 +65,7 @@ def __init__(self, parent=None): 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/snapshots.py b/ecrit/ui/overlays/snapshots.py index 420ecd5..cca5e27 100644 --- a/ecrit/ui/overlays/snapshots.py +++ b/ecrit/ui/overlays/snapshots.py @@ -115,6 +115,7 @@ def __init__(self, parent=None): 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) @@ -123,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() @@ -140,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) diff --git a/ecrit/ui/overlays/sprint_timer.py b/ecrit/ui/overlays/sprint_timer.py index ce44a95..625ae8c 100644 --- a/ecrit/ui/overlays/sprint_timer.py +++ b/ecrit/ui/overlays/sprint_timer.py @@ -47,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) @@ -55,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 eeba29f..fcd15cd 100644 --- a/ecrit/ui/overlays/statistics.py +++ b/ecrit/ui/overlays/statistics.py @@ -50,6 +50,7 @@ def __init__(self, parent=None): 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 7888888..626123e 100644 --- a/ecrit/ui/overlays/sync_settings.py +++ b/ecrit/ui/overlays/sync_settings.py @@ -43,6 +43,7 @@ def __init__(self, parent=None): 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) @@ -62,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) @@ -73,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") @@ -82,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") @@ -92,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") @@ -101,9 +106,11 @@ 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() @@ -133,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) @@ -141,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/screens/dashboard.py b/ecrit/ui/screens/dashboard.py index 3465425..9916254 100644 --- a/ecrit/ui/screens/dashboard.py +++ b/ecrit/ui/screens/dashboard.py @@ -181,6 +181,7 @@ def _build_ui(self): 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) @@ -200,6 +201,7 @@ 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) @@ -207,6 +209,7 @@ def _build_ui(self): 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) diff --git a/ecrit/ui/screens/editor.py b/ecrit/ui/screens/editor.py index a5417cd..cbefc5c 100644 --- a/ecrit/ui/screens/editor.py +++ b/ecrit/ui/screens/editor.py @@ -264,6 +264,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) @@ -883,6 +885,7 @@ def __init__(self, parent=None): 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) @@ -890,12 +893,14 @@ def __init__(self, parent=None): 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.setAccessibleName("Structure template") self.template_combo.addItem("— Structure Template —", "") for key, display_name in list_templates(): self.template_combo.addItem(display_name, key) @@ -905,6 +910,7 @@ def __init__(self, parent=None): 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) @@ -918,17 +924,21 @@ def __init__(self, parent=None): 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 = 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() diff --git a/ecrit/ui/styles/stylesheet.py b/ecrit/ui/styles/stylesheet.py index c2ce881..335ae0f 100644 --- a/ecrit/ui/styles/stylesheet.py +++ b/ecrit/ui/styles/stylesheet.py @@ -19,6 +19,11 @@ def generate(t: ThemeTokens) -> str: 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 {{ 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(): From 5589ae435f92284dac3c8440a2e9902810592029 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 21:39:07 +0000 Subject: [PATCH 10/22] Fix deprecated Qt.UserRole and remove unused imports - Replace Qt.UserRole with Qt.ItemDataRole.UserRole in settings.py (3 occurrences) to prevent potential AttributeError on strict PySide6 - Remove unused QPushButton import from main.py - Remove unused QGridLayout import from settings.py Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01NoacdztkMbQ5BREp9rVqNw --- ecrit/main.py | 2 +- ecrit/ui/overlays/settings.py | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/ecrit/main.py b/ecrit/main.py index a038216..5ef3731 100644 --- a/ecrit/main.py +++ b/ecrit/main.py @@ -1,7 +1,7 @@ """Écrit — main application entry point.""" import sys -from PySide6.QtWidgets import QApplication, QMainWindow, QPushButton, QStackedWidget, QVBoxLayout, QWidget +from PySide6.QtWidgets import QApplication, QMainWindow, QStackedWidget, QVBoxLayout, QWidget from PySide6.QtCore import Qt, QTimer, Signal as QtSignal from PySide6.QtGui import QShortcut, QKeySequence diff --git a/ecrit/ui/overlays/settings.py b/ecrit/ui/overlays/settings.py index ed8bc38..def9f91 100644 --- a/ecrit/ui/overlays/settings.py +++ b/ecrit/ui/overlays/settings.py @@ -3,8 +3,7 @@ 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 @@ -356,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): @@ -367,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 @@ -400,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 From f36defce2a77e2cc0ca15ffea868753e719ad7cc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 00:02:29 +0000 Subject: [PATCH 11/22] Fix CRDT data loss, Replace All escape bug, and regex match index - Fix OT transform expanding delete to swallow concurrent inserts, causing silent data loss in collaborative editing - Fix case-insensitive Replace All interpreting backslash sequences in replacement text as regex escapes (\n, \t, \1) - Fix regex find match-index using pattern length instead of actual match length for variable-length patterns - Fix CRDT delete-subsumed position to use op2.position consistently - Skip outline/plan saves when no data exists to avoid unnecessary I/O - Add regression test for CRDT insert-inside-delete-range Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01NoacdztkMbQ5BREp9rVqNw --- ecrit/collab/crdt.py | 10 +++++----- ecrit/main.py | 7 +++++-- ecrit/ui/screens/editor.py | 11 ++++++++--- tests/test_collab.py | 12 ++++++++++++ 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/ecrit/collab/crdt.py b/ecrit/collab/crdt.py index d62da76..dca54e6 100644 --- a/ecrit/collab/crdt.py +++ b/ecrit/collab/crdt.py @@ -97,12 +97,13 @@ def transform(self, op1: Operation, op2: Operation) -> Operation: if op2.op_type == OperationType.INSERT: insert_len = len(op2.text) if op1.op_type == OperationType.DELETE and op1.position < op2.position < op1.position + op1.length: - # op2 insert splits op1's delete range — expand length to skip inserted text + # op2 insert lands inside op1's delete range — preserve the inserted text + # by splitting the delete: only delete up to the insert point return Operation( op_type=op1.op_type, position=op1.position, text=op1.text, - length=op1.length + insert_len, + length=op1.length, user_id=op1.user_id, timestamp=op1.timestamp, revision=op1.revision, @@ -138,11 +139,10 @@ def transform(self, op1: Operation, op2: Operation) -> Operation: overlap = max(0, overlap_end - overlap_start) new_length = op1.length - overlap if new_length <= 0: - # op1's delete is entirely subsumed by op2 + # op1's delete is entirely subsumed by op2 — becomes a no-op return Operation( op_type=op1.op_type, - position=max(op1.position, op2.position) - min(op1.position, op2.position) - if op1.position >= op2.position else op1.position, + position=op2.position if op1.position >= op2.position else op1.position, text=op1.text, length=0, user_id=op1.user_id, diff --git a/ecrit/main.py b/ecrit/main.py index 5ef3731..cc2c0c7 100644 --- a/ecrit/main.py +++ b/ecrit/main.py @@ -360,8 +360,11 @@ def _on_content_changed(self): saved = STATE.save_script() if saved: self._module_registry.call_hook(HOOK_AFTER_SAVE, STATE.script_content) - STATE.save_outline(self.editor.outline.canvas._nodes) - STATE.save_plan_documents(self.editor.plan.get_documents()) + 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() diff --git a/ecrit/ui/screens/editor.py b/ecrit/ui/screens/editor.py index cbefc5c..775ee54 100644 --- a/ecrit/ui/screens/editor.py +++ b/ecrit/ui/screens/editor.py @@ -2083,8 +2083,13 @@ def _update_match_count(self, text, case_sensitive, regex): cursor_pos = self.manuscript.editor.textCursor().position() current = 1 for i, m in enumerate(matches): - pos = m if isinstance(m, int) else m.start() - if pos >= cursor_pos - len(text): + 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: @@ -2138,7 +2143,7 @@ def _do_replace_all(self, find_text, replace_text): new_content = content.replace(find_text, replace_text) else: import re - new_content = re.sub(re.escape(find_text), replace_text, content, flags=re.IGNORECASE) + new_content = re.sub(re.escape(find_text), replace_text.replace('\\', '\\\\'), content, flags=re.IGNORECASE) if new_content != content: editor.setPlainText(new_content) self.find_bar.set_match_count(0, 0) diff --git a/tests/test_collab.py b/tests/test_collab.py index e9aa8ad..8050b23 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_does_not_swallow_concurrent_insert(self): + """A delete must not expand to consume text inserted inside its range.""" + 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) + # The delete length must NOT grow to swallow the inserted text + assert transformed.length == 5 + assert transformed.position == 2 + def test_transform_preserves_other_fields(self): crdt = TextCRDT(user_id="u1") op1 = Operation( From 554097fb6615b4653a2efc183aff70b4e7ccf05e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 00:22:44 +0000 Subject: [PATCH 12/22] Fix 7 collab bugs: LAN join, OT transform, thread safety, undo history - Fix LAN peer join always failing: include session_id in LAN discovery broadcasts so peers can authenticate with the P2P host - Fix OT transform for insert-inside-delete: expand delete length to cover the gap created by concurrent insert, ensuring all originally targeted characters are removed - Add threading lock to P2P _clients dict to prevent concurrent modification crashes between accept_loop and broadcast threads - Cap pending ops list at 100 entries to prevent unbounded memory growth - Bridge local editor changes to CRDT broadcast via contentsChange signal so local edits are actually sent to collaboration peers - Debounce remote collab disk writes with 2-second QTimer instead of synchronous save on every keystroke - Fix Replace All to use QTextCursor-based replacement instead of setPlainText, preserving the undo history stack Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01NoacdztkMbQ5BREp9rVqNw --- ecrit/collab/crdt.py | 6 +++--- ecrit/collab/lan.py | 6 ++++++ ecrit/collab/p2p.py | 33 ++++++++++++++++++++++++--------- ecrit/collab/session.py | 11 ++++++++--- ecrit/main.py | 37 ++++++++++++++++++++++++++++++++++++- ecrit/ui/screens/editor.py | 38 ++++++++++++++++++++++++++++---------- tests/test_collab.py | 8 ++++---- 7 files changed, 109 insertions(+), 30 deletions(-) diff --git a/ecrit/collab/crdt.py b/ecrit/collab/crdt.py index dca54e6..7d8eb1e 100644 --- a/ecrit/collab/crdt.py +++ b/ecrit/collab/crdt.py @@ -97,13 +97,13 @@ def transform(self, op1: Operation, op2: Operation) -> Operation: if op2.op_type == OperationType.INSERT: insert_len = len(op2.text) if op1.op_type == OperationType.DELETE and op1.position < op2.position < op1.position + op1.length: - # op2 insert lands inside op1's delete range — preserve the inserted text - # by splitting the delete: only delete up to the insert point + # 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, text=op1.text, - length=op1.length, + length=op1.length + insert_len, user_id=op1.user_id, timestamp=op1.timestamp, revision=op1.revision, 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 41dc77d..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 @@ -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 dc84661..5b6281e 100644 --- a/ecrit/collab/session.py +++ b/ecrit/collab/session.py @@ -104,6 +104,7 @@ 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 @@ -113,7 +114,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, ) @@ -162,12 +163,16 @@ def leave(self) -> None: def apply_local_insert(self, position: int, text: str) -> None: op = self._crdt.insert(position, text) self._pending_ops.append(op) + if len(self._pending_ops) > 100: + self._pending_ops = self._pending_ops[-50:] 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) + if len(self._pending_ops) > 100: + self._pending_ops = self._pending_ops[-50:] msg = CollabMessage.operation(self.user_id, op) self._p2p.broadcast(msg.to_json()) @@ -234,7 +239,7 @@ def _handle_message(self, peer_id: str, raw: str) -> None: if self.role == CollabRole.HOST: 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) @@ -250,7 +255,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) diff --git a/ecrit/main.py b/ecrit/main.py index cc2c0c7..fdc4637 100644 --- a/ecrit/main.py +++ b/ecrit/main.py @@ -789,27 +789,53 @@ def _on_collab_started(self, session): session._crdt.set_text(content) 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() + self._collab_suppress_local = True editor.blockSignals(True) editor.setPlainText(text) cursor = editor.textCursor() 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 @@ -838,6 +864,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() diff --git a/ecrit/ui/screens/editor.py b/ecrit/ui/screens/editor.py index 775ee54..7242c6a 100644 --- a/ecrit/ui/screens/editor.py +++ b/ecrit/ui/screens/editor.py @@ -2130,20 +2130,38 @@ def _do_replace_all(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 - content = editor.toPlainText() + 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 - flags = 0 if case_sensitive else re.IGNORECASE try: - new_content = re.sub(find_text, replace_text, content, flags=flags) + re.compile(find_text) except re.error: return - else: - if case_sensitive: - new_content = content.replace(find_text, replace_text) - else: + 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 - new_content = re.sub(re.escape(find_text), replace_text.replace('\\', '\\\\'), content, flags=re.IGNORECASE) - if new_content != content: - editor.setPlainText(new_content) + 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/tests/test_collab.py b/tests/test_collab.py index 8050b23..424dfbd 100644 --- a/tests/test_collab.py +++ b/tests/test_collab.py @@ -172,16 +172,16 @@ def test_transform_insert_in_deleted_range(self): transformed = crdt.transform(op1, op2) assert transformed.position == 3 - def test_transform_delete_does_not_swallow_concurrent_insert(self): - """A delete must not expand to consume text inserted inside its range.""" + 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) - # The delete length must NOT grow to swallow the inserted text - assert transformed.length == 5 + # 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): From a946dc2a9595e2edb96a1669be5f674eed01c23d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 00:36:26 +0000 Subject: [PATCH 13/22] Fix 5 bugs from adversarial review: host race, title template, replace, sync - Fix collab host race condition: pass actual document content to session.host() instead of empty string, preventing early-connecting guests from receiving a blank document - Fix title template duplication: default end index to len(lines) so content without blank-line separators replaces the entire title page instead of duplicating it - Fix single Replace regex mismatch: convert U+2029 paragraph separators to newlines before regex matching, consistent with Replace All - Fix DeviceSync class attribute sharing: create per-instance handler subclass so concurrent transfer servers don't overwrite credentials - Fix double stat() calls in project loading: cache stat results to halve syscalls on large project folders Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01NoacdztkMbQ5BREp9rVqNw --- ecrit/companion/device_sync.py | 8 +++++--- ecrit/main.py | 7 +++---- ecrit/stores/app_state.py | 14 +++++++++++--- ecrit/ui/overlays/collaboration.py | 4 +++- ecrit/ui/screens/editor.py | 2 +- 5 files changed, 23 insertions(+), 12 deletions(-) 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/main.py b/ecrit/main.py index fdc4637..c3b9d89 100644 --- a/ecrit/main.py +++ b/ecrit/main.py @@ -781,12 +781,11 @@ def _show_collaboration(self): 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.editor.manuscript.presence_bar.setVisible(True) self._active_collab_session = session self._collab_suppress_local = False @@ -1158,7 +1157,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 diff --git a/ecrit/stores/app_state.py b/ecrit/stores/app_state.py index ae5cc61..15a832d 100644 --- a/ecrit/stores/app_state.py +++ b/ecrit/stores/app_state.py @@ -55,13 +55,21 @@ 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: diff --git a/ecrit/ui/overlays/collaboration.py b/ecrit/ui/overlays/collaboration.py index ff05d9f..c8724c1 100644 --- a/ecrit/ui/overlays/collaboration.py +++ b/ecrit/ui/overlays/collaboration.py @@ -26,6 +26,8 @@ def __init__(self, parent=None): self.setModal(True) self._session: CollabSession | None = None + self._host_content: str = "" + self._host_title: str = "" layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -225,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/screens/editor.py b/ecrit/ui/screens/editor.py index 7242c6a..a4fcf04 100644 --- a/ecrit/ui/screens/editor.py +++ b/ecrit/ui/screens/editor.py @@ -2108,7 +2108,7 @@ def _do_replace(self, find_text, replace_text): editor = self.manuscript.editor cursor = editor.textCursor() if cursor.hasSelection(): - selected = cursor.selectedText() + selected = cursor.selectedText().replace("
", "\n") if use_regex: import re flags = 0 if case_sensitive else re.IGNORECASE From a33338f0a8e9b0cbe58c111d94e65f506c36dab2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 00:50:13 +0000 Subject: [PATCH 14/22] Fix session thread safety, element cycle, collab undo, and dead code - Add threading lock to CollabSession to protect CRDT and pending_ops from concurrent access between recv thread and main Qt thread - Fix _detect_element_type to return 'dialogue' for lowercase-starting text, restoring the full Tab cycle through all Fountain elements - Replace setPlainText with cursor-based text insertion in collab text handler to preserve the undo history stack during collaboration - Simplify tautological ternary in CRDT OT delete-delete transform - Remove unused home_clicked signal from TitleBar Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01NoacdztkMbQ5BREp9rVqNw --- ecrit/collab/crdt.py | 2 +- ecrit/collab/session.py | 40 ++++++++++++++++++-------------- ecrit/main.py | 7 +++--- ecrit/ui/components/title_bar.py | 1 - ecrit/ui/screens/editor.py | 2 ++ tests/test_adversarial.py | 1 + 6 files changed, 31 insertions(+), 22 deletions(-) diff --git a/ecrit/collab/crdt.py b/ecrit/collab/crdt.py index 7d8eb1e..7e3a934 100644 --- a/ecrit/collab/crdt.py +++ b/ecrit/collab/crdt.py @@ -149,7 +149,7 @@ def transform(self, op1: Operation, op2: Operation) -> Operation: timestamp=op1.timestamp, revision=op1.revision, ) - new_pos = min(op1.position, op2.position) if op1.position < op2.position else op2.position + new_pos = min(op1.position, op2.position) return Operation( op_type=op1.op_type, position=new_pos, diff --git a/ecrit/collab/session.py b/ecrit/collab/session.py index 5b6281e..db14106 100644 --- a/ecrit/collab/session.py +++ b/ecrit/collab/session.py @@ -2,6 +2,7 @@ from __future__ import annotations +import threading import uuid from enum import Enum from dataclasses import dataclass, field @@ -53,6 +54,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 @@ -161,18 +163,20 @@ 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) - if len(self._pending_ops) > 100: - self._pending_ops = self._pending_ops[-50:] + with self._lock: + op = self._crdt.insert(position, text) + self._pending_ops.append(op) + if len(self._pending_ops) > 100: + self._pending_ops = self._pending_ops[-50:] 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) - if len(self._pending_ops) > 100: - self._pending_ops = self._pending_ops[-50:] + with self._lock: + op = self._crdt.delete(position, length) + self._pending_ops.append(op) + if len(self._pending_ops) > 100: + self._pending_ops = self._pending_ops[-50:] msg = CollabMessage.operation(self.user_id, op) self._p2p.broadcast(msg.to_json()) @@ -227,13 +231,14 @@ 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: @@ -268,8 +273,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/main.py b/ecrit/main.py index c3b9d89..cbd5591 100644 --- a/ecrit/main.py +++ b/ecrit/main.py @@ -821,12 +821,13 @@ def _apply_collab_text(self, text: str): if editor.toPlainText() != text: cursor_pos = editor.textCursor().position() self._collab_suppress_local = True - editor.blockSignals(True) - editor.setPlainText(text) 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 if not hasattr(self, "_collab_save_timer"): diff --git a/ecrit/ui/components/title_bar.py b/ecrit/ui/components/title_bar.py index 26c83ae..b917948 100644 --- a/ecrit/ui/components/title_bar.py +++ b/ecrit/ui/components/title_bar.py @@ -141,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() diff --git a/ecrit/ui/screens/editor.py b/ecrit/ui/screens/editor.py index a4fcf04..9908767 100644 --- a/ecrit/ui/screens/editor.py +++ b/ecrit/ui/screens/editor.py @@ -499,6 +499,8 @@ def _detect_element_type(self, line: str) -> str: 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: 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 From 55144b63040edf5a7937c64c10b2288640fbc665 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 01:00:32 +0000 Subject: [PATCH 15/22] Fix OT tie-breaking, proofread navigation, signal duplication, auto-sync - Add user_id-based tie-breaking for concurrent same-position inserts in OT transform, preventing permanent document divergence - Fix ProofreadPhase issue navigation: use findBlockByNumber (logical block index) instead of findBlockByLineNumber (visual line index) - Prevent duplicate signal connections in collab by calling _on_collab_left before connecting new signals in _on_collab_started - Add concurrency guard to _auto_sync_if_enabled to prevent spawning multiple simultaneous git push threads - Log auto-export failures instead of silently swallowing them Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01NoacdztkMbQ5BREp9rVqNw --- ecrit/collab/crdt.py | 6 +++++- ecrit/main.py | 18 +++++++++++++----- ecrit/ui/screens/editor.py | 2 +- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/ecrit/collab/crdt.py b/ecrit/collab/crdt.py index 7e3a934..66e867e 100644 --- a/ecrit/collab/crdt.py +++ b/ecrit/collab/crdt.py @@ -108,7 +108,11 @@ def transform(self, op1: Operation, op2: Operation) -> Operation: timestamp=op1.timestamp, revision=op1.revision, ) - if op1.position >= op2.position: + if op1.position > op2.position or ( + op1.position == op2.position + and 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, diff --git a/ecrit/main.py b/ecrit/main.py index cbd5591..3ddfe35 100644 --- a/ecrit/main.py +++ b/ecrit/main.py @@ -378,14 +378,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 @@ -405,8 +411,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: @@ -786,6 +793,7 @@ def _show_collaboration(self): self._collab_dialog.exec() def _on_collab_started(self, session): + self._on_collab_left() self.editor.manuscript.presence_bar.setVisible(True) self._active_collab_session = session self._collab_suppress_local = False diff --git a/ecrit/ui/screens/editor.py b/ecrit/ui/screens/editor.py index 9908767..b438717 100644 --- a/ecrit/ui/screens/editor.py +++ b/ecrit/ui/screens/editor.py @@ -1505,7 +1505,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()) From 99382724317bfcf38d9c8e6fbc205d53e6fc4885 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 03:51:01 +0000 Subject: [PATCH 16/22] Replace error swallowing with proper logging, honest cloud stubs, expand test coverage, and update docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace 16+ except-Exception-pass patterns with specific exception types (OSError, ValueError, json.JSONDecodeError, etc.) and proper logging at debug/warning levels across app_state, module_system, session, snapshots, remote_sync, dashboard, new_project, and all exporters - Replace cloud export fake-success stubs with honest _StubExporter base that returns False/empty — no more fake URLs or fake auth - Add 112 new tests across 5 files: plugin API/marketplace, companion sync bundles/reader export, name generator, LAN/P2P networking, and cloud export config/stubs - Fix README: correct test count (879), build command (maturin develop), add maturin prerequisite, clarify cloud export as scaffolding Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_01NoacdztkMbQ5BREp9rVqNw --- README.md | 9 +- ecrit/collab/session.py | 6 +- ecrit/export/epub_export.py | 1 + ecrit/export/odt_export.py | 4 + ecrit/export/pdf_export.py | 2 + ecrit/screenplay/module_system.py | 5 +- ecrit/stores/app_state.py | 53 ++++--- ecrit/sync/cloud_export.py | 124 ++-------------- ecrit/sync/remote_sync.py | 2 +- ecrit/ui/overlays/snapshots.py | 2 +- ecrit/ui/screens/dashboard.py | 2 +- ecrit/ui/screens/new_project.py | 2 +- tests/test_cloud_export.py | 170 +++++++++++++++++++++ tests/test_collab_network.py | 210 ++++++++++++++++++++++++++ tests/test_companion.py | 148 +++++++++++++++++++ tests/test_name_generator.py | 47 ++++++ tests/test_plugins.py | 237 ++++++++++++++++++++++++++++++ 17 files changed, 882 insertions(+), 142 deletions(-) create mode 100644 tests/test_cloud_export.py create mode 100644 tests/test_collab_network.py create mode 100644 tests/test_companion.py create mode 100644 tests/test_name_generator.py create mode 100644 tests/test_plugins.py 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/session.py b/ecrit/collab/session.py index db14106..c4396e3 100644 --- a/ecrit/collab/session.py +++ b/ecrit/collab/session.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import threading import uuid from enum import Enum @@ -13,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" @@ -212,7 +215,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): + logger.debug("Ignoring malformed collab message from %s", peer_id) return if msg.msg_type == MessageType.JOIN: diff --git a/ecrit/export/epub_export.py b/ecrit/export/epub_export.py index bb99195..a8e8125 100644 --- a/ecrit/export/epub_export.py +++ b/ecrit/export/epub_export.py @@ -483,4 +483,5 @@ def export_epub_to_path(content: str, path: str, title: str = "", author: str = 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..f4427a0 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 = """ str: import ecrit_core parsed = json.loads(ecrit_core.parse_fountain(script_content)) 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] diff --git a/ecrit/export/pdf_export.py b/ecrit/export/pdf_export.py index 208e153..6def54f 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 ( @@ -35,6 +36,7 @@ def _build_document(script_content: str, font_size: int = 12, include_title_page import ecrit_core parsed = json.loads(ecrit_core.parse_fountain(script_content)) except Exception: + logging.getLogger("ecrit.export.pdf").debug("Fountain parse unavailable, inserting raw text", exc_info=True) cursor.insertText(script_content) return doc 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/stores/app_state.py b/ecrit/stores/app_state.py index 15a832d..7f9e601 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() @@ -72,8 +76,8 @@ def _load_projects_py(self) -> list: 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): @@ -85,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): @@ -95,29 +99,30 @@ 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 Exception: - pass + 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 Exception: - pass + 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() @@ -134,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 @@ -155,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"): @@ -196,7 +202,8 @@ def save_outline(self, nodes: list) -> bool: path = Path(self.current_project_path) / "outline.json" path.write_text(json.dumps(nodes, indent=2), encoding="utf-8") return True - except Exception: + except OSError as exc: + logger.warning("Failed to save outline: %s", exc) return False def save_plan_documents(self, documents: list) -> bool: @@ -206,7 +213,8 @@ def save_plan_documents(self, documents: list) -> bool: path = Path(self.current_project_path) / "plan_documents.json" path.write_text(json.dumps(documents, indent=2), encoding="utf-8") return True - except Exception: + except OSError as exc: + logger.warning("Failed to save plan documents: %s", exc) return False def parse_script(self) -> dict: @@ -214,7 +222,7 @@ def parse_script(self) -> dict: try: return json.loads(ecrit_core.parse_fountain(self.script_content)) 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: @@ -238,7 +246,7 @@ def get_stats(self) -> dict: try: return json.loads(ecrit_core.get_script_stats(self.script_content)) 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: @@ -343,12 +351,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: @@ -356,8 +365,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..65b54cd 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 = "" # --------------------------------------------------------------------------- diff --git a/ecrit/sync/remote_sync.py b/ecrit/sync/remote_sync.py index 40074ab..4fa09dc 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 (FileNotFoundError, subprocess.TimeoutExpired, OSError): pass return [] diff --git a/ecrit/ui/overlays/snapshots.py b/ecrit/ui/overlays/snapshots.py index cca5e27..f91cea5 100644 --- a/ecrit/ui/overlays/snapshots.py +++ b/ecrit/ui/overlays/snapshots.py @@ -23,7 +23,7 @@ def _run_git(project_path: str, *args) -> str: timeout=10, ) return result.stdout.strip() - except Exception: + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): return "" diff --git a/ecrit/ui/screens/dashboard.py b/ecrit/ui/screens/dashboard.py index 9916254..9adf156 100644 --- a/ecrit/ui/screens/dashboard.py +++ b/ecrit/ui/screens/dashboard.py @@ -327,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/new_project.py b/ecrit/ui/screens/new_project.py index 235e097..97edbc0 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, OSError): _DIALECTS = [] _CATEGORIES = {} diff --git a/tests/test_cloud_export.py b/tests/test_cloud_export.py new file mode 100644 index 0000000..cf4d0b9 --- /dev/null +++ b/tests/test_cloud_export.py @@ -0,0 +1,170 @@ +"""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_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_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_companion.py b/tests/test_companion.py new file mode 100644 index 0000000..39426b2 --- /dev/null +++ b/tests/test_companion.py @@ -0,0 +1,148 @@ +"""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 "