Files
cowork-local/presentation/folder/code_editor.py
T
f9f6bc01fd
CI / test (push) Canceled after 0s
Feature/delta team/epic r04 (#7)
## Summary

epic r04 - begin refactor

## Change Type

- [x] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
2026-08-31 05:15:13 +00:00

221 lines
9.5 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:
"""Dựng một định dạng ký tự cho bộ tô màu cú pháp."""
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):
"""Tô màu cú pháp bằng Pygments.
Tô lại sau một nhịp trễ (``QTimer`` chạy một lần) thay vì ngay mỗi phím: gõ
nhanh trong file lớn mà tô đồng bộ thì giao diện khựng.
"""
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():
"""Dựng bảng ánh xạ loại token của Pygments sang màu theo theme đang dùng."""
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:
"""Chọn bộ phân tích cú pháp theo đuôi tệp; không nhận ra thì tắt tô màu."""
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):
"""Định dạng của một token; leo dần lên token cha cho tới khi khớp một luật."""
for ttype, fmt in self._rules:
if tok in ttype:
return fmt
return None
def _retokenize(self) -> None:
"""Phân tích lại toàn bộ tài liệu và ghi nhớ định dạng cho từng dòng.
Pygments phân tích theo cả tệp chứ không theo từng dòng, nên không thể
tô đúng nếu chỉ nhìn một dòng — ví dụ chuỗi nhiều dòng hay khối chú thích.
"""
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
"""Áp định dạng đã tính sẵn cho một dòng. Qt gọi hàm này cho từng dòng hiện trên màn."""
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):
"""Máng số dòng vẽ bên trái ô soạn thảo."""
def __init__(self, editor):
"""Dải số dòng bên trái ô soạn mã."""
super().__init__(editor)
self._editor = editor
def sizeHint(self) -> QSize:
"""Bề rộng máng số dòng, do ô soạn thảo tính theo số chữ số của dòng cuối."""
return QSize(self._editor.line_number_width(), 0)
def paintEvent(self, event): # noqa: N802
"""Nhờ ô soạn thảo vẽ — nó mới biết dòng nào đang hiện ở đâu."""
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):
"""Ô soạn mã: phông đều, không tự xuống dòng, tab rộng 4 ký tự.
Không tự xuống dòng là cố ý — mã bị bẻ dòng thì lệch thụt đầu dòng và khó
đọc hơn là phải cuộn ngang.
"""
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:
"""Bề rộng cần cho máng số dòng, tính theo số chữ số của dòng cuối cùng."""
digits = max(2, len(str(max(1, self.blockCount()))))
return 12 + self.fontMetrics().horizontalAdvance("9") * digits
def _update_gutter_width(self) -> None:
"""Chừa lề trái đúng bằng bề rộng máng số dòng."""
self.setViewportMargins(self.line_number_width(), 0, 0, 0)
def _on_update_request(self, rect, dy: int) -> None:
"""Cuộn hoặc vẽ lại vùng nào thì máng số dòng cuộn/vẽ lại đúng vùng đó."""
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
"""Đổi kích thước thì đặt lại hình chữ nhật của máng số dòng."""
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:
"""Vẽ số của các dòng đang hiện trên màn, bỏ qua dòng bị gập hoặc nằm ngoài khung."""
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:
"""Nạp nội dung tệp vào ô soạn thảo và bật tô màu theo đuôi tệp."""
self.setPlainText(text)
self._highlighter.set_filename(path, text)
__all__ = ["CodeEditor", "PygmentsHighlighter"]