etaf-playground/examples/task-workbench.el
2026-09-07 03:33:33 +08:00

375 lines
15 KiB
EmacsLisp
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

;;; task-workbench.el --- Composable task workbench -*- lexical-binding: t; -*-
;; SPDX-License-Identifier: GPL-3.0-or-later
;;; Commentary:
;; A complete public API example: reusable components and cells, controlled
;; inputs, slots, Context, Theme, resources, lifecycle, and independent Apps.
;; Load this module and run `wb-open'. Use `wb-close' to dispose the App.
;;; Code:
(require 'cl-lib)
(require 'subr-x)
(require 'etaf-ui)
;;; Data and ordinary functions
(defconst wb-tasks
(append
'((:id 1 :title "设计组件接口" :done nil)
(:id 2 :title "编写使用示例" :done nil)
(:id 3 :title "检查状态隔离" :done t)
(:id 4 :title "验证键盘操作" :done nil)
(:id 5 :title "补充文档" :done t))
(cl-loop for id from 6 to 100
for title = (nth (mod id 10)
'("整理项目资料" "检查发布清单" "优化任务流程"
"更新使用指南" "确认界面文案" "梳理组件边界"
"复核交互细节" "清理过期任务" "完善测试记录"
"准备版本说明"))
collect (list :id id :title (format "%s %03d" title id)
:done (zerop (mod id 4)))))
"Initial rows copied independently into each workbench.")
(defun wb-task-id (task)
"Return TASK's stable identity."
(plist-get task :id))
(defun wb-query (data query)
"Apply QUERY to DATA and load its first page."
(etaf-data-set-query data query)
(etaf-data-set-page data 1)
(etaf-data-load data))
(defun wb-theme (dark)
"Return the workbench palette for DARK or light mode."
(if dark
'(:ui-fg "#E5EAF3" :ui-bg "#111827" :ui-border "#38465B"
:ui-muted-fg "#A1AEC1" :ui-success-fg "#70D6B4"
:ui-panel-fg "#E5EAF3" :ui-panel-bg "#1B2535"
:ui-panel-border "#38465B"
:ui-button-primary-fg "#FFFFFF" :ui-button-primary-bg "#4869D8"
:ui-button-primary-border "#4869D8"
:ui-button-secondary-fg "#DCE5FF" :ui-button-secondary-bg "#2C3C60"
:ui-button-secondary-border "#2C3C60"
:ui-button-ghost-fg "#B8C5DA" :ui-button-ghost-bg "#1B2535"
:ui-button-ghost-border "#38465B"
:ui-checkbox-enabled-fg "#D6E0F1" :ui-checkbox-enabled-bg "#263449"
:ui-checkbox-enabled-border "#465872"
:ui-disabled-fg "#8390A5" :ui-disabled-bg "#253044"
:ui-disabled-border "#334157"
:ui-table-border "#38465B" :ui-table-selected-fg "#E4ECFF"
:ui-table-selected-bg "#2C3C60" :ui-pagination-muted-fg "#A1AEC1")
'(:ui-fg "#172033" :ui-bg "#F3F5F8" :ui-border "#D8DFE8"
:ui-muted-fg "#647084" :ui-success-fg "#24745C"
:ui-panel-fg "#172033" :ui-panel-bg "#FFFFFF"
:ui-panel-border "#D8DFE8"
:ui-button-primary-fg "#FFFFFF" :ui-button-primary-bg "#3657D6"
:ui-button-primary-border "#3657D6"
:ui-button-secondary-fg "#263C78" :ui-button-secondary-bg "#E8EDF8"
:ui-button-secondary-border "#E8EDF8"
:ui-button-ghost-fg "#526174" :ui-button-ghost-bg "#FFFFFF"
:ui-button-ghost-border "#D8DFE8"
:ui-checkbox-enabled-fg "#33425C" :ui-checkbox-enabled-bg "#F0F3F9"
:ui-checkbox-enabled-border "#CDD6E4"
:ui-disabled-fg "#8290A3" :ui-disabled-bg "#F0F2F6"
:ui-disabled-border "#E0E5ED"
:ui-table-border "#E2E7EF" :ui-table-selected-fg "#263C78"
:ui-table-selected-bg "#E8EDF8" :ui-pagination-muted-fg "#647084")))
;;; Action: one named business operation reused by add/update/delete
(etaf-action-define wb-mutate (runtime data operation payload)
(ignore runtime)
(etaf-data-set-page data 1)
(etaf-data-mutate data operation payload))
(etaf-action-define wb-focus (runtime target)
(etaf-focus runtime target))
;;; Behavior: stable callbacks, additive to the Button's business action
(defun wb-trace-press ()
"Report a completed control action."
(message "动作已执行"))
(defun wb-trace-cleanup ()
"Report disposal of a control's reusable behavior."
(message "按钮行为已释放"))
(defun wb-trace-install ()
"Return the cleanup callback for a control's reusable behavior."
#'wb-trace-cleanup)
(etaf-define-behavior wb-trace ()
(etaf-behavior-create 'wb-trace
:on-press #'wb-trace-press
:install #'wb-trace-install))
(etaf-define-component wb-button (&key label on-press disabled variant)
:view
(etaf-button :label label :on-press on-press :disabled disabled
:variant variant :padding '(0 2)
:use (list (wb-trace))))
;;; Stateless layout: props, default slot, named slots, scoped styles
(etaf-define-component wb-shell (&key title)
:view
(column :width '(viewport) :height '(viewport-height) :overflow 'scroll
:item-gap 1
:color (etaf-theme-token :ui-fg)
:bgcolor (etaf-theme-token :ui-bg)
(flex :width 'stretch :flex-wrap 'wrap :gap '(1 (16))
:align-items 'center :justify-content 'space-between
(column :flex-grow 1 :min-width 0
(text :class "title" (expr title))
(etaf-label :variant 'muted :text "个人任务 · 本地工作空间"))
(flex :width 'max-content :flex-wrap 'wrap :gap '(1 (10))
:align-items 'center
(slot :name 'toolbar)))
(flex :width 'stretch :flex-wrap 'wrap :gap '(1 (16))
:align-items 'stretch
(slot))
(slot :name 'footer (text "准备就绪")))
:styles
(styles
("&" :padding (1 3))
(".title" :font-weight bold)
(".etaf-table-body" :item-gap 1)
(".etaf-table-header" :padding (0 1))))
;;; Reusable cell: Context dependency + controlled Checkbox + disabled Button
(etaf-define-component wb-task-controls (&key task)
:setup
(etaf-inject 'wb-data nil t)
:render
(let ((data (etaf-state))
(item task)) ; Capture this render's ordinary prop.
(etaf-view
(row :item-gap 10
(etaf-checkbox
:label "完成" :checked (plist-get item :done)
:on-press #'wb-trace-press ; Additive; does not replace on-change.
:on-change
(lambda (next)
(etaf-dispatch 'wb-mutate data 'update
(plist-put (copy-sequence item) :done next))))
(wb-button
:label "删除" :variant 'ghost :disabled (not (plist-get item :done))
:on-press
(lambda ()
(etaf-dispatch 'wb-mutate data 'delete (wb-task-id item))))))))
(defun wb-controls-cell (row)
"Return reusable task controls for ROW at the consuming cell."
(etaf-view (wb-task-controls :task row)))
(defconst wb-columns
'((:key :title :label "任务" :width (fr 1))
(:key :actions :label "操作" :width 22 :cell wb-controls-cell))
"Task columns with stable identities and an ordinary View factory.")
;;; Dynamic composition: expr returns an ordinary Component
(etaf-define-component wb-task-info (&key task)
:view
(column
(text :font-weight 'bold (expr (plist-get task :title)))
(text (expr (if (plist-get task :done) "已完成" "进行中")))))
(etaf-define-component wb-detail ()
:setup
(list :data (etaf-inject 'wb-data nil t) :expanded (etaf-ref t))
:render
(let* ((state (etaf-state))
(data (plist-get state :data))
(expanded (plist-get state :expanded)))
(etaf-view
(etaf-panel :title "详情" :item-gap 1
(expr
(if-let* ((task (etaf-data-selected-item data)))
(etaf-view (wb-task-info :task task))
(etaf-view (text "点击任务行查看详情"))))
(etaf-checkbox
:label "显示说明" :checked (etaf-value expanded)
:on-change (lambda (next) (setf (etaf-value expanded) next)))
(box :if (etaf-value expanded) :height 3 :overflow 'scroll
(column
(text :for (tip '("点击行:选择任务"
"勾选:修改完成状态"
"完成后可以删除"
"TAB移动焦点"
"RET激活控件"))
:key tip
(expr tip))))))))
;;; Resource: explicit status/error/retry, scoped result cleanup
;;; This is a synchronous demo loader, not a simulated asynchronous API.
(defun wb-service-cleanup ()
"Report disposal of a previously loaded service result."
(message "旧服务结果已释放"))
(etaf-define-component wb-service ()
:setup
(let* ((fail-next (etaf-ref nil))
(resource
(etaf-resource
(lambda ()
(when (etaf-value fail-next)
(setf (etaf-value fail-next) nil)
(error "模拟加载失败"))
(etaf-resource-result "服务正常"
:cleanup #'wb-service-cleanup))
:immediate nil)))
(etaf-on-mounted (lambda () (etaf-resource-load resource)))
(list :resource resource :fail-next fail-next))
:render
(let* ((state (etaf-state))
(resource (plist-get state :resource))
(fail-next (plist-get state :fail-next)))
(etaf-view
(etaf-panel :title "服务状态" :item-gap 1
(text
(expr
(pcase (etaf-resource-status resource)
('success (etaf-resource-value resource))
('error (error-message-string (etaf-resource-error resource)))
(_ "加载中…"))))
(row :item-gap 10
(wb-button :label "刷新 / 重试" :variant 'secondary
:on-press (lambda () (etaf-resource-load resource)))
(wb-button :label "模拟失败" :variant 'ghost
:on-press
(lambda ()
(setf (etaf-value fail-next) t)
(etaf-resource-load resource))))))))
;;; App owner: Data, refs, computed, watch, Context, Theme, lifecycle
(etaf-define-component wb-app ()
:setup
(let* ((data (etaf-data-controller
(etaf-data-memory-source (copy-tree wb-tasks) :id-key :id)
:page-size 10 :auto-load nil))
(dark (etaf-ref nil))
(next-id (etaf-ref (1+ (apply #'max (mapcar #'wb-task-id wb-tasks)))))
(summary
(etaf-computed
(lambda ()
(format "共 %d 条 · 当前页 %d 条"
(etaf-value (etaf-data-total data))
(length (etaf-value (etaf-data-items data))))))))
(etaf-provide 'wb-data data)
(etaf-theme-provide
(etaf-computed (lambda () (wb-theme (etaf-value dark)))))
(etaf-watch dark
(lambda (new _old)
(message "切换到%s主题" (if new "深色" "浅色"))))
(etaf-on-mounted (lambda () (etaf-data-load data)))
(etaf-on-updated (lambda () (message "工作台已更新")))
(etaf-on-unmounted (lambda () (message "工作台已卸载")))
;; Data, computed, watch and nested Resource scopes dispose automatically.
(list :data data :dark dark :next-id next-id :summary summary
:add-ref (make-symbol "wb-add")))
:render
(let* ((state (etaf-state))
(data (plist-get state :data))
(dark (plist-get state :dark))
(next-id (plist-get state :next-id))
(summary (plist-get state :summary))
(add-ref (plist-get state :add-ref)))
(etaf-view
(wb-shell :title "任务工作台"
(slot :name 'toolbar
(wb-button
:ref add-ref :label "新增任务"
:on-press
(lambda ()
(let ((title (string-trim (read-string "任务名称:"))))
(unless (string-empty-p title)
(let ((id (etaf-value next-id)))
(cl-incf (etaf-value next-id))
(etaf-dispatch 'wb-mutate data 'insert
(list :id id :title title :done nil)))))))
(etaf-checkbox :label "深色" :checked (etaf-value dark)
:on-change (lambda (next) (setf (etaf-value dark) next)))
(wb-button :label "定位新增按钮" :variant 'ghost
:on-press (lambda () (etaf-dispatch 'wb-focus add-ref))))
;; Parent owns each panel's participation in this Flex layout.
(etaf-panel :title "筛选" :flex-basis 18 :flex-grow 1 :flex-shrink 0 :item-gap 1
(flex :flex-wrap 'wrap :gap '(1 (10)) :align-items 'stretch
(wb-button :label "全部" :width 'stretch :flex-basis 16 :flex-grow 1
:variant (if (null (etaf-value (etaf-data-query data)))
'secondary 'ghost)
:on-press (lambda () (wb-query data nil)))
(wb-button :label "未完成" :width 'stretch :flex-basis 16 :flex-grow 1
:variant (if (equal (etaf-value (etaf-data-query data)) '(:done nil))
'secondary 'ghost)
:on-press (lambda () (wb-query data '(:done nil))))
(wb-button :label "已完成" :width 'stretch :flex-basis 16 :flex-grow 1
:variant (if (equal (etaf-value (etaf-data-query data)) '(:done t))
'secondary 'ghost)
:on-press (lambda () (wb-query data '(:done t))))
(wb-button :label "搜索" :variant 'secondary :width 'stretch
:flex-basis 16 :flex-grow 1
:on-press (lambda () (wb-query data (read-string "搜索:"))))))
(etaf-panel :title "任务" :flex-grow 4 :flex-basis 64 :min-width 0
:item-gap 1
(etaf-data-grid
:controller data :columns wb-columns :row-key #'wb-task-id
:on-row-press (lambda (row) (etaf-data-select-one data (wb-task-id row)))
:loading-label "读取任务…" :error-label "读取失败,请重新筛选"
:empty-label "没有匹配的任务"
(slot :name 'footer
(etaf-pagination :controller data
:previous-label " 上一页" :next-label "下一页 ")))
(etaf-number-input
:label "每页条数" :value (etaf-value (etaf-data-page-size data))
:min 1 :max 20
:on-change
(lambda (next)
(etaf-data-set-page-size data next)
(etaf-data-set-page data 1)
(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))
(wb-detail :flex-basis 28 :flex-grow 1)
(wb-service :flex-basis 28 :flex-grow 1))
(slot :name 'footer
(grid :width 'stretch :grid-template-columns '((fr 1) (fr 1)) :gap 1
(etaf-label :variant 'muted :text (etaf-value summary))
(etaf-label :variant 'muted
:text "数据仅保存在当前工作台内存中")))))))
;;; Open / close. Each buffer gets independent App state and local refs.
(defun wb-open (&optional buffer-name)
"Open an independent workbench in BUFFER-NAME or the default buffer."
(interactive)
(switch-to-buffer
(etaf-mount (or buffer-name "*任务工作台*") (etaf-view (wb-app)))))
(defun wb-close (&optional buffer-name)
"Dispose the workbench in BUFFER-NAME and close its buffer."
(interactive)
(let ((name (or buffer-name "*任务工作台*")))
(when-let* ((runtime (etaf-runtime-for-buffer name)))
(etaf-unmount runtime))
(when-let* ((buffer (get-buffer name)))
(kill-buffer buffer))))
;; Usage:
;; (wb-open)
;; (wb-open "*另一个独立工作台*")
;; (wb-close)
(provide 'task-workbench)
;;; task-workbench.el ends here