From d4fdb694ef963134bad2b6b5be5e27a904037c9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 15 Oct 2026 02:25:43 -0400 Subject: [PATCH] Menus: hover intent for submenus; panels occlude; flip left Panels occlude the rows under them. A submenu without room on the right opens on the left of its row. A submenu opens after the pointer rests 150 ms; a row crossed toward an open submenu waits 300 ms. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 4 +- src/menus/aim.rs | 120 +++++++++++++++++++++++++++++ src/menus/draw.rs | 56 ++++++++++---- src/menus/menu.rs | 75 +++++++++--------- src/menus/mod.rs | 1 + src/menus/tests/aim.rs | 167 +++++++++++++++++++++++++++++++++++++++++ src/menus/tests/mod.rs | 1 + 7 files changed, 375 insertions(+), 49 deletions(-) create mode 100644 src/menus/aim.rs create mode 100644 src/menus/tests/aim.rs diff --git a/AGENTS.md b/AGENTS.md index 4cbf985..50a3357 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Web views: `wry` 0.57 (Apache-2.0 OR MIT, default features off, macOS only) lays a child WKWebView over the window. - Terminal: `alacritty_terminal` 0.26 (Apache-2.0, default features off, native only) for the grid, its parser and the pseudo-terminal; `futures` carries its events. `vte` 0.15 (Apache-2.0 OR MIT), the parser it re-exports, reads `AnsiText`'s codes alone, so colored output builds for wasm32. - Diffs: `similar` 3.2 (Apache-2.0) for line and word diffs and three-way merges; its `unicode` feature splits words at punctuation. -- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `MarkdownRenderer` draws display math through `typography::Latex`, a ```` ```mermaid ```` block through `documents::Diagram`, and pictures from the owner's folder (`base`) or `data:` addresses (`base64` 0.22, MIT OR Apache-2.0); web pictures stay unloaded. Inline math shows as TeX: gpui text carries no inline pictures. Inline it reads as Typora's defaults: `:shortcode:` turns to its emoji as GitHub reads it (the `emojis` crate's names, GitHub's today, so `:cricket:` is the insect; read after escapes and references, so `\:smile:` turns too; inside an autolink or `` it stays text; within one DOM text node, which filtered tags do not split, the first `:name:`-shaped run decides for all: blank text or a space before it turns every shortcode, else none, so `x:smile: :smile:` stays), inline HTML's `u`, `b`/`strong`, `i`/`em`, `s`/`del`/`strike`, `code`/`kbd`, `mark`, `br` and `a href` draw as what they mean GFM's filtered tags (`script`, `style`, `title`, `textarea`, `iframe`, `xmp`, `noembed`, `noframes`, `plaintext`) show as text, and other tags, hyphenated names too, vanish, leaving their text; a tag's name runs to white space or `/`, as HTML5 reads it; attribute values split on ASCII whitespace alone and decode as HTML decodes them; each Markdown container is the element GitHub writes (`em`, `strong`, `del`, `a`), on one stack with the tags and repaired as HTML5 repairs: an end tag closes the innermost element of its name and all above it, of which only formatting elements (`a`, `b`, `code`, `em`, `i`, `s`, `strike`, `strong`, `u`) reopen, a new `a` ends the open one and an `a` without `href` links nothing; other tags are not tracked, and `
` breaks as `
` does; and a footnote reference is its number in superscript, numbered by first reference, as its definition is; one `~` still strikes. At the root, blocks read as GitHub shows them: a quote whose first line is plain text reading `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` in any case, after escapes and references, spaces and tabs around it, with more after it, is an alert under its title (Important in the palette's violet, `syntax.keyword`, read at 4.5:1); a paragraph that is `[toc]` alone, as Typora reads it, lists every heading by depth, and a press on one reveals it as a link press does (`MarkdownDocument`'s `reveal`), the heading found where `CodeEditor` anchors carried it through each edit since the read, gone if an edit or an undo took it, and a secondary press acts on press, since a block writing back moves the list; front matter shows in a box without its fences; an HTML block shows its text with the inline tags above, white space collapsed and owed as a browser lays it, block elements breaking lines and closing what opened inside them, HTML5's start tags ending an open `p`, `fieldset` and `menu` too, and an `li` start an `li`, a `dd` or `dt` start either, as HTML5 ends them, comments dropped, the filtered tags shown as text, and an unclosed tag dropping the rest; a rule is a 2px `border_strong` line; bullets go •, ◦, ▪ by list depth, ordered lists counted. Exports read a tree as the drawing does through `MarkdownReader` (`block` gives its `MarkdownBlock`s, `inline` its text, `note` and `label` its footnotes, `headings` the `[toc]` list), one fresh stack per paragraph, heading or cell. `MarkdownDocument` holds a whole file in one `CodeEditor` (text and undo, colored by `CodeEditor::highlighter` with `markdown_highlights`): visual draws the file's top-level blocks in a gpui `list`, a pressed block opening as its source in a `TextInput` whose typing is the document's text at once and whose write-back is one undo step; a press in a table opens that cell alone, in place, as its source in a one-line field (a press on its text at the pressed character, past it at its end; a table block opened by its edge, the keyboard or a step from a neighbor opens its first or last cell), Tab and Shift-Tab going to the next and previous cell, Up and Down to the cell above and below or out of the table, Enter and Escape writing it back, a bare `|` typed in it escaped as the cell keeps it, and a table edit writing it back before it runs; source is the editor; preview draws without presses. A written-back block, a mode switch or a new text is read at once up to 128 KiB, longer ones on the background executor; typing in source or split reads 300 ms after the last key, off the main thread (markdown-rs grows with the square of its nodes, not its bytes: in release, 128 KiB of one-letter paragraphs takes 1.7 s, 16 KiB 42 ms, while 128 KiB of prose takes 20 ms; a block-heavy text under the cutoff can still hold the UI thread past 100 ms, a known limit). Texts past `LARGEST_READ` (2 MiB) are never read: no blocks, no outline. A new read keeps the list's blocks alike at both ends. One caret serves every mode: the editor's selection, which a written-back block moves; `set_caret` and `set_top_line` set caret and view apart; a top line asked (numbered, so a late frame callback finishes only its own ask; new text places it again, clamped to the text as it then is) waits for the read, lands on its block, then a frame later at its share of the block's height; `reveal` is such an ask with the caret at its line; a switch between source and the list keeps the top line, one between visual and preview keeps the list's scroll as it is. A block opens only when the read is of the text as it is now. A pressed block keeps its identity across the write-back of the one before it, and opens with the caret on the pressed character: each drawn piece keeps its written span and how it decodes (Markdown's escapes and references for text, as markdown-rs decodes them, autolink text as written, HTML's references for an HTML block's runs, none for code spans but a table cell's `\|`, cut as CommonMark cuts their end spaces), and is matched from its end, an equal character first, a drawn space next against a line end, decoded or not, so quote marks and indents at a line's start are never taken for text and an alert's text reads past its marker; text before an emoji lands at its piece's start; code, front matter, a link definition and an HTML block's runs read back line for line, LF, CRLF and CR alike, each drawn line ending its written one, a tab cut to spaces landing on the tab; a block's drawn positions count from its own start, so an edit before it keeps it alike. An open block stays open while a menu or dialog borrows focus, so the overlay's command reaches it with its selection; it writes back once the overlay lets focus go anywhere else (`primitives::lent`). A block dropped while it holds focus, as an edit past it drops it, hands focus to the document, so its keys still reach the document. A secondary press on a drawn block opens nothing, an empty document's open on focus included: it writes back an open block, refuses the press if that changed the pressed block's kind, text or drawing (a definition's new address too), moves the caret there and emits `DocumentEvent::Pointed`, whose `DocumentTarget` names the block's kind, a link's or picture's address, and whether it lies in a table, and the text of the code block it lies in. One in the open block keeps it open with its selection and emits the same target for the pressed byte, not the selection, read apart as drawing reads it (an inline anchor's address, a definition elsewhere for a reference); it is dropped if the text changed meanwhile or a later press came first. A text field and the code editor keep a selection a right press lands in, else put the caret there, as browsers do. `MarkdownDocument::delete_image(at)` takes the picture at a target's byte away in one step, refused elsewhere as `EditRefusal::NoImage` (Typora's Delete Image; `InlineFormat::Image` keeps the alt text). In visual mode with no block open, a paste that brings no picture puts the clipboard's text at the caret. `MarkdownDocument::format` toggles an `InlineFormat` at the selection as Typora's toggle does: inside that style it comes off; a selection is wrapped, its end spaces left out and the same style it partly covers taken in; a caret takes its word, or an empty pair between stop characters or on a blank line; `Clear` takes every inline mark off the blocks it touches, inline math's `$`, footnote brackets and escape backslashes too, an autolink's kept as written; code, math, HTML and front matter refuse, as does an end inside a node's marks or a link address, or a selection crossing a block, a link or another mark (`EditRefusal`); the toggle always reads off the UI thread, one at a time (another refuses as `Busy`), and lands only if text and selection are unchanged and preview is not shown, a refusal met there coming as `DocumentEvent::EditRefused`; past the largest read it refuses; in an open block it edits the field, which stays open, and joins its one write-back step. `MarkdownDocument::block` runs a `BlockCommand` at the caret as Typora's Paragraph menu: a heading level (the same level keeps it, as Typora's menu calls it), paragraph, promote and demote (a paragraph promotes to 6, 6 demotes to a paragraph) take the caret's line alone, a setext heading past level 2 going ATX, a closing `#` run or a block opener left bare escaped so the text stays; math, HTML or a table take an empty paragraph after instead, code refuses; quote and list toggles unwrap the holder (an alert's marker goes, an outer opener stays), retype a list of another kind with its lines re-indented, task boxes and code indent kept, or make each paragraph line an item, a selection past a list taking each touched sibling; rule, code, math, `[toc]`, footnote and link definition split the paragraph at the caret, bold, italic and strike closed before and reopened after, a link, code, math or HTML refusing; front matter goes on top once; an empty line comes before (a list item's or footnote's text moving below its opener) or after; delete takes the block, or the holder of a lone paragraph, a blank line kept where neighbors would join; a task's first text, which GFM reads as text, takes no block. Edits keep each line's container prefix, put one on lazy lines that must stay, and add a blank line where text would join. A heading change or split is parsed again before it is written and read through `MarkdownReader`, as the drawing reads it, white space counting only in code and math: if any shown character or inline format would change, or the block would read as another kind, it refuses as `EditRefusal::Misread`; a task list over code refuses as `Code`, and an alert whose marker, read as the alert reader reads it, shares its line as `Misread`; an alert's body block keeps its indent; a fence is told by its source, not its text; a task box taken off escapes the block opener it bares, on the marker's line or below. `MarkdownDocument::edit_table` adds, deletes and moves rows and columns as Typora's table menu (the head row trades places with the first, a row with cells past the head's columns never becomes the head, an edge refuses as `TableRefusal::Edge`), tidies, and deletes the whole table as `BlockCommand::Delete` does, outside a table refusing as `TableRefusal::Outside`; every table it writes pads each column to its display width, 4 to 60, through `unicode-width` 0.2 (MIT OR Apache-2.0), a tab counting 4 and a control character 0 as Typora's `wcswidth` counts them, in the table's own line ending, as Typora's `reformatTable`, its refusals coming as `EditRefusal::Cells`. Formats, block commands and table edits share one runner and its `EditRefusal`, `DocumentEvent::EditRefused` and one-at-a-time rule: an edit inside the open block edits its field; one past it writes the block back first, a second undo step. `CodeEditor` splits lines on LF alone, so End can leave the caret between CR and LF; edits and a new table read it before the CR. Blocks are Tab stops that Enter or Space opens; preview takes no presses, undo or redo. `CodeEditor` keeps at most 64 MiB of undo and redo text, dropping the oldest undo and then the farthest redo after every record, composition, undo and redo. `BlockData::from_markdown` and its export still read with `pulldown-cmark` 0.13 (MIT, default features off); it adds GitHub's alerts, a callout's form, and reads each field's inline Markdown from the source by offset, as written. +- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `MarkdownRenderer` draws display math through `typography::Latex`, a ```` ```mermaid ```` block through `documents::Diagram`, and pictures from the owner's folder (`base`) or `data:` addresses (`base64` 0.22, MIT OR Apache-2.0); web pictures stay unloaded. Inline math shows as TeX: gpui text carries no inline pictures. Inline it reads as Typora's defaults: `:shortcode:` turns to its emoji as GitHub reads it (the `emojis` crate's names, GitHub's today, so `:cricket:` is the insect; read after escapes and references, so `\:smile:` turns too; inside an autolink or `` it stays text; within one DOM text node, which filtered tags do not split, the first `:name:`-shaped run decides for all: blank text or a space before it turns every shortcode, else none, so `x:smile: :smile:` stays), inline HTML's `u`, `b`/`strong`, `i`/`em`, `s`/`del`/`strike`, `code`/`kbd`, `mark`, `br` and `a href` draw as what they mean GFM's filtered tags (`script`, `style`, `title`, `textarea`, `iframe`, `xmp`, `noembed`, `noframes`, `plaintext`) show as text, and other tags, hyphenated names too, vanish, leaving their text; a tag's name runs to white space or `/`, as HTML5 reads it; attribute values split on ASCII whitespace alone and decode as HTML decodes them; each Markdown container is the element GitHub writes (`em`, `strong`, `del`, `a`), on one stack with the tags and repaired as HTML5 repairs: an end tag closes the innermost element of its name and all above it, of which only formatting elements (`a`, `b`, `code`, `em`, `i`, `s`, `strike`, `strong`, `u`) reopen, a new `a` ends the open one and an `a` without `href` links nothing; other tags are not tracked, and `
` breaks as `
` does; and a footnote reference is its number in superscript, numbered by first reference, as its definition is; one `~` still strikes. At the root, blocks read as GitHub shows them: a quote whose first line is plain text reading `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` in any case, after escapes and references, spaces and tabs around it, with more after it, is an alert under its title (Important in the palette's violet, `syntax.keyword`, read at 4.5:1); a paragraph that is `[toc]` alone, as Typora reads it, lists every heading by depth, and a press on one reveals it as a link press does (`MarkdownDocument`'s `reveal`), the heading found where `CodeEditor` anchors carried it through each edit since the read, gone if an edit or an undo took it, and a secondary press acts on press, since a block writing back moves the list; front matter shows in a box without its fences; an HTML block shows its text with the inline tags above, white space collapsed and owed as a browser lays it, block elements breaking lines and closing what opened inside them, HTML5's start tags ending an open `p`, `fieldset` and `menu` too, and an `li` start an `li`, a `dd` or `dt` start either, as HTML5 ends them, comments dropped, the filtered tags shown as text, and an unclosed tag dropping the rest; a rule is a 2px `border_strong` line; bullets go •, ◦, ▪ by list depth, ordered lists counted. Exports read a tree as the drawing does through `MarkdownReader` (`block` gives its `MarkdownBlock`s, `inline` its text, `note` and `label` its footnotes, `headings` the `[toc]` list), one fresh stack per paragraph, heading or cell. `MarkdownDocument` holds a whole file in one `CodeEditor` (text and undo, colored by `CodeEditor::highlighter` with `markdown_highlights`): visual draws the file's top-level blocks in a gpui `list`, a pressed block opening as its source in a `TextInput` whose typing is the document's text at once and whose write-back is one undo step; a press in a table opens that cell alone, in place, as its source in a one-line field (a press on its text at the pressed character, past it at its end; a table block opened by its edge, the keyboard or a step from a neighbor opens its first or last cell), Tab and Shift-Tab going to the next and previous cell, Up and Down to the cell above and below or out of the table, Enter and Escape writing it back, a bare `|` typed in it escaped as the cell keeps it, a trailing backslash doubled only when a pipe follows at once, an emptied edge cell of a row without that outer pipe written as one so its column stays, the caret read through the same escaping, a padding press on the open cell keeping its typing, and a table edit writing it back before it runs; source is the editor; preview draws without presses. A written-back block, a mode switch or a new text is read at once up to 128 KiB, longer ones on the background executor; typing in source or split reads 300 ms after the last key, off the main thread (markdown-rs grows with the square of its nodes, not its bytes: in release, 128 KiB of one-letter paragraphs takes 1.7 s, 16 KiB 42 ms, while 128 KiB of prose takes 20 ms; a block-heavy text under the cutoff can still hold the UI thread past 100 ms, a known limit). Texts past `LARGEST_READ` (2 MiB) are never read: no blocks, no outline. A new read keeps the list's blocks alike at both ends. One caret serves every mode: the editor's selection, which a written-back block moves; `set_caret` and `set_top_line` set caret and view apart; a top line asked (numbered, so a late frame callback finishes only its own ask; new text places it again, clamped to the text as it then is) waits for the read, lands on its block, then a frame later at its share of the block's height; `reveal` is such an ask with the caret at its line; a switch between source and the list keeps the top line, one between visual and preview keeps the list's scroll as it is. A block opens only when the read is of the text as it is now. A pressed block keeps its identity across the write-back of the one before it, and opens with the caret on the pressed character: each drawn piece keeps its written span and how it decodes (Markdown's escapes and references for text, as markdown-rs decodes them, autolink text as written, HTML's references for an HTML block's runs, none for code spans but a table cell's `\|`, cut as CommonMark cuts their end spaces), and is matched from its end, an equal character first, a drawn space next against a line end, decoded or not, so quote marks and indents at a line's start are never taken for text and an alert's text reads past its marker; text before an emoji lands at its piece's start; code, front matter, a link definition and an HTML block's runs read back line for line, LF, CRLF and CR alike, each drawn line ending its written one, a tab cut to spaces landing on the tab; a block's drawn positions count from its own start, so an edit before it keeps it alike. An open block stays open while a menu or dialog borrows focus, so the overlay's command reaches it with its selection; it writes back once the overlay lets focus go anywhere else (`primitives::lent`). A block dropped while it holds focus, as an edit past it drops it, hands focus to the document, so its keys still reach the document. A secondary press on a drawn block opens nothing, an empty document's open on focus included: it writes back an open block, refuses the press if that changed the pressed block's kind, text or drawing (a definition's new address too), moves the caret there and emits `DocumentEvent::Pointed`, whose `DocumentTarget` names the block's kind, a link's or picture's address, and whether it lies in a table, and the text of the code block it lies in. One in the open block keeps it open with its selection and emits the same target for the pressed byte, not the selection, read apart as drawing reads it (an inline anchor's address, a definition elsewhere for a reference); it is dropped if the text changed meanwhile or a later press came first. A text field and the code editor keep a selection a right press lands in, else put the caret there, as browsers do. `MarkdownDocument::delete_image(at)` takes the picture at a target's byte away in one step, refused elsewhere as `EditRefusal::NoImage` (Typora's Delete Image; `InlineFormat::Image` keeps the alt text). In visual mode with no block open, a paste that brings no picture puts the clipboard's text at the caret. `MarkdownDocument::format` toggles an `InlineFormat` at the selection as Typora's toggle does: inside that style it comes off; a selection is wrapped, its end spaces left out and the same style it partly covers taken in; a caret takes its word, or an empty pair between stop characters or on a blank line; `Clear` takes every inline mark off the blocks it touches, inline math's `$`, footnote brackets and escape backslashes too, an autolink's kept as written; code, math, HTML and front matter refuse, as does an end inside a node's marks or a link address, or a selection crossing a block, a link or another mark (`EditRefusal`); the toggle always reads off the UI thread, one at a time (another refuses as `Busy`), and lands only if text and selection are unchanged and preview is not shown, a refusal met there coming as `DocumentEvent::EditRefused`; past the largest read it refuses; in an open block it edits the field, which stays open, and joins its one write-back step. `MarkdownDocument::block` runs a `BlockCommand` at the caret as Typora's Paragraph menu: a heading level (the same level keeps it, as Typora's menu calls it), paragraph, promote and demote (a paragraph promotes to 6, 6 demotes to a paragraph) take the caret's line alone, a setext heading past level 2 going ATX, a closing `#` run or a block opener left bare escaped so the text stays; math, HTML or a table take an empty paragraph after instead, code refuses; quote and list toggles unwrap the holder (an alert's marker goes, an outer opener stays), retype a list of another kind with its lines re-indented, task boxes and code indent kept, or make each paragraph line an item, a selection past a list taking each touched sibling; rule, code, math, `[toc]`, footnote and link definition split the paragraph at the caret, bold, italic and strike closed before and reopened after, a link, code, math or HTML refusing; front matter goes on top once; an empty line comes before (a list item's or footnote's text moving below its opener) or after; delete takes the block, or the holder of a lone paragraph, a blank line kept where neighbors would join; a task's first text, which GFM reads as text, takes no block. Edits keep each line's container prefix, put one on lazy lines that must stay, and add a blank line where text would join. A heading change or split is parsed again before it is written and read through `MarkdownReader`, as the drawing reads it, white space counting only in code and math: if any shown character or inline format would change, or the block would read as another kind, it refuses as `EditRefusal::Misread`; a task list over code refuses as `Code`, and an alert whose marker, read as the alert reader reads it, shares its line as `Misread`; an alert's body block keeps its indent; a fence is told by its source, not its text; a task box taken off escapes the block opener it bares, on the marker's line or below. `MarkdownDocument::edit_table` adds, deletes and moves rows and columns as Typora's table menu (the head row trades places with the first, a row with cells past the head's columns never becomes the head, an edge refuses as `TableRefusal::Edge`), tidies, and deletes the whole table as `BlockCommand::Delete` does, outside a table refusing as `TableRefusal::Outside`; every table it writes pads each column to its display width, 4 to 60, through `unicode-width` 0.2 (MIT OR Apache-2.0), a tab counting 4 and a control character 0 as Typora's `wcswidth` counts them, in the table's own line ending, as Typora's `reformatTable`, its refusals coming as `EditRefusal::Cells`. Formats, block commands and table edits share one runner and its `EditRefusal`, `DocumentEvent::EditRefused` and one-at-a-time rule: an edit inside the open block edits its field; one past it writes the block back first, a second undo step. `CodeEditor` splits lines on LF alone, so End can leave the caret between CR and LF; edits and a new table read it before the CR. Blocks are Tab stops that Enter or Space opens; preview takes no presses, undo or redo. `CodeEditor` keeps at most 64 MiB of undo and redo text, dropping the oldest undo and then the farthest redo after every record, composition, undo and redo. `BlockData::from_markdown` and its export still read with `pulldown-cmark` 0.13 (MIT, default features off); it adds GitHub's alerts, a callout's form, and reads each field's inline Markdown from the source by offset, as written. - Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames and rims map tiles. - Math and diagrams: RaTeX 0.1.14 (`ratex-parser`, `ratex-layout`, `ratex-svg` with `embed-fonts`, `ratex-types`; MIT, KaTeX's fonts OFL) typesets `Latex` to SVG with outlined glyphs; `mermaid-rs-renderer` 0.3.1 (MIT, default features off) draws `Diagram`. Both go through gpui's `SvgRenderer` at the window's scale, loaded as gpui assets off the main thread; `usvg` 0.46, gpui's own, reads a diagram's size. A RaTeX or Mermaid panic on parsed input becomes the element's error. Each element holds its picture through `primitives::held`: a new source, or the element leaving the window, removes the old asset from gpui's cache and drops its image from the atlas after the frame. - Maps: Natural Earth's 110m countries (public domain). `assets/maps/countries.geojson`, trimmed by `scripts/countries.py` to an ISO code, a name and positions to 0.01°, backs `maps::WorldMap`; the gallery's tiles are cut from the same file, z0 to z3, by `scripts/tiles.py` with Pillow. @@ -88,7 +88,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - 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. - A palette goes through `navigation::palette::Palette`: query field, grouped rows, cursor, keys and focus live there. CommandPalette, QuickOpen, QuickSwitcher, SearchPalette and QuickLauncher only build rows. -- A menu goes through `menus::menu`: rows, submenus, keys, focus and presses outside live there, for DropdownMenu, OverflowMenu, SplitButton, ContextMenu, SearchableMenu and MenuBar. A press on the host's own box is the host's to handle, so a trigger toggles and a right click moves a context menu without a close and reopen in one event. A row of icon buttons (`Menu::buttons`) is one entry: Up and Down reach it, Left and Right cross it, a reader presses a button through Click and hears the marked one, and a mark holds its item by label, so a row that changes under it runs nothing unasked. +- A menu goes through `menus::menu`: rows, submenus, keys, focus and presses outside live there, for DropdownMenu, OverflowMenu, SplitButton, ContextMenu, SearchableMenu and MenuBar. A press on the host's own box is the host's to handle, so a trigger toggles and a right click moves a context menu without a close and reopen in one event. A row of icon buttons (`Menu::buttons`) is one entry: Up and Down reach it, Left and Right cross it, a reader presses a button through Click and hears the marked one, and a mark holds its item by label, so a row that changes under it runs nothing unasked. Panels occlude what lies under them. A submenu that does not fit on the right opens on the left of its row, sliding in from that side. A row's submenu opens once the pointer rests on it 150 ms; while a submenu is open, a row crossed inside the triangle from the last pointer place to the submenu's near corners waits 300 ms and switches only if the pointer still rests there (`menus::aim`); a row outside it marks at once. Keys and presses open at once. - gpui focuses the nearest focusable ancestor on mouse down, and apps wrap everything in a `FocusScope`. A press that must leave focus alone calls `prevent_default`, as buttons and context regions do. - gpui runs the click of every pressed element under the pointer, a row's with its button's. A pressable row that holds buttons stops their press at their box on mouse down (`files::transfer`), as `overlays::media_button` stops its own. - gpui draws a window's first frame inside `open_window`. State the first frame reads, such as `cx.set_menus`, is set before it opens. diff --git a/src/menus/aim.rs b/src/menus/aim.rs new file mode 100644 index 0000000..624b39e --- /dev/null +++ b/src/menus/aim.rs @@ -0,0 +1,120 @@ +//! Hover intent: open after a pause, hold while aiming at a submenu. + +use std::time::Duration; + +use gpui::{App, Bounds, Entity, Pixels, Point}; + +use super::{ + menu::{Open, mark}, + model::Menu, +}; + +/// A submenu row opens after the pointer rests this long. +pub(super) const OPEN: Duration = Duration::from_millis(150); +/// A row crossed toward an open submenu waits this long. +pub(super) const HOLD: Duration = Duration::from_millis(300); + +/// The pointer moved over row `ix` at `depth`, a submenu row when `sub`. +pub(super) fn hover( + state: &Entity, + menu: &Menu, + (depth, ix): (usize, usize), + sub: bool, + at: Point, + cx: &mut App, +) { + let seen = state.update(cx, |open, _| { + let from = open.pointer.replace(at); + open.over = Some((depth, ix)); + let level = open.levels.get(depth)?; + let shown = (open.levels.get(depth + 1)).map(|level| level.panel); + Some((from, level.at, shown)) + }); + let Some((from, marked, shown)) = seen else { + return; + }; + if marked == Some(ix) { + if !sub || shown.is_some() { + state.update(cx, |open, _| open.pending = None); + } else if state.read(cx).pending.is_none() { + later(state, menu, (depth, ix), OPEN, cx); + } + return; + } + let aimed = from + .zip(shown.filter(|panel| *panel != Bounds::default())) + .is_some_and(|(from, panel)| toward(from, at, panel)); + if aimed { + log::debug!("menu: row {ix} of level {depth} waits on the aim"); + return later(state, menu, (depth, ix), HOLD, cx); + } + state.update(cx, |open, _| open.pending = None); + mark(state, menu, depth, (ix, 0), false, cx); + if sub { + later(state, menu, (depth, ix), OPEN, cx); + } +} + +/// Marks row `ix` after `wait`, its submenu open, if still under the pointer. +fn later( + state: &Entity, + menu: &Menu, + (depth, ix): (usize, usize), + wait: Duration, + cx: &mut App, +) { + let (state_, menu) = (state.clone(), menu.clone()); + let task = cx.spawn(async move |cx| { + cx.background_executor().timer(wait).await; + cx.update(|cx| { + if state_.read(cx).over != Some((depth, ix)) { + return log::debug!("menu: the pointer left row {ix} before it opened"); + } + state_.update(cx, |open, _| open.pending = None); + mark(&state_, &menu, depth, (ix, 0), true, cx); + }); + }); + state.update(cx, |open, _| open.pending = Some(task)); +} + +/// Whether `to` lies in the triangle from `from` to `panel`'s near edge. +pub(super) fn toward(from: Point, to: Point, panel: Bounds) -> bool { + let near = if panel.left() >= from.x { + panel.left() + } else { + panel.right() + }; + let (top, bottom) = ( + Point::new(near, panel.top()), + Point::new(near, panel.bottom()), + ); + let side = |a: Point, b: Point| { + f32::from(b.x - a.x) * f32::from(to.y - a.y) - f32::from(b.y - a.y) * f32::from(to.x - a.x) + }; + let sides = [side(from, top), side(top, bottom), side(bottom, from)]; + sides.iter().all(|side| *side >= 0.0) || sides.iter().all(|side| *side <= 0.0) +} + +#[cfg(test)] +mod tests { + use gpui::{Bounds, point, px, size}; + + use super::toward; + + #[test] + fn the_aim_holds_toward_the_panel_on_either_side() { + let right = Bounds::new(point(px(200.), px(0.)), size(px(100.), px(200.))); + let from = point(px(100.), px(100.)); + assert!(toward(from, point(px(110.), px(98.)), right)); + assert!( + !toward(from, point(px(100.), px(130.)), right), + "straight down" + ); + let left = Bounds::new(point(px(0.), px(0.)), size(px(50.), px(200.))); + assert!(toward(from, point(px(90.), px(102.)), left)); + assert!( + !toward(from, point(px(110.), px(100.)), left), + "away from it" + ); + } +} diff --git a/src/menus/draw.rs b/src/menus/draw.rs index b9cc473..4958240 100644 --- a/src/menus/draw.rs +++ b/src/menus/draw.rs @@ -1,13 +1,14 @@ use std::rc::Rc; use gpui::{ - AnyElement, App, Div, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, - MouseButton, ParentElement, Role, SharedString, Stateful, Styled, accesskit::Action, div, - prelude::*, + AnyElement, App, Bounds, Div, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, + MouseButton, ParentElement, Pixels, Role, SharedString, Stateful, Styled, accesskit::Action, + canvas, div, prelude::*, }; use super::{ - menu::{Open, at_depth, choose, mark, measure}, + aim, + menu::{Open, at_depth, choose, mark}, model::{Entry, Kind, Menu, MenuItem}, }; use crate::{ @@ -195,6 +196,9 @@ pub(super) fn panel( let drawn = button(item, (ix, column), current, press, cx); drawn.when(!item.disabled, |button| { button.on_mouse_move(move |_, _, cx| { + hover.update(cx, |open, _| { + (open.over, open.pending) = (Some((depth, ix)), None); + }); let open = hover.read(cx); let marked = open.levels.get(depth).map(|l| (l.at, l.column)); if marked != Some((Some(ix), column)) { @@ -210,17 +214,12 @@ pub(super) fn panel( let (hover, click, close, menu) = (state.clone(), state.clone(), close.clone(), menu.clone()); let hover_menu = menu.clone(); + let label = item.label.clone(); row(item, ix, marked, cx) + .debug_selector(move || format!("menu-row-{label}")) .when(!item.disabled, |row| { - row.on_mouse_move(move |_, _, cx| { - let open = hover.read(cx); - let Some(level) = open.levels.get(depth) else { - return; - }; - let shown = open.levels.len() > depth + 1; - if level.at != Some(ix) || (sub && !shown) { - mark(&hover, &hover_menu, depth, (ix, 0), true, cx); - } + row.on_mouse_move(move |event, _, cx| { + aim::hover(&hover, &hover_menu, (depth, ix), sub, event.position, cx) }) .on_mouse_down( MouseButton::Left, @@ -242,9 +241,20 @@ pub(super) fn panel( .into_any_element() } }); + let left = state.clone(); surface((id.clone(), format!("panel-{depth}")), cx) .role(Role::Menu) + .debug_selector(move || format!("menu-panel-{depth}")) .relative() + // Rows under this panel take no hover. + .occlude() + .on_mouse_move(move |_, _, cx| { + left.update(cx, |open, _| { + if open.over.is_some_and(|(over, _)| over != depth) { + open.over = None; + } + }) + }) // Presses here are the menu's, not its host's. .on_mouse_down(MouseButton::Left, |_, window, cx| { window.prevent_default(); @@ -263,3 +273,23 @@ pub(super) fn panel( changed })) } + +/// Records a box into the host's state: the host's own, a marked row's or a panel's. +pub(super) fn measure( + state: Entity, + write: impl Fn(&mut Open, Bounds) -> bool + 'static, +) -> impl IntoElement { + canvas( + move |bounds, window, cx| { + if state.update(cx, |open, _| write(open, bounds)) { + state.update(cx, |_, cx| cx.notify()); + window.request_animation_frame(); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full() +} diff --git a/src/menus/menu.rs b/src/menus/menu.rs index 29d01db..57f9d0e 100644 --- a/src/menus/menu.rs +++ b/src/menus/menu.rs @@ -1,13 +1,13 @@ use std::rc::Rc; use gpui::{ - Animation, AnimationExt, AnyElement, App, Bounds, ElementId, Entity, InteractiveElement, - IntoElement, ParentElement, Pixels, Point, SharedString, Styled, Window, anchored, canvas, div, - prelude::*, + Anchor, Animation, AnimationExt, AnyElement, App, Bounds, ElementId, Entity, + InteractiveElement, IntoElement, ParentElement, Pixels, Point, SharedString, Styled, Task, + Window, anchored, div, prelude::*, }; use super::{ - draw, + draw::{self, measure}, model::{Kind, Menu}, }; use crate::{ @@ -80,6 +80,12 @@ pub(crate) struct Open { host: Bounds, anchor: Bounds, takeover: Option>, + /// The pointer's last place over a row. + pub(super) pointer: Option>, + /// The row under the pointer: depth and index. + pub(super) over: Option<(usize, usize)>, + /// A delayed mark; dropping it cancels. + pub(super) pending: Option>, } impl Open { @@ -121,6 +127,7 @@ impl Open { let takeover = state.update(cx, |open, cx| { open.spot = None; open.levels.clear(); + (open.pointer, open.over, open.pending) = (None, None, None); cx.notify(); open.takeover.take() }); @@ -204,6 +211,7 @@ pub(super) fn choose( if item.disabled { return; } + state.update(cx, |open, _| open.pending = None); if let Kind::Sub(sub) = &item.kind { let first = first_of(sub, sub.step(None, 1)); mark(state, menu, depth, (at, column), false, cx); @@ -236,6 +244,7 @@ fn keys( let Some(here) = at_depth(menu, &levels, depth) else { return false; }; + state.update(cx, |open, _| open.pending = None); let (at, column) = (levels[depth].at, levels[depth].column); let sub = at .and_then(|at| here.item_at(at, column)) @@ -278,26 +287,6 @@ fn on_buttons(menu: &Menu, state: &Entity, cx: &App) -> bool { .is_some_and(|(here, ix)| here.is_buttons(ix)) } -/// Records a box into the host's state: the host's own, a marked row's or a panel's. -pub(super) fn measure( - state: Entity, - write: impl Fn(&mut Open, Bounds) -> bool + 'static, -) -> impl IntoElement { - canvas( - move |bounds, window, cx| { - if state.update(cx, |open, _| write(open, bounds)) { - state.update(cx, |_, cx| cx.notify()); - window.request_animation_frame(); - } - }, - |_, _, _, _| {}, - ) - .absolute() - .top_0() - .left_0() - .size_full() -} - /// Measures the host's box: presses on it are the host's, and when `hangs_under` the menu hangs under it. pub(crate) fn measure_host(state: Entity, hangs_under: bool) -> impl IntoElement { measure(state, move |open, bounds| { @@ -464,20 +453,38 @@ pub(crate) fn hang( .with_priority(1) .into_any_element(), }; + let room = window.viewport_size().width; let subs = (1..depth).filter_map(|level| { - let row = state.read(cx).levels[level - 1].row; + let open = state.read(cx); + let row = open.levels[level - 1].row; + let width = match open.levels[level].panel.size.width { + width if width > Pixels::ZERO => width, + _ => cx.theme().menu_width().to_pixels(window.rem_size()), + }; + // No room on the right: open on the left, over nothing. + let left = row.right() + width > room && row.left() >= width; + let (corner, from, slide) = match left { + true => (Anchor::TopRight, row.origin, -1.0), + false => (Anchor::TopLeft, row.top_right(), 1.0), + }; (row != Bounds::default()).then(|| { raise( (id.clone(), format!("level-{level}")), - anchored().position(row.top_right()).snap_to_window().child( - draw::panel(id, menu, state, level, &close, None, cx) - .mt_neg_1() - .with_animation( - (id.clone(), format!("sub-{level}")), - enter.clone(), - |panel, t| panel.opacity(t).ml(motion::NUDGE * (t - 1.0)), - ), - ), + anchored() + .anchor(corner) + .position(from) + .snap_to_window() + .child( + draw::panel(id, menu, state, level, &close, None, cx) + .mt_neg_1() + .with_animation( + (id.clone(), format!("sub-{level}")), + enter.clone(), + move |panel, t| { + panel.opacity(t).left(motion::NUDGE * (t - 1.0) * slide) + }, + ), + ), ) .with_priority(2) .into_any_element() diff --git a/src/menus/mod.rs b/src/menus/mod.rs index d16405e..21bdd53 100644 --- a/src/menus/mod.rs +++ b/src/menus/mod.rs @@ -1,3 +1,4 @@ +mod aim; mod bar; mod draw; mod hosts; diff --git a/src/menus/tests/aim.rs b/src/menus/tests/aim.rs new file mode 100644 index 0000000..c1f753a --- /dev/null +++ b/src/menus/tests/aim.rs @@ -0,0 +1,167 @@ +//! Hover intent: delayed submenus, the aim triangle, flips, occlusion. + +use gpui::{ + Bounds, Context, FocusHandle, IntoElement, Modifiers, MouseButton, ParentElement, Pixels, + Render, Styled, TestAppContext, VisualTestContext, Window, div, point, px, size, +}; + +use super::settle; +use crate::{ + menus::{ContextMenu, Menu, MenuItem, aim}, + primitives::FocusScope, + theme::Theme, +}; + +/// A region whose context menu has two submenus and a plain row. +struct Region { + root: FocusHandle, +} + +impl Render for Region { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let insert = Menu::new() + .item(MenuItem::new("Row above")) + .item(MenuItem::new("Row below")) + .item(MenuItem::new("Column left")); + let delete = Menu::new() + .item(MenuItem::new("Delete row")) + .item(MenuItem::new("Delete column")); + let menu = Menu::new() + .item(MenuItem::submenu("Insert", insert)) + .item(MenuItem::submenu("Delete", delete)) + .item(MenuItem::new("Plain")); + FocusScope::new(&self.root).size_full().child( + ContextMenu::new("region", menu) + .size_full() + .child(div().size_full()), + ) + } +} + +/// A region in a window `width` wide, its menu opened at `x`. +fn region(width: f32, x: f32, cx: &mut TestAppContext) -> &mut VisualTestContext { + cx.update(|cx| { + Theme::init(cx); + crate::forms::bind_keys(cx); + }); + let (_, cx) = cx.add_window_view(|_, cx| Region { + root: cx.focus_handle(), + }); + cx.simulate_resize(size(px(width), px(600.0))); + settle(cx); + let at = point(px(x), px(20.0)); + cx.simulate_mouse_move(at, None, Modifiers::none()); + cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none()); + cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none()); + settle(cx); + cx +} + +fn bounds(name: &str, cx: &mut VisualTestContext) -> Option> { + cx.debug_bounds(Box::leak(name.to_string().into_boxed_str())) +} + +fn row(label: &str, cx: &mut VisualTestContext) -> Bounds { + bounds(&format!("menu-row-{label}"), cx).expect("the row is drawn") +} + +fn shown(label: &str, cx: &mut VisualTestContext) -> bool { + bounds(&format!("menu-row-{label}"), cx).is_some() +} + +fn move_to(at: gpui::Point, cx: &mut VisualTestContext) { + cx.simulate_mouse_move(at, None, Modifiers::none()); + settle(cx); +} + +fn wait(time: std::time::Duration, cx: &mut VisualTestContext) { + cx.executor().advance_clock(time); + settle(cx); +} + +/// Opens Insert's submenu by resting on it. +fn rest_on_insert(cx: &mut VisualTestContext) { + move_to(row("Insert", cx).center(), cx); + wait(aim::OPEN, cx); + assert!(shown("Row above", cx), "Insert's submenu opened"); +} + +#[gpui::test] +fn a_submenu_opens_after_the_pointer_rests(cx: &mut TestAppContext) { + let cx = region(1000.0, 40.0, cx); + move_to(row("Insert", cx).center(), cx); + assert!(!shown("Row above", cx), "not yet"); + wait(aim::OPEN, cx); + assert!(shown("Row above", cx)); +} + +#[gpui::test] +fn aiming_at_the_submenu_holds_it_across_rows(cx: &mut TestAppContext) { + let cx = region(1000.0, 40.0, cx); + rest_on_insert(cx); + let delete = row("Delete", cx); + move_to(point(delete.right() - px(16.0), delete.center().y), cx); + assert!( + shown("Row above", cx), + "crossing Delete keeps Insert's submenu" + ); + wait(aim::HOLD / 2, cx); + assert!(shown("Row above", cx)); + move_to(row("Row below", cx).center(), cx); + wait(aim::HOLD, cx); + assert!(shown("Row above", cx), "the submenu was reached"); + assert!(!shown("Delete row", cx)); +} + +#[gpui::test] +fn resting_on_a_crossed_row_switches_after_the_hold(cx: &mut TestAppContext) { + let cx = region(1000.0, 40.0, cx); + rest_on_insert(cx); + let delete = row("Delete", cx); + move_to(point(delete.right() - px(16.0), delete.center().y), cx); + wait(aim::HOLD, cx); + assert!(shown("Delete row", cx), "Delete's submenu replaced it"); + assert!(!shown("Row above", cx)); +} + +#[gpui::test] +fn moving_away_from_the_submenu_switches_at_once(cx: &mut TestAppContext) { + let cx = region(1000.0, 40.0, cx); + rest_on_insert(cx); + let insert = row("Insert", cx).center(); + move_to(point(insert.x, row("Delete", cx).center().y), cx); + assert!(!shown("Row above", cx), "straight down left Insert"); + wait(aim::OPEN, cx); + assert!(shown("Delete row", cx)); +} + +#[gpui::test] +fn a_submenu_without_room_on_the_right_opens_on_the_left(cx: &mut TestAppContext) { + let cx = region(800.0, 790.0, cx); + rest_on_insert(cx); + let parent = bounds("menu-panel-0", cx).expect("the menu"); + let sub = bounds("menu-panel-1", cx).expect("the submenu"); + // It meets the row's edge, as on the right, sliding in. + let edge = row("Insert", cx).left() + crate::motion::NUDGE; + assert!(sub.left() < parent.left(), "{sub:?} left of {parent:?}"); + assert!(sub.right() <= edge, "{sub:?} leaves the rows of {parent:?}"); +} + +#[gpui::test] +fn rows_under_a_submenu_take_no_hover(cx: &mut TestAppContext) { + let cx = region(320.0, 10.0, cx); + rest_on_insert(cx); + let (plain, sub) = ( + row("Plain", cx), + bounds("menu-panel-1", cx).expect("submenu"), + ); + assert!(sub.left() < plain.right(), "the submenu covers the menu"); + let under = point((sub.left() + plain.right()) / 2.0, plain.center().y); + assert!(sub.contains(&under), "the point is on the submenu"); + move_to(under, cx); + wait(aim::HOLD + aim::OPEN, cx); + assert!( + shown("Row above", cx), + "Plain under the submenu stayed unmarked" + ); +} diff --git a/src/menus/tests/mod.rs b/src/menus/tests/mod.rs index f5a35ad..840097a 100644 --- a/src/menus/tests/mod.rs +++ b/src/menus/tests/mod.rs @@ -4,6 +4,7 @@ use gpui::{ Window, div, point, prelude::*, px, }; +mod aim; mod buttons; mod more;