diff --git a/DESIGN.md b/DESIGN.md index 8d8fd17..72d0256 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -3,7 +3,7 @@ ## Source of truth - Status: Active -- Last refreshed: 2026-09-05 +- Last refreshed: 2026-09-08 - Primary product surface: generic `etaf-playground.el` workspace plus the `examples/research-shelf.etaf` / `.el` / `.ecss` consumer triplet - Additional product surface: `examples/task-workbench.el`, a complete @@ -253,15 +253,22 @@ lane passed. - Responsive: retain declarative Flex wrapping in source order. Side columns have stable basis sizes; the task list grows. Root padding and viewport scroll are legitimate composition and must work together without example workarounds. -- Panel alignment: each workspace Flex line stretches its columns to a shared - height, keeping the filter panel's bottom aligned with the main content. A - wrapped row sizes independently; no viewport-specific height constants. +- Panel alignment: workspace panels align at the top and keep their own + content-driven heights in layout lines. Toggling “显示说明” collapses the + detail content without changing the filter, task or service line counts. Following cards + and the footer may move naturally in document flow; do not reserve invisible + help space or introduce viewport-specific height constants. This follows + the user's 2026-09-08 review of disclosure-induced panel resizing. +- Native text geometry: Emacs shares ascent and descent across each displayed + row. Moving content may therefore change a card's pixel height while its + layout line count stays fixed. The user explicitly accepts this behavior; + preserve layout semantics and interaction instead of forcing uniform rows. - Width distribution: filter/list/inspector grow with weights `1/4/1`, keeping emphasis on the task list while a side column fills its line when wrapped. Filter buttons wrap into a horizontal group when their panel has room. Detail and service cards share a wrapping Flex: stacked in the desktop side - column, side by side with equal heights on a wider wrapped row, then stacked - again when each card's usable minimum width no longer fits. + column, side by side with independent heights on a wider wrapped row, then + stacked again when each card's usable minimum width no longer fits. - Table sizing: the task title receives the remaining column width through the existing `(fr 1)` Grid track convention; the action column keeps its compact character width. Header and body consume the same tracks. Resizing changes diff --git a/examples/task-workbench.el b/examples/task-workbench.el index 0965e71..42edeb8 100644 --- a/examples/task-workbench.el +++ b/examples/task-workbench.el @@ -129,7 +129,7 @@ :align-items 'center (slot :name 'toolbar))) (flex :width 'stretch :flex-wrap 'wrap :gap '(1 (16)) - :align-items 'stretch + :align-items 'flex-start (slot)) (slot :name 'footer (text "准备就绪"))) :styles @@ -338,7 +338,7 @@ (etaf-data-load data)))) (flex :flex-wrap 'wrap :flex-basis 36 :flex-grow 1 :min-width 0 - :align-content 'space-between :align-items 'stretch :gap '(1 (16)) + :align-content 'space-between :align-items 'flex-start :gap '(1 (16)) (wb-detail :flex-basis 28 :flex-grow 1) (wb-service :flex-basis 28 :flex-grow 1)) diff --git a/scripts/task-workbench-gui-scenarios.el b/scripts/task-workbench-gui-scenarios.el index 8765bc6..935e68b 100644 --- a/scripts/task-workbench-gui-scenarios.el +++ b/scripts/task-workbench-gui-scenarios.el @@ -195,6 +195,49 @@ cannot establish that Emacs painted a control on the same visual line." (list identity (car bounds) (cdr bounds)))) (number-sequence 1 10))))) +(defun wb-gui--panel-heights (context) + "Observe CONTEXT's four card identities and layout lines in source order." + (let ((runtime (wb-gui--runtime context)) + panels) + (with-current-buffer (etaf-gui-verifier-context-target-buffer context) + (dolist (entry (etaf-runtime-host-props-entries runtime)) + (let ((class (plist-get (cdr entry) :class))) + (when (member "etaf-panel" (if (stringp class) (split-string class) class)) + (let* ((bounds (etaf-host-ref-bounds runtime (car entry))) + (start (car bounds)) + (end (and bounds (1- (cdr bounds))))) + (unless (and start end) + (error "Workbench panel has no live bounds")) + (push (list :ref (car entry) :start start + :lines (1+ (- (line-number-at-pos end) + (line-number-at-pos start)))) + panels)))))) + (unless (= (length panels) 4) + (error "Expected four Workbench panels, got %d" (length panels))) + (sort panels (lambda (left right) + (< (plist-get left :start) (plist-get right :start)))))) + +(defun wb-gui--help-layout-preserved-p (context expanded-p) + "Check CONTEXT's disclosure state and unaffected card heights in layout lines. +EXPANDED-P also requires the detail card to recover its original height. +Native Emacs glyph rows may have different pixel heights as content moves." + (let* ((before (etaf-gui-verifier-context-get context 'help-panels-before)) + (after (wb-gui--panel-heights context)) + (height (lambda (panels index) + (plist-get (nth index panels) :lines)))) + (etaf-gui-verifier-context-put context 'help-panels-current after) + (and before + (eq expanded-p (and (wb-gui--text-p context "点击行:选择任务") t)) + (equal (mapcar (lambda (panel) (plist-get panel :ref)) before) + (mapcar (lambda (panel) (plist-get panel :ref)) after)) + (cl-every (lambda (index) + (= (funcall height before index) + (funcall height after index))) + '(0 1 3)) + (if expanded-p + (= (funcall height before 2) (funcall height after 2)) + (< (funcall height after 2) (funcall height before 2)))))) + (defun wb-gui--capture-theme-baseline (context) "Capture CONTEXT's initial geometry once it survives another paint turn." (when (etaf-gui-verifier-context-get context 'light-layout) @@ -507,10 +550,15 @@ cannot establish that Emacs painted a control on the same visual line." (lambda (c) (wb-gui--enter c "每页条数 10 ✎" "2")) (lambda (c) (and (equal (wb-gui--ids c) '(2 3)) (wb-gui--text-p c "Page 1 / 50")))) - (wb-gui--action "hide-help" (lambda (c) (wb-gui--press c "显示说明")) - (lambda (c) (not (wb-gui--text-p c "点击行:选择任务")))) + (wb-gui--action + "hide-help" + (lambda (c) + (etaf-gui-verifier-context-put + c 'help-panels-before (wb-gui--panel-heights c)) + (wb-gui--press c "显示说明")) + (lambda (c) (wb-gui--help-layout-preserved-p c nil))) (wb-gui--action "show-help" (lambda (c) (wb-gui--press c "显示说明")) - (lambda (c) (wb-gui--text-p c "点击行:选择任务"))) + (lambda (c) (wb-gui--help-layout-preserved-p c t))) (wb-gui--action "resource-failure" (lambda (c) (wb-gui--press c "模拟失败")) (lambda (c) (wb-gui--text-p c "模拟加载失败"))) (wb-gui--action "resource-retry" (lambda (c) (wb-gui--press c "刷新 / 重试")) diff --git a/tests/task-workbench-tests.el b/tests/task-workbench-tests.el index a376b31..592a203 100644 --- a/tests/task-workbench-tests.el +++ b/tests/task-workbench-tests.el @@ -108,15 +108,50 @@ (line-number-at-pos (etaf-host-ref-position runtime (wb-test--control runtime "新增任务"))))))) -(cl-defmacro wb-test--with-app ((runtime data) &rest body) - "Mount a fresh app bound as RUNTIME and DATA, then execute BODY." +(defun wb-test--card-layout (runtime control-label) + "Return public bounds and line geometry for CONTROL-LABEL's card in RUNTIME." + (let* ((control (wb-test--control runtime control-label)) + (panels + (cl-remove-if-not + (lambda (entry) + (let ((classes (plist-get (cdr entry) :class))) + (member "etaf-panel" + (if (stringp classes) (split-string classes) classes)))) + (etaf-runtime-host-props-entries runtime))) + (ancestries (etaf-runtime-host-ancestries + runtime (cons control (mapcar #'car panels)))) + (matches + (cl-remove-if-not + (lambda (entry) + (memq (car (gethash (car entry) ancestries)) + (gethash control ancestries))) + panels))) + (should (= (length matches) 1)) + (let* ((host (caar matches)) + (bounds (etaf-host-ref-bounds runtime host))) + (should bounds) + (should (< (car bounds) (cdr bounds))) + (with-current-buffer (etaf-runtime-buffer runtime) + (let ((top (line-number-at-pos (car bounds))) + (bottom (line-number-at-pos (1- (cdr bounds))))) + (list :host host :bounds (copy-tree bounds) + :top top :bottom bottom :height (1+ (- bottom top)) + :left + (save-excursion + (goto-char (car bounds)) + (ebox-string-pixel-width + (buffer-substring (line-beginning-position) (point)))))))))) + +(cl-defmacro wb-test--with-app ((runtime data &optional viewport-width) &rest body) + "Mount RUNTIME and DATA at optional VIEWPORT-WIDTH, then execute BODY." (declare (indent 1)) `(with-temp-buffer (let (,runtime ,data) (unwind-protect (progn (etaf-mount (current-buffer) (etaf-view (wb-app)) - '(:viewport-width 1600 :viewport-height 80)) + (list :viewport-width (or ,viewport-width 1600) + :viewport-height 80)) (setq ,runtime (etaf-runtime-for-buffer (current-buffer)) ,data (wb-test--data ,runtime)) ,@body) @@ -288,6 +323,92 @@ (should (equal widths (wb-test--line-widths runtime))) (should (wb-test--desktop-header-inline-p runtime))))) +(ert-deftest wb-workbench-explanation-toggle-isolates-card-heights () + "Explanation changes only detail height across three rendered layouts." + (dolist (viewport '((250 . stacked-sidebar) (500 . side-by-side-sidebar) + (70 . single-column))) + (ert-info ((format "Workbench explanation layout: %S" viewport)) + (wb-test--with-app (runtime data (car viewport)) + (etaf-dispatch-event runtime (wb-test--row runtime 1) 'press) + (cl-labels + ((cards () + (mapcar (lambda (entry) + (cons (car entry) (wb-test--card-layout runtime (cdr entry)))) + '((filter . "全部") (tasks . "每页条数 10 ✎") + (detail . "显示说明") (service . "刷新 / 重试"))))) + (let* ((before (cards)) + (text (wb-test--text runtime)) + (items (etaf-value (etaf-data-items data))) + (selected (etaf-data-selected-item data)) + (detail (wb-test--instance runtime 'wb-detail)) + (service (wb-test--instance runtime 'wb-service)) + (expanded (plist-get (etaf--component-instance-state detail) :expanded)) + (resource (plist-get (etaf--component-instance-state service) :resource)) + (rows (mapcar (lambda (id) (wb-test--row runtime id)) + (number-sequence 1 10))) + (control (wb-test--control runtime "显示说明")) + (filter (alist-get 'filter before)) + (tasks (alist-get 'tasks before)) + (detail-layout (alist-get 'detail before)) + (service-layout (alist-get 'service before))) + ;; Prove the viewport actually exercises the intended wrapping + ;; shape from published host bounds, rather than style declarations. + (pcase (cdr viewport) + ('stacked-sidebar + (should (= (plist-get filter :top) (plist-get tasks :top) + (plist-get detail-layout :top))) + (should (< (plist-get tasks :left) (plist-get detail-layout :left))) + (should (< (plist-get detail-layout :bottom) + (plist-get service-layout :top)))) + ('side-by-side-sidebar + (should (= (plist-get filter :top) (plist-get tasks :top) + (plist-get detail-layout :top) (plist-get service-layout :top))) + (should (< (plist-get detail-layout :left) + (plist-get service-layout :left)))) + ('single-column + (should (< (plist-get filter :bottom) (plist-get tasks :top))) + (should (< (plist-get tasks :bottom) (plist-get detail-layout :top))) + (should (< (plist-get detail-layout :bottom) + (plist-get service-layout :top))))) + (should (etaf-value expanded)) + (should (= (wb-task-id selected) 1)) + (should (string-match-p "点击行:选择任务" text)) + (dolist (visible '(nil t)) + (wb-test--press runtime "显示说明") + (let ((current (cards))) + (message "Workbench explanation width=%d visible=%S heights=%S -> %S" + (car viewport) visible + (mapcar (lambda (entry) (plist-get (cdr entry) :height)) before) + (mapcar (lambda (entry) (plist-get (cdr entry) :height)) current)) + (dolist (card '(filter tasks service)) + (should (= (plist-get (alist-get card before) :height) + (plist-get (alist-get card current) :height)))) + (should (funcall (if visible #'= #'<) + (plist-get (alist-get 'detail current) :height) + (plist-get detail-layout :height))) + (dolist (card '(filter tasks detail service)) + (should (equal (plist-get (alist-get card before) :host) + (plist-get (alist-get card current) :host)))) + (should (eq visible (etaf-value expanded))) + (should (eq detail (wb-test--instance runtime 'wb-detail))) + (should (eq service (wb-test--instance runtime 'wb-service))) + (should (eq items (etaf-value (etaf-data-items data)))) + (should (eq selected (etaf-data-selected-item data))) + (should (= (etaf-value (etaf-data-total data)) 100)) + (should (= (etaf-value (etaf-data-page data)) 1)) + (should (= (etaf-value (etaf-data-page-size data)) 10)) + (should (eq (etaf-resource-status resource) 'success)) + (should (equal rows (mapcar (lambda (id) (wb-test--row runtime id)) + (number-sequence 1 10)))) + (should (equal control (wb-test--control runtime "显示说明"))) + (should (string-match-p "设计组件接口" (wb-test--text runtime))) + (should (string-match-p "服务正常" (wb-test--text runtime))) + (should (eq visible + (not (null (string-match-p "点击行:选择任务" + (wb-test--text runtime)))))))) + (should (equal before (cards))) + (should (equal text (wb-test--text runtime))))))))) + (ert-deftest wb-workbench-theme-render-failure-retains-published-handlers () "A failed theme render keeps the published UI and usable old callbacks." (wb-test--with-app (runtime data)