presentation/folder/
ai_edit_runner.py 325 một lượt AI sửa file, từ gửi tới xem trước
document_preview_manager.py 317 PDF/Word/Excel/PowerPoint/ảnh/HTML/mã
ai_file_editor_dialog.py 317 dựng panel AI + chọn model
code_editor.py 183 ô soạn mã, đánh số dòng, tô cú pháp
ai_output_writer.py 140 phần DUY NHẤT chạm vào file người dùng
image_model_picker.py 115 dò model sinh ảnh trên mọi provider
file_helpers.py 112 nhận dạng loại file + ngưỡng
workspace_file_tree.py 38 cây thư mục
ui/folder_tab.py 305 lắp ráp + retranslate
Plan ghi 3 file; khối lượng thật cần 8. Hai file tôi thêm ngoài dự kiến vì
đọc kỹ thì chúng là ranh giới thật:
* ai_output_writer.py — tách ra vì đây là phần duy nhất THẬT SỰ ghi đè file
của người dùng. Mọi thứ trước nó chỉ dựng bản xem trước. Ranh giới đó đáng
nhìn thấy trong cấu trúc thư mục.
* image_model_picker.py — chỗ duy nhất trong màn Thư mục biết tới nhiều
provider cùng lúc (nó gợi ý được model sinh ảnh của provider KHÁC cái đang
chọn).
Gom mọi hằng nhận dạng loại file (_IMAGE_SUFFIXES, _HAS_PDF, _MAX_EDIT_BYTES…)
về file_helpers.py: cả tám file trong gói đều hỏi tới, để rải ra thì thêm một
đuôi file phải sửa vài chỗ.
LẠI IMPORT LAZY THỤT LỀ: regex đổi mức tương đối của tôi chỉ khớp đầu dòng
nên bỏ sót import nằm trong thân hàm — 3 checker đỏ. Lần này tôi sửa một lượt
cho CẢ cây presentation/ thay vì riêng thư mục vừa tách; nó tìm ra thêm 3 file
ở scheduling cũng đang sai mà chưa nổ.
756 test xanh. 24/24 checker qua.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
184 lines
7.1 KiB
Python
184 lines
7.1 KiB
Python
"""Ô soạn mã có đánh số dòng và tô màu cú pháp — R08-T12.
|
|
|
|
Dùng cho cả xem lẫn sửa file văn bản. Tô màu qua Pygments nếu có; không
|
|
có thì vẫn soạn được, chỉ mất màu.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from .file_helpers import (
|
|
_MAX_HIGHLIGHT_CHARS, _fmt,
|
|
)
|
|
|
|
import os
|
|
from PySide6.QtCore import QRect, QSize, Qt, QTimer
|
|
from PySide6.QtGui import QColor, QFont, QPainter, QSyntaxHighlighter, QTextCharFormat
|
|
from PySide6.QtWidgets import QPlainTextEdit, QWidget
|
|
from ...i18n import tr
|
|
from ...theme import current_palette
|
|
|
|
|
|
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()
|
|
# Ordered specific → general: first matching token type wins.
|
|
# Colours are resolved when the editor is built, so reopening a file
|
|
# after a theme switch re-highlights it in the new theme.
|
|
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)
|
|
# Surface comes from the central style sheet (#codeEditor) — see theme.py.
|
|
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()
|
|
|
|
# ---- line-number gutter -------------------------------------------------
|
|
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)
|