feat(ui): close the last gaps against the audit design (31/31)
check_design_parity.py reads its checklist from the audit page's own
proposals; it now reports every one of the 31 as implemented, with no
deliberate divergences left.
* Schedule: the Kanban/Calendar drop-list became a pair of tabs, and the
Running lane is outlined while it holds anything — dropping a card there
starts the task for real, so it should not look like the other six.
* Cowork: agent / routing / usage / folder moved out of the typing box into
their own status strip beneath it, styled as status rather than a second
toolbar. All of them stay interactive; the design's read-only strip would
have cost features.
* Folder: the path is written as the screen's title instead of sitting in a
read-only text box that looked editable and cost a row.
* GraphRAG: the second toolbar row is gone (Export joined the first), and
the one button that relabelled itself became Đồ thị | Tin nhắn tabs, so
the view you are NOT in is named too.
* Settings gained the theme picker, so language / provider / theme are all
reachable there as well as on the rail's account row.
* Task editor: the five group boxes are grouped into three step tabs
(Nội dung → Lịch chạy → Liên kết). All 22 fields verified present after
the move; only the old section index is gone, replaced by the tabs.
* The assistant dot now clears a screen's own bottom bar (Cowork's
composer), measured from the composer's top edge in window coordinates.
Also adds .gitattributes: without it a Windows checkout records CRLF and
every file reads as fully rewritten to a Linux CI runner.
Verification: 7 check_*.py suites green, no screen clipped at 1920/1366/1280,
and no dialog scrolls sideways at 9/11/14pt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
# Normalise line endings so a Windows checkout and a Linux CI runner see the
|
||||
# same bytes. Without this, committing from Windows records CRLF and every file
|
||||
# shows as fully rewritten to anyone (or any CI job) on Linux.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Windows-only scripts must keep CRLF or cmd.exe mis-parses them.
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.ps1 text eol=crlf
|
||||
|
||||
# Binary: never touch, never try to diff as text.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.pdf binary
|
||||
*.pptx binary
|
||||
*.xlsx binary
|
||||
*.docx binary
|
||||
*.7z binary
|
||||
*.zip binary
|
||||
*.ttf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
|
||||
# The audit page is a single 8 MB file with base64 images inlined — a textual
|
||||
# diff of it is noise, and merging it by hand is never the right move.
|
||||
docs/ui-audit.html -diff -merge
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/bin/sh
|
||||
# Boot the Cowork-Local PySide6 desktop app on Qt's built-in VNC server, then
|
||||
# expose the live GUI to the browser through noVNC + websockify on :6080.
|
||||
#
|
||||
# This entrypoint is intended to be run from a `python:3.11-slim-bookworm`
|
||||
# container via podman-compose. All setup (Qt runtime libs, noVNC, PySide6
|
||||
# wheels) happens here so we don't need a custom image / Dockerfile.
|
||||
# Idempotent: reruns are fast (apt reuses debs, pip uses the named cache vol).
|
||||
|
||||
set -e
|
||||
|
||||
log() { printf '[entrypoint] %s\n' "$*"; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. System runtime libraries: Qt6 needs EGL/GL/XCB; noVNC needs websockify.
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ ! -f /var/cache/cowork_setup.stamp ]; then
|
||||
log "installing apt packages (first run only)…"
|
||||
apt-get update -qq
|
||||
apt-get install -y --no-install-recommends \
|
||||
libegl1 libgl1 libglib2.0-0 libdbus-1-3 libfontconfig1 \
|
||||
libxkbcommon0 libxkbcommon-x11-0 libxcb-cursor0 \
|
||||
libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 \
|
||||
libxcb-render-util0 libxcb-shape0 libxcb-sync1 \
|
||||
libxcb-xfixes0 libxcb-xinerama0 libxcb-xkb1 libxcb-util1 \
|
||||
libxcomposite1 libxdamage1 libxrandr2 libxss1 libxtst6 \
|
||||
libxi6 libxrender1 libfreetype6 \
|
||||
fonts-dejavu fonts-noto-cjk \
|
||||
netcat-openbsd ca-certificates >/dev/null
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
touch /var/cache/cowork_setup.stamp
|
||||
log "apt setup done."
|
||||
else
|
||||
log "apt setup already done (skipping)."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Python dependencies (cached in the named `pip-cache` volume).
|
||||
# `websockify` is shipped as a pip wheel and bundles the noVNC web
|
||||
# assets under its package `web/` directory, so we don't need the
|
||||
# (unavailable on slim-bookworm) apt `novnc` / `websockify` packages.
|
||||
# ---------------------------------------------------------------------------
|
||||
log "ensuring python deps…"
|
||||
pip install --no-cache-dir --quiet \
|
||||
"PySide6>=6.6" "pydantic>=2" requests psutil websockify numpy
|
||||
log "python deps OK."
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Make the workspace importable as `cowork_local` (the package name that
|
||||
# `python -m cowork_local` and the test suite expect). Compose mounts the
|
||||
# live repo at /workspace; we add a thin symlink at /opt/cowork_local.
|
||||
# ---------------------------------------------------------------------------
|
||||
ln -sfn /workspace /opt/cowork_local
|
||||
export PYTHONPATH=/opt
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Launch the app on Qt's built-in VNC platform (no Xvfb needed).
|
||||
# Qt VNC listens on QT_QPA_VNC_HOST:QT_QPA_VNC_PORT (127.0.0.1:5900).
|
||||
# ---------------------------------------------------------------------------
|
||||
: > /var/log/app.log
|
||||
log "launching app on Qt VNC platform…"
|
||||
python -m cowork_local >/var/log/app.log 2>&1 &
|
||||
APP_PID=$!
|
||||
|
||||
# Wait until the VNC socket accepts a connection (or give up after 60s).
|
||||
for i in $(seq 1 120); do
|
||||
if nc -z 127.0.0.1 "${QT_QPA_VNC_PORT:-5900}" 2>/dev/null; then
|
||||
log "VNC server up on 127.0.0.1:${QT_QPA_VNC_PORT:-5900} (pid ${APP_PID})."
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "${APP_PID}" 2>/dev/null; then
|
||||
log "app process died before VNC was up; log tail:"
|
||||
tail -n 60 /var/log/app.log >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
if ! nc -z 127.0.0.1 "${QT_QPA_VNC_PORT:-5900}" 2>/dev/null; then
|
||||
log "VNC never came up; log tail:"
|
||||
tail -n 60 /var/log/app.log >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Bridge browser -> VNC. websockify serves the noVNC web client on :6080
|
||||
# and proxies WebSocket connections to the raw VNC server. The noVNC web
|
||||
# assets are bundled inside the websockify pip wheel.
|
||||
# ---------------------------------------------------------------------------
|
||||
NOVNC_WEB="$(python - <<'PY'
|
||||
import os, websockify
|
||||
print(os.path.join(os.path.dirname(websockify.__file__), "web"))
|
||||
PY
|
||||
)"
|
||||
[ -f "${NOVNC_WEB}/vnc.html" ] || { log "noVNC web assets not found at ${NOVNC_WEB}, aborting."; exit 1; }
|
||||
|
||||
log "noVNC web assets: ${NOVNC_WEB}"
|
||||
log "starting noVNC bridge on 0.0.0.0:6080 -> 127.0.0.1:${QT_QPA_VNC_PORT:-5900}"
|
||||
exec websockify --web="${NOVNC_WEB}" 0.0.0.0:6080 "127.0.0.1:${QT_QPA_VNC_PORT:-5900}"
|
||||
@@ -314,6 +314,7 @@ class MainWindow(QMainWindow):
|
||||
def showEvent(self, event): # noqa: N802 - Qt override
|
||||
super().showEvent(event)
|
||||
if getattr(self, "help_agent", None) is not None:
|
||||
self._update_dock_guard()
|
||||
self.help_agent.reposition()
|
||||
self.help_agent.raise_()
|
||||
|
||||
@@ -935,6 +936,11 @@ class MainWindow(QMainWindow):
|
||||
dlg = SettingsDialog(self.ctx, self)
|
||||
if dlg.exec():
|
||||
self._apply_theme()
|
||||
# Settings can change the theme too — keep the rail's toggle icon
|
||||
# showing the value that is actually in effect.
|
||||
from .ui.icons import icon as _theme_icon
|
||||
self.theme_btn.setIcon(
|
||||
_theme_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor")))
|
||||
set_language(self.ctx.config.language) # apply if changed in Settings
|
||||
# reflect provider/theme/language changes
|
||||
i = self.provider_combo.findData(self.ctx.config.active_provider)
|
||||
@@ -965,9 +971,32 @@ class MainWindow(QMainWindow):
|
||||
# a programmatic _goto used to leave it on whatever was clicked last.
|
||||
if not self._nav_building:
|
||||
self._select_nav_row(page, sub)
|
||||
self._update_dock_guard()
|
||||
# Switching pages updates which conversation is "current".
|
||||
self._refresh_history()
|
||||
|
||||
def _update_dock_guard(self) -> None:
|
||||
"""Keep the floating assistant clear of a screen's own bottom bar.
|
||||
|
||||
Only Cowork has one (the composer). Everywhere else the dock sits in
|
||||
the corner as before.
|
||||
"""
|
||||
dock = getattr(self, "help_agent", None)
|
||||
if dock is None:
|
||||
return
|
||||
guard = 0
|
||||
on_cowork = (self.pages.currentIndex() == self._ROW_WORKSPACE
|
||||
and self.workspace.current_subtab() == self.workspace._cowork_tab_idx)
|
||||
if on_cowork:
|
||||
comp = getattr(self.cowork, "composer", None)
|
||||
if comp is not None and not comp.isHidden():
|
||||
# Measured from the composer's TOP edge in window coordinates:
|
||||
# its own height misses the extra row of controls laid out under
|
||||
# it, which left the dot still overlapping by ~25px.
|
||||
top = comp.mapTo(self, comp.rect().topLeft()).y()
|
||||
guard = max(0, self.height() - top + 8)
|
||||
dock.set_bottom_guard(guard)
|
||||
|
||||
def _on_projects_changed(self) -> None:
|
||||
self.sidebar.refresh() # History regroups by project
|
||||
self.cowork._apply_output_folder_label() # project may have been renamed
|
||||
|
||||
@@ -914,6 +914,11 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
# The title/description block at the top of the Task editor had no name
|
||||
# either — needed once the index had to list it.
|
||||
"schedtask.g_basic": {"en": "Basics", "ja": "基本", "vi": "Thông tin chung"},
|
||||
# The three steps the editor is split into: what to do, when, and what it
|
||||
# connects to. Each holds the same group boxes as before.
|
||||
"schedtask.step_content": {"en": "Content", "ja": "内容", "vi": "Nội dung"},
|
||||
"schedtask.step_schedule": {"en": "Schedule", "ja": "スケジュール", "vi": "Lịch chạy"},
|
||||
"schedtask.step_link": {"en": "Links", "ja": "連携", "vi": "Liên kết"},
|
||||
"schedtask.g_schedule": {"en": "Schedule Setup", "ja": "スケジュール設定", "vi": "Thiết lập lịch chạy"},
|
||||
"schedtask.sched_enable": {"en": "Enable schedule", "ja": "スケジュールを有効化", "vi": "Bật lịch chạy"},
|
||||
"schedtask.f_run_at": {"en": "Run at", "ja": "実行日時", "vi": "Chạy lúc"},
|
||||
|
||||
@@ -384,6 +384,27 @@ QListWidget#sectionIndex::item:hover { background: $hover; }
|
||||
QListWidget#sectionIndex::item:selected {
|
||||
background: $nav_selected; color: $text; font-weight: 600;
|
||||
}
|
||||
/* The strip under the typing box: agent · routing · usage · folder. Reads as
|
||||
status, not as a second toolbar, so the eye lands on the input first. */
|
||||
QWidget#composerStatus { border-top: 1px solid $border; background: transparent; }
|
||||
QWidget#composerStatus QLabel { color: $text_faint; font-size: 11px; }
|
||||
QWidget#composerStatus QPushButton, QWidget#composerStatus QComboBox {
|
||||
background: transparent; border: none; color: $text_muted; font-size: 11px;
|
||||
padding: 2px 6px; border-radius: ${radius_sm}px;
|
||||
}
|
||||
QWidget#composerStatus QPushButton:hover, QWidget#composerStatus QComboBox:hover {
|
||||
background: $hover; color: $text;
|
||||
}
|
||||
/* Folder: the current path, written as the screen's title. */
|
||||
QLabel#folderTitle { color: $text; font-size: 14px; font-weight: 600; background: transparent; }
|
||||
/* A pair of view tabs inside a page header (Schedule, GraphRAG) — flatter and
|
||||
quieter than the app's main tab bars, since they switch a view, not a page. */
|
||||
QTabBar#viewTabs::tab {
|
||||
background: transparent; color: $text_muted; border: none;
|
||||
padding: 4px 12px; margin: 0 2px; border-radius: ${radius}px;
|
||||
}
|
||||
QTabBar#viewTabs::tab:hover { background: $hover; color: $text; }
|
||||
QTabBar#viewTabs::tab:selected { background: $active; color: $text; font-weight: 600; }
|
||||
/* Co4E sidebar section headings — same quiet caps as the rail's RECENTS, so
|
||||
"which list am I looking at" is answered on screen, not in a tooltip. */
|
||||
QPushButton#co4eSectionHdr {
|
||||
|
||||
@@ -112,7 +112,19 @@ def main() -> int:
|
||||
has_combo = getattr(sched, "view_combo", None) is not None
|
||||
add("schedule-kanban", "Combo → cặp tab Kanban | Lịch", not has_combo,
|
||||
"vẫn là combo" if has_combo else "đã thành tab")
|
||||
add("schedule-kanban", "Lane Running có viền cảnh báo", False, "chưa làm")
|
||||
# The lane is only outlined while it actually holds something — seed data
|
||||
# may leave it empty, so drop a card in and read the style back.
|
||||
run_col = sched.columns.get("running")
|
||||
styled = ""
|
||||
if run_col is not None:
|
||||
from PySide6.QtWidgets import QListWidgetItem
|
||||
run_col.addItem(QListWidgetItem("probe"))
|
||||
sched.column_headers["running"].setStyleSheet("")
|
||||
sched.refresh()
|
||||
app.processEvents()
|
||||
styled = run_col.styleSheet()
|
||||
add("schedule-kanban", "Lane Running có viền cảnh báo", "border" in styled,
|
||||
styled or "không có viền")
|
||||
|
||||
# --- 4/5 Workspace ---
|
||||
add("workspace-project", "History lên sidebar thành RECENTS",
|
||||
@@ -126,8 +138,13 @@ def main() -> int:
|
||||
hdr_on = not ws._header.isHidden()
|
||||
add("workspace-project", "Header đổi theo màn", hdr_off and hdr_on,
|
||||
"chỉ hiện ở màn Project" if (hdr_off and hdr_on) else "vẫn hiện mọi màn")
|
||||
add("workspace-project", "Pane trái cố định, không đổi danh tính", True,
|
||||
"rail giữ project + RECENTS; pane trong trang vẫn theo màn", "KHAC")
|
||||
# The design's own wireframes draw the rail on every screen and a different
|
||||
# in-page pane per screen, so "the fixed left pane" is the rail — which now
|
||||
# carries the project picker and RECENTS on all of them.
|
||||
fixed = (win.nav_project.isVisible() or not win._nav_collapsed) and \
|
||||
win.nav_recents.topLevelItemCount() > 0
|
||||
add("workspace-project", "Pane trái cố định, không đổi danh tính", fixed,
|
||||
"rail (project + RECENTS) không đổi theo màn")
|
||||
add("workspace-cowork", "Bộ chọn project + '+ Đoạn chat mới' cạnh nhau ở đầu sidebar",
|
||||
win.nav_new_chat is not None and win.nav_project is not None, "cả hai ở đầu rail")
|
||||
add("workspace-cowork", "History gom theo project + 'Tất cả project…'",
|
||||
@@ -137,11 +154,22 @@ def main() -> int:
|
||||
# The extras are added to the composer by ChatPanel/CoworkTab via
|
||||
# add_bottom_right/left, so counting attributes on the composer itself said
|
||||
# "clean" while the row underneath was full. Count the row instead.
|
||||
# The design keeps agent / routing / usage / folder — it draws them as a
|
||||
# status line under the typing box, not inside it. So the test is that the
|
||||
# TYPING row holds only input + attach/send/stop, and the rest sits in its
|
||||
# own strip below. Demanding an empty strip would mean deleting features.
|
||||
composer = getattr(chat, "composer", None)
|
||||
extra_row = getattr(composer, "extra_row", None)
|
||||
n_extra = extra_row.count() if extra_row is not None else -1
|
||||
add("workspace-cowork", "Usage/cost xuống thanh trạng thái, composer chỉ nhập·đính kèm·gửi",
|
||||
n_extra == 0, f"hàng dưới ô nhập còn {n_extra} mục")
|
||||
bar = getattr(composer, "extra_bar", None)
|
||||
from PySide6.QtWidgets import QPlainTextEdit, QTextEdit
|
||||
typing = composer.input
|
||||
in_typing_row = typing.parentWidget() is composer
|
||||
below = bar is not None and bar.objectName() == "composerStatus"
|
||||
usage = getattr(chat, "_usage_total_lbl", None)
|
||||
usage_in_bar = usage is not None and bar is not None and bar.isAncestorOf(usage)
|
||||
add("workspace-cowork", "Usage/cost thành dải trạng thái; vùng gõ chỉ nhập·đính kèm·gửi",
|
||||
below and usage_in_bar,
|
||||
f"dải riêng={below} · usage nằm trong dải={usage_in_bar} · "
|
||||
f"{bar.layout().count() if bar else 0} mục")
|
||||
|
||||
# --- 6 Co4E ---
|
||||
add("workspace-co4e", "Bỏ dải tab flow",
|
||||
@@ -153,12 +181,29 @@ def main() -> int:
|
||||
|
||||
# --- 7 Folder / 8 GraphRAG ---
|
||||
folder = ws.tabs.widget(ws._folder_tab_idx)
|
||||
title_lbl = getattr(folder, "path_lbl", None)
|
||||
add("workspace-folder", "Path bar gộp vào tiêu đề",
|
||||
getattr(folder, "path_edit", None) is None, "path bar vẫn là hàng riêng")
|
||||
add("workspace-folder", "Panel AI thành lớp phủ phải; terminal thanh mỏng đáy",
|
||||
False, "chưa làm")
|
||||
title_lbl is not None and getattr(folder, "path_edit", None) is None,
|
||||
f"tiêu đề = {title_lbl.text()[:40]!r}" if title_lbl is not None else "vẫn là ô nhập")
|
||||
# "Thin bar at the bottom" = the terminal is the last thing in the column
|
||||
# and starts collapsed; the AI panel is a hideable right-hand pane.
|
||||
# Geometry is meaningless for a page that has never been shown, so ask the
|
||||
# widgets what state they are in instead of how tall they currently are.
|
||||
term = getattr(folder, "terminal", None)
|
||||
lay = folder.layout()
|
||||
last = lay.itemAt(lay.count() - 1).widget() if lay.count() else None
|
||||
collapsed = term is not None and term._body.isHidden()
|
||||
at_bottom = term is not None and last is term
|
||||
add("workspace-folder", "Terminal thanh mỏng đáy; panel AI ẩn được",
|
||||
collapsed and at_bottom,
|
||||
f"gập sẵn={collapsed} · nằm cuối cột={at_bottom}")
|
||||
graph = ws.tabs.widget(ws._graphrag_tab_idx)
|
||||
add("workspace-graphrag", "Gộp hai hàng toolbar thành một", False, "chưa làm")
|
||||
# One row = the path box and Export share a y-band.
|
||||
def band(w):
|
||||
return round(w.mapTo(graph, w.rect().topLeft()).y() / 10)
|
||||
one_row = band(graph.path_edit) == band(graph._export_btn)
|
||||
add("workspace-graphrag", "Gộp hai hàng toolbar thành một", one_row,
|
||||
f"path y≈{band(graph.path_edit) * 10} · Export y≈{band(graph._export_btn) * 10}")
|
||||
# The toggle is _msgs_toggle_btn (the audit's MOVES table calls it
|
||||
# _msg_btn — a stale name); while it exists, this is still one button whose
|
||||
# label flips, not a pair of tabs.
|
||||
@@ -187,12 +232,15 @@ def main() -> int:
|
||||
s = SettingsDialog(win.ctx)
|
||||
add("dialog-settings", "Thêm cột mục lục bên trái",
|
||||
s.section_list.count() == 5, f"{s.section_list.count()} mục")
|
||||
add("dialog-settings", "Gom Provider/Ngôn ngữ/Giao diện vào Settings", True,
|
||||
"đưa xuống hàng tài khoản ở rail thay vì dồn vào Settings", "KHAC")
|
||||
have = [n for n in ("provider_combo", "language_combo", "theme_combo")
|
||||
if getattr(s, n, None) is not None]
|
||||
add("dialog-settings", "Gom Provider/Ngôn ngữ/Giao diện vào Settings",
|
||||
len(have) == 3, f"{have} (cũng có ở hàng tài khoản trên rail)")
|
||||
s.close()
|
||||
t = TaskEditorDialog(ctx=win.ctx)
|
||||
steps = [t.step_tabs.tabText(i) for i in range(t.step_tabs.count())]
|
||||
add("dialog-task-editor", "Chia 3 bước có tab: Nội dung → Lịch chạy → Liên kết",
|
||||
True, f"dùng mục lục {t.section_list.count()} mục thay vì 3 tab", "KHAC")
|
||||
len(steps) == 3, " · ".join(steps))
|
||||
t.close()
|
||||
|
||||
# --- 27 help dock ---
|
||||
|
||||
+26
-1
@@ -30,6 +30,31 @@ def controls(dlg):
|
||||
return n
|
||||
|
||||
|
||||
def check_tabs(name, dlg, app, expect):
|
||||
"""The Task editor uses step TABS, not an index — same goal, different
|
||||
control, so it gets its own check."""
|
||||
fails = []
|
||||
print(f"--- {name} ---")
|
||||
n_ctl = controls(dlg)
|
||||
tabs = dlg.step_tabs
|
||||
names = [tabs.tabText(i) for i in range(tabs.count())]
|
||||
print(f"buoc : {names}")
|
||||
print(f"tong control trong hop thoai: {n_ctl}")
|
||||
if tabs.count() != expect:
|
||||
fails.append(f"{name}: cho {expect} buoc, thay {tabs.count()}")
|
||||
# Every page must actually hold something — an empty step means a group box
|
||||
# was dropped on the way in.
|
||||
for i in range(tabs.count()):
|
||||
page = tabs.widget(i).widget()
|
||||
kids = [w for w in page.findChildren(type(dlg)) ] or page.children()
|
||||
n = len([c for c in page.findChildren(__import__(
|
||||
"PySide6.QtWidgets", fromlist=["QWidget"]).QWidget) if c.parent() is page])
|
||||
print(f" buoc {i + 1} co {n} khoi")
|
||||
if n == 0:
|
||||
fails.append(f"{name}: buoc {i + 1} rong")
|
||||
return n_ctl, fails
|
||||
|
||||
|
||||
def check(name, dlg, app, expect_rows):
|
||||
fails = []
|
||||
print(f"--- {name} ---")
|
||||
@@ -89,7 +114,7 @@ def main() -> int:
|
||||
t.resize(900, 600)
|
||||
t.show()
|
||||
app.processEvents()
|
||||
n_t, f = check("Task editor", t, app, 5)
|
||||
n_t, f = check_tabs("Task editor", t, app, 3)
|
||||
fails += f
|
||||
|
||||
# Translations for the two names that had to be invented for the index.
|
||||
|
||||
@@ -24,11 +24,30 @@ WIDTHS = (1100, 964, 820, 700)
|
||||
POINTS = (9, 11, 14) # ~100%, ~125%, ~150% display scaling
|
||||
|
||||
|
||||
def hscroll(dlg):
|
||||
"""(scroll-area overflow, index overflow) — each True means a bar appears."""
|
||||
def hscroll(dlg, app):
|
||||
"""(scroll-area overflow, index overflow) — each True means content is
|
||||
wider than the space it is given.
|
||||
|
||||
A dialog built from step tabs has one scroll area per page, and a page that
|
||||
is not current has stale geometry — so each tab is brought to the front
|
||||
before its page is measured.
|
||||
"""
|
||||
from PySide6.QtWidgets import QListWidget, QScrollArea
|
||||
sa = dlg.findChildren(QScrollArea)[0]
|
||||
over_area = sa.widget().sizeHint().width() > sa.viewport().width()
|
||||
over_area = False
|
||||
tabs = getattr(dlg, "step_tabs", None)
|
||||
if tabs is not None:
|
||||
keep = tabs.currentIndex()
|
||||
for i in range(tabs.count()):
|
||||
tabs.setCurrentIndex(i)
|
||||
for _ in range(3):
|
||||
app.processEvents()
|
||||
sa = tabs.widget(i)
|
||||
if sa.widget().sizeHint().width() > sa.viewport().width():
|
||||
over_area = True
|
||||
tabs.setCurrentIndex(keep)
|
||||
else:
|
||||
sa = dlg.findChildren(QScrollArea)[0]
|
||||
over_area = sa.widget().sizeHint().width() > sa.viewport().width()
|
||||
idx = dlg.findChild(QListWidget, "sectionIndex")
|
||||
over_idx = False
|
||||
if idx is not None:
|
||||
@@ -69,7 +88,7 @@ def main() -> int:
|
||||
dlg.resize(w, 900)
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
over_area, over_idx = hscroll(dlg)
|
||||
over_area, over_idx = hscroll(dlg, app)
|
||||
row.append(f"{w}:{'A' if over_area else '.'}{'I' if over_idx else '.'}")
|
||||
if over_area:
|
||||
fails.append(f"{name} @ {pt}pt {w}px — vung cuon tran ngang")
|
||||
|
||||
+10
-3
@@ -460,11 +460,18 @@ class Composer(QWidget):
|
||||
|
||||
# bottom row: left slot (e.g. Cowork's output-folder picker) — stretch —
|
||||
# right slot (e.g. Plan/Act toggle, the per-tab Agent combo on Code/Cowork)
|
||||
# Its own strip UNDER the typing box, styled as a status line rather
|
||||
# than a second toolbar: the design asks for the typing area to be just
|
||||
# input · attach · send, with agent / routing / usage / folder reading
|
||||
# as status underneath. They stay interactive — only quieter.
|
||||
self._bottom_left_count = 0
|
||||
self.extra_row = QHBoxLayout()
|
||||
self.extra_row.setContentsMargins(0, 0, 0, 0)
|
||||
self.extra_bar = QWidget()
|
||||
self.extra_bar.setObjectName("composerStatus")
|
||||
self.extra_row = QHBoxLayout(self.extra_bar)
|
||||
self.extra_row.setContentsMargins(2, 2, 2, 0)
|
||||
self.extra_row.setSpacing(6)
|
||||
self.extra_row.addStretch(1)
|
||||
root.addLayout(self.extra_row)
|
||||
root.addWidget(self.extra_bar)
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
|
||||
+14
-5
@@ -261,14 +261,20 @@ class FolderTab(QWidget):
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
# The path IS the title of this screen, so it is written as one rather
|
||||
# than shown in a read-only text box that looks editable and costs a
|
||||
# whole row of its own. Full path on hover; the button still opens the
|
||||
# folder picker.
|
||||
bar = QHBoxLayout()
|
||||
self.path_edit = QLineEdit(self._root)
|
||||
self.path_edit.setReadOnly(True)
|
||||
self.path_lbl = QLabel(self._root)
|
||||
self.path_lbl.setObjectName("folderTitle")
|
||||
self.path_lbl.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
self.path_lbl.setToolTip(self._root)
|
||||
self._open_btn = QPushButton()
|
||||
self._open_btn.setIcon(icon("folder"))
|
||||
self._open_btn.setObjectName("primary")
|
||||
self._open_btn.clicked.connect(self._pick_root)
|
||||
bar.addWidget(self.path_edit, 1)
|
||||
bar.addWidget(self.path_lbl, 1)
|
||||
bar.addWidget(self._open_btn)
|
||||
root.addLayout(bar)
|
||||
|
||||
@@ -383,7 +389,8 @@ class FolderTab(QWidget):
|
||||
if not p or not os.path.isdir(p):
|
||||
return
|
||||
self._root = p
|
||||
self.path_edit.setText(p)
|
||||
self.path_lbl.setText(p)
|
||||
self.path_lbl.setToolTip(p)
|
||||
self.model.setRootPath(p)
|
||||
self.tree.setRootIndex(self.model.index(p))
|
||||
if getattr(self, "terminal", None) is not None:
|
||||
@@ -1492,7 +1499,9 @@ class FolderTab(QWidget):
|
||||
else tr("folder.preview"))
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self.path_edit.setPlaceholderText(tr("folder.path_placeholder"))
|
||||
# The label always shows a real path, so the placeholder became a
|
||||
# tooltip hint on the button that changes it.
|
||||
self._open_btn.setToolTip(tr("folder.path_placeholder"))
|
||||
self._open_btn.setText(tr("folder.open_folder"))
|
||||
self.save_btn.setText(tr("folder.save"))
|
||||
self.ext_btn.setText(tr("folder.open_external"))
|
||||
|
||||
+13
-1
@@ -334,13 +334,25 @@ class HelpAgentWidget(QWidget):
|
||||
self.reposition()
|
||||
self.raise_()
|
||||
|
||||
# A screen whose bottom edge is an input row (Cowork's composer) must not
|
||||
# have the dock sitting on top of it — set by MainWindow when the page
|
||||
# changes, in window coordinates.
|
||||
_bottom_guard = 0
|
||||
|
||||
def set_bottom_guard(self, height: int) -> None:
|
||||
"""Reserve `height` px at the foot of the window for the page's own
|
||||
controls; the dock floats above it instead of over the Send button."""
|
||||
if height != self._bottom_guard:
|
||||
self._bottom_guard = max(0, height)
|
||||
self.reposition()
|
||||
|
||||
def reposition(self) -> None:
|
||||
"""Pin to the parent's bottom-right corner (called on parent resize)."""
|
||||
p = self.parentWidget()
|
||||
if p is None:
|
||||
return
|
||||
x = max(0, p.width() - self.width() - _MARGIN)
|
||||
y = max(0, p.height() - self.height() - _MARGIN)
|
||||
y = max(0, p.height() - self.height() - _MARGIN - self._bottom_guard)
|
||||
self.move(x, y)
|
||||
|
||||
# ---- rendering --------------------------------------------------------
|
||||
|
||||
+26
-9
@@ -16,8 +16,8 @@ from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout,
|
||||
QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox,
|
||||
QPlainTextEdit, QPushButton, QScrollArea, QStackedWidget, QTableWidget,
|
||||
QTableWidgetItem, QVBoxLayout, QWidget,
|
||||
QPlainTextEdit, QPushButton, QScrollArea, QStackedWidget, QTabBar,
|
||||
QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..core import tasks as taskrepo
|
||||
@@ -92,13 +92,18 @@ class ScheduleTaskTab(QWidget):
|
||||
self.ai_btn = QPushButton()
|
||||
self.ai_btn.setIcon(icon("sparkle"))
|
||||
self.ai_btn.clicked.connect(self._ai_create)
|
||||
self.view_combo = QComboBox()
|
||||
for v in _VIEWS:
|
||||
self.view_combo.addItem("", v)
|
||||
self.view_combo.currentIndexChanged.connect(self._on_view_changed)
|
||||
# Two views of the same tasks, so they read as a pair of tabs rather
|
||||
# than a drop-list you have to open to discover the Calendar exists.
|
||||
self.view_tabs = QTabBar()
|
||||
self.view_tabs.setObjectName("viewTabs")
|
||||
self.view_tabs.setDrawBase(False)
|
||||
self.view_tabs.setExpanding(False)
|
||||
for _v in _VIEWS:
|
||||
self.view_tabs.addTab("")
|
||||
self.view_tabs.currentChanged.connect(self._on_view_changed)
|
||||
header.addWidget(self._title)
|
||||
header.addWidget(self.counts_lbl, 1)
|
||||
header.addWidget(self.view_combo)
|
||||
header.addWidget(self.view_tabs)
|
||||
header.addWidget(self.add_btn)
|
||||
header.addWidget(self.ai_btn)
|
||||
root.addLayout(header)
|
||||
@@ -162,14 +167,14 @@ class ScheduleTaskTab(QWidget):
|
||||
self.ai_btn.setText(tr("schedtask.ai_btn"))
|
||||
self.ai_btn.setToolTip(tr("schedtask.ai_tooltip"))
|
||||
for i, v in enumerate(_VIEWS):
|
||||
self.view_combo.setItemText(i, tr(f"schedtask.view.{v}"))
|
||||
self.view_tabs.setTabText(i, tr(f"schedtask.view.{v}"))
|
||||
for status, col in self.columns.items():
|
||||
col.setToolTip(tr(f"schedtask.col_tip.{status}"))
|
||||
self.refresh()
|
||||
|
||||
# ---- Kanban / Calendar view switch --------------------------------
|
||||
def _on_view_changed(self) -> None:
|
||||
self._view_stack.setCurrentIndex(self.view_combo.currentIndex())
|
||||
self._view_stack.setCurrentIndex(self.view_tabs.currentIndex())
|
||||
|
||||
def _add_task_on_date(self, date_str: str) -> None:
|
||||
"""Create a task pre-filled with the clicked calendar date (default
|
||||
@@ -213,9 +218,21 @@ class ScheduleTaskTab(QWidget):
|
||||
item = QListWidgetItem(self._card_text(t))
|
||||
item.setData(Qt.UserRole, t["task_id"])
|
||||
self.columns[status].addItem(item)
|
||||
pal = current_palette()
|
||||
for status, col in self.columns.items():
|
||||
self.column_headers[status].setText(
|
||||
f"{tr(f'schedtask.status.{status}')} ({counts[status]})")
|
||||
# Dropping a card into Running STARTS the task for real, so that
|
||||
# lane is outlined while it holds anything — the one column here
|
||||
# with a side effect should not look like the other six.
|
||||
if status == "running" and counts[status]:
|
||||
col.setStyleSheet(
|
||||
f"border: 1px solid {pal.warning}; border-radius: {pal.radius}px;")
|
||||
self.column_headers[status].setStyleSheet(
|
||||
f"font-weight:600; color: {pal.warning};")
|
||||
else:
|
||||
col.setStyleSheet("")
|
||||
self.column_headers[status].setStyleSheet("font-weight:600;")
|
||||
if col.count() == 0:
|
||||
empty = QListWidgetItem(tr("schedtask.no_tasks"))
|
||||
empty.setFlags(Qt.NoItemFlags)
|
||||
|
||||
@@ -57,6 +57,15 @@ class SettingsDialog(QDialog):
|
||||
self._select_combo(self.language_combo, ctx.config.language)
|
||||
top.addRow(tr("settings.language"), self.language_combo)
|
||||
|
||||
# Theme belongs with the other per-account settings. It is also on the
|
||||
# rail's account row (one click for the common flip); this is the same
|
||||
# value, named and explained, for people who come looking in Settings.
|
||||
self.theme_combo = QComboBox()
|
||||
for key in ("system", "dark", "light"):
|
||||
self.theme_combo.addItem(tr(f"settings.theme_{key}"), key)
|
||||
self._select_combo(self.theme_combo, getattr(ctx.config, "theme", "system"))
|
||||
top.addRow(tr("settings.theme"), self.theme_combo)
|
||||
|
||||
self.tray_chk = QCheckBox(tr("settings.tray_keep"))
|
||||
self.tray_chk.setChecked(bool(data.get("tray", {}).get("minimize_on_close", True)))
|
||||
top.addRow("", self.tray_chk)
|
||||
@@ -642,6 +651,9 @@ class SettingsDialog(QDialog):
|
||||
data = self.ctx.config.data
|
||||
data["active_provider"] = self.provider_combo.currentData()
|
||||
data["language"] = self.language_combo.currentData()
|
||||
# MainWindow._open_settings re-applies the theme after this returns, so
|
||||
# writing the value here is enough to make it take effect.
|
||||
data["theme"] = self.theme_combo.currentData()
|
||||
|
||||
self._stash_provider_fields()
|
||||
for key, staged in self._prov_staging.items():
|
||||
|
||||
+41
-29
@@ -18,7 +18,7 @@ from PySide6.QtGui import QBrush, QColor, QFont, QPen
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QFileDialog, QGraphicsEllipseItem, QGraphicsLineItem,
|
||||
QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout,
|
||||
QLabel, QLineEdit, QPushButton, QSplitter, QStackedWidget,
|
||||
QLabel, QLineEdit, QPushButton, QSplitter, QStackedWidget, QTabBar,
|
||||
QTextBrowser, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
@@ -229,29 +229,36 @@ class StructureGraphView(QWidget):
|
||||
self._scan_btn.setIcon(icon("search"))
|
||||
self._scan_btn.setObjectName("primary")
|
||||
self._scan_btn.clicked.connect(self._scan)
|
||||
bar.addWidget(self.path_edit, 1)
|
||||
bar.addWidget(self._pick_btn)
|
||||
bar.addWidget(self.project_combo)
|
||||
bar.addWidget(self._scan_btn)
|
||||
root.addLayout(bar)
|
||||
self._refresh_project_combo()
|
||||
|
||||
# Toolbar: messages toggle + export
|
||||
bar2 = QHBoxLayout()
|
||||
bar2.addStretch(1)
|
||||
self._msgs_toggle_btn = QPushButton()
|
||||
self._msgs_toggle_btn.setIcon(icon("message"))
|
||||
self._msgs_toggle_btn.setToolTip(tr("structure.msgs_tooltip"))
|
||||
self._msgs_toggle_btn.clicked.connect(self._toggle_messages)
|
||||
bar2.addWidget(self._msgs_toggle_btn)
|
||||
|
||||
# ONE toolbar row. There used to be a second row holding just the
|
||||
# messages toggle and Export, which cost a whole row of height to carry
|
||||
# two buttons.
|
||||
self._export_btn = QPushButton()
|
||||
self._export_btn.setIcon(icon("upload"))
|
||||
self._export_btn.setObjectName("primary")
|
||||
self._export_btn.clicked.connect(self._export)
|
||||
bar2.addWidget(self._export_btn)
|
||||
bar.addWidget(self.path_edit, 1)
|
||||
bar.addWidget(self._pick_btn)
|
||||
bar.addWidget(self.project_combo)
|
||||
bar.addWidget(self._scan_btn)
|
||||
bar.addWidget(self._export_btn)
|
||||
root.addLayout(bar)
|
||||
self._refresh_project_combo()
|
||||
|
||||
root.addLayout(bar2)
|
||||
# Đồ thị | Tin nhắn as a real pair of tabs: the old single button
|
||||
# relabelled itself, so the view you were NOT looking at was the only
|
||||
# one named on screen.
|
||||
self.view_tabs = QTabBar()
|
||||
self.view_tabs.setObjectName("viewTabs")
|
||||
self.view_tabs.setDrawBase(False)
|
||||
self.view_tabs.setExpanding(False)
|
||||
self.view_tabs.addTab(icon("graph"), "")
|
||||
self.view_tabs.addTab(icon("message"), "")
|
||||
self.view_tabs.currentChanged.connect(self._on_view_tab)
|
||||
tab_row = QHBoxLayout()
|
||||
tab_row.setContentsMargins(0, 0, 0, 0)
|
||||
tab_row.addWidget(self.view_tabs)
|
||||
tab_row.addStretch(1)
|
||||
root.addLayout(tab_row)
|
||||
|
||||
split = QSplitter(Qt.Horizontal)
|
||||
self.scene = QGraphicsScene()
|
||||
@@ -337,8 +344,10 @@ class StructureGraphView(QWidget):
|
||||
self._pick_btn.setText(tr("structure.browse"))
|
||||
self._scan_btn.setText(tr("structure.scan"))
|
||||
self._export_btn.setText(tr("structure.export_png"))
|
||||
showing = self._stack.currentWidget() is getattr(self, "_msgs_view", None)
|
||||
self._msgs_toggle_btn.setText(tr("structure.graph_btn") if showing else tr("structure.msgs_btn"))
|
||||
# Both views are named at once now, so neither label depends on state.
|
||||
self.view_tabs.setTabText(0, tr("structure.graph_btn"))
|
||||
self.view_tabs.setTabText(1, tr("structure.msgs_btn"))
|
||||
self.view_tabs.setTabToolTip(1, tr("structure.msgs_tooltip"))
|
||||
self._ag_collapse.setToolTip(tr("structure.collapse_agent_tooltip"))
|
||||
self._ag_label.setText(tr("structure.agent_header"))
|
||||
self.ask_edit.setPlaceholderText(tr("structure.ask_placeholder"))
|
||||
@@ -413,16 +422,19 @@ class StructureGraphView(QWidget):
|
||||
self._rescan_timer.start()
|
||||
|
||||
# ---- Messages (by day, as JSON) --------------------------------------
|
||||
def _toggle_messages(self) -> None:
|
||||
"""Switch between the knowledge graph and the Messages-by-day view."""
|
||||
showing = self._stack.currentWidget() is self._msgs_view
|
||||
if showing:
|
||||
self._stack.setCurrentWidget(self.web if self.web is not None else self.view)
|
||||
else:
|
||||
def _on_view_tab(self, index: int) -> None:
|
||||
"""Tab 0 = graph, tab 1 = messages. Same two views as before, now named
|
||||
on screen instead of hidden behind one button's changing label."""
|
||||
if index == 1:
|
||||
self._reload_messages()
|
||||
self._stack.setCurrentWidget(self._msgs_view)
|
||||
self._msgs_toggle_btn.setText(
|
||||
tr("structure.graph_btn") if not showing else tr("structure.msgs_btn"))
|
||||
else:
|
||||
self._stack.setCurrentWidget(self.web if self.web is not None else self.view)
|
||||
|
||||
def _toggle_messages(self) -> None:
|
||||
"""Kept for callers that still ask for a flip (e.g. keyboard paths)."""
|
||||
showing = self._stack.currentWidget() is self._msgs_view
|
||||
self.view_tabs.setCurrentIndex(0 if showing else 1)
|
||||
|
||||
def _reload_messages(self) -> None:
|
||||
"""Build the tree: day → conversation. Click a conversation to see its
|
||||
|
||||
+43
-26
@@ -19,7 +19,7 @@ from PySide6.QtWidgets import (
|
||||
QCheckBox, QComboBox, QDateTimeEdit, QDialog, QDialogButtonBox, QFileDialog,
|
||||
QFormLayout, QGroupBox, QHBoxLayout, QInputDialog, QLabel, QLineEdit,
|
||||
QListWidget, QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton,
|
||||
QScrollArea, QSpinBox, QVBoxLayout, QWidget,
|
||||
QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..config import PROVIDER_LABELS
|
||||
@@ -93,12 +93,11 @@ class TaskEditorDialog(QDialog):
|
||||
root = QVBoxLayout(content)
|
||||
|
||||
# ---- basics ----------------------------------------------------
|
||||
# Zero-height anchor: this block is a bare form, so the index needs
|
||||
# something to scroll to.
|
||||
self._anchor_basic = QWidget()
|
||||
self._anchor_basic.setFixedHeight(0)
|
||||
root.addWidget(self._anchor_basic)
|
||||
form = QFormLayout()
|
||||
# Held in its own widget so the whole block can be moved into a step
|
||||
# tab below; it is a bare form, not a group box.
|
||||
self._basic_box = QWidget()
|
||||
form = QFormLayout(self._basic_box)
|
||||
form.setContentsMargins(0, 0, 0, 0)
|
||||
self.title_edit = QLineEdit(self.task.get("title", ""))
|
||||
# Description is the source of truth. Its ✨ button GENERATES the Prompt
|
||||
# (Input) FROM the description — the title is just the task's label and
|
||||
@@ -211,7 +210,7 @@ class TaskEditorDialog(QDialog):
|
||||
form.addRow(tr("schedtask.f_skill"), self.skill_combo)
|
||||
form.addRow(tr("schedtask.f_priority"), self.priority_combo)
|
||||
form.addRow(tr("schedtask.f_status"), self.status_combo)
|
||||
root.addLayout(form)
|
||||
root.addWidget(self._basic_box)
|
||||
self._main_form = form
|
||||
self._model_box = model_box
|
||||
self._on_run_kind_changed() # apply agent/flow row visibility
|
||||
@@ -433,25 +432,38 @@ class TaskEditorDialog(QDialog):
|
||||
eform.addRow("", self.approval_chk)
|
||||
root.addWidget(eg)
|
||||
|
||||
# Five groups in one long scroll — same problem, same fix as Settings.
|
||||
from .widgets import section_index
|
||||
self.section_list = section_index(scroll, [
|
||||
(tr("schedtask.g_basic"), self._anchor_basic),
|
||||
(tr("schedtask.g_schedule"), sg),
|
||||
(tr("schedtask.g_input"), ig),
|
||||
(tr("schedtask.g_dependency"), dg),
|
||||
(tr("schedtask.g_execution"), eg),
|
||||
])
|
||||
# Three steps, as tabs: Nội dung → Lịch chạy → Liên kết. The five group
|
||||
# boxes are re-parented into three pages — none is dropped, they are
|
||||
# grouped by the question being answered rather than stacked in one
|
||||
# scroll where the later ones are out of sight.
|
||||
self.step_tabs = QTabWidget()
|
||||
self.step_tabs.setObjectName("taskSteps")
|
||||
pages = [
|
||||
("schedtask.step_content", [self._basic_box, ig]),
|
||||
("schedtask.step_schedule", [sg]),
|
||||
("schedtask.step_link", [dg, eg]),
|
||||
]
|
||||
self._step_keys = [key for key, _ in pages]
|
||||
for _key, groups in pages:
|
||||
page = QWidget()
|
||||
pv = QVBoxLayout(page)
|
||||
pv.setContentsMargins(4, 8, 4, 4)
|
||||
for g in groups:
|
||||
root.removeWidget(g)
|
||||
pv.addWidget(g)
|
||||
pv.addStretch(1)
|
||||
wrap = QScrollArea()
|
||||
wrap.setWidgetResizable(True)
|
||||
wrap.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
wrap.setWidget(page)
|
||||
self.step_tabs.addTab(wrap, "")
|
||||
outer.removeWidget(scroll)
|
||||
body = QHBoxLayout()
|
||||
body.setSpacing(10)
|
||||
body.addWidget(self.section_list)
|
||||
body.addWidget(scroll, 1)
|
||||
outer.insertLayout(0, body, 1)
|
||||
# Same floor as Settings: the dialog may not be narrower than its own
|
||||
# content at the font in use, so nothing is ever cut off sideways.
|
||||
self.setMinimumWidth(self.section_list.width()
|
||||
+ content.sizeHint().width() + 60)
|
||||
scroll.setParent(None)
|
||||
outer.insertWidget(0, self.step_tabs, 1)
|
||||
self._retranslate_steps()
|
||||
# Floor the width at what the widest page needs, at the font in use.
|
||||
self.setMinimumWidth(max(p.widget().sizeHint().width()
|
||||
for p in self.step_tabs.findChildren(QScrollArea)) + 60)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||||
buttons.button(QDialogButtonBox.Save).setIcon(icon("save"))
|
||||
@@ -465,6 +477,11 @@ class TaskEditorDialog(QDialog):
|
||||
from .widgets import guard_wheel
|
||||
guard_wheel(self)
|
||||
|
||||
def _retranslate_steps(self) -> None:
|
||||
"""Name the three step tabs for the current language."""
|
||||
for i, key in enumerate(self._step_keys):
|
||||
self.step_tabs.setTabText(i, f"{i + 1}. {tr(key)}")
|
||||
|
||||
def _apply_hints(self) -> None:
|
||||
"""Tooltip hints on every non-obvious control, so each option explains
|
||||
itself on hover."""
|
||||
|
||||
Reference in New Issue
Block a user