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) <noreply@anthropic.com>
This commit is contained in:
1 parent
f1771b3880
commit
d4fdb694ef
7 files changed
+375
-49
No files matched your search
@@ -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 `<code>` 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 `</br>` breaks as `<br>` 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 keeLine truncated
|
||||
- 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 `<code>` 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 `</br>` breaks as `<br>` 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 paragraLine truncated
|
||||
- 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.
|
||||
|
||||
@@ -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<Open>,
|
||||
menu: &Menu,
|
||||
(depth, ix): (usize, usize),
|
||||
sub: bool,
|
||||
at: Point<Pixels>,
|
||||
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<Open>,
|
||||
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<Pixels>, to: Point<Pixels>, panel: Bounds<Pixels>) -> 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<Pixels>, b: Point<Pixels>| {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
+43
-13
@@ -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<Open>,
|
||||
write: impl Fn(&mut Open, Bounds<Pixels>) -> 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()
|
||||
}
|
||||
+41
-34
@@ -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<Pixels>,
|
||||
anchor: Bounds<Pixels>,
|
||||
takeover: Option<Entity<Takeover>>,
|
||||
/// The pointer's last place over a row.
|
||||
pub(super) pointer: Option<Point<Pixels>>,
|
||||
/// The row under the pointer: depth and index.
|
||||
pub(super) over: Option<(usize, usize)>,
|
||||
/// A delayed mark; dropping it cancels.
|
||||
pub(super) pending: Option<Task<()>>,
|
||||
}
|
||||
|
||||
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<Open>, 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<Open>,
|
||||
write: impl Fn(&mut Open, Bounds<Pixels>) -> 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<Open>, 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()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod aim;
|
||||
mod bar;
|
||||
mod draw;
|
||||
mod hosts;
|
||||
|
||||
@@ -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<Self>) -> 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<Bounds<Pixels>> {
|
||||
cx.debug_bounds(Box::leak(name.to_string().into_boxed_str()))
|
||||
}
|
||||
|
||||
fn row(label: &str, cx: &mut VisualTestContext) -> Bounds<Pixels> {
|
||||
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<Pixels>, 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"
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use gpui::{
|
||||
Window, div, point, prelude::*, px,
|
||||
};
|
||||
|
||||
mod aim;
|
||||
mod buttons;
|
||||
mod more;
|
||||
|
||||
|
||||
Reference in new issue
Block a user