diff --git a/AGENTS.md b/AGENTS.md index e5dd968..ee07385 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,19 +44,21 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Fail fast. No silent fallbacks. Log state changes with `log`. - Files stay at or under 500 lines. Comments are one short line, and rare. - A repeating animation (spinner, breath, sweep) holds still under reduced motion instead of shortening, or it would spin at 1 ms a turn. An effect set off by a change (flash, shake, a bar that fades at rest) reads elapsed time through `motion::since_change` rather than restarting an animation id, so what it holds keeps its state: an animation's id passes to every descendant, and a new one rebuilds their focus, drags and hover. -- `motion::duration()` honors reduced motion. Springs overshoot: use them inside animators, never as gpui easings, because gpui asserts eased values stay in `0..=1`. +- `motion::duration()` honors reduced motion. Springs overshoot. gpui hands an easing's value to the whole animator unclamped, so `motion::spring` goes inside the animator, on only what may overshoot (`forms::Switch` springs its thumb's travel, not its colors). - `svg()` paints only with its own `text_color`. `Icon` always sets one. - Buttons stay out of focus on click (`prevent_default` on mouse down). Tab still reaches them. - The focus ring is a 1px focus-colored border (`FocusRing`). gpui paints shadows under the element, so a shadow ring floods transparent elements. Focusable elements keep a 1px border, transparent at rest. - `ely_gpui_component::init` binds Tab and Shift-Tab to `FocusNext` and `FocusPrev`. Wrap the root in `FocusScope` with `.root()` and focus its handle. A root scope takes focus when the focused element leaves the tree, as a toast's or an alert's button does. -- gpui's `Window::dispatch_event` returns a private type, so the gallery scripts mouse input by posting `NSEvent`s to its own queue. +- The gallery scripts mouse input by posting `NSEvent`s to its own queue, so a press takes AppKit's path and reaches a native view over the window (`misc::WebView`). `Window::dispatch_event` reaches only gpui's listeners. gpui's normal window takes mouse moves from a tracking area, which only a real cursor feeds, so the harness sets the window's `acceptsMouseMovedEvents` for posted moves. +- gpui starts a drag on the first move past its threshold, and only the moves after it reach `on_drag_move`. gpui 0.2.2's mac window replayed a held drag every 16 ms; gpui_macos replays only while `pressedMouseButtons` shows a real button, which posted events never set. So `Step::DragTo` posts each move twice, as the replay did: the second post of the first step reaches `on_drag_move`, where a component that anchors on its first drag move sets its anchor (Scrollbar, Knob, Split). +- Every shot of a gpui window, a page's or a script's, waits three of its frames through `on_next_frame`: gpui presents without waiting on the GPU, and a heavy page's draw outlasts a fixed pause, so a shot could show the frame before the last step. A page's pauses stay, for work off the main thread such as pictures decoding. A covered window draws no frames, so its capture fails after 5 s instead of shooting a stale frame. - `hover()` needs an element id. -- gpui applies hover and group-hover styles once an element has a hitbox, in prepaint and paint, but text takes its color at layout, so a hover cannot recolor text. What shows on hover rests `invisible()` and turns `visible()` through `group_hover` (`chat::ConversationItem`, `messaging::ChatMessage`). -- gpui 0.2.2 panics on a deferred draw inside another. Whatever draws over the page goes through `primitives::raise`: at the top it defers; inside something raised it lays out on its own and draws last there, so a list opened in a dialog lies over the dialog. A raise takes its owner's id and draws under it, so its keyed state and focus hold while other raises come and go. `scripts/check.sh` refuses gpui's `deferred` anywhere else. +- gpui takes hover at layout only on an element with an id, from a state its mouse moves keep, so `.id(..).hover(..)` recolors text. Without an id, `hover` applies at paint, after the text took its color, and a mouse move alone redraws nothing. After a key press paint drops the hover and layout keeps it until the mouse moves, so a hover that sets text and background shows only the text. What shows on hover rests `invisible()` and turns `visible()` through `group_hover` (`chat::ConversationItem`, `messaging::ChatMessage`). +- Whatever draws over the page goes through `primitives::raise`: at the top it defers; inside something raised it lays out on its own and draws last there, so a list opened in a dialog lies over the dialog. gpui takes a deferred draw inside another but paints them all by priority alone, so a tooltip (0) deferred inside a fullscreen dialog (1) would draw under it. A raise takes its owner's id and draws under it, so its keyed state and focus hold while other raises come and go. `scripts/check.sh` refuses gpui's `deferred` anywhere else. - gpui hides hover after a key press until the mouse moves. A hover that holds or shows something (the toast stack, a carousel, tooltips, hover cards, charts, scroll activity) sets `HoverListenerMode::InputModalityIndependent`, so typing under a resting pointer keeps it. A hover preview the keyboard also moves keeps the default on its items; the box that clears it on leave listens independently, so a real exit still reports, and ignores a leave that follows a key through `last_input_was_keyboard` (`forms::Calendar`). - `occlude()` blocks the pointer for everything painted before it, its own ancestors too. Put it on the outermost box that should stop the pointer, as the toast stack does, or the ancestors' hover never fires. - A drag that pans, turns or slides goes through gpui's drag, whose moves reach the owner in or out of its box; `on_mouse_move` stops at the box's edge. gpui sends a drag's moves to every listener of its type, so each payload carries its owner's `EntityId` and handlers check it. -- gpui 0.2.2 paints pictures without a transform, so a picture turns by its pixels: `media::turn` turns decoded frames by quarter turns off the main thread, an asset keyed by the picture and the turn. An svg turns to any angle through its `Transformation`, so a control that turns shows one (`interaction::Rotatable`). +- gpui paints pictures without a transform, so a picture turns by its pixels: `media::turn` turns decoded frames by quarter turns off the main thread, an asset keyed by the picture and the turn. gpui's loader turns a photo upright by its EXIF orientation first, so `media::ImageViewer` turns from the upright frame and `media::ImageUpload` measures that frame. An svg turns to any angle through its `Transformation`, so a control that turns shows one (`interaction::Rotatable`). - A box that keeps a cursor over its items rings the item the cursor is on, not the box, as SelectableList marks its row (`interaction::SelectionArea`). - Boxes that scroll together go through `interaction::ScrollSync`: the pane a wheel turned leads, and a canvas laid before the panes copies its place in prepaint, before they draw, so no frame shows them apart. - A component that takes a starting value from its owner keeps it in `layout::seeded`: a new value from the owner replaces local drags. A field that reports its words as they change keeps what it sent, so the owner's echo leaves the caret alone (`mail::SignatureEditor`). A cursor inside a seeded view stays while it lies in what is shown, so the owner's echo of a turn leaves it (`calendar::CalendarMonthView`, `YearView`). @@ -72,7 +74,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - `TextInput::bounds_for` reads the last layout. Text changed this frame clamps to the laid-out end until the next paint. - The harness types with `dispatch_keystroke` through `AnyWindowHandle`, which leaves the root view free to redraw. - The macOS open panel runs out of process too. Escape does not reach it; the harness sends it `cancel:`. -- gpui's `test-support` swaps its executor: `block` fails on a real dispatcher. It stays behind this crate's `test-support` feature, never on for the gallery. +- gpui's `test-support` stays behind this crate's `test-support` feature and is never on for the gallery: it tracks every entity handle for leaks and records every `debug_selector`'s bounds each frame. - A tracked `FocusHandle` is a Tab stop only when built with `.tab_stop(true)`. `tab_index` on the div reaches only handles gpui makes itself. `primitives::tab_stop` keeps one per id. - Floating lists go below their anchor, or above when only above has room (`forms::options::float`). gpui's own switch keeps the anchor point, so a flipped list would cover its trigger. An option list is at least its trigger's width and at most the window's less `window_margin` each side, so gpui can snap it inside and its rows end in an ellipsis; wider, gpui pins it left and the rest runs off. - A trigger that opens a list goes through `forms::select::listing`: toggle, keys, blur and popup live there, for Select, TabOverflowMenu and Breadcrumb alike. A field that suggests as you type floats its rows through `forms::select::Picker` and `forms::options::Popup`, as Combobox and TagInput do. @@ -95,19 +97,19 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - `primitives::Severity` names info, success, warning and danger with their colors and icons, for alerts and feedback alike. - Rows that move are keyed. `motion::Flip` places them absolutely from last frame's heights, so no frame shows a row at its new place before it glides there, and drives the glide from elapsed time: an animation id that changes per move would rebuild the row's keyed state. `Reorder` holds a row by key, drags with an empty ghost and moves the held row itself. - A marker that slides between items (segment thumb, tab line) measures them with `motion::slide` and eases with `glide`. -- A picker hands focus to its popup on open and back to its trigger on pick or Escape; it closes once focus leaves both (`forms::date::picker`). Its trigger acts on press and keys, not on click, so a pick made on Enter's press cannot reopen it on the release; `messaging::StatusSetter`'s emoji field reuses it. +- A picker hands focus to its popup on open and back to its trigger on pick or Escape; it closes once focus leaves both (`forms::date::picker`). `messaging::StatusSetter`'s emoji field reuses it. - Motion for a value change keys its animation on `motion::changes`, so it replays per change and stays still on first paint. Key only the part that moves: a keyed ancestor gives every descendant a new id, and focused buttons lose focus. - A spotlight or tour takes its target's box from `primitives::Measure`; the lit box glides between targets. - Thumbs stay inside their component's box: the track is padded by half a thumb (`Slider`, `ColorPicker`, `GradientEditor`, `media::ImageCropper`). - gpui's `Hsla::opacity` scales alpha; `alpha` sets it. - A rounded box does not clip its children. Each layer inside takes the radius itself; `checker` takes one. - A window keeps one `feedback::Toaster` and renders one `ToastViewport`; anything holding the entity pushes to it. Messages share `primitives::Severity`. -- Only gpui can build `ExternalPaths` with paths, so drop rules live in `forms::files::dropped`, where tests reach them. `taken` adds file kinds by extension, for drops and the dialog alike (`DropZone::kinds`). -- What a dropped file leads to is drawn by a function of what was read, which tests render: gpui's test platform builds no `ExternalPaths` and leaves `prompt_for_paths` unimplemented (`settings::importer::read_row`). +- Drop rules live in `forms::files::dropped`. `taken` adds file kinds by extension, for drops and the dialog alike (`DropZone::kinds`). +- What a dropped file leads to is drawn by a function of what was read (`settings::importer::read_row`). gpui's test platform builds `ExternalPaths` with paths and answers a file dialog through `simulate_path_prompt_response`. - A stroke drawn with the pointer goes through `forms::pen`: a press starts it, a drag extends it, and the lift hands it over, in or out of the pad (`SignaturePad`, `generative::InpaintCanvas`, `calendar::TimeGrid`'s drag on empty time). - gpui runs key bindings before key listeners. A container takes a child's bound key through the action with `capture_action`, as `Form` takes `Submit`. - `ScrollHandle::scroll_to_item` runs in the container's prepaint against last frame's bounds and overflow, so a call before a list's first frame does nothing. Call it from a child's prepaint once the handle has bounds, then request one frame (`navigation::editor_tabs`). A floating list reveals its cursor, and a strip its focused item, through `forms::options::reveal` and `revealer`. A focused Tab stop deeper in a scroll box sets the box's offset whole from its own painted bounds in prepaint, once per focus, through `layout::bring_into_view` along the box's axis; the canvas measures the stop with its ring (`messaging::GifPicker`). `layout::reveal_when_focused` is that canvas; it lies over a box that holds the stop's ring (`buttons::SegmentedControl`, `project::KanbanBoard`). Never poll layout with `request_animation_frame`. -- gpui animations run on the wall clock; timers run on the executor's clock, which tests advance by hand. A test that needs an animation settled turns on the theme's reduced motion. Clocks read `background_executor().now()` and wake only their own view. +- gpui animations run on the wall clock, except `Animation::repeat_synced` loops, which read the executor's clock; timers run on the executor's clock, which tests advance by hand. A test that needs an animation settled turns on the theme's reduced motion. Clocks read `background_executor().now()` and wake only their own view. - Machine-read marks, QR codes and barcodes, paint `ink` on `paper`: dark on light in both themes, so cameras read them. - A layer over pressable content that must still pass scrolls to its ancestors uses `block_mouse_except_scroll`; `occlude` hides them from scrolls too. - Trackpad momentum arrives as `TouchPhase::Moved` after `Ended`, so a gesture acts only between `Started` and `Ended` (`lists::SwipeableListItem`). @@ -118,15 +120,17 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Script clicks aim at a probe's corner, so a scripted target must fit in the window; a tall probe puts its top off screen. The log names each probe's bounds as the script aims, and a probe around content with no width of its own measures smaller than what it draws, so a scripted demo sets its width. - `scripts/check.sh` fails by its exit status; a pipe into `tail` hides it. - Reduced motion shortens animations to 1ms on the wall clock. Tests sleep past it between refreshes to see where one ends. -- The test platform never runs next-frame callbacks, and its text metrics are simplified: each character takes a fixed advance. Tests refresh the window in place of the display link, and click where layout does not hang on text, such as padding and slots. +- The test platform runs next-frame callbacks only when a test calls `Window::simulate_next_frame`. Its text metrics are simplified: each character takes a fixed advance, unless the test builds its context with `TestAppContext::build_with_text_system`. Tests refresh the window in place of the display link, and click where layout does not hang on text, such as padding and slots. - An absolute element with no insets sits where it would flow: after the content in a block, inside the padding in a flex box. A canvas that measures its parent pins itself with `.top_0().left_0()`. - Keyed state lives while its element renders in consecutive frames. An overlay rendered only while open starts fresh each time. -- Long grids scroll in a `uniform_list`. gpui has no nearest scroll, so a key move up scrolls with `Top` and down with `Bottom` (`forms::picks`). A searchable grid of picks, emoji, icons or stickers, goes through `forms::picks`'s `Grid`; `uniform_list` gives every row one height, so a grid of large cells shows one group at a time instead of titles (`messaging::StickerPicker`). +- Long grids scroll in a `uniform_list`; a key move up scrolls with `Top` and down with `Bottom` (`forms::picks`). A searchable grid of picks, emoji, icons or stickers, goes through `forms::picks`'s `Grid`; `uniform_list` gives every row one height, so a grid of large cells shows one group at a time instead of titles (`messaging::StickerPicker`). - Masks reshape edits inside `TextInput::set_fit`, which sees the replaced range and the typed text. A diff after the fact cannot tell typed characters from kept ones. - Ids inside a reusable component carry its owner's id or `EntityId`, animation ids too. Twin ids share focus, click and animation state. A child keyed by its place inherits the state of what stood there before; key it by what it shows (`files::FileExplorer` keys a column by its folder). - Text wraps at the width its box had when measured. A cross-axis `max_w` comes too late, and a flex column counts wrapped text as one line. Put prose in a plain block inside a flex row with `flex_1().max_w(..)` (`feedback::states`). -- gpui 0.2.2 keeps a no-wrap line's first measure, taken at full width inside flex and scroll boxes, so `.truncate()` clips there instead of ending in an ellipsis. A line that may overflow is a `typography::Ellipsis`. -- gpui 0.2.2's `line_clamp` wraps once and lays the rest on the last line, cut hard; centered, it loses both ends. A name whose ends matter is a `typography::MiddleEllipsis` on one line (`files::FileGrid`). +- A line that may overflow is a `typography::Ellipsis`, which shapes its cut at the width its box painted with. +- gpui's text answers a min-content probe with the width it last shaped at, often one line, so a set width around prose does not give way in a narrow flex row; a host that lets such a column shrink gives it `min_w_0` (the gallery's stacked DescriptionList). +- Taffy 0.13 no longer floors a flex item's least width at its row's padding and border, and a cached content measure hides a `flex_1` item's own width, so a text field's least width held no room to type. `forms::Input`'s text slot keeps an empty strut under the text, its padding and transparent hairlines at no height, so the field's least width holds the frame's inset. +- gpui's `line_clamp` lays the rest of the text on its last line and cuts it hard; centered, it loses both ends. A name whose ends matter is a `typography::MiddleEllipsis` on one line (`files::FileGrid`). - Set an explicit line height on any box that clips text. gpui's default leading is taller than a tight box, and the clip eats descenders. - gpui's `img` takes its picture's pixel size for any `Auto` side, and its aspect ratio beats percent heights in flow. `Image` pins it absolute at full size, so an `Image` needs a sized box. - A file on its way is a `files::Transfer` drawn by one row in `files::transfer`: TransferQueue and DownloadManager share it, and its actions follow its state. @@ -135,10 +139,11 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A file's icon comes from `primitives::file_icon`, one map from name to icon for every chapter, under the owner's `primitives::IconTheme`, whose rules by name and extension come first; `files::FileIcon` draws it. What fits across a width comes from `layout::columns_for`, and `layout::fit` adds the inset that centers it. - Badges, tags and avatars live in `data_display`; forms and shell draw theirs from there, and `data_display` imports neither. A hue worn in a row, a label's or an event's, is `data_display::color_mark`, and `Palette::hue` gives the chart color by index. Avatars take square pictures. A tag is never wider than its container; a long label ends in an ellipsis beside its remove mark. - A picture drawn in code reaches gpui as a `RenderImage`. A host that replaces one frees the old through `App::drop_image`, or every frame stays in the sprite atlas (the gallery's model). -- `img()` keeps loading state, and moves an animated picture's frames, only with an id. Content masks are rectangles, so rounded corners survive only when the image fills its box without cropping. -- gpui reads a `&str` picture source as a web address. A file goes through `Path` (`documents::source` in the crate, `Path::new` in the gallery). -- Taffy 0.13 sizes a flex column by a ratio box's width before its cap, so chat's `shaped` pictures sit in a block inside such a column (`chat::ImageMessage`, `VideoMessage`, `ImageGrid`). -- Taffy 0.9's grid rows overgrow tiles that keep a ratio. Tiles in a picture's shape sit in flex rows of `flex_1` cells (`generative::GenerationGrid`). A cell's padding counts in its flex base, so a row of fewer cells gives each more width; cells that match across rows keep their inset on a box inside (`messaging::VideoCallGrid`). +- `img()` keeps loading state, and moves an animated picture's frames, only with an id; the frames hold still while its window is inactive or gpui's own `reduce_motion` is on, which the theme's reduced motion does not set. Content masks are rectangles, but `img()` draws only the part of its picture inside its box and rounds that part with its own radius, so a `Cover` crop keeps its corners. +- gpui reads a `&str` picture source with a scheme as a web address, and any other as an embedded asset. A file goes through `Path` (`documents::source` in the crate, `Path::new` in the gallery). +- Taffy sizes a flex column by the height a ratio box has at its set width, before its cap, so chat's `shaped` pictures sit in a block inside such a column (`chat::ImageMessage`, `VideoMessage`, `ImageGrid`). +- Taffy 0.13 collapses a child's vertical margin through a block parent with no padding or border on that side, and its size cache keeps only that block's size, so a box that measures it again leaves the margin out. A component moves a box with a relative offset, or with padding where what follows moves too, not with such a margin: `layout::Drawer` pulls and snaps its panel, and a fullscreen `overlays::Dialog` slides its card, with `top`; `motion::Stagger`, `Transition`, a centered Dialog and the palette's card rise with padding, so the box a scrim centers grows with them; the gallery's inspector sample is its probe's own child. A margin that stops at its parent is safe: a flex or grid item's own, or a child's of a flex item or of a box with padding or a border on that side. A flex wrapper stops the collapse but gives its items a definite height, so percent heights inside start to resolve (the Drawer's `max_h(relative(0.85))`). +- Tiles in a picture's shape sit in flex rows of `flex_1` cells (`generative::GenerationGrid`). A cell's padding counts in its flex base, so a row of fewer cells gives each more width; cells that match across rows keep their inset on a box inside (`messaging::VideoCallGrid`). - Rise reads green and fall red unless a component's `red_up` swaps them, as markets in East Asia read. `finance::quotes::moves` picks the pair. - Charts lay out in `f32` pixels of their own box, tested without gpui, and paint through `charts::paint::at`: a canvas's paint gets window coordinates, and `with_element_offset` works only in prepaint. Their sizes come from `theme.chart()`. - A label pinned to a point sits in a zero-size absolute box that centers it with flex (`charts::axes`), so no offset guesses at text size. @@ -146,33 +151,34 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - An owner's palette, edited or imported, reaches the theme per mode through `Theme::set_palette`, and `Theme::palette` names the one the shown mode fades to; high contrast strengthens Ely's own palettes alone. A color goes by its field's name through `token` and `token_mut`, on `Palette` and `Syntax` alike. Ely's palettes pass `tooling::A11yChecker`'s audit in both modes, with high contrast and without; a test holds them there. - `ScrollHandle` keeps its children's bounds unscrolled: a child paints at its bounds plus the offset. A press on a scrolled child subtracts both, and an offset that brings a child to a place is set whole, not added (`documents::find::reveal`). - A find box is `editor::FindWidget`, over code, a terminal or a document's pages. It shows the replace fold and the option toggles only when the owner handles them. -- Taffy 0.9 hands negative free space to auto margins and centering alike, so a centered child wider than its scroll box starts past the scroll's reach. Center only what fits the measured box (`documents::DocumentViewer`). +- Taffy hands negative free space to cross-axis auto margins and to centering alike, and gpui has no safe alignment, so a centered child wider than its scroll box starts past the scroll's reach. Center only what fits the measured box (`documents::DocumentViewer`). - A mode that leaves on Escape holds its own focus through `primitives::hold_focus`: turned on while focus is elsewhere, it takes focus so Escape reaches it, and `hand_back` returns it (`documents::ZenMode`, `collab::FollowMode`). - A chat holds at its newest message through gpui's `list` with `ListAlignment::Bottom`: it sticks while scrolled to the end, and `scroll_to` one past the last item sticks it again; that call sends no scroll event, so the owner clears its own away state (`chat::MessageList`). - Anything pressable is a Tab stop: a row, card or toggle takes `tab_index(0)` and `focus_ring`, with a 1px border that stays transparent where it shows none; gpui presses it on the release of Enter or Space. - A list of pressable rows goes through `lists::SelectableList`, whose cursor reaches them by key: Up and Down move, Enter or a double press activates (`media::Playlist`). Enter activates on its release, armed by its own press, so an activation that moves focus leaves the release nothing to press (`onboarding::HelpPanel`). Typing a row's first letters moves the cursor there, the letters joining within a pause on the executor's clock; a letter held with Command, Control or Alt stays the app's. A bare `ListItem` press is the pointer's only. Titled sections of such lists go through `lists::Sections`, for ChannelList, MemberList and MailboxList; each owner builds its rows and keeps its own checks. -- Columns that must line up across rows sit absolutely at fractions of their row, as the bars over them do (`calendar::CalendarMonthView`): with real text, gpui 0.2.2 lets a `flex_1` cell's text widen it, and the test platform's fixed advances hide it. +- Columns that must line up across rows sit absolutely at fractions of their row, as the bars over them do (`calendar::CalendarMonthView`): with real text, gpui lets a `flex_1` cell's text widen it, and the test platform's fixed advances hide it. - A row that holds text beside fixed parts lets the text give way: the text takes `flex_1().min_w_0()` and an `Ellipsis`; counts, icons, dates and chevrons take `flex_none`. A label that must stay whole wraps in that block instead, as `forms::Checkbox`'s does. +- gpui reports no text baselines, so under `items_baseline` a leaf aligns by its bottom. A block takes its first in-flow child's baseline, so a box led by a rolling column or a padded badge aligns oddly: the digit column stays a block, and a row whose value may be a badge aligns `items_end` (`typography::AnimatedNumber`, `data_display::DescriptionList`). +- gpui snaps every authored length to device pixels before layout, each on its own, so a stack moved by a multiple of a row keeps the row a whole number of device pixels, or the error grows with the multiple (`typography::AnimatedNumber`). - A mark over a text field reads the field at paint time, from a canvas laid over it (`collab::RemoteCursor`): by then `TextInput::bounds_for` holds this frame's layout, and a still page never renders again to catch up. - A callback field holds a named `type` alias; clippy's type_complexity rejects `Option>`. A chapter shares one alias per shape. -- A component that fills its container takes no width on its root: a block or a flex column stretches it. Percents fill badly in taffy 0.9. `w_full` in a flex column that a block measures by content keeps its content's width: taffy lays the column out once at unknown width and keeps that layout when the width comes out the same. `min_w_full` resolves against a block's outer width, padding and all (`forms::FormField`). In a flex row the host gives it `flex_1`; as a window's root it sits in a `size_full` box. A root centered under a cap keeps `w_full`, since `mx_auto` needs a width to fill (`layout::Container`); so does one that keeps a ratio, since taffy 0.9 follows a ratio from a set width, not a stretch (`layout::AspectRatio`, and `primitives::framed` under VideoPlayer, CameraPreview, ModelViewer, ParticipantTile and InpaintCanvas). A row that hands the text field element itself a `flex_1` share keeps `w_full` too: stretched without a width, taffy 0.9 can keep a pass that laid the share at no width, and the field draws nothing (`forms::Input`, `InputGroup`, `TagInput`); a row around one of those, as `NumberInput`'s is, takes none. `Button::full_width` is its host asking, since a button takes no style. Each chapter's test holds its filling roots at the full width of such a column through `layout::tests::narrow_width`, which reads a root by its `debug_selector`. +- A component that fills its container takes no width on its root: a block or a flex column stretches it, in a flex row the host gives it `flex_1`, and as a window's root it fills the window, since gpui sets a window root's auto sides to the window's size. A percent width ignores the root's own margins, so they push it past its container, and in a flex row it takes the whole row as its basis and leaves a `flex_1().min_w_0()` neighbor nothing. A root centered under a cap keeps `w_full`, since a flex column stretches no child with an auto side margin, and `mx_auto` needs a width to fill (`layout::Container`). So does a box that keeps a ratio: a block follows its ratio from a stretch, but a flex or grid box only from a set width, and callers may make one flex (`layout::AspectRatio`, and `primitives::framed` under VideoPlayer, CameraPreview, ModelViewer, ParticipantTile and InpaintCanvas). `Button::full_width` is its host asking, since a button takes no style. Each chapter's test holds its filling roots at the full width of a flex column in a padded block through `layout::tests::narrow_width`, which reads a root by its `debug_selector`. - A field's `Highlight` sets color, wash, weight, slant and strike per span; measuring and painting share one set of runs, so wrapped heights match. - A block editor keeps one undo for the document: text and structure together, as snapshots through `forms::History`; it takes Undo and Redo before its fields do. - gpui calls focus listeners only while the window is active. A test that needs focus events activates its window with `window.activate_window()`. - A terminal takes Tab and Shift-Tab through bindings under `ElyTerminal`, ahead of focus moves; other keys go through `terminal::keys` in a key listener, and typed text through its input handler. - A shell in the gallery starts without startup files and with a fixed prompt, so captures show no user or host. -- gpui 0.2.2 has no `(ElementId, usize)` id. A child id names its index: `(self.id.clone(), format!("row-{ix}"))`. +- gpui has no `(ElementId, usize)` id. A child id names its index: `(self.id.clone(), format!("row-{ix}"))`. - Clippy's `single_range_in_vec_init` rejects `[a..b]` and `vec![a..b]`; bind the range first. - A component built on `lists::Tree` fills its box; the host gives it a height. A fixed-width column beside a `flex_1` one takes `flex_none`, or it shrinks. - Editor rows read their text through `CodeEditor::row_text`: a multi-line ghost cuts its line, and the rest follows the ghost's last line. -- gpui styles a drag-over only on an element with its own hitbox. A veil that shows while files hover takes the drop itself (`chat::DragDropOverlay`). - gpui clears `debug_bounds` each frame: a test sees an element leave as well as appear. - The system time zone comes from `typography::format::system_zone`, which fails loud; jiff's `TimeZone::system()` falls back to UTC in silence. - A Select with nothing to pick stays shut: disabled, it shows its placeholder; open, it fails (`media::DeviceSelector` says no device was found). - A list opens with its cursor on the current choice: Select on the chosen row, Combobox on its value, MultiSelect on the first one ticked, SelectableList on its first selected row. A selection the owner makes, not the echo of the list's own pick, moves SelectableList's cursor there. - An event repeats as RFC 5545 has it: weeks count from the start's own week, and a month or a year without the start's date is passed over (`calendar::Recurrence`). - A search step, a tool call or an agent's step says where it stands with `chat::StepState` and draws it with `chat::step_mark`. -- A flex line whose free space goes to an auto margin loses its gaps in taffy 0.9. A row that parts left and right groups its sides and uses `justify_between`; `ml_auto` is safe only beside a `flex_1` item that leaves no free space. +- A row that parts left and right groups its sides and uses `justify_between`. An auto margin along a line keeps the line's gaps, but taffy also hands the space it took to the line's justify. A line with an auto margin keeps the default justify and runs forward (`forms::MultiSelect`'s chevron); centered, ended, spaced, or reversed as `i18n::Direction::row` is under a right-to-left locale, its items run out of its box. - A header that holds a name beside counts and actions wraps: `flex_wrap`, the name `flex_1` with `min_w(label_width)`, the rest one `flex_none` group that drops below. - Every header, row and strip works at 280px: a name keeps its minimum width, the rest wraps below or scrolls, and nothing draws past its box. - gpui takes a scroll box's reach from its direct children's bounds, and a block child stretches to the box. Content that scrolls sideways sets its least width on that child (`tables::body::least`). @@ -193,14 +199,13 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Focus that must follow an item to another parent is taken by the owner and keyed by the item: a handle gpui makes for an element dies with its path (`project::KanbanBoard`, whose cards keep focus across columns). - The theme's `platform`, the system's to start, decides the window buttons Ely draws, the title bar's layout and key caps, `secondary` included (`typography::keys::keystroke`). A title bar whose buttons the OS draws follows the OS. - A centered title sits between a start and an end that grow alike from a zero basis, their padding on a box inside them, since taffy counts padding in the flex base; their content is their least width, so the title gives way before either end (`shell::TitleBar`). -- A pane the window's blur shows through is `shell::Vibrancy`, filled with the palette's `glass`, in a window opened `WindowBackgroundAppearance::Blurred`: vibrancy on macOS, acrylic on Windows. gpui 0.2.2 has no Mica. +- A pane the window's blur shows through is `shell::Vibrancy`, filled with the palette's `glass`, in a window opened `WindowBackgroundAppearance::Blurred`: vibrancy on macOS, acrylic on Windows. gpui's `MicaBackdrop` and `MicaAltBackdrop` are DWM backdrops from Windows 11 build 22621; older builds get none, in silence, and macOS plain transparency. - The chart's hues go by `theme::HUE_NAMES`, for an event's color and a label's alike. - Text whose every character counts, encoded data and keys, goes through `typography::literal`: JetBrains Mono draws `==` and `->` as ligatures (`devtools::Encoder`). -- Messages go through `i18n::I18n`, a global the app sets: `cx.global::().text(key, args)` fills each `{name}`. Every catalog holds the first one's keys; a missing key, argument or catalog fails. A right-to-left locale mirrors a stack with `Direction::row` and sets its text with `Direction::align`; CoreText orders mixed Hebrew and Latin runs on macOS, but gpui sets no paragraph direction, so a line takes its first strong character's: `text` leads a right-to-left message with U+200F, so its words suit labels, not a field's value, and `Locale::number` a right-to-left minus with U+200E, as CLDR does. gpui 0.2.2 wraps shaped glyphs in the order they are drawn, so a right-to-left line that wraps comes out garbled: right-to-left text stays on one line, an `Ellipsis`, which cuts by characters and sits by its text's alignment. +- Messages go through `i18n::I18n`, a global the app sets: `cx.global::().text(key, args)` fills each `{name}`. Every catalog holds the first one's keys; a missing key, argument or catalog fails. A right-to-left locale mirrors a stack with `Direction::row` and sets its text with `Direction::align`; CoreText orders mixed Hebrew and Latin runs on macOS, but gpui sets no paragraph direction, so a line takes its first strong character's: `text` leads a right-to-left message with U+200F, so its words suit labels, not a field's value, and `Locale::number` a right-to-left minus with U+200E, as CLDR does. gpui wraps shaped glyphs in the order they are drawn, on the web as on macOS, so a right-to-left line that wraps comes out garbled: right-to-left text stays on one line, an `Ellipsis`, which cuts by characters and sits by its text's alignment. - gpui orders Tab stops by `tab_index` before paint order, and registers none for an `invisible()` element. A stop that must come first where it sits, yet draw over what paints after it, stays in flow at zero size and raises what it shows (`i18n::SkipLink`): a lower `tab_index` would sort it before the root scope's own handle, so Tab from the root would pass it. - Color-blind safe charts swap Ely's chart hues for a set fitted to stay apart under Machado's protanopia, deuteranopia and tritanopia; a test holds every pair 0.10 apart in Oklab and each hue 3:1 on its page (`Theme::set_color_blind_safe`, in Ely's own palettes alone). -- gpui 0.2.2's Metal renderer starts its instance buffer at 2 MB; a frame that outgrows it logs "failed to render … retrying with larger instance buffer size" and the buffer doubles, up to 256 MB. A world map's first frame grows it to 16 MB, then draws: the log is gpui's recovery, not a failure. -- gpui 0.2.2 packs pictures in its atlas without padding and samples them with a linear filter, so a scaled picture's edge takes color from its atlas neighbor, and pictures laid edge to edge show seams. A map tile carries a one-pixel rim copied from its own edge and draws one of its pixels larger each side, clipped to its box (`maps::tile`). +- gpui packs pictures in its atlas without padding and samples them with a linear filter, on the web as on macOS, so a scaled picture's edge takes color from its atlas neighbor, and pictures laid edge to edge show seams. A map tile carries a one-pixel rim copied from its own edge and draws one of its pixels larger each side, clipped to its box (`maps::tile`). - A wall clock reads `Timestamp::now` and wakes its view at each whole second through a keyed task, only while the owner passes no moment; a stopwatch reads the executor's clock, so tests advance it (`misc::Clock`, `misc::Stopwatch`). - A number shown to so many significant digits goes through `typography::format::significant`, which rounds through Rust's exponent form, so no binary noise shows at any size (`misc::Calculator`, `misc::UnitConverter`). - A component that reads the host's frames reads one at a time off the main thread and passes over frames that come meanwhile; when a read ends it redraws, so the newest frame is read next (`misc::QrCodeScanner`). @@ -208,7 +213,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A map keeps places in `f64`: at zoom 19 the world is 134 million pixels across. Its wheel pans and Command or Control with the wheel zooms about the pointer, as the canvas's does. Its tiles stay in gpui's asset cache, as every `img()` picture does. Its popup is an overlay that is not modal: it takes focus when it shows, and the frame it stops showing, by any way out, hands focus back. Its layers, lines and areas unwrap across the date line and repeat on every world in view. Clustered pins gather by squares of the world, not of the view, so a pan never regroups them; a zoom may, so it hands a focused pin's or cluster's focus to the map. - gpui's inspector (debug builds) runs no mouse listener while picking and holds a pick on a press without a redraw, so Ely's panel requests a frame each render while picking (`tooling::install_inspector`). Its panel scrolls, so the box model it draws over the page goes through `primitives::raise`, which paints unclipped. The panel is a second root, out of the app's scope, so it owns Tab through a `FocusScope` of its own. -- A tool that measures redraws cannot move: any animation redraws the view it counts (`tooling::RenderCounter`). `AnyView::cached` renders again only on its own notify or a window refresh. A meter that counts frames keeps its view drawing while shown (`tooling::FpsMeter`). +- A tool that measures redraws cannot move: any animation redraws the view it counts (`tooling::RenderCounter`). `AnyView::cached` renders again only when it or a view inside it notifies, its bounds, clip or text style change, or the window refreshes; gpui refreshes the window whenever input turns between the keyboard and the pointer. A meter that counts frames keeps its view drawing while shown (`tooling::FpsMeter`). - A logger of input lays its canvas before what it holds, so its capture listeners run first and see what a child stops. Key downs come through `App::intercept_keystrokes`, which runs before bindings, and a key context on its box tells it focus is inside (`tooling::EventLogger`). It also hears a lone modifier's release as a keystroke, which the logger skips. ## Decisions @@ -224,8 +229,11 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - 2026-09-25: `unexpected_cfgs` declares `feature = "cargo-clippy"`, which the objc 0.2 macros test. - 2026-09-26: Terminal proven with `alacritty_terminal` 0.26: its grid, parser and pseudo-terminal, drawn by Ely. A replayed grid serves recorded output and tests. - 2026-09-27: ThemeSwitcher is `settings::ThemeSelector`: `src/theme` sits under every chapter and cannot draw with buttons. -- 2026-09-28: QRCodeScanner proven with `rqrr` 0.11: the host hands in frames as `RenderImage`s, read off the main thread; gpui 0.2.2 has no camera. +- 2026-09-28: QRCodeScanner proven with `rqrr` 0.11: the host hands in frames as `RenderImage`s, read off the main thread; gpui has no camera. - 2026-09-28: WebView proven with `wry` 0.57: a child WKWebView over the window, placed from a canvas in prepaint at its box each frame and hidden once the box leaves the view. It draws above everything gpui paints and gpui's clipping does not reach it; gpui's test windows have no native handle, so the capture proves it. - 2026-09-28: InspectorOverlay is gpui's inspector drawn by Ely, `tooling::install_inspector`, in debug builds only, as gpui has it. - 2026-09-28: every text pair the contrast audit holds meets 4.5:1: `fg_subtle` is #696764 light and #94928f dark, `syntax.comment` with it, and the light syntax's number, constant and attribute are #a3591b, #aa5529 and #9f5d12; the contrast audit found them under. - 2026-09-28: the site shows components live, as gpui-kit.com does, not as screenshots. gpui 0.2.2 cannot build for the browser: Zed added gpui_web on 2026-02-26 and has published no gpui since 0.2.2. So gpui moves to Zed's repo by git at 1a28cff (T44b), the code gpui-pre 0.3.7 republishes; the source stays first-party, and this replaces the 2026-09-24 choice. The terminal stays native: alacritty_terminal's polling does not build for wasm32. +- 2026-09-28: gpui builds an AccessKit tree on macOS, Windows and Linux, not the web, from elements with an id and a `role`, so the accessibility entries turn from `blocked` to `prove`. It still has no tray or badge API, so TrayIcon and DockBadge call AppKit. +- 2026-09-28: SystemNotification is `prove`: gpui's `show_system_notification` posts only from an app bundle; unbundled, as `cargo run` starts the gallery, it logs and drops the call, and the web posts nothing. +- 2026-09-29: the ported debug gallery draws its heaviest page about 1.6 times slower than on gpui 0.2.2 (tooling: 9 fps, worst frame 117 ms, against 14 fps and 72 ms), with gpui's accessibility tree on or off (`Application::new_inaccessible` measured the same). Ely's code did not change there; the frame cost is gpui's. diff --git a/README.md b/README.md index ea8c5bf..86d4c44 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,16 @@ Status: early. `TASKS.md` tracks every component, chapter by chapter. ```toml [dependencies] ely-gpui-component = { git = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Components" } -gpui = "0.2.2" +gpui = { git = "https://github.com/zed-industries/zed", rev = "1a28cff4b409169bac058bca40dfbfeb7621d19b" } +gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "1a28cff4b409169bac058bca40dfbfeb7621d19b", features = ["font-kit"] } ``` ```rust use ely_gpui_component::{Assets, theme::{Mode, Theme}}; -use gpui::{App, Application}; +use gpui::App; fn main() { - Application::new().with_assets(Assets).run(|cx: &mut App| { + gpui_platform::application().with_assets(Assets).run(|cx: &mut App| { ely_gpui_component::init(cx); Theme::set_mode(Mode::Dark, cx); }); @@ -37,7 +38,7 @@ cargo run --example gallery -- --capture shots # macOS: PNG of every page, lig ## Build notes -- gpui 0.2.2 compiles its Metal shaders at runtime here (`runtime_shaders`, on by default), so a full Xcode install is not required. +- gpui compiles its Metal shaders at runtime here (`runtime_shaders`, on by default), so a full Xcode install is not required. gpui_platform needs `font-kit`, or macOS draws no text. - Tested on macOS only. The capture tool needs macOS. ## License diff --git a/TASKS.md b/TASKS.md index 156de12..16378c4 100644 --- a/TASKS.md +++ b/TASKS.md @@ -8,10 +8,10 @@ Per-component lines live in `tasks/`. Tags there: | Tag | Meaning | |-----|---------| | `→ path` | Alias or duplicate. One home, at `path`. | -| `native` | gpui 0.2.2 ships it. The gallery shows the native call. | +| `native` | gpui ships it. The gallery shows the native call. | | `host` | Ely draws the UI. The host app supplies the engine data. | -| `prove` | Needs a crate or platform API not yet proven with gpui 0.2.2. | -| `blocked` | Cannot be real on gpui 0.2.2. Reason given. | +| `prove` | Needs a crate or platform API not yet proven with gpui. | +| `blocked` | Cannot be real on gpui. Reason given. | | `(Txx)` | Built early in task `Txx` as a dependency. | ## Items diff --git a/examples/gallery/capture.rs b/examples/gallery/capture.rs index 1b44076..3dfbd74 100644 --- a/examples/gallery/capture.rs +++ b/examples/gallery/capture.rs @@ -47,6 +47,7 @@ async fn shoot_all( .enumerate() .filter(|(ix, _)| only.is_none_or(|only| only == *ix)); let gallery = window.update(cx, |_, window, _| number(window))??; + window.update(cx, |_, window, _| take_posted_moves(window))??; for (ix, page) in chosen { for (choice, name) in [(Choice::Light, "light"), (Choice::Dark, "dark")] { window.update(cx, |gallery, window, cx| { @@ -68,6 +69,7 @@ async fn shoot_all( format!("-{segment}") }; let path = dir.join(format!("{}-{name}{suffix}.png", page.slug)); + script::shown(window.into(), cx).await?; snapshot(gallery, &path)?; log::info!("capture: wrote {}", path.display()); if offset >= max { @@ -88,9 +90,9 @@ async fn shoot_all( Ok(()) } -/// A gpui window's number, as AppKit and CoreGraphics know it. +/// A gpui window's AppKit window. #[cfg(target_os = "macos")] -pub fn number(window: &Window) -> Result { +fn native(window: &Window) -> Result { use cocoa::base::id; use objc::{msg_send, sel, sel_impl}; use raw_window_handle::{HasWindowHandle, RawWindowHandle}; @@ -100,14 +102,33 @@ pub fn number(window: &Window) -> Result { let RawWindowHandle::AppKit(appkit) = handle.as_raw() else { bail!("window is not an AppKit window"); }; - let number: i64 = unsafe { - let view = appkit.ns_view.as_ptr() as id; - let ns_window: id = msg_send![view, window]; - msg_send![ns_window, windowNumber] - }; + Ok(unsafe { msg_send![appkit.ns_view.as_ptr() as id, window] }) +} + +/// A gpui window's number, as AppKit and CoreGraphics know it. +#[cfg(target_os = "macos")] +pub fn number(window: &Window) -> Result { + use objc::{msg_send, sel, sel_impl}; + + let number: i64 = unsafe { msg_send![native(window)?, windowNumber] }; u32::try_from(number).context("window number out of range") } +/// Takes posted mouse moves; gpui's tracking area takes only real ones. +#[cfg(target_os = "macos")] +fn take_posted_moves(window: &Window) -> Result<()> { + use cocoa::{appkit::NSWindow, base::YES}; + + unsafe { native(window)?.setAcceptsMouseMovedEvents_(YES) }; + log::info!("capture: the window takes posted mouse moves"); + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn take_posted_moves(_: &Window) -> Result<()> { + bail!("capture needs macOS window APIs") +} + /// This process's frontmost window above the normal level: a popover or menu. #[cfg(target_os = "macos")] pub fn popup_number() -> Result { diff --git a/examples/gallery/main.rs b/examples/gallery/main.rs index 4bd1dde..8c3514b 100644 --- a/examples/gallery/main.rs +++ b/examples/gallery/main.rs @@ -125,7 +125,7 @@ fn main() -> Result<()> { traffic_light_position: Some(point(px(16.0), px(18.0))), }), window_min_size: Some(size(px(880.0), px(560.0))), - // Draws every frame while a demo window is key, as gpui 0.2.2 did. + // Keeps drawing while a demo window is key. inactive_frame_interval: None, ..Default::default() }; diff --git a/examples/gallery/pages/data/records.rs b/examples/gallery/pages/data/records.rs index 87e6a24..e4d55e6 100644 --- a/examples/gallery/pages/data/records.rs +++ b/examples/gallery/pages/data/records.rs @@ -45,7 +45,7 @@ pub fn descriptions(cx: &mut App) -> impl IntoElement + use<> { ), ) .child( - div().w_64().child( + div().w_64().min_w_0().child( DescriptionList::new() .stacked() .item("Ship to", "12 Harbour Street, Wellington 6011, New Zealand") diff --git a/examples/gallery/pages/forms/pickers.rs b/examples/gallery/pages/forms/pickers.rs index 7e51361..8d1ddc4 100644 --- a/examples/gallery/pages/forms/pickers.rs +++ b/examples/gallery/pages/forms/pickers.rs @@ -69,7 +69,7 @@ pub fn select(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { )), ) .child(blocked( - "NativeSelect: gpui 0.2.2 has no native popup menu, so Select draws its own.", + "NativeSelect: gpui has no native popup menu, so Select draws its own.", cx, )) } diff --git a/examples/gallery/pages/i18n/a11y.rs b/examples/gallery/pages/i18n/a11y.rs index d92bf3d..ef0368c 100644 --- a/examples/gallery/pages/i18n/a11y.rs +++ b/examples/gallery/pages/i18n/a11y.rs @@ -65,7 +65,7 @@ pub fn skip_link(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> ), ) .child(blocked( - "LiveRegion / Announcer, AccessibleIcon, AriaLabel, ScreenReaderOnly: gpui 0.2.2 exposes no accessibility tree, so no label or live region reaches a screen reader.", + "LiveRegion / Announcer, AccessibleIcon, AriaLabel, ScreenReaderOnly: not yet shown: gpui builds an AccessKit tree from elements with an id and a role, and no screen reader here reads it.", cx, )) } diff --git a/examples/gallery/pages/interaction/pointer.rs b/examples/gallery/pages/interaction/pointer.rs index eb45176..f47861c 100644 --- a/examples/gallery/pages/interaction/pointer.rs +++ b/examples/gallery/pages/interaction/pointer.rs @@ -321,6 +321,6 @@ pub fn shapes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { cx, )), ) - .child(blocked("Pinch: gpui 0.2.2 forwards no magnify event, so a trackpad pinch never reaches a view.", cx)), + .child(blocked("Pinch: gpui hands a trackpad pinch to on_pinch, and the viewport does not zoom on it yet.", cx)), ) } diff --git a/examples/gallery/pages/layout/mod.rs b/examples/gallery/pages/layout/mod.rs index b20e900..b1cbc9d 100644 --- a/examples/gallery/pages/layout/mod.rs +++ b/examples/gallery/pages/layout/mod.rs @@ -23,20 +23,20 @@ const SCRIPT: &[Step] = &[ Step::Hover("scroll-area"), Step::Wait(300), Step::Shot("scroll-hover"), - Step::DownAt("scrollbar", 313.0, 12.0), - Step::DragTo("scrollbar", 313.0, 110.0), - Step::UpAt("scrollbar", 313.0, 110.0), + Step::DownAt("scrollbar", 310.0, 12.0), + Step::DragTo("scrollbar", 310.0, 110.0), + Step::UpAt("scrollbar", 310.0, 110.0), Step::Shot("scrollbar-drag"), - Step::DownAt("scrollbar", 313.0, 110.0), - Step::DragTo("scrollbar", 313.0, 12.0), - Step::UpAt("scrollbar", 313.0, 12.0), - Step::DownAt("sticky", 313.0, 12.0), - Step::DragTo("sticky", 313.0, 76.0), - Step::UpAt("sticky", 313.0, 76.0), + Step::DownAt("scrollbar", 310.0, 110.0), + Step::DragTo("scrollbar", 310.0, 12.0), + Step::UpAt("scrollbar", 310.0, 12.0), + Step::DownAt("sticky", 310.0, 12.0), + Step::DragTo("sticky", 310.0, 76.0), + Step::UpAt("sticky", 310.0, 76.0), Step::Shot("sticky-pushed"), - Step::DownAt("sticky", 313.0, 76.0), - Step::DragTo("sticky", 313.0, 12.0), - Step::UpAt("sticky", 313.0, 12.0), + Step::DownAt("sticky", 310.0, 76.0), + Step::DragTo("sticky", 310.0, 12.0), + Step::UpAt("sticky", 310.0, 12.0), Step::Click("collapse-toggle"), Step::Wait(400), Step::Shot("collapsed"), diff --git a/examples/gallery/pages/misc/web.rs b/examples/gallery/pages/misc/web.rs index 181e586..dbab2ea 100644 --- a/examples/gallery/pages/misc/web.rs +++ b/examples/gallery/pages/misc/web.rs @@ -53,7 +53,7 @@ pub fn render(cx: &App) -> impl IntoElement + use<> { cx, ) .child(blocked( - "PrintButton: gpui 0.2.2 has no print API.", + "PrintButton: gpui has no print API.", cx, )), ) diff --git a/examples/gallery/pages/motion/effects.rs b/examples/gallery/pages/motion/effects.rs index 0d87abb..7271485 100644 --- a/examples/gallery/pages/motion/effects.rs +++ b/examples/gallery/pages/motion/effects.rs @@ -234,7 +234,7 @@ pub fn backdrops(cx: &mut App) -> impl IntoElement + use<> { ), ) .child(blocked( - "Lottie Player is blocked: gpui 0.2.2 paints paths without the clip masks, mattes, blend modes and radial gradients Lottie files lean on, so frames would come out wrong.", + "Lottie Player is blocked: gpui paints paths without the clip masks, mattes, blend modes and radial gradients Lottie files lean on, so frames would come out wrong.", cx, )) } diff --git a/examples/gallery/pages/motion/moves.rs b/examples/gallery/pages/motion/moves.rs index 82d56d5..2e99a40 100644 --- a/examples/gallery/pages/motion/moves.rs +++ b/examples/gallery/pages/motion/moves.rs @@ -246,6 +246,6 @@ pub fn reorder(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { ) .child(div().w_96().child(list)) .child(Caption::new( - "Motion is gpui's with_animation, timed by motion::duration and eased by the curves in motion. Spring is motion::spring. CountUp is typography::AnimatedNumber::count_up. A collapse is layout::Collapsible. A scale entrance is blocked: gpui 0.2.2 transforms only icons.", + "Motion is gpui's with_animation, timed by motion::duration and eased by the curves in motion. Spring is motion::spring. CountUp is typography::AnimatedNumber::count_up. A collapse is layout::Collapsible. A scale entrance is blocked: gpui transforms only svgs.", )) } diff --git a/examples/gallery/pages/primitives/parts.rs b/examples/gallery/pages/primitives/parts.rs index b274fb5..2137246 100644 --- a/examples/gallery/pages/primitives/parts.rs +++ b/examples/gallery/pages/primitives/parts.rs @@ -197,7 +197,7 @@ pub fn backdrop(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { pub fn visually_hidden(cx: &App) -> impl IntoElement + use<> { section("VisuallyHidden", "Text for screen readers only.", cx).child(blocked( - "Blocked: gpui 0.2.2 has no accessibility tree to speak to.", + "Not yet shown: gpui builds an AccessKit tree from elements with an id and a role, and no screen reader here reads it.", cx, )) } diff --git a/examples/gallery/pages/shell/system.rs b/examples/gallery/pages/shell/system.rs index fc0fb36..49a64f9 100644 --- a/examples/gallery/pages/shell/system.rs +++ b/examples/gallery/pages/shell/system.rs @@ -138,7 +138,7 @@ pub fn notification(cx: &App) -> impl IntoElement + use<> { cx, ) .child(blocked( - "Blocked: an unbundled app has no notification center, and gpui 0.2.2 has no notification API.", + "Not yet shown: gpui posts a system notification only from an app bundle, and the gallery runs unbundled.", cx, )) } diff --git a/examples/gallery/pages/theme/platform.rs b/examples/gallery/pages/theme/platform.rs index 3652918..7be0164 100644 --- a/examples/gallery/pages/theme/platform.rs +++ b/examples/gallery/pages/theme/platform.rs @@ -29,7 +29,7 @@ struct Owned(bool); pub fn platform(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { section( "Platform Style · IconThemeProvider · VibrancyBackground", - "The theme's platform decides window buttons and key caps, and starts as the system's. An icon theme lays an owner's rules over Ely's file icons. A frosted pane lets a blurred window's backdrop through; gpui 0.2.2 blurs as vibrancy on macOS and acrylic on Windows, and has no Mica.", + "The theme's platform decides window buttons and key caps, and starts as the system's. An icon theme lays an owner's rules over Ely's file icons. A frosted pane lets a blurred window's backdrop through; gpui blurs as vibrancy on macOS and acrylic on Windows, and sets Mica on Windows 11.", cx, ) .child( diff --git a/examples/gallery/pages/tooling/inspection.rs b/examples/gallery/pages/tooling/inspection.rs index 89711b9..4c6f707 100644 --- a/examples/gallery/pages/tooling/inspection.rs +++ b/examples/gallery/pages/tooling/inspection.rs @@ -75,7 +75,7 @@ pub fn sections(window: &mut Window, cx: &mut App) -> Vec { .child(inspect()) .child(probe( "inspect-sample", - div().child(boxed(div().m_3().p_4()).child("A box to pick")), + boxed(div().m_3().p_4()).child("A box to pick"), )), ) .into_any_element(), diff --git a/examples/gallery/pages/typography/text.rs b/examples/gallery/pages/typography/text.rs index 9373b54..46d9eb1 100644 --- a/examples/gallery/pages/typography/text.rs +++ b/examples/gallery/pages/typography/text.rs @@ -150,7 +150,7 @@ fn narrow(cx: &App) -> gpui::Div { pub fn truncate(cx: &App) -> impl IntoElement + use<> { section( "Truncate / Ellipsis", - "One line, an ellipsis at the end, in the text style around it. It holds in flex boxes, where gpui's own .truncate() clips.", + "One line, an ellipsis at the end, in the text style around it, cut by the shaped line at the width its box gets.", cx, ) .child( diff --git a/examples/gallery/script.rs b/examples/gallery/script.rs index 2792bd8..20feda0 100644 --- a/examples/gallery/script.rs +++ b/examples/gallery/script.rs @@ -1,6 +1,7 @@ use std::{path::PathBuf, time::Duration}; use anyhow::{Context as _, Result}; +use futures::future::{Either, select}; use gpui::{AsyncApp, Bounds, Keystroke, Pixels, Point, WindowHandle, point, px}; use crate::{ @@ -53,6 +54,8 @@ pub enum Step { } const FRAME: Duration = Duration::from_millis(120); +/// How long a shot waits for each frame of the window it shoots. +const SHOWN: Duration = Duration::from_secs(5); /// How long an out-of-process file panel may take to open. const PANEL: Duration = Duration::from_secs(10); const DRAG_STEPS: u32 = 10; @@ -148,10 +151,13 @@ pub async fn play( last.x + (goal.x - last.x) * t, last.y + (goal.y - last.y) * t, ); - send(window, Mouse::Drag, at, cx)?; - cx.background_executor() - .timer(Duration::from_millis(16)) - .await; + // Each move twice, as gpui 0.2.2 replayed a held drag; gpui_macos replays only under a real button. + for _ in 0..2 { + send(window, Mouse::Drag, at, cx)?; + cx.background_executor() + .timer(Duration::from_millis(16)) + .await; + } } last = goal; } @@ -188,12 +194,14 @@ pub async fn play( } Step::Shot(name) => { let file = path(name); + shown(window.into(), cx).await?; snapshot(window.update(cx, |_, window, _| number(window))??, &file)?; log::info!("script: wrote {}", file.display()); } Step::ShotWindow(key, name) => { let file = path(name); let handle = opened(key, cx)?; + shown(handle, cx).await?; snapshot(handle.update(cx, |_, window, _| number(window))??, &file)?; log::info!("script: wrote {}", file.display()); } @@ -247,6 +255,24 @@ pub async fn play( Ok(()) } +/// Waits three frames: the first draws what the steps left, and AppKit shows it by the third, since gpui presents without waiting on the GPU. +pub async fn shown(window: gpui::AnyWindowHandle, cx: &mut AsyncApp) -> Result<()> { + for _ in 0..3 { + let (drawn, frame) = futures::channel::oneshot::channel(); + window.update(cx, |_, window, _| { + window.on_next_frame(move |_, _| drawn.send(()).unwrap_or_default()) + })?; + match select(frame, cx.background_executor().timer(SHOWN)).await { + Either::Left((Ok(()), _)) => {} + Either::Left((Err(_), _)) => anyhow::bail!("the window dropped its frame callback"), + Either::Right(_) => { + anyhow::bail!("the window drew no frame in {SHOWN:?}; is it hidden?") + } + } + } + Ok(()) +} + /// Presses a key without holding the root view; Enter and Space also release through AppKit, since components pick on release. fn press(window: gpui::AnyWindowHandle, stroke: Keystroke, cx: &mut AsyncApp) -> Result<()> { let release = match stroke.key.as_str() { diff --git a/src/data_display/records.rs b/src/data_display/records.rs index 653312e..6fc1c1c 100644 --- a/src/data_display/records.rs +++ b/src/data_display/records.rs @@ -60,7 +60,7 @@ impl RenderOnce for DescriptionList { .flex() .when(stacked, |row| row.flex_col().gap_0p5()) .when(!stacked, |row| { - row.flex_wrap().items_baseline().gap_x_4().gap_y_0p5() + row.flex_wrap().items_end().gap_x_4().gap_y_0p5() }) .py_2() .when(lined && ix < last, |row| { diff --git a/src/data_display/stat.rs b/src/data_display/stat.rs index 201bc65..e01b51f 100644 --- a/src/data_display/stat.rs +++ b/src/data_display/stat.rs @@ -126,8 +126,10 @@ impl Statistic { impl RenderOnce for Statistic { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); - let unit = |text: SharedString| { + let id = &self.id; + let unit = |part: &str, text: SharedString| { div() + .debug_selector(|| format!("statistic-{part} {id}")) .text_size(theme.text_size(TextSize::Md)) .text_color(theme.colors.fg_muted) .child(text) @@ -147,13 +149,13 @@ impl RenderOnce for Statistic { .flex() .items_baseline() .gap_1() - .children(self.prefix.map(unit)) + .children(self.prefix.map(|text| unit("prefix", text))) .child( - AnimatedNumber::new(self.id, self.value) + AnimatedNumber::new(self.id.clone(), self.value) .decimals(self.decimals) .size(self.size), ) - .children(self.suffix.map(unit)), + .children(self.suffix.map(|text| unit("suffix", text))), ) .children(self.trend) } diff --git a/src/data_display/tests.rs b/src/data_display/tests.rs index 077d515..df74a34 100644 --- a/src/data_display/tests.rs +++ b/src/data_display/tests.rs @@ -5,12 +5,16 @@ use std::{ }; use gpui::{ - Bounds, Context, IntoElement, Modifiers, ParentElement, Pixels, Render, Styled, TestAppContext, - VisualTestContext, Window, div, point, px, + AnyElement, Bounds, Context, IntoElement, Modifiers, ParentElement, Pixels, Render, Styled, + TestAppContext, VisualTestContext, Window, div, point, px, }; -use super::{Carousel, DescriptionList, PropertyGrid, PropertyGroup}; -use crate::{primitives::Measure, theme::Theme}; +use super::{Badge, Carousel, DescriptionList, PropertyGrid, PropertyGroup, Statistic, Tone}; +use crate::{ + primitives::Measure, + theme::{TextSize, Theme}, + typography::AnimatedNumber, +}; /// A property grid whose drawn height the test reads. struct Inspector(Rc>); @@ -69,15 +73,19 @@ fn a_property_group_folds_from_its_header_and_stays_folded(cx: &mut TestAppConte } /// A one-row description list, this wide, that reports where the list and its value sit. -struct Facts(Pixels, Rc; 2]>>); +struct Facts(Pixels, fn() -> AnyElement, Rc; 2]>>); + +fn license() -> AnyElement { + "Apache-2.0".into_any_element() +} impl Render for Facts { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - let (list, value) = (self.1.clone(), self.1.clone()); + let (list, value) = (self.2.clone(), self.2.clone()); let value = Measure::new("value", move |bounds, _, _| { value.set([value.get()[0], bounds]) }) - .child("Apache-2.0"); + .child((self.1)()); div().w(self.0).child( Measure::new("list", move |bounds, _, _| { list.set([bounds, list.get()[1]]) @@ -93,7 +101,7 @@ fn a_value_drops_below_its_label_when_the_row_runs_short(cx: &mut TestAppContext let mut place = |width: f32| { let seen = Rc::new(Cell::new([Bounds::default(); 2])); let shared = seen.clone(); - let (_, cx) = cx.add_window_view(move |_, _| Facts(px(width), shared)); + let (_, cx) = cx.add_window_view(move |_, _| Facts(px(width), license, shared)); settle(cx); let [list, value] = seen.get(); (value.left() - list.left(), value.top() - list.top()) @@ -116,6 +124,81 @@ fn a_value_drops_below_its_label_when_the_row_runs_short(cx: &mut TestAppContext ); } +#[gpui::test] +fn a_badge_value_rests_on_its_labels_line(cx: &mut TestAppContext) { + cx.update(Theme::init); + let mut foot = |value: fn() -> AnyElement| { + let seen = Rc::new(Cell::new([Bounds::default(); 2])); + let shared = seen.clone(); + let (_, cx) = cx.add_window_view(move |_, _| Facts(px(400.0), value, shared)); + settle(cx); + let [list, value] = seen.get(); + value.bottom() - list.top() + }; + let text = foot(license); + let badge = foot(|| { + Badge::new("Paid") + .tone(Tone::Success) + .dot() + .into_any_element() + }); + assert_eq!(badge, text, "a badge ends where its label's line does"); +} + +/// A statistic with units on both sides. +struct Revenue; + +impl Render for Revenue { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Statistic::new("revenue", "Revenue", 48_210.0) + .prefix("$") + .suffix("ms") + } +} + +#[gpui::test] +fn units_rest_on_the_bottom_of_a_statistics_number(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + Theme::update(cx, |theme| theme.reduced_motion = true); + }); + let (_, cx) = cx.add_window_view(|_, _| Revenue); + settle(cx); + let number = cx + .debug_bounds("animated-number revenue") + .expect("the number"); + for unit in ["statistic-prefix revenue", "statistic-suffix revenue"] { + let bounds = cx.debug_bounds(unit).expect("a unit"); + assert_eq!( + bounds.bottom(), + number.bottom(), + "{unit} rests on the number's bottom" + ); + } +} + +/// A lone digit in extra-small text, whose line is no whole number of device pixels. +struct Nine; + +impl Render for Nine { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + AnimatedNumber::new("nine", 9.0).size(TextSize::Xs) + } +} + +#[gpui::test] +fn a_rolled_digit_sits_square_in_its_cell(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + Theme::update(cx, |theme| theme.reduced_motion = true); + }); + let (_, cx) = cx.add_window_view(|_, _| Nine); + settle(cx); + let cell = cx.debug_bounds("rolling-cell").expect("the cell"); + let nine = cx.debug_bounds("rolling-9").expect("the nine"); + assert_eq!(nine.top(), cell.top(), "the nine fills its cell"); +} + /// A three-slide carousel, 200 wide, that turns every five seconds; each slide reports where it sits. struct Deck(Rc>); diff --git a/src/finance/tests.rs b/src/finance/tests.rs index 5a659c6..5c47444 100644 --- a/src/finance/tests.rs +++ b/src/finance/tests.rs @@ -7,8 +7,8 @@ use gpui::{ use jiff::Timestamp; use crate::finance::{ - CandlestickChart, ChartSync, DepthChart, Leg, MarketHeatmap, PayoffDiagram, PointFigureChart, - RenkoChart, candles::Candle, stage::Visible, + CandlestickChart, ChartSync, CurrencyConverter, DepthChart, Leg, MarketHeatmap, PayoffDiagram, + PointFigureChart, RenkoChart, candles::Candle, stage::Visible, }; use crate::{layout::tests::narrow_width, theme::Theme}; @@ -147,3 +147,30 @@ fn every_chart_fills_a_column_its_block_measures_by_content(cx: &mut TestAppCont "each chart spans the card inside its padding" ); } + +/// A converter in a 460px box beside a card 260 wide, in a 280px row. +struct Squeezed; + +impl Render for Squeezed { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let converter = CurrencyConverter::new("converter", 1000.0, ("USD", "EUR"), 0.9174); + div().w(px(280.)).child( + div() + .flex() + .gap_8() + .child(div().w(px(460.)).child(converter)) + .child(div().flex_1().child(div().w(px(260.)).h(px(100.)))), + ) + } +} + +#[gpui::test] +fn a_squeezed_converter_keeps_room_for_its_currencies(cx: &mut TestAppContext) { + cx.update(Theme::init); + let (_, cx) = cx.add_window_view(|_, _| Squeezed); + cx.run_until_parked(); + let text = cx + .debug_bounds("input-text") + .expect("the second currency's text"); + assert_eq!(text.size.width, px(26.), "the frame's inset for its name"); +} diff --git a/src/forms/group.rs b/src/forms/group.rs index 79e2364..55f1bf3 100644 --- a/src/forms/group.rs +++ b/src/forms/group.rs @@ -95,9 +95,9 @@ impl RenderOnce for InputGroup { .child(addon) }; div() + .debug_selector(|| "group-root".into()) .flex() .items_center() - .w_full() .h(theme.control_height(self.size)) .overflow_hidden() .rounded(theme.radius(Radius::Md)) diff --git a/src/forms/input.rs b/src/forms/input.rs index 2211057..eb1e399 100644 --- a/src/forms/input.rs +++ b/src/forms/input.rs @@ -90,9 +90,9 @@ impl RenderOnce for Input { }; let (state, clear_id) = (self.state.clone(), self.state.entity_id()); div() + .debug_selector(|| "input-root".into()) .flex() .gap_2() - .w_full() .px(theme.control_padding(self.size)) .map(|frame| { if multi { @@ -119,7 +119,15 @@ impl RenderOnce for Input { window.focus(&focus, cx) }) .children(self.prefix) - .child(div().flex_1().min_w_0().child(self.state)) + .child( + div() + .debug_selector(|| "input-text".into()) + .flex_1() + .min_w_0() + .child(self.state) + // An empty strut keeps the frame's inset to type in; squeezed, it spills unseen. + .child(div().px(theme.control_padding(self.size)).border_x_1()), + ) .when(self.clearable && !empty && !disabled, |frame| { frame.child( IconButton::new(("input-clear", clear_id), IconName::CircleX) diff --git a/src/forms/tags.rs b/src/forms/tags.rs index 8cc92bf..c66a5b6 100644 --- a/src/forms/tags.rs +++ b/src/forms/tags.rs @@ -215,7 +215,6 @@ impl RenderOnce for TagInput { .flex_wrap() .items_center() .gap_1() - .w_full() .min_h(theme.control_height(ControlSize::Md)) .px_1() .py_0p5() diff --git a/src/forms/tests/structure.rs b/src/forms/tests/structure.rs index 82ef81e..5984d4d 100644 --- a/src/forms/tests/structure.rs +++ b/src/forms/tests/structure.rs @@ -7,8 +7,8 @@ use super::setup; use crate::{ buttons::Button, forms::{ - Choice, DatePicker, EmailInput, FieldArray, Form, FormField, Input, InputEvent, - MentionInput, NumberInput, Select, Slider, TextInput, + Choice, DatePicker, EmailInput, FieldArray, Form, FormField, Input, InputEvent, InputGroup, + MentionInput, NumberInput, Select, Slider, TagInput, TextInput, }, layout::tests::narrow_width, }; @@ -224,10 +224,19 @@ fn fields_fill_a_column_their_block_measures_by_content(cx: &mut TestAppContext) narrow_width(cx, "number-root", |_, _| { NumberInput::new("number", 42.0).into_any_element() }), + narrow_width(cx, "input-root", move |window, cx| { + Input::new(&field(window, cx)).into_any_element() + }), + narrow_width(cx, "group-root", move |window, cx| { + InputGroup::new(&field(window, cx)).into_any_element() + }), + narrow_width(cx, "tag-input tags", |_, _| { + TagInput::new("tags", ["a"]).into_any_element() + }), ]; assert_eq!( widths, - [px(240.0); 6], + [px(240.0); 9], "each field spans the card inside its padding" ); } diff --git a/src/interaction/rotatable.rs b/src/interaction/rotatable.rs index 7492f50..1b95217 100644 --- a/src/interaction/rotatable.rs +++ b/src/interaction/rotatable.rs @@ -28,7 +28,7 @@ fn angle_at(bounds: Bounds, at: Point, snap: bool) -> f32 { type OnTurn = Rc; -/// An svg turned to its angle, in degrees clockwise from straight up, inside a ring with a knob at that angle. A drag on the knob turns it, Shift snapping to 15°; with focus, Left and Right turn it by 15°. gpui 0.2.2 turns svgs alone, so the picture is an svg path. +/// An svg turned to its angle, in degrees clockwise from straight up, inside a ring with a knob at that angle. A drag on the knob turns it, Shift snapping to 15°; with focus, Left and Right turn it by 15°. gpui turns svgs alone, so the picture is an svg path. #[derive(IntoElement)] pub struct Rotatable { id: ElementId, diff --git a/src/layout/sheet.rs b/src/layout/sheet.rs index d412b9e..04b5f44 100644 --- a/src/layout/sheet.rs +++ b/src/layout/sheet.rs @@ -187,6 +187,7 @@ impl RenderOnce for Drawer { let (close_up, close_out) = (close.clone(), close); let panel = div() .id("drawer-panel") + .debug_selector(|| "drawer-panel".into()) .relative() .w_full() .max_h(relative(0.85)) @@ -198,7 +199,7 @@ impl RenderOnce for Drawer { .rounded_t(theme.radius(Radius::Xl)) .bg(theme.colors.overlay) .shadow(theme.elevation(Elevation::Modal)) - .mb(-offset) + .top(offset) .on_drag_move(move |event: &DragMoveEvent, _, cx| { let pull = event.drag(cx); if pull.owner != owner { @@ -282,7 +283,7 @@ impl RenderOnce for Drawer { .with_animation( ("drawer-snap", snaps), Animation::new(quick).with_easing(motion::ease_out_cubic), - move |wrap, t| wrap.mb(-(released * (1.0 - t))), + move |wrap, t| wrap.top(released * (1.0 - t)), ) .into_any_element() } else { diff --git a/src/layout/tests.rs b/src/layout/tests.rs index ad223f6..b0be8bd 100644 --- a/src/layout/tests.rs +++ b/src/layout/tests.rs @@ -1,8 +1,14 @@ +use std::time::Duration; + use gpui::{ - AnyElement, App, Context, IntoElement, ParentElement, Pixels, Render, Styled, TestAppContext, - Window, div, px, + AnyElement, App, Bounds, Context, InteractiveElement, IntoElement, Modifiers, MouseButton, + ParentElement, Pixels, Point, Render, Styled, TestAppContext, VisualTestContext, Window, div, + point, px, }; +use super::Drawer; +use crate::theme::Theme; + type Build = Box AnyElement>; /// One element in a column that a padded block measures by content. @@ -32,3 +38,91 @@ pub(crate) fn narrow_width( cx.run_until_parked(); cx.debug_bounds(selector).expect(selector).size.width } + +/// A drawer holding a body the test can find. +struct Pulled; + +impl Render for Pulled { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full().child( + Drawer::new("drawer", |_, _| {}) + .child(div().debug_selector(|| "drawer-body".into()).h(px(120.0))), + ) + } +} + +fn body(cx: &mut VisualTestContext) -> Bounds { + cx.debug_bounds("drawer-body").expect("the body") +} + +/// Opens a drawer and waits out its entrance. +fn opened(cx: &mut TestAppContext, reduced: bool) -> &mut VisualTestContext { + cx.update(|cx| { + Theme::init(cx); + Theme::update(cx, |theme| theme.reduced_motion = reduced); + }); + let (_, cx) = cx.add_window_view(|_, _| Pulled); + cx.run_until_parked(); + std::thread::sleep(Duration::from_millis(if reduced { 10 } else { 250 })); + cx.update(|window, _| window.refresh()); + cx +} + +/// Presses on the grab and drags it down by `dy`, past gpui's drag threshold. +fn pull(cx: &mut VisualTestContext, dy: f32) -> Point { + let rest = body(cx).origin; + let grab = point(rest.x + px(10.0), rest.y - px(28.0)); + cx.simulate_mouse_down(grab, MouseButton::Left, Modifiers::none()); + for step in [4.0, 8.0, dy] { + let at = point(grab.x, grab.y + px(step)); + cx.simulate_mouse_move(at, MouseButton::Left, Modifiers::none()); + } + point(grab.x, grab.y + px(dy)) +} + +#[gpui::test] +fn a_resting_drawer_reaches_the_bottom(cx: &mut TestAppContext) { + let cx = opened(cx, true); + let panel = cx.debug_bounds("drawer-panel").expect("the panel"); + let bottom = cx.update(|window, _| window.viewport_size().height); + assert_eq!( + body(cx).size.height, + px(120.0), + "the panel's 0.85 cap leaves a resting body whole" + ); + assert_eq!( + panel.bottom(), + bottom, + "the panel reaches the window's bottom" + ); +} + +#[gpui::test] +fn a_pulled_drawer_follows_the_pointer(cx: &mut TestAppContext) { + let cx = opened(cx, true); + let at = pull(cx, 8.0); + let held = body(cx).origin.y; + cx.simulate_mouse_move( + at + point(px(0.0), px(40.0)), + MouseButton::Left, + Modifiers::none(), + ); + assert_eq!( + body(cx).origin.y - held, + px(40.0), + "the panel moves with the pull" + ); +} + +#[gpui::test] +fn a_released_drawer_glides_back(cx: &mut TestAppContext) { + let cx = opened(cx, false); + let rest = body(cx).origin.y; + let at = pull(cx, 40.0); + cx.simulate_mouse_up(at, MouseButton::Left, Modifiers::none()); + let lift = body(cx).origin.y - rest; + assert!( + lift > px(20.0), + "the panel glides back from {lift:?} below rest" + ); +} diff --git a/src/motion/tests.rs b/src/motion/tests.rs index ab508f7..d876b1d 100644 --- a/src/motion/tests.rs +++ b/src/motion/tests.rs @@ -402,3 +402,39 @@ fn effects_keep_their_content_and_let_presses_through(cx: &mut TestAppContext) { settle(cx); assert_eq!(view.read_with(cx, |effects, _| effects.presses), 1); } + +/// Rows rising in, in a sized box down a column that fills a row. +struct Rising; + +impl Render for Rising { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let rows = ["Inbox", "Drafts", "Sent"].map(|name| { + div() + .debug_selector(move || name.into()) + .px_4() + .py_2() + .child(name) + }); + let column = div().flex().flex_col().child( + div() + .debug_selector(|| "risen".into()) + .w(px(300.0)) + .child(super::Stagger::new("rise").children(rows)), + ); + div().size_full().flex().child(div().flex_1().child(column)) + } +} + +#[gpui::test] +fn a_stagger_box_takes_in_its_first_row_rise(cx: &mut TestAppContext) { + cx.update(Theme::init); + let (_, cx) = cx.add_window_view(|_, _| Rising); + cx.run_until_parked(); + let bounds = |cx: &mut VisualTestContext, name| cx.debug_bounds(name).expect(name); + let (held, first, last) = (bounds(cx, "risen"), bounds(cx, "Inbox"), bounds(cx, "Sent")); + assert!( + first.top() - held.top() > super::NUDGE, + "the first row starts risen" + ); + assert_eq!(last.bottom(), held.bottom(), "the box ends with its rows"); +} diff --git a/src/motion/transition.rs b/src/motion/transition.rs index be1d156..3cdcbfb 100644 --- a/src/motion/transition.rs +++ b/src/motion/transition.rs @@ -63,7 +63,8 @@ pub(super) fn arrive(element: E, entrance: Entrance, t: f32) -> E { let t = ease_out_cubic(t); match entrance { Entrance::Fade => element.opacity(t), - Entrance::Rise => element.opacity(t).mt(NUDGE * 2.0 * (1.0 - t)), + // Padding, as taffy's size cache drops a margin that collapses through a block. + Entrance::Rise => element.opacity(t).pt(NUDGE * 2.0 * (1.0 - t)), Entrance::Slide => element.opacity(t).ml(NUDGE * -4.0 * (1.0 - t)), } } diff --git a/src/navigation/palette/mod.rs b/src/navigation/palette/mod.rs index 07c1661..9346c41 100644 --- a/src/navigation/palette/mod.rs +++ b/src/navigation/palette/mod.rs @@ -306,8 +306,9 @@ impl Palette { } let (up, down, escape) = (go.clone(), go, self.on_close); let (enter, chosen) = (pick, cursor.clone()); - div() + let card = div() .id(self.id.clone()) + .debug_selector(|| "palette-card".into()) .flex() .flex_col() .w(theme.palette_size().width) @@ -381,12 +382,12 @@ impl Palette { .child(self.empty), ) }), - ) - .with_animation( - (self.id, "in"), - Animation::new(motion::duration(motion::BASE, cx)) - .with_easing(motion::ease_out_cubic), - |card, t| card.opacity(t).mt(motion::NUDGE * (1.0 - t)), - ) + ); + // Padding grows the box a scrim centers, as a margin taffy kept would. + div().child(card).with_animation( + (self.id, "in"), + Animation::new(motion::duration(motion::BASE, cx)).with_easing(motion::ease_out_cubic), + |card, t| card.opacity(t).pt(motion::NUDGE * (1.0 - t)), + ) } } diff --git a/src/navigation/palette/tests.rs b/src/navigation/palette/tests.rs index 7fceb15..44014c7 100644 --- a/src/navigation/palette/tests.rs +++ b/src/navigation/palette/tests.rs @@ -269,3 +269,17 @@ fn enter_picks_on_release_so_the_opener_stays_shut(cx: &mut TestAppContext) { press("enter", cx); assert_eq!(got(&view, cx), (false, Some("zoom".into()))); } + +#[gpui::test] +fn an_opening_palette_rises_in_the_box_its_scrim_centers(cx: &mut TestAppContext) { + let (_, cx) = host(Kind::Commands, cx); + let top = |cx: &mut VisualTestContext| cx.debug_bounds("palette-card").expect("the card").top(); + let first = top(cx); + std::thread::sleep(std::time::Duration::from_millis(250)); + settle(cx); + let lift = first - top(cx); + assert!( + lift > gpui::px(0.0) && lift < crate::motion::NUDGE * 0.75, + "the box grows with the rise, so the card starts half a nudge low: {lift:?}" + ); +} diff --git a/src/overlays/dialog.rs b/src/overlays/dialog.rs index cd94cbf..4405009 100644 --- a/src/overlays/dialog.rs +++ b/src/overlays/dialog.rs @@ -229,16 +229,22 @@ impl RenderOnce for Dialog { .children(body) .children(actions) }; - let rise = if fullscreen { - motion::NUDGE * 4.0 + let arrive = + Animation::new(motion::duration(motion::BASE, cx)).with_easing(motion::ease_out_cubic); + let card = if fullscreen { + card.with_animation((self.id.clone(), "in"), arrive, |card, t| { + card.opacity(t).top(motion::NUDGE * 4.0 * (1.0 - t)) + }) + .into_any_element() } else { - motion::NUDGE + // Padding grows the box the scrim centers, as a margin taffy kept would. + div() + .child(card) + .with_animation((self.id.clone(), "in"), arrive, |card, t| { + card.opacity(t).pt(motion::NUDGE * (1.0 - t)) + }) + .into_any_element() }; - let card = card.with_animation( - (self.id.clone(), "in"), - Animation::new(motion::duration(motion::BASE, cx)).with_easing(motion::ease_out_cubic), - move |card, t| card.opacity(t).mt(rise * (1.0 - t)), - ); let layer = div() .when(fullscreen, |layer| layer.size_full()) .on_key_down(move |event, window, cx| { diff --git a/src/overlays/tests/mod.rs b/src/overlays/tests/mod.rs index fa1c11e..c9940a5 100644 --- a/src/overlays/tests/mod.rs +++ b/src/overlays/tests/mod.rs @@ -432,3 +432,31 @@ fn a_tall_dialog_body_keeps_its_height_and_scrolls(cx: &mut TestAppContext) { fn a_popover_opened_by_its_owner_takes_no_icon() { let _ = Popover::with_opener("own", |_| div(), |_, _, _| div()).icon(IconName::Clock); } + +/// A dialog open from the first frame, with a body the test can find. +struct Rising; + +impl Render for Rising { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full().child( + Dialog::new("rising", "Rising", |_, _| {}) + .child(div().debug_selector(|| "rising-body".into()).h(px(40.0))), + ) + } +} + +#[gpui::test] +fn an_opening_dialog_rises_in_the_box_its_scrim_centers(cx: &mut TestAppContext) { + cx.update(Theme::init); + let (_, cx) = cx.add_window_view(|_, _| Rising); + cx.run_until_parked(); + let top = |cx: &mut VisualTestContext| cx.debug_bounds("rising-body").expect("the body").top(); + let first = top(cx); + std::thread::sleep(Duration::from_millis(250)); + settle(cx); + let lift = first - top(cx); + assert!( + lift > px(0.0) && lift < crate::motion::NUDGE * 0.75, + "the box grows with the rise, so the card starts half a nudge low: {lift:?}" + ); +} diff --git a/src/primitives/image.rs b/src/primitives/image.rs index 0ddc38a..589216e 100644 --- a/src/primitives/image.rs +++ b/src/primitives/image.rs @@ -33,7 +33,7 @@ pub(crate) fn framed(ratio: f32, cx: &App) -> Div { frame } -/// Themed `img()` that fills the box it is given; a `Cover` crop keeps square corners. +/// Themed `img()` that fills the box it is given; a `Cover` crop keeps its rounded corners. #[derive(IntoElement)] pub struct Image { id: ElementId, diff --git a/src/primitives/layer.rs b/src/primitives/layer.rs index fbd750a..78deb1e 100644 --- a/src/primitives/layer.rs +++ b/src/primitives/layer.rs @@ -16,7 +16,7 @@ fn nested() -> bool { LAYERS.with_borrow(|layers| !layers.is_empty()) } -/// Draws `child` over the page, as gpui's `deferred` does. gpui cannot defer a draw inside a deferred one, so what is raised inside something raised is laid out and drawn last within it, under `id`: a list opened in a dialog lies over the dialog and keeps its state. +/// Draws `child` over the page, as gpui's `deferred` does. gpui paints deferred draws by priority alone, so what is raised inside something raised is laid out and drawn last within it, under `id`: a list opened in a dialog lies over the dialog and keeps its state. pub fn raise(id: impl Into, child: impl IntoElement) -> Raised { let layer = Layer { child: child.into_any_element(), diff --git a/src/tables/tests/mod.rs b/src/tables/tests/mod.rs index 84f0d4b..fb4368a 100644 --- a/src/tables/tests/mod.rs +++ b/src/tables/tests/mod.rs @@ -314,6 +314,31 @@ fn removing_the_rule_being_typed_leaves_the_next_rule_its_own_value(cx: &mut Tes ); } +/// One rule, Change is above, in a 280px column. +struct NarrowRule; + +impl Render for NarrowRule { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let rule = FilterRule { + column: "change".into(), + test: super::Test::Above, + value: "".into(), + }; + div() + .w(px(280.0)) + .child(FilterBuilder::new("narrow", [("change", "Change")]).rules([rule], false)) + } +} + +#[gpui::test] +fn a_rule_keeps_room_for_its_value_in_a_narrow_column(cx: &mut TestAppContext) { + cx.update(Theme::init); + let (_, cx) = cx.add_window_view(|_, _| NarrowRule); + settle(cx); + let text = cx.debug_bounds("input-text").expect("the value's text"); + assert_eq!(text.size.width, px(18.0), "the frame's inset to type in"); +} + /// A two-column grid whose row count the test sets. struct Shrinking(Rc>); diff --git a/src/typography/fit.rs b/src/typography/fit.rs index 0dc48df..c59945f 100644 --- a/src/typography/fit.rs +++ b/src/typography/fit.rs @@ -111,7 +111,7 @@ fn offset(align: TextAlign, free: Pixels) -> Pixels { } } -/// One line in the text style around it, cut by `fit` to its box and set by its alignment. gpui 0.2.2 keeps a line's first measure, so its own `truncate` clips instead in flex boxes. +/// One line in the text style around it, cut by `fit` to its box and set by its alignment. fn cut_line(text: SharedString, fit: fn(&str, Pixels, &mut Window) -> String) -> Div { let line = text.clone(); div() diff --git a/src/typography/rolling.rs b/src/typography/rolling.rs index b46d0b2..8a7a781 100644 --- a/src/typography/rolling.rs +++ b/src/typography/rolling.rs @@ -124,8 +124,11 @@ impl RenderOnce for AnimatedNumber { let duration = motion::duration(motion::SLOW * 2, cx); let theme = cx.theme(); let size = theme.text_size(self.size); - let line = size.to_pixels(window.rem_size()) * LEADING; + let scale = window.scale_factor(); + // gpui snaps rows and margin apart; keep whole device pixels. + let line = (size.to_pixels(window.rem_size()) * LEADING * scale).round() / scale; let row = tabular(div()) + .debug_selector(|| format!("animated-number {}", self.id)) .id(self.id) .flex() .h(line) @@ -157,11 +160,15 @@ impl RenderOnce for AnimatedNumber { .into_any_element(); }; let start = old.to_digit(10).unwrap_or(0); - let stack = - div().flex().flex_col().children((0..10).map(|digit| { - div().h(line).child(SharedString::from(digit.to_string())) - })); + // A block column lends the cell no baseline. + let stack = div().children((0..10).map(|digit| { + div() + .debug_selector(move || format!("rolling-{digit}")) + .h(line) + .child(SharedString::from(digit.to_string())) + })); div() + .debug_selector(|| "rolling-cell".into()) .h(line) .overflow_hidden() .child(stack.with_animation( diff --git a/tasks/ch01-10.md b/tasks/ch01-10.md index 67ede0d..f70f122 100644 --- a/tasks/ch01-10.md +++ b/tasks/ch01-10.md @@ -17,7 +17,7 @@ - [x] Show / When — native `.when()` / `.when_some()` - [x] For / Each — native `.children(iter)` - [x] Fragment — native: children take iterators -- [x] VisuallyHidden — blocked: gpui 0.2.2 has no accessibility tree +- [ ] VisuallyHidden — prove: gpui builds an AccessKit tree on macOS, Windows and Linux, not the web, from elements with an id and a `role`; a box that draws nothing, with an `aria_label`, must reach the macOS tree with its label - [x] FocusRing - [x] FocusScope / FocusTrap - [x] ClickOutside — native `on_mouse_down_out` @@ -129,7 +129,7 @@ - [x] TrayIcon / TrayMenu — macOS `NSStatusItem`; other systems get an error - [x] DockBadge — macOS dock tile; other systems get an error - [x] JumpList — native `cx.set_dock_menu()` -- [x] SystemNotification — blocked: an unbundled app has no notification center; gpui 0.2.2 has no API +- [ ] SystemNotification — prove: on macOS `cx.show_system_notification` posts only from an app bundle; unbundled, as `cargo run` starts it, gpui logs and drops the call, so Ely must check the bundle identifier and fail loud. A bundled gallery must post one and hand its response to the owner through `on_system_notification_response` - [x] MultiWindow Manager - [x] WindowSwitcher - [x] QuickLauncher → `navigation::QuickLauncher` (T07c): a SearchPalette in an always-on-top window @@ -205,7 +205,7 @@ - [x] RadioCard - [x] Switch / Toggle - [x] Select -- [x] NativeSelect — blocked: gpui 0.2.2 has no native popup menu +- [x] NativeSelect — blocked: gpui has no native popup menu - [x] Combobox - [x] Autocomplete → `forms::Combobox::free` - [x] MultiSelect diff --git a/tasks/ch11-20.md b/tasks/ch11-20.md index 446f2ba..c41b598 100644 --- a/tasks/ch11-20.md +++ b/tasks/ch11-20.md @@ -27,7 +27,7 @@ ## T11c Motion · Presence & order — `src/motion` -- [x] Transition — fade, slide, scale, collapse → `motion::Transition` fades, rises and slides; collapse is `layout::Collapsible`; scale is blocked: gpui 0.2.2 transforms only icons +- [x] Transition — fade, slide, scale, collapse → `motion::Transition` fades, rises and slides; collapse is `layout::Collapsible`; scale is blocked: gpui transforms only svgs - [x] AnimatePresence → `motion::AnimatePresence` - [x] Motion → gpui's `with_animation`, timed by `motion::duration` and eased by the curves in `motion` - [x] Spring → `motion::spring` @@ -46,7 +46,7 @@ - [x] Shake → `motion::Shake` - [x] ParticleBackground → `motion::ParticleBackground` - [x] AnimatedGradient — slow tonal drift → `motion::AnimatedGradient` -- [x] Lottie Player — blocked: gpui 0.2.2 paints paths without the clip masks, mattes, blend modes and radial gradients Lottie files lean on, so frames would come out wrong +- [x] Lottie Player — blocked: gpui paints paths without the clip masks, mattes, blend modes and radial gradients Lottie files lean on, so frames would come out wrong ## T12a Data Display · People & numbers — `src/data_display` diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index 1534733..d3aa399 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -225,12 +225,12 @@ - [x] DragGhost → DragPreview (native) - [x] Sortable / SortableContext → `motion::Reorder` - [x] Resizable -- [x] Rotatable — an svg: gpui 0.2.2 turns no other element +- [x] Rotatable — an svg: gpui turns no other element - [x] Selectable / SelectionArea - [x] RubberBandSelection → `interaction::SelectionArea` - [x] LongPress → `primitives::Pressable` - [x] DoubleClick — native `ClickEvent::click_count` -- [x] Gesture — prove: wheel zoom through `layout::Viewport`, two-finger swipe through `lists::SwipeableListItem`; pinch blocked, gpui 0.2.2 forwards no magnify event +- [ ] Gesture — prove: wheel zoom through `layout::Viewport`, two-finger swipe through `lists::SwipeableListItem`; pinch is not yet shown: gpui hands a trackpad magnify to `on_pinch` as a `PinchEvent`, and `layout::Viewport` must zoom about the pointer on one - [x] PanZoom → `layout::Viewport` ## T38b Interaction · Keys, scroll and focus @@ -269,7 +269,7 @@ - [x] IconThemeProvider → `primitives::IconTheme`, an owner's rules by name and extension over `file_icon` - [x] Platform Style → `theme::Platform`, the theme's `platform`: the window buttons Ely draws, the title bar, key caps - [x] VibrancyBackground / Acrylic → `shell::Vibrancy` in a window opened `WindowBackgroundAppearance::Blurred` -- [ ] Mica — blocked: gpui 0.2.2's one blur, `Blurred`, is acrylic on Windows +- [ ] Mica — prove: a window opened `WindowBackgroundAppearance::MicaBackdrop` or `MicaAltBackdrop` gets a DWM backdrop from Windows 11 build 22621, none on older builds, in silence, and plain transparency on macOS; `shell::Vibrancy` must show Mica through its `glass` on Windows 11, where the capture harness does not run ## T40a i18n — `src/i18n` @@ -280,12 +280,12 @@ ## T40b a11y — `src/i18n` -- [ ] LiveRegion / Announcer — blocked: gpui 0.2.2 has no accessibility tree; AppKit's announcement request needs none, but no process here is trusted to observe VoiceOver take it +- [ ] LiveRegion / Announcer — prove: gpui has no live setter, but `a11y_synthetic_children` hands over the element's own AccessKit node, which takes `set_live`; on macOS AccessKit then posts an announcement of the node's value when it appears and each time the value changes. A box with an id, a `role` and its words in `aria_value` must show each new value in the macOS tree. Neither that tree nor `Window::debug_a11y_tree_json` shows the live flag, and no process here is trusted to observe VoiceOver take the announcement - [x] SkipLink → `i18n::SkipLink`: a Tab stop in flow that shows only while focused, raised over the page, and hands focus to its target -- [ ] AccessibleIcon — blocked: no accessibility tree -- [ ] AriaLabel helper — blocked: no accessibility tree +- [ ] AccessibleIcon — prove: an svg makes no node, so an `Icon` takes its name from a box around it with an id, `role(Role::Image)` and an `aria_label`; that box must reach the macOS tree with its name +- [ ] AriaLabel helper — prove: gpui's own `aria_label`, on an element with an id and a `role`; plain strings make no node, so a control's name goes on its box. A labeled button must reach the macOS tree with its name - [x] KeyboardNavigationHelper → `interaction::RovingFocus` -- [ ] ScreenReaderOnly — blocked: no accessibility tree +- [ ] ScreenReaderOnly → VisuallyHidden (prove) - [x] ColorBlindSafe Palette → `Theme::set_color_blind_safe`: chart hues fitted to stay apart under Machado's simulated protanopia, deuteranopia and tritanopia, each 3:1 on its page ## T41a Maps · View — `src/maps` @@ -343,7 +343,7 @@ - [x] WebView — proven: `wry` 0.57 lays a child WKWebView over the window → `misc::WebView`, an address or the owner's HTML, following its box each frame and hidden out of view; it draws above gpui and ignores its clipping, and a press anywhere gpui draws, or the view leaving, hands the keyboard back. macOS only - [x] IframeEmbed / EmbedCard → `misc::IframeEmbed`, a page in a frame of its own under its address, with Open in browser; EmbedCard is `chat::LinkPreviewCard` - [x] HTMLPreview → `misc::WebView` -- [x] PrintButton — blocked: gpui 0.2.2 has no print API +- [x] PrintButton — blocked: gpui has no print API - [x] ShareSheet → `buttons::ShareButton` ## T42g Misc · Export and import