Hồi quy đã vá
-------------
F-12 Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
`chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
`composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
gắn 1 tệp, khớp bản trước refactor.
F-01 Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
`folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
`ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
tiên hỏng thì đổi provider chính là lúc phải thử lại.
F-07 Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
`check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.
F-11 Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
và không bao giờ chạy.
Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:
graph_renderer.py -> graph_scene_builder.py + graph_export.py
co4e_workflow_service.py -> co4e_run_history.py
json_config_repository.py -> config_sections.py
agents_admin_tab.py -> shared/agent_kind_visuals.py
File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.
Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.
Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.
859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
314 lines
13 KiB
Python
314 lines
13 KiB
Python
"""Embed a LibreOffice editor window inside a Qt panel (Windows only).
|
|
|
|
LibreOffice has no official embedding API, so this launches a *private*
|
|
LibreOffice instance (its own UserInstallation profile so the window is easy to
|
|
find) opening the document, then reparents that top-level window (window class
|
|
``SALFRAME``) into a Qt container via Win32 ``SetParent`` / ``SetWindowLong``.
|
|
|
|
This is best-effort and experimental. On non-Windows, when LibreOffice isn't
|
|
installed, or if the window can't be located/reparented, it degrades gracefully
|
|
to an "Open in LibreOffice" button (a normal external window) so nothing breaks.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
from PySide6.QtCore import Qt, QTimer, QUrl
|
|
from PySide6.QtGui import QDesktopServices, QWindow
|
|
from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QVBoxLayout, QWidget
|
|
|
|
from ..core.doc_extract import find_soffice
|
|
from ..i18n import on_language_changed, tr
|
|
from .icons import icon
|
|
|
|
# Binary document formats that LibreOffice should own (plain text / csv stay in
|
|
# the built-in text editor, so they are intentionally excluded here).
|
|
DOC_SUFFIXES = {
|
|
".doc", ".docx", ".odt", ".rtf",
|
|
".xls", ".xlsx", ".ods",
|
|
".ppt", ".pptx", ".odp",
|
|
".pdf",
|
|
}
|
|
|
|
|
|
def is_document(path) -> bool:
|
|
"""Đuôi tệp này có phải tài liệu LibreOffice mở được không."""
|
|
return Path(path).suffix.lower() in DOC_SUFFIXES
|
|
|
|
|
|
def _win_user32():
|
|
"""user32 with explicit HWND-safe signatures.
|
|
|
|
Without argtypes, ctypes passes Python ints as 32-bit ``c_int``, which
|
|
truncates 64-bit window handles on Win64 and makes every call operate on the
|
|
wrong window. Declaring HWND (a pointer) keeps the full handle."""
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
|
|
u = ctypes.windll.user32
|
|
u.IsWindowVisible.argtypes = [wintypes.HWND]
|
|
u.IsWindowVisible.restype = wintypes.BOOL
|
|
u.GetClassNameW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int]
|
|
u.GetClassNameW.restype = ctypes.c_int
|
|
u.GetWindowTextW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int]
|
|
u.GetWindowTextW.restype = ctypes.c_int
|
|
u.GetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int]
|
|
u.GetWindowLongW.restype = ctypes.c_long
|
|
u.SetWindowLongW.argtypes = [wintypes.HWND, ctypes.c_int, ctypes.c_long]
|
|
u.SetWindowLongW.restype = ctypes.c_long
|
|
u.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int]
|
|
u.ShowWindow.restype = wintypes.BOOL
|
|
u.PostMessageW.argtypes = [wintypes.HWND, ctypes.c_uint, wintypes.WPARAM, wintypes.LPARAM]
|
|
u.PostMessageW.restype = wintypes.BOOL
|
|
return u
|
|
|
|
|
|
class LibreOfficeView(QWidget):
|
|
"""Hosts an embedded LibreOffice window (Windows) or an external-open fallback."""
|
|
|
|
POLL_MS = 400
|
|
MAX_TRIES = 40 # ~16s to find the window before giving up
|
|
|
|
def __init__(self):
|
|
"""Khung nhúng cửa sổ LibreOffice vào trong ứng dụng.
|
|
|
|
Chưa chạy tiến trình nào; hồ sơ riêng và cửa sổ được dựng ở lần mở tệp đầu
|
|
tiên.
|
|
"""
|
|
super().__init__()
|
|
self._proc: subprocess.Popen | None = None
|
|
self._profile_dir: Path | None = None
|
|
self._container: QWidget | None = None
|
|
self._foreign: QWindow | None = None
|
|
self._hwnd: int | None = None
|
|
self._path: str | None = None
|
|
self._tries = 0
|
|
|
|
self._poll = QTimer(self)
|
|
self._poll.setInterval(self.POLL_MS)
|
|
self._poll.timeout.connect(self._try_embed)
|
|
|
|
self._lay = QVBoxLayout(self)
|
|
self._lay.setContentsMargins(0, 0, 0, 0)
|
|
self._lay.setSpacing(8)
|
|
self._info = QLabel("", alignment=Qt.AlignCenter)
|
|
self._info.setObjectName("hint")
|
|
self._info.setWordWrap(True)
|
|
self._lay.addWidget(self._info)
|
|
self._open_btn = QPushButton()
|
|
self._open_btn.setIcon(icon("document"))
|
|
self._open_btn.setObjectName("primary")
|
|
self._open_btn.clicked.connect(self._open_external)
|
|
self._open_btn.setVisible(False)
|
|
self._lay.addWidget(self._open_btn, alignment=Qt.AlignCenter)
|
|
|
|
# Make sure any embedded instance is torn down when the app quits.
|
|
app = QApplication.instance()
|
|
if app is not None:
|
|
app.aboutToQuit.connect(self.close_document)
|
|
on_language_changed(self._retranslate)
|
|
|
|
def _retranslate(self) -> None:
|
|
"""Áp lại chữ theo ngôn ngữ đang chọn."""
|
|
self._open_btn.setText(tr("libreoffice.open_btn"))
|
|
|
|
# ---- public API --------------------------------------------------
|
|
def open_document(self, path: str) -> None:
|
|
"""Mở một tài liệu: đóng cái đang mở, khởi động soffice rồi nhúng cửa sổ của nó."""
|
|
self.close_document()
|
|
self._path = str(path)
|
|
soffice = find_soffice()
|
|
if not soffice:
|
|
self._show_message(tr("libreoffice.not_found"), offer_open=False)
|
|
return
|
|
if sys.platform != "win32":
|
|
self._show_message(tr("libreoffice.windows_only"), offer_open=True)
|
|
return
|
|
self._launch_and_embed(soffice)
|
|
|
|
def close_document(self) -> None:
|
|
"""Đóng tài liệu: dừng bộ đếm dò, gỡ cửa sổ nhúng và tắt tiến trình soffice."""
|
|
self._poll.stop()
|
|
self._tries = 0
|
|
if self._container is not None:
|
|
self._container.setParent(None)
|
|
self._container.deleteLater()
|
|
self._container = None
|
|
self._foreign = None
|
|
# Ask LibreOffice to close its window gracefully (lets it prompt to save),
|
|
# then clean up the throwaway profile.
|
|
if self._hwnd is not None:
|
|
self._post_close(self._hwnd)
|
|
self._hwnd = None
|
|
self._cleanup_proc(graceful=True)
|
|
self._clear_info()
|
|
|
|
# ---- launch + embed (Windows) ------------------------------------
|
|
def _launch_and_embed(self, soffice: str) -> None:
|
|
"""Khởi động soffice với một profile RIÊNG rồi bắt đầu dò cửa sổ của nó.
|
|
|
|
Profile riêng trong thư mục tạm là bắt buộc: dùng chung profile mặc định thì
|
|
instance thứ hai sẽ nối vào instance đang chạy của người dùng và không có
|
|
cửa sổ mới nào để nhúng.
|
|
"""
|
|
try:
|
|
self._profile_dir = Path(tempfile.mkdtemp(prefix=f"lo-embed-{uuid.uuid4().hex[:8]}-"))
|
|
profile_url = "file:///" + str(self._profile_dir).replace("\\", "/")
|
|
args = [
|
|
soffice,
|
|
f"-env:UserInstallation={profile_url}",
|
|
"--norestore", "--nologo", "--nofirststartwizard",
|
|
"--minimized", # don't flash an external window before we embed it
|
|
self._path,
|
|
]
|
|
self._proc = subprocess.Popen(args)
|
|
except Exception as exc: # noqa: BLE001
|
|
self._show_message(tr("libreoffice.start_failed", err=exc), offer_open=True)
|
|
return
|
|
self._show_message(tr("libreoffice.opening"))
|
|
self._tries = 0
|
|
self._poll.start()
|
|
|
|
def _try_embed(self) -> None:
|
|
"""Một nhịp dò: tìm cửa sổ soffice và nhúng, quá ``MAX_TRIES`` thì bỏ cuộc.
|
|
|
|
Phải dò theo nhịp vì soffice tạo cửa sổ sau khi tiến trình đã khởi động —
|
|
không có tín hiệu nào báo "cửa sổ đã sẵn sàng".
|
|
"""
|
|
self._tries += 1
|
|
if self._tries > self.MAX_TRIES:
|
|
self._poll.stop()
|
|
self._show_message(tr("libreoffice.embed_failed"), offer_open=True)
|
|
return
|
|
hwnd = self._find_lo_window()
|
|
if hwnd:
|
|
self._poll.stop()
|
|
self._embed_hwnd(hwnd)
|
|
|
|
def _find_lo_window(self) -> int | None:
|
|
"""Find a visible top-level LibreOffice frame for this document."""
|
|
try:
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
user32 = _win_user32()
|
|
except Exception: # noqa: BLE001
|
|
return None
|
|
stem = Path(self._path).stem.lower() if self._path else ""
|
|
found: list[int] = []
|
|
|
|
@ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
|
def _cb(hwnd, _lparam):
|
|
"""Callback duyệt cửa sổ của Windows: nhận cửa sổ đang hiện và đúng lớp của soffice."""
|
|
if not user32.IsWindowVisible(hwnd):
|
|
return True
|
|
cls = ctypes.create_unicode_buffer(256)
|
|
user32.GetClassNameW(hwnd, cls, 256)
|
|
if "SALFRAME" not in cls.value:
|
|
return True
|
|
title = ctypes.create_unicode_buffer(512)
|
|
user32.GetWindowTextW(hwnd, title, 512)
|
|
# Match the document we just opened (title is "<file> - LibreOffice …").
|
|
if stem and stem in title.value.lower():
|
|
found.append(int(hwnd))
|
|
return False
|
|
return True
|
|
|
|
try:
|
|
user32.EnumWindows(_cb, 0)
|
|
except Exception: # noqa: BLE001
|
|
return None
|
|
return found[0] if found else None
|
|
|
|
def _embed_hwnd(self, hwnd: int) -> None:
|
|
"""Đổi cha của cửa sổ soffice sang widget này và bỏ khung viền của nó."""
|
|
try:
|
|
user32 = _win_user32()
|
|
GWL_STYLE = -16
|
|
WS_CHILD = 0x40000000
|
|
WS_VISIBLE = 0x10000000
|
|
WS_CAPTION = 0x00C00000
|
|
WS_THICKFRAME = 0x00040000
|
|
WS_POPUP = 0x80000000
|
|
style = user32.GetWindowLongW(hwnd, GWL_STYLE)
|
|
style = (style & ~WS_CAPTION & ~WS_THICKFRAME & ~WS_POPUP) | WS_CHILD | WS_VISIBLE
|
|
user32.SetWindowLongW(hwnd, GWL_STYLE, style)
|
|
|
|
self._hwnd = hwnd
|
|
self._foreign = QWindow.fromWinId(hwnd)
|
|
self._container = QWidget.createWindowContainer(self._foreign, self)
|
|
self._container.setFocusPolicy(Qt.StrongFocus)
|
|
self._clear_info()
|
|
self._lay.addWidget(self._container, 1)
|
|
# Reveal only now that it's reparented, so it never flashes outside.
|
|
user32.ShowWindow(hwnd, 1) # SW_SHOWNORMAL
|
|
except Exception as exc: # noqa: BLE001
|
|
self._show_message(tr("libreoffice.embed_error", err=exc), offer_open=True)
|
|
|
|
# ---- cleanup -----------------------------------------------------
|
|
def _post_close(self, hwnd: int) -> None:
|
|
"""Gửi lệnh đóng tới cửa sổ soffice đang nhúng.
|
|
|
|
Đóng lịch sự thay vì giết tiến trình, để LibreOffice kịp dọn file khoá — bỏ
|
|
qua bước này thì lần mở sau nó báo tài liệu đang bị dùng.
|
|
"""
|
|
try:
|
|
WM_CLOSE = 0x0010
|
|
_win_user32().PostMessageW(hwnd, WM_CLOSE, 0, 0)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
def _cleanup_proc(self, graceful: bool = True) -> None:
|
|
"""Dọn tiến trình soffice và thư mục profile tạm của nó.
|
|
|
|
``graceful=True`` đóng lịch sự trước rồi mới giết; profile tạm luôn bị xoá,
|
|
nếu không mỗi lần mở tài liệu lại để lại một thư mục rác.
|
|
"""
|
|
proc, self._proc = self._proc, None
|
|
profile, self._profile_dir = self._profile_dir, None
|
|
if proc is not None and proc.poll() is None:
|
|
try:
|
|
if not graceful:
|
|
proc.terminate()
|
|
proc.wait(timeout=3)
|
|
except Exception: # noqa: BLE001
|
|
try:
|
|
proc.kill()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
if profile is not None:
|
|
shutil.rmtree(profile, ignore_errors=True)
|
|
|
|
def _open_external(self) -> None:
|
|
"""Mở tài liệu bằng ứng dụng mặc định của hệ điều hành — lối thoát khi không
|
|
nhúng được cửa sổ.
|
|
"""
|
|
if not self._path:
|
|
return
|
|
soffice = find_soffice()
|
|
try:
|
|
if soffice:
|
|
subprocess.Popen([soffice, self._path])
|
|
return
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
QDesktopServices.openUrl(QUrl.fromLocalFile(self._path))
|
|
|
|
# ---- small helpers -----------------------------------------------
|
|
def _show_message(self, text: str, offer_open: bool = False) -> None:
|
|
"""Hiện một dòng thông báo thay cho khung xem, kèm nút mở bằng ứng dụng ngoài
|
|
nếu có.
|
|
"""
|
|
self._info.setText(text)
|
|
self._info.setVisible(True)
|
|
self._open_btn.setVisible(bool(offer_open and self._path))
|
|
|
|
def _clear_info(self) -> None:
|
|
"""Ẩn dòng thông báo và nút mở ngoài."""
|
|
self._info.setVisible(False)
|
|
self._open_btn.setVisible(False)
|