Team Hoa, EPIC R08 (UI/Application Separation) - Team Hoa scope only
(R08-T11 -> T14; R08-T01->T10 belong to Team Duy/Team Nam).
- R08-T11: ui/schedule_task_tab.py (795 lines) -> presentation/scheduling/
{kanban_board_widget,calendar_view_widget,ai_task_creator_dialog,
ai_task_import_dialog,run_history_dialog}.py + schedule_task_tab.py
shell. Kanban CRUD/drag-drop now goes through
application/scheduling/task_application_service.py (R07-T04) instead of
~30 lines of inline if/elif per drag target.
- R08-T12: ui/folder_tab.py (1587 lines, the largest of the four) ->
presentation/folder/{workspace_file_tree,document_preview_manager,
code_editor,office_document_renderer,ai_file_editor_dialog,
ai_edit_model_resolver,ai_edit_pipeline}.py + folder_tab.py shell.
Closes the R06-T05 loop: FileWorkspaceService existed since R06 with
zero production call sites (confirmed by grep); every plain-text write
(save/create/write_content) now goes through it, gaining path
containment and a Python-syntax warning the original code never had.
Pure helpers (_read_text, _is_probably_text, _pptx_available,
_split_code_block, _parse_ai_output) moved to
application/workspaces/{file_preview_helpers,ai_edit_output}.py.
- R08-T13: ui/dashboard_tab.py (437 lines) -> presentation/dashboard/
{token_usage_card_widget,usage_chart_widget,habits_widget}.py +
dashboard_tab.py shell, backed by a new
application/monitoring/dashboard_query_service.py (pricing/period/
summary queries the three widgets used to each recompute separately).
Directory-ownership note left in the checklist for Team Nam.
- R08-T14: ui/structure_graph_view.py (1035 lines) ->
presentation/graph/{graph_scene_items,graph_renderer,
graph_messages_view,graph_qa_widget}.py + structure_graph_view.py
shell. Extraction helpers (_pdf_to_markdown, _extract_file_contents)
moved to application/workspaces/graph_index_service.py (pure Python).
Renderer and Q&A panel talk only through signals
(node_selected/graph_rendered/raw_json_ready/project_changed) - neither
imports the other.
- presentation/shared/web_engine_support.py: HAS_WEB_ENGINE, previously
duplicated (folder_tab imported it FROM structure_graph_view.py) - now
one shared flag instead of one screen importing another screen's module.
All four old ui/*.py files deleted; app.py and ui/workspace_tab.py updated
to the new import paths (each god-file only had 1-2 real construction
sites, so import sites were updated directly rather than kept as a
strangler-fig shim - unlike core/tools.py at R05, which had dozens).
pytest: 377 pass (+94 vs the R07 baseline of 328; same 4 pre-existing
failures as the R05/R06 baseline, unrelated to this work).
scripts/check_imports.py: PASS. python -c "import cowork_local.app": OK.
Every new file < 400 lines (largest: graph_renderer.py, 391).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
191 lines
7.3 KiB
Python
191 lines
7.3 KiB
Python
"""CodeEditor — the VS-Code-style code/text editor widget (R08-T12, split
|
|
out of ``document_preview_manager.py`` to keep that file under the 400-line
|
|
cap; originally ``ui/folder_tab.py``, lines 61-236 of the original
|
|
1587-line file: the Pygments token-colour helper, ``PygmentsHighlighter``,
|
|
``_LineNumbers``, ``CodeEditor``).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from PySide6.QtCore import QRect, QSize, Qt, QTimer
|
|
from PySide6.QtGui import QColor, QFont, QPainter, QSyntaxHighlighter, QTextCharFormat
|
|
from PySide6.QtWidgets import QPlainTextEdit, QWidget
|
|
|
|
from cowork_local.theme import current_palette
|
|
|
|
_MAX_HIGHLIGHT_CHARS = 300_000 # skip colouring huge files (keeps typing snappy)
|
|
|
|
|
|
# ── VS-Code-Dark+-ish token palette ────────────────────────────────────────
|
|
def _fmt(color: str, *, italic: bool = False, bold: bool = False) -> QTextCharFormat:
|
|
f = QTextCharFormat()
|
|
f.setForeground(QColor(color))
|
|
if italic:
|
|
f.setFontItalic(True)
|
|
if bold:
|
|
f.setFontWeight(QFont.Bold)
|
|
return f
|
|
|
|
|
|
class PygmentsHighlighter(QSyntaxHighlighter):
|
|
"""Colour the whole document with Pygments and apply per-block. Re-lexes the
|
|
full text (debounced) so multi-line strings/comments colour correctly."""
|
|
|
|
def __init__(self, document):
|
|
super().__init__(document)
|
|
from pygments.lexers.special import TextLexer
|
|
self._lexer = TextLexer(stripnl=False)
|
|
self._ranges: list[tuple[int, int, QTextCharFormat]] = []
|
|
self._rules = self._build_rules()
|
|
self._timer = QTimer(self)
|
|
self._timer.setSingleShot(True)
|
|
self._timer.setInterval(250)
|
|
self._timer.timeout.connect(self._retokenize)
|
|
document.contentsChanged.connect(self._timer.start)
|
|
|
|
@staticmethod
|
|
def _build_rules():
|
|
from pygments.token import (
|
|
Comment, Error, Keyword, Name, Number, Operator, Punctuation, String,
|
|
)
|
|
p = current_palette()
|
|
return [
|
|
(Comment, _fmt(p.code_comment, italic=True)),
|
|
(Keyword.Type, _fmt(p.code_type)),
|
|
(Keyword, _fmt(p.code_keyword)),
|
|
(Name.Function, _fmt(p.code_func)),
|
|
(Name.Class, _fmt(p.code_type)),
|
|
(Name.Decorator, _fmt(p.code_func)),
|
|
(Name.Builtin, _fmt(p.code_type)),
|
|
(Name.Tag, _fmt(p.code_keyword)),
|
|
(Name.Attribute, _fmt(p.code_attr)),
|
|
(String.Doc, _fmt(p.code_comment, italic=True)),
|
|
(String, _fmt(p.code_string)),
|
|
(Number, _fmt(p.code_number)),
|
|
(Operator, _fmt(p.code_fg)),
|
|
(Punctuation, _fmt(p.code_fg)),
|
|
(Error, _fmt(p.code_error)),
|
|
]
|
|
|
|
def set_filename(self, filename: str, text: str = "") -> None:
|
|
from pygments.lexers import get_lexer_for_filename, guess_lexer
|
|
from pygments.lexers.special import TextLexer
|
|
from pygments.util import ClassNotFound
|
|
try:
|
|
self._lexer = get_lexer_for_filename(filename, stripnl=False)
|
|
except ClassNotFound:
|
|
try:
|
|
self._lexer = guess_lexer(text) if text.strip() else TextLexer()
|
|
except ClassNotFound:
|
|
self._lexer = TextLexer(stripnl=False)
|
|
self._retokenize()
|
|
|
|
def _fmt_for(self, tok):
|
|
for ttype, fmt in self._rules:
|
|
if tok in ttype:
|
|
return fmt
|
|
return None
|
|
|
|
def _retokenize(self) -> None:
|
|
from pygments import lex
|
|
text = self.document().toPlainText()
|
|
self._ranges = []
|
|
if len(text) <= _MAX_HIGHLIGHT_CHARS:
|
|
pos = 0
|
|
for tok, val in lex(text, self._lexer):
|
|
fmt = self._fmt_for(tok)
|
|
if fmt is not None and val:
|
|
self._ranges.append((pos, pos + len(val), fmt))
|
|
pos += len(val)
|
|
self.rehighlight()
|
|
|
|
def highlightBlock(self, text: str) -> None: # noqa: N802 - Qt override
|
|
if not self._ranges:
|
|
return
|
|
bstart = self.currentBlock().position()
|
|
bend = bstart + len(text)
|
|
for start, end, fmt in self._ranges:
|
|
if end <= bstart or start >= bend:
|
|
continue
|
|
s = max(start, bstart) - bstart
|
|
e = min(end, bend) - bstart
|
|
if e > s:
|
|
self.setFormat(s, e - s, fmt)
|
|
|
|
|
|
class _LineNumbers(QWidget):
|
|
def __init__(self, editor):
|
|
super().__init__(editor)
|
|
self._editor = editor
|
|
|
|
def sizeHint(self) -> QSize:
|
|
return QSize(self._editor.line_number_width(), 0)
|
|
|
|
def paintEvent(self, event): # noqa: N802
|
|
self._editor.paint_line_numbers(event)
|
|
|
|
|
|
class CodeEditor(QPlainTextEdit):
|
|
"""A dark, monospaced editor with a line-number gutter + Pygments colouring —
|
|
the Sublime/VS-Code look for viewing & editing source files."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.setObjectName("codeEditor")
|
|
self.setLineWrapMode(QPlainTextEdit.NoWrap)
|
|
self.setTabStopDistance(4 * self.fontMetrics().horizontalAdvance(" "))
|
|
font = QFont("Consolas")
|
|
font.setStyleHint(QFont.Monospace)
|
|
font.setPointSize(10)
|
|
self.setFont(font)
|
|
self._gutter = _LineNumbers(self)
|
|
self.blockCountChanged.connect(lambda _=0: self._update_gutter_width())
|
|
self.updateRequest.connect(self._on_update_request)
|
|
self._highlighter = PygmentsHighlighter(self.document())
|
|
self._update_gutter_width()
|
|
|
|
def line_number_width(self) -> int:
|
|
digits = max(2, len(str(max(1, self.blockCount()))))
|
|
return 12 + self.fontMetrics().horizontalAdvance("9") * digits
|
|
|
|
def _update_gutter_width(self) -> None:
|
|
self.setViewportMargins(self.line_number_width(), 0, 0, 0)
|
|
|
|
def _on_update_request(self, rect, dy: int) -> None:
|
|
if dy:
|
|
self._gutter.scroll(0, dy)
|
|
else:
|
|
self._gutter.update(0, rect.y(), self._gutter.width(), rect.height())
|
|
if rect.contains(self.viewport().rect()):
|
|
self._update_gutter_width()
|
|
|
|
def resizeEvent(self, event): # noqa: N802
|
|
super().resizeEvent(event)
|
|
cr = self.contentsRect()
|
|
self._gutter.setGeometry(QRect(cr.left(), cr.top(), self.line_number_width(), cr.height()))
|
|
|
|
def paint_line_numbers(self, event) -> None:
|
|
p = current_palette()
|
|
painter = QPainter(self._gutter)
|
|
painter.fillRect(event.rect(), QColor(p.code_gutter_bg))
|
|
block = self.firstVisibleBlock()
|
|
num = block.blockNumber()
|
|
top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
|
|
bottom = top + self.blockBoundingRect(block).height()
|
|
painter.setPen(QColor(p.code_gutter_fg))
|
|
while block.isValid() and top <= event.rect().bottom():
|
|
if block.isVisible() and bottom >= event.rect().top():
|
|
painter.drawText(0, int(top), self._gutter.width() - 6,
|
|
self.fontMetrics().height(), Qt.AlignRight,
|
|
str(num + 1))
|
|
block = block.next()
|
|
top = bottom
|
|
bottom = top + self.blockBoundingRect(block).height()
|
|
num += 1
|
|
|
|
def load_file(self, path: str, text: str) -> None:
|
|
self.setPlainText(text)
|
|
self._highlighter.set_filename(path, text)
|
|
|
|
|
|
__all__ = ["CodeEditor", "PygmentsHighlighter"]
|