ebox/docs/user/ebox-user-guide.en.md
Kinneyzhang 79f5bc23d1 feat: add CSS sizing and native text interaction capabilities
Normalize size units and intrinsic sizing across Elisp and native layout. Add help, pointer, hover-style and keymap support with reusable interaction adapters.

Keep content updates local, preserve scroll caches and hover borders, and avoid rebuilding retained plans and ownership metadata for stable geometry.

Validation: make check and native-rust-tests passed; targeted native interaction and scroll publication regressions passed.
2026-09-09 22:25:18 +08:00

24 KiB

Ebox user guide

中文

Ebox is the low-level Text/Box layout and buffer-rendering package. This guide uses one public author grammar and keeps framework-integration details separate. See the API reference for the complete function inventory.

1. Load Ebox

(require 'ebox)

Loading Ebox does not create a buffer, enable a mode in the current buffer, or build Rust.

2. Learn one author model

An Ebox document contains Text and Box nodes. The author grammar has exactly seven entries:

Entry Meaning
"text" Short form for one Text node.
(text ... "text") Text with explicit text properties.
(box ... CHILD...) A normal visual Box.
(row ... CHILD...) A Box with simple horizontal child layout.
(column ... CHILD...) A Box with simple vertical child layout.
(flex ... CHILD...) A Box with Flex child layout.
(grid ... CHILD...) A Box with Grid child layout.

Children are always nested directly. A layout form is a Box with a selected child-layout algorithm, not a different kind of visual object.

(defvar ebox-guide-input
  (ebox-build
   '(column :padding ((lh 1) (ch 2))
            :border ((px 1) solid "#8A93A6")
            (text :color "#263244" "Research notes")
            (row :item-gap (ch 1)
                 (box :id "status" :background-color "#F4F6FB" "Inbox")
                 (box :background-color "#EEF2FF" "Archive")))))

ebox-build returns an opaque CanonicalEboxInput. It keeps the canonical forest and its source generation together. Pass that value unchanged to the render and publication functions; ordinary author code does not extract or reassemble its internal nodes.

Use box with geometry but no child when an empty rectangular area is needed. No extra node type is necessary.

Prefer defaults and inheritance

Write only what changes the intended result. Omit redundant default values, place shared text styles on an existing common parent, and keep only child overrides. Reference panels still spell out the property they teach, even when its value is the default.

Ebox inherits :color, :font-family, :font-size, :font-weight, :font-style, :text-align, :wrap-mode, and :visibility. Geometry and background colors do not inherit; an unpainted child can show its parent's background. For example:

(ebox-build
 '(column :width (ch 40) :color "#1F2328" :bgcolor "#FAF7F0"
          (box "Shared text color")
          (box :color "#B45309" "Local accent")
          (box :height (lh 1))))

Inheritance does not expand where a property may be authored. For example, :text-align is declared on box; it cannot be moved onto a row, column, flex, or grid merely because its value can inherit.

In this ordinary Column, the separator fills the available width and explicitly reserves one line with :height (lh 1). No width or repeated background is needed. An otherwise empty Box with auto height has zero content height; use :height (lh 1) when one blank line is intended. Padding and borders can still add their own outer extent. This is context-dependent: an auto-width child in a Row uses intrinsic width, and Flex/Grid have their own item sizing rules. Do not replace stretch with auto everywhere, or remove a default-looking value that overrides inherited styles, a stylesheet, or another shorthand. Zero sides can often be omitted with :padding-inline or :padding-block when no other declaration sets them.

3. Choose the layout that states the intent

Use box for ordinary content, row or column for direct one-axis composition, flex when free space or wrapping matters, and grid for two-dimensional tracks or explicit placement.

Row and column

row and column accept :item-gap and :cross-align. Their children remain ordinary Text or Box nodes.

(ebox-build
 '(row :item-gap (ch 2) :cross-align center
       (box :width (ch 12) "Left")
       (box :width (ch 12) "Right")))

Flex

Flex container properties belong to flex. Participation properties belong directly to a child box because they describe the parent-child relationship.

(ebox-build
 '(flex :width (px 480)
        :flex-flow (row wrap)
        :gap ((lh 1) (px 12))
        (box :flex (1 1 auto) "Primary")
        (box :flex-grow 2 "Secondary")))

Grid

Grid placement is one-based. Tracks may be fixed, auto, fractional, minmax, or repeat values. Placement properties also belong directly to a child box.

(ebox-build
 '(grid :width (px 640)
        :grid-template-columns ((px 200) (fr 1) (fr 1))
        :grid-template-rows ((lh 1) (lh 1))
        :gap ((lh 1) (px 12))
        (box :grid-column (1 :span 3) "Header")
        (box :grid-column 1 :grid-row 2 "Navigation")
        (box :grid-column 2 :grid-row 2 "Main")
        (box :grid-column 3 :grid-row 2 "Aside")))

4. Use geometry and paint properties

Box geometry uses explicit (unit number) values. The same syntax applies to width, height, their minimum/maximum bounds, padding, margins, gaps, borders, Flex basis, and fixed Grid tracks. Ebox implements a CSS sizing subset with an Elisp data representation, not CSS strings such as "80ch".

Unit Meaning
(px 240) 240 pixels.
(% 50) 50% of the property's containing-block reference size.
(vw 100) 100% of the viewport width.
(vh 100) 100% of the viewport height.
(ch 80) 80 times the effective font's advance width for 0.
(lh 3) Three effective line heights.

ch does not count arbitrary characters or CJK glyphs. 1vw and 1vh each mean 1% of the corresponding viewport axis. A displayed Ebox viewport is the target window's usable body area, excluding its mode line and header line; a Playground preview therefore uses the preview window.

Unit constraints by property

The following table is the unit contract for all Ebox author entry points. Inline means horizontal and block means vertical in Ebox's supported writing mode. These axis restrictions deliberately narrow CSS's general length model.

Value context Allowed units Properties
Inline geometry px, ch, vw, % width, min-width, max-width; left/right and inline padding/margin; column-gap; Row item-gap; Grid column tracks.
Block geometry lh, vh, % height, min-height, max-height; top/bottom and block padding/margin; row-gap; Column item-gap; Grid row tracks.
Flex main size The parent Flex's main-axis units flex-basis and the basis in (grow shrink basis); row directions use inline units, column directions use block units.
Border paint thickness px, ch, lh, vw, vh border-width, each side's border width, and the width component of border shorthands; percentages are forbidden.

The same restrictions apply recursively to every branch of calc, min, max, and clamp, and to nested Grid track functions. A disallowed unit is an error when ebox-build constructs the tree, even if arithmetic would cancel it or another branch would win. For example, :height (px 24), :width (lh 3), and :height (min (lh 2) (px 24)) are rejected. Border thickness is a separate paint value, so :border ((px 1) solid "red") is valid on all four sides.

Stylesheets and dynamic updates follow the same contract: after computed styles are known and before layout, Ebox rechecks the parent Flex direction and child basis. Invalid updates do not publish to an existing buffer. Numeric :flex 1 uses an implicit (% 0) basis, valid on either main axis; explicit bases still follow the parent direction.

Shorthands are checked after expansion onto their sides or axes. A one-value padding, margin, or gap must therefore be valid on both axes: use % for such a shared length, or spell out the two axes, for example :padding ((lh 1) (ch 2)) and :gap ((lh 1) (px 12)). Use :padding-inline (px 12) or :padding-block (lh 1) to set only one axis.

Percent widths refer to containing-block width; percent heights require a definite containing-block height and otherwise follow the property's indefinite-size rule. Percentage padding and margins on every side refer to containing-block width. Border widths do not accept percentages. Negative margins and auto margins are outside this subset; the current buffer backend does not implement overlapping margin geometry.

Property Keywords Default
width, height auto, min-content, max-content, fit-content, stretch auto
min-width, min-height auto, min-content, max-content, fit-content, stretch auto
max-width, max-height none, min-content, max-content, fit-content, stretch none

For a normal block root with an available viewport, auto width fills the available space and auto height follows content. min-content and max-content request intrinsic sizes. fit-content fits available space between those intrinsic bounds; stretch fills available space with the margin box. none removes a maximum-size constraint. Automatic minimums depend on layout; an explicit (px 0) minimum allows an item to shrink below its automatic content minimum on the inline axis. Use (lh 0) for the block minimum. An empty Box with no padding, border, or explicit height has zero height; (box :height (lh 1)) explicitly reserves one blank line.

The four size functions compose units without evaluating Lisp during layout:

'(column :width (min (% 100) (ch 80))
         :height (calc (- (vh 100) (lh 1)))
         (box :width (max (px 120) (% 25)) "Sidebar")
         (box :width (clamp (ch 20) (% 50) (ch 60)) "Body"))

calc accepts one arithmetic expression; + and - combine lengths, * multiplies a length by a scalar, and / divides it by a nonzero scalar. min and max choose from one or more permitted lengths. clamp takes minimum, preferred, and maximum lengths. Functions may nest. Bare numbers inside math are scalars, never implicit ch or lh lengths.

Units and functions remain data until layout, so viewport and containing-block changes are resolved again. Floating-point intermediate results are preserved: 33vw of an 853-pixel viewport is 281.49 pixels before display quantization. The buffer backend materializes block geometry in whole lines; fractional line targets are quantized at that boundary. Vertical px lengths are not part of the author contract. :overflow hidden currently clips vertical lines; horizontal overflow is not pixel-clipped by the buffer backend, so content can extend beyond the computed width.

Bare geometry numbers, one-element pixel lists, viewport, viewport-height, contain, and parameterized fit-content are rejected. Even zero needs an axis-appropriate unit: (px 0) inline or (lh 0) block. Grid (fr n) remains a track fraction; Grid placement and Flex growth/shrink factors remain dimensionless numbers.

Text style declarations accept only font, foreground/background, and text-decoration properties. Text also accepts the four native node capabilities described below. Padding, margin, border, size, :outer, overflow, visibility, and wrapping policy belong only to Box. Font and color on Box may feed inherited Text facts, but never give Text Box geometry.

(ebox-build
 '(box :width (px 420)
       :padding ((lh 1) (ch 2))
       :margin ((lh 0) (ch 1))
       :border ((px 1) solid "#8A93A6")
       :color "#263244"
       :background-color "#FFFFFF"
       "A readable panel"))

Use :outer inline or :outer block to state how a Box participates in its parent. The child-layout algorithm still comes from the form name.

Native node interaction

Text and every Box form accept :help-echo, :pointer, :hover-style, and :keymap. These are explicit node capabilities outside the CSS cascade. For example, render a Box with dynamic help, a hand pointer, hover paint, and one callback for both mouse and keyboard activation:

(ebox-render-to-buffer
 "*Ebox Interaction*"
 (ebox-build
  `(box :id "action" :padding ((lh 1) (ch 2))
        :border ((px 1) solid "#5893A3")
        :help-echo ,(ebox-help-create
                     (lambda ()
                       (format-time-string "Help requested at %H:%M:%S")))
        :pointer hand
        :hover-style (:color "#FFFFFF" :background-color "#286477"
                      :text-decoration-line underline)
        :keymap ,(ebox-keymap-create :activate #'describe-mode)
        "Click or press RET / SPC here to describe this buffer's mode.")))

:help-echo accepts a string, a native function with arguments (WINDOW OBJECT POSITION), or nil. Prefer ebox-help-create to adapt a zero-argument business function returning a string or nil; Ebox supplies the hovered buffer's context. Emacs calls the function when requesting help; the DSL does not call it while building the tree. :pointer accepts text, arrow, vdrag, modeline, hand, hdrag, nhdrag, hourglass, or nil. Help display and exact pointer appearance depend on the user's Emacs settings and window system.

:hover-style accepts nil or an Ebox paint plist containing only :color, :background-color, :text-decoration-line, :text-decoration-color, and :text-decoration-style. Within a rendered line, one declaring node's text and padding share a native mouse-face, with unspecified attributes taken from that node's base style. Differently colored child text covered by this hover uses the same hover base. Physical left/right borders are excluded from hover; horizontal border strokes remain intact. Font metrics and geometry do not change. The API reference defines the accepted values. Ebox compiles this plist to native mouse-face; there is no arbitrary native-property passthrough.

Box help, pointer, and keymap cover its rendered content, padding, and border, excluding that Box's margin and structural newlines; hover follows the rule above. A nested Text or Box can replace each capability with an explicit value. Explicit nil blocks an enclosing value; an omitted property leaves the enclosing surface coverage in place.

:keymap accepts a native Emacs keymap or nil. Prefer ebox-keymap-create: its :activate callback takes no arguments and handles RET, [return], SPC, and [mouse-1]. Use :bindings for an alist of key-description strings or event vectors paired with zero-argument callbacks. The helper uses the event window's buffer for mouse callbacks, so a callback can directly update a semantic ID with (ebox-region-update "action" :help-echo "Activated").

Move point into the surface to use keyboard bindings. Raw native keymaps are also accepted; their mouse commands must handle the event's target buffer themselves. Ebox uses native command dispatch; it does not create application state or focus navigation. A hand pointer alone does not bind a click command.

The Playground's interaction lab keeps its business functions and state in interaction-reference.el, with its layout in interaction-reference.ebox. It demonstrates callbacks, nested overrides, and replacing or removing all four capabilities through public region updates.

5. Render and publish

ebox-render returns propertized text without changing a live buffer. ebox-render-to-buffer mounts a retained TP surface and returns its buffer. Both functions, and ebox-display-buffer, accept the opaque value returned by ebox-build.

(ebox-render ebox-guide-input)
(ebox-render-to-buffer "*Ebox Guide*" ebox-guide-input)

Build a fresh canonical input and use ebox-commit for an atomic update:

(ebox-commit
 "*Ebox Guide*"
 (ebox-build
  '(column :padding ((lh 1) (ch 2))
           (text "Updated notes")
           (box :key body "The new input is caller-owned."))))

Validation, rendering, or publication failure leaves the previous buffer and runtime state intact. ebox-buffer-update-report returns a defensive copy of the last successful update report.

6. Query and update a mounted surface

:id, :class, and :key are author metadata. Selectors use ECSS semantics. In the target mounted buffer, pass the semantic :id directly:

(with-current-buffer "*Ebox Guide*"
  (ebox-region-update "status" :color "#166534"))

For an explicit buffer address, resolve an :id to an opaque, surface-scoped handle:

(let ((handle (ebox-region-resolve "*Ebox Guide*" "status")))
  (ebox-region-update handle :color "#166534"))

The same API replaces native capabilities on Text or Box regions. For the interaction example above:

(with-current-buffer "*Ebox Interaction*"
  (ebox-region-update "action" :help-echo "Updated help" :pointer 'arrow
                      :hover-style '(:background-color "#F6D6AB"))
  (ebox-region-update "action" :help-echo nil :pointer nil
                      :hover-style nil :keymap nil))

Update arguments are evaluated Elisp, so literal symbols and plists need quotes, unlike properties inside already quoted DSL data. Omitted update properties stay unchanged. Explicit nil clears that capability and blocks an enclosing value. Ebox snapshots keymaps; replace :keymap to publish new bindings instead of mutating the original map. Clearing a node keymap leaves ordinary buffer and global bindings available.

ebox-selector-query-buffer returns document-ordered matches from a mounted buffer. ebox-selector-update-buffer applies one style update to all editable matches. Numeric region ids are diagnostic render metadata, not stable update handles.

7. Resize and scroll

When content exceeds a finite height, :overflow scroll creates an internal scroll window. Keyboard scrolling targets point; wheel and trackpad scrolling target the mouse position carried by the event. Remaining distance passes through enclosing scroll owners from inner to outer, then to ordinary Emacs scrolling. Events over another column, outside a box, or on the mode line do not select an unrelated scroll box elsewhere on the page.

(ebox-build
 '(box :id log :width (px 420) :height (lh 8) :overflow scroll
       "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9"))

Visible mounted buffers follow their display window. Integrations may apply an explicit viewport with:

(ebox-rerender-buffer-with-context
 (get-buffer "*Ebox Guide*") 800 30)

8. Standalone .ebox files

A .ebox file contains one ordinary Elisp expression whose value is the layout data passed to ebox-build. Quote the whole list for a static layout:

'(column :padding ((lh 1) (ch 2))
         :cross-align center
         (text :color "#263244" "Title")
         (box :width (px 240) "Body"))

The sibling ebox-playground reads exactly one expression, evaluates it with lexical bindings, and passes the resulting data to ebox-build. It does not evaluate individual properties or children. The expression is exactly what you would write as the argument to (ebox-build ...) in an .el file; omit that outer call in .ebox.

Put necessary business functions and variables in a same-basename .el file beside the layout. For notes.ebox, the Playground loads notes.el when it exists, before evaluating the layout on every preview. This applies to C-c C-c, ebox-playground-open-file, and file render entry points. It loads the exact .el source, so saving changes is enough for the next preview; there is no require cache or preference for a stale .elc. If the companion is absent, the standalone .ebox still works. Companion load errors stop the render before the layout is evaluated.

For example, notes.el owns the data and action:

;;; notes.el --- Notes example behavior -*- lexical-binding: t; -*-
(defvar notes-body "A long article.")

(defun notes-help ()
  "Return business help for the article."
  (format "Article: %s" notes-body))

(defun notes-activate ()
  "Mark the article in the current Ebox buffer."
  (ebox-region-update "article" :color "#166534"))

notes.ebox stays focused on the layout:

`(column :padding ((lh 1) (ch 2))
         (box :id "article" :width (px 480)
              :help-echo ,(ebox-help-create #'notes-help)
              :pointer hand
              :keymap ,(ebox-keymap-create :activate #'notes-activate)
              ,notes-body))

Backquote keeps layout data literal, , inserts a value, and ,@ inserts a list of children. Avoid helpers whose only purpose is hiding repeated DSL property lists. Each preview loads the companion and then evaluates the layout once; ordinary Elisp variable definitions determine whether application state is initialized, retained, or reset.

Migration from the old property-evaluation format is explicit: add a quote to the whole static layout and remove the quotes around its list and symbol properties. For dynamic layouts, use backquote and commas at the values to evaluate. For example, old :padding '(1 2) becomes :padding ((lh 1) (ch 2)) inside a quoted layout, and a computed pixel width becomes :width (px ,(+ 200 40)) inside a backquoted layout. Bare structural forms are no longer interpreted as DSL automatically; there is no legacy-format auto-detection.

9. Typed integration API

Frameworks that already normalize author input may bypass the list DSL. This is an integration API, not a second author grammar. One source builder owns the complete source generation. Each TextNode or BoxNode receives an opaque handle from that builder and node-owned facts projected from the same normalized declarations. The framework then seals the builder and transports the forest and source index together as one CanonicalEboxInput.

(let* ((builder (ebox-source-builder-create))
       (root-declarations nil)
       (left-declarations nil)
       (right-declarations nil)
       (root-handle
        (ebox-source-builder-bind
         builder :declarations root-declarations))
       (left-handle
        (ebox-source-builder-bind
         builder :declarations left-declarations))
       (right-handle
        (ebox-source-builder-bind
         builder :declarations right-declarations))
       (left
        (ebox-text-create
         :value "Left"
         :source-handle left-handle
         :owned-facts
         (ebox-canonical-facts-from-declarations
          'text left-declarations)))
       (right
        (ebox-text-create
         :value "Right"
         :source-handle right-handle
         :owned-facts
         (ebox-canonical-facts-from-declarations
          'text right-declarations)))
       (root
        (ebox-box-create
         :layout (ebox-row-layout-create
                  :item-gap '(ch 1) :cross-align 'center)
         :children (list left right)
         :source-handle root-handle
         :owned-facts
         (ebox-canonical-facts-from-declarations
          'row root-declarations))))
  (ebox-canonical-input-create
   (list root)
   (ebox-source-builder-finish builder)))

ebox-source-builder-create, ebox-source-builder-bind, ebox-source-builder-finish, ebox-canonical-facts-from-declarations, the typed node/layout constructors, and ebox-canonical-input-create belong to this integration boundary. Here :layout, :children, :source-handle, and :owned-facts are evaluated constructor fields. They are not author properties and do not extend the seven-entry grammar.

10. Optional native reflow and verification

The Rust module is optional and Ebox never builds it while loading:

(ebox-native-status)
(ebox-native-build)

From the repository root:

make docs-contract-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make interaction-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make dsl-tests EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs
make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs