fix: keep workbench card heights independent when toggling help

This commit is contained in:
Kinneyzhang 2026-09-08 21:29:36 +08:00
parent 920efc36f7
commit ffafd668ab
4 changed files with 190 additions and 14 deletions

View File

@ -3,7 +3,7 @@
## Source of truth ## Source of truth
- Status: Active - Status: Active
- Last refreshed: 2026-09-05 - Last refreshed: 2026-09-08
- Primary product surface: generic `etaf-playground.el` workspace plus the - Primary product surface: generic `etaf-playground.el` workspace plus the
`examples/research-shelf.etaf` / `.el` / `.ecss` consumer triplet `examples/research-shelf.etaf` / `.el` / `.ecss` consumer triplet
- Additional product surface: `examples/task-workbench.el`, a complete - 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 - Responsive: retain declarative Flex wrapping in source order. Side columns
have stable basis sizes; the task list grows. Root padding and viewport scroll have stable basis sizes; the task list grows. Root padding and viewport scroll
are legitimate composition and must work together without example workarounds. are legitimate composition and must work together without example workarounds.
- Panel alignment: each workspace Flex line stretches its columns to a shared - Panel alignment: workspace panels align at the top and keep their own
height, keeping the filter panel's bottom aligned with the main content. A content-driven heights in layout lines. Toggling “显示说明” collapses the
wrapped row sizes independently; no viewport-specific height constants. 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 - 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. 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. 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 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 column, side by side with independent heights on a wider wrapped row, then
again when each card's usable minimum width no longer fits. stacked again when each card's usable minimum width no longer fits.
- Table sizing: the task title receives the remaining column width through the - Table sizing: the task title receives the remaining column width through the
existing `(fr 1)` Grid track convention; the action column keeps its compact existing `(fr 1)` Grid track convention; the action column keeps its compact
character width. Header and body consume the same tracks. Resizing changes character width. Header and body consume the same tracks. Resizing changes

View File

@ -129,7 +129,7 @@
:align-items 'center :align-items 'center
(slot :name 'toolbar))) (slot :name 'toolbar)))
(flex :width 'stretch :flex-wrap 'wrap :gap '(1 (16)) (flex :width 'stretch :flex-wrap 'wrap :gap '(1 (16))
:align-items 'stretch :align-items 'flex-start
(slot)) (slot))
(slot :name 'footer (text "准备就绪"))) (slot :name 'footer (text "准备就绪")))
:styles :styles
@ -338,7 +338,7 @@
(etaf-data-load data)))) (etaf-data-load data))))
(flex :flex-wrap 'wrap :flex-basis 36 :flex-grow 1 :min-width 0 (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-detail :flex-basis 28 :flex-grow 1)
(wb-service :flex-basis 28 :flex-grow 1)) (wb-service :flex-basis 28 :flex-grow 1))

View File

@ -195,6 +195,49 @@ cannot establish that Emacs painted a control on the same visual line."
(list identity (car bounds) (cdr bounds)))) (list identity (car bounds) (cdr bounds))))
(number-sequence 1 10))))) (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) (defun wb-gui--capture-theme-baseline (context)
"Capture CONTEXT's initial geometry once it survives another paint turn." "Capture CONTEXT's initial geometry once it survives another paint turn."
(when (etaf-gui-verifier-context-get context 'light-layout) (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) (wb-gui--enter c "每页条数 10 ✎" "2"))
(lambda (c) (and (equal (wb-gui--ids c) '(2 3)) (lambda (c) (and (equal (wb-gui--ids c) '(2 3))
(wb-gui--text-p c "Page 1 / 50")))) (wb-gui--text-p c "Page 1 / 50"))))
(wb-gui--action "hide-help" (lambda (c) (wb-gui--press c "显示说明")) (wb-gui--action
(lambda (c) (not (wb-gui--text-p c "点击行:选择任务")))) "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 "显示说明")) (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 "模拟失败")) (wb-gui--action "resource-failure" (lambda (c) (wb-gui--press c "模拟失败"))
(lambda (c) (wb-gui--text-p c "模拟加载失败"))) (lambda (c) (wb-gui--text-p c "模拟加载失败")))
(wb-gui--action "resource-retry" (lambda (c) (wb-gui--press c "刷新 / 重试")) (wb-gui--action "resource-retry" (lambda (c) (wb-gui--press c "刷新 / 重试"))

View File

@ -108,15 +108,50 @@
(line-number-at-pos (line-number-at-pos
(etaf-host-ref-position runtime (wb-test--control runtime "新增任务"))))))) (etaf-host-ref-position runtime (wb-test--control runtime "新增任务")))))))
(cl-defmacro wb-test--with-app ((runtime data) &rest body) (defun wb-test--card-layout (runtime control-label)
"Mount a fresh app bound as RUNTIME and DATA, then execute BODY." "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)) (declare (indent 1))
`(with-temp-buffer `(with-temp-buffer
(let (,runtime ,data) (let (,runtime ,data)
(unwind-protect (unwind-protect
(progn (progn
(etaf-mount (current-buffer) (etaf-view (wb-app)) (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)) (setq ,runtime (etaf-runtime-for-buffer (current-buffer))
,data (wb-test--data ,runtime)) ,data (wb-test--data ,runtime))
,@body) ,@body)
@ -288,6 +323,92 @@
(should (equal widths (wb-test--line-widths runtime))) (should (equal widths (wb-test--line-widths runtime)))
(should (wb-test--desktop-header-inline-p 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 () (ert-deftest wb-workbench-theme-render-failure-retains-published-handlers ()
"A failed theme render keeps the published UI and usable old callbacks." "A failed theme render keeps the published UI and usable old callbacks."
(wb-test--with-app (runtime data) (wb-test--with-app (runtime data)