T07b navigation places: nav item and group, navigation menu, back and forward history, table of contents with anchors, go to line; measuring canvases pinned to their parent

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 12:14:58 -04:00
1 parent 0883a999f4
commit c02dd7d1c3
21 files changed
+1548 -56

No files matched your search

+1
View File
@@ -67,6 +67,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- 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 before its bounds are stored, so a first-frame call misfires. Call it from a child's prepaint once the handle has bounds, then request one frame (`navigation::editor_tabs`). Never poll layout with `request_animation_frame`.
- 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.
- 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()`.
- 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::glyphs`).
- 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.
+3 -1
View File
@@ -30,7 +30,7 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T06f Forms · Other
- [x] T06g Forms · Structure
- [x] T07a Navigation · Tabs & paths
- [ ] T07b Navigation · Places
- [x] T07b Navigation · Places
- [ ] T07c Navigation · Palettes & tour
- [ ] T08 Menus
- [ ] T09 Overlays
@@ -141,3 +141,5 @@ Per-component lines live in `tasks/`. Tags there:
| T07a | 1 | FAIL | editor tabs keyed their scroll on the count of changes: a far tab chosen at mount stayed off screen, and after one change every redraw pulled a manual scroll back |
| T07a | 2 | FAIL | in a hidden or zero-width strip the follow asked for a frame on every render, 126 redraws in 2 s |
| T07a | 3 | FAIL | AGENTS.md said the test platform gives text no width; it gives each character a fixed advance (fixed after the cap) |
| T07b | 1 | FAIL | an open navigation menu kept entry and link indexes past lists that shrank, and disabled links still fired by click or Enter |
| T07b | 2 | PASS | |
+40 -1
View File
@@ -1,4 +1,5 @@
mod paths;
mod places;
mod tabs;
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
@@ -10,7 +11,7 @@ pub const PAGE: Page = Page {
number: 7,
slug: "navigation",
title: "Navigation",
summary: "Tabs, paths, pages and steps: where you are, and the ways out.",
summary: "Tabs, paths, places and palettes: where you are, and the ways out.",
render,
script: SCRIPT,
};
@@ -44,6 +45,39 @@ const SCRIPT: &[Step] = &[
Step::UpAt("wizard", 531.0, 173.0),
Step::Wait(500),
Step::Shot("wizard"),
Step::DownAt("nav-sidebar", 60.0, 58.0),
Step::UpAt("nav-sidebar", 60.0, 58.0),
Step::DownAt("nav-sidebar", 266.0, 26.0),
Step::UpAt("nav-sidebar", 266.0, 26.0),
Step::Wait(500),
Step::Shot("nav-folded"),
Step::DownAt("nav-sidebar", 28.0, 26.0),
Step::UpAt("nav-sidebar", 28.0, 26.0),
Step::DownAt("nav-menu", 42.0, 14.0),
Step::UpAt("nav-menu", 42.0, 14.0),
Step::Wait(300),
Step::Shot("nav-menu-open"),
Step::Key("right"),
Step::Key("down"),
Step::Wait(300),
Step::Shot("nav-menu-docs"),
Step::Key("escape"),
Step::DownAt("history", 63.0, 12.0),
Step::UpAt("history", 63.0, 12.0),
Step::Wait(300),
Step::Shot("history-list"),
Step::Key("escape"),
Step::DownAt("toc", 60.0, 90.0),
Step::UpAt("toc", 60.0, 90.0),
Step::Wait(500),
Step::Shot("toc"),
Step::DownAt("goto", 100.0, 14.0),
Step::UpAt("goto", 100.0, 14.0),
Step::Key("secondary-a"),
Step::Type("120:8"),
Step::Wait(200),
Step::Shot("goto"),
Step::Key("enter"),
Step::Rest,
];
@@ -54,5 +88,10 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(paths::breadcrumb(window, cx))
.child(paths::pagination(window, cx))
.child(paths::steps(window, cx))
.child(places::sidebar(window, cx))
.child(places::menu(window, cx))
.child(places::history(window, cx))
.child(places::contents(window, cx))
.child(places::go_to_line(window, cx))
.into_any_element()
}
+240
View File
@@ -0,0 +1,240 @@
use ely_gpui_component::{
buttons::{ButtonVariant, IconButton},
forms::{Choice, TextInput},
layout::Sidebar,
navigation::{
Anchor, BackForwardNavigation, GoToLine, NavGroup, NavItem, NavigationMenu, Sections,
TableOfContents,
},
primitives::IconName,
theme::{ActiveTheme, Radius},
typography::{Heading, Paragraph},
};
use gpui::{
App, Entity, InteractiveElement, IntoElement, ParentElement, ScrollHandle, SharedString,
StatefulInteractiveElement, Styled, Window, div, px,
};
use crate::{
probe::probe,
ui::{keep, section, set, specimen},
};
pub fn sidebar(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let here = keep("nav-here", || SharedString::from("inbox"), window, cx);
let folded = keep("nav-folded", || false, window, cx);
let open = keep("nav-open", || true, window, cx);
let (now, fold, shown) = (here.read(cx).clone(), *folded.read(cx), *open.read(cx));
let item = |id: &'static str, icon: IconName, label: &'static str| {
let here = here.clone();
NavItem::new(id, icon, label)
.active(now.as_ref() == id)
.folded(fold)
.on_click(move |_, cx| set(&here, id.into(), cx))
};
let toggle = folded.clone();
let groups = open.clone();
section(
"Sidebar Navigation / NavItem / NavGroup",
"Places in a side column. Groups fold; folded to a rail, items keep their icons and name themselves on hover.",
cx,
)
.child(probe(
"nav-sidebar",
div()
.flex()
.w(px(560.0))
.h(px(300.0))
.border_1()
.border_color(cx.theme().colors.border)
.rounded(cx.theme().radius(Radius::Lg))
.overflow_hidden()
.child(
Sidebar::new("nav-sidebar", fold).child(
div()
.flex()
.flex_col()
.gap_3()
.p_2()
.child(item("inbox", IconName::Inbox, "Inbox").count("12"))
.child(item("drafts", IconName::FileText, "Drafts"))
.child(
NavGroup::new("nav-projects", "Projects", shown)
.folded(fold)
.on_toggle(move |next, _, cx| set(&groups, next, cx))
.child(item("ely", IconName::Box, "Ely"))
.child(item("website", IconName::Globe, "Website"))
.child(item("research", IconName::BookOpen, "Research")),
),
),
)
.child(
div().p_3().child(
IconButton::new("nav-fold", IconName::PanelLeft)
.variant(ButtonVariant::Ghost)
.tooltip(if fold { "Unfold" } else { "Fold" })
.on_click(move |_, _, cx| set(&toggle, !fold, cx)),
),
),
))
}
pub fn menu(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let went = keep("menu-went", || None::<SharedString>, window, cx);
let caption = went
.read(cx)
.clone()
.map_or("pick a link".to_string(), |value| {
format!("went to {value}")
});
section(
"NavigationMenu",
"A click opens an entry's panel; while one is open, hovering another switches. Arrows walk it, Escape closes.",
cx,
)
.child(specimen(
caption,
probe(
"nav-menu",
NavigationMenu::new("nav-menu")
.entry(
"Product",
[
Choice::new("tour", "Tour").icon(IconName::Compass).note("What it does, in five minutes"),
Choice::new("pricing", "Pricing").icon(IconName::CreditCard).note("Plans for one to a thousand"),
Choice::new("changes", "Changelog").icon(IconName::History).note("Every release, dated"),
Choice::new("status", "Status").icon(IconName::Activity).note("Uptime, live"),
],
)
.entry(
"Docs",
[
Choice::new("guide", "Guide").icon(IconName::BookOpen).note("From install to ship"),
Choice::new("api", "API").icon(IconName::Code).note("Every type and call"),
Choice::new("examples", "Examples").icon(IconName::Layers).note("Small apps to copy"),
Choice::new("faq", "Questions").icon(IconName::CircleHelp).note("Asked and answered"),
],
)
.on_select(move |value, _, cx| set(&went, Some(value.clone()), cx)),
),
cx,
))
}
pub fn history(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let at = keep("history-at", || 3usize, window, cx);
let now = *at.read(cx);
let places = [
("home", "Home", IconName::House),
("search", "Search: gpui", IconName::Search),
("repo", "zed-industries/zed", IconName::GitBranch),
("file", "crates/gpui/src/window.rs", IconName::FileText),
];
let label = places[now].1;
section(
"BackForwardNavigation / History",
"Back and forward step one place; the clock lists every place, newest first.",
cx,
)
.child(specimen(
format!("at {label}"),
probe(
"history",
BackForwardNavigation::new(
"history",
places.map(|(value, label, icon)| Choice::new(value, label).icon(icon)),
now,
)
.on_go(move |to, _, cx| set(&at, to, cx)),
),
cx,
))
}
const CHAPTERS: [(&str, &str, usize); 5] = [
("start", "Getting started", 0),
("install", "Install", 1),
("first", "A first window", 1),
("theme", "Theme and tokens", 0),
("motion", "Motion", 0),
];
pub fn contents(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let scroll = keep("toc-scroll", ScrollHandle::new, window, cx)
.read(cx)
.clone();
let sections: Entity<Sections> = {
let scroll = scroll.clone();
keep("toc-sections", move || Sections::new(scroll), window, cx)
};
let body = "Each section is long enough to scroll past. The line in the table follows the section in view; a click brings a section to the top.";
let document = CHAPTERS.iter().map(|(value, title, depth)| {
Anchor::new(&sections, *value).child(
div()
.flex()
.flex_col()
.gap_2()
.pb_8()
.child(if *depth == 0 {
Heading::h4(*title)
} else {
Heading::h5(*title)
})
.child(Paragraph::new(body))
.child(Paragraph::new(body)),
)
});
let toc = CHAPTERS.iter().fold(
TableOfContents::new("toc", &sections),
|toc, (value, title, depth)| toc.entry(*value, *title, *depth),
);
section(
"Anchor / TableOfContents",
"Anchors mark where sections start. The table marks the one in view and brings any to the top.",
cx,
)
.child(
div()
.flex()
.items_start()
.gap_6()
.child(probe("toc", div().w(px(200.0)).child(toc)))
.child(
div()
.id("toc-document")
.w(px(420.0))
.h(px(260.0))
.overflow_y_scroll()
.track_scroll(&scroll)
.children(document),
),
)
}
pub fn go_to_line(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let state: Entity<TextInput> = window.use_keyed_state("goto-line", cx, |window, cx| {
TextInput::new(window, cx).placeholder("Line or line:column")
});
let went = keep("goto-went", || None::<(usize, Option<usize>)>, window, cx);
let caption = match *went.read(cx) {
None => "Enter jumps".to_string(),
Some((line, None)) => format!("jumped to line {line}"),
Some((line, Some(column))) => format!("jumped to {line}:{column}"),
};
section(
"JumpTo / GoToLine",
"Line, or line and column. The note says where you will land, or why you cannot.",
cx,
)
.child(specimen(
caption,
probe(
"goto",
div().w(px(260.0)).child(
GoToLine::new("goto", &state, 240)
.on_jump(move |line, column, _, cx| set(&went, Some((line, column)), cx)),
),
),
cx,
))
}
+2
View File
@@ -198,6 +198,8 @@ impl RenderOnce for GradientEditor {
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.on_mouse_down(MouseButton::Left, move |event, window, cx| {
+2
View File
@@ -191,6 +191,8 @@ fn area(
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
}
+2
View File
@@ -58,6 +58,8 @@ pub(crate) fn checker(radius: Rems, cx: &App) -> impl IntoElement + use<> {
},
)
.absolute()
.top_0()
.left_0()
.size_full()
}
+2
View File
@@ -156,6 +156,8 @@ impl RenderOnce for Knob {
},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.when(!self.disabled, |knob| {
+2 -2
View File
@@ -70,7 +70,7 @@ pub use mention::{MentionInput, mention_highlights};
pub use multi::MultiSelect;
pub use number::{NumberInput, ScrubInput};
pub use options::Choice;
pub(crate) use options::{Pick, Run, step};
pub(crate) use options::{OnFlag, OnValue, Pick, Run, float, step, surface};
pub use pairs::{FieldArray, KeyValueInput, ListInput};
pub use path::PathInput;
pub use pattern::{RegexInput, regex_highlights};
@@ -88,7 +88,7 @@ pub use structure::{
};
pub use switch::Switch;
pub use tags::TagInput;
pub(crate) use text::bind_keys;
pub(crate) use text::{Enter, bind_keys};
pub use text::{Highlight, InputEvent, TextInput};
pub use transfer::TransferList;
pub use unit::UnitInput;
+2
View File
@@ -200,6 +200,8 @@ impl RenderOnce for SignaturePad {
move |bounds, _, window, _| paint(&shown, bounds, width, color, window),
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.on_mouse_down(MouseButton::Left, move |event, _, cx| {
+2
View File
@@ -205,6 +205,8 @@ impl Track {
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.when(!self.disabled, |inner| {
+133
View File
@@ -0,0 +1,133 @@
use std::rc::Rc;
use gpui::{
App, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, Styled, Window, div,
};
use crate::{
forms::{Enter, Input, TextInput},
primitives::{Icon, IconName},
theme::{ActiveTheme, IconSize, TextSize},
};
/// Where `text` points in a file of `lines` lines, as "line" or "line:column"; `None` while empty.
pub(crate) fn target(text: &str, lines: usize) -> Result<Option<(usize, Option<usize>)>, String> {
let text = text.trim();
if text.is_empty() {
return Ok(None);
}
let number = |part: &str| part.trim().parse::<usize>().ok().filter(|n| *n >= 1);
let (line, column) = match text.split_once(':') {
Some((line, column)) => (number(line), number(column).map(Some)),
None => (number(text), Some(None)),
};
let (Some(line), Some(column)) = (line, column) else {
return Err("Type a line number, or line:column".into());
};
if line > lines {
return Err(format!("Line {line} is past the last line, {lines}"));
}
Ok(Some((line, column)))
}
type OnJump = Rc<dyn Fn(usize, Option<usize>, &mut Window, &mut App)>;
/// A field that jumps to "line" or "line:column". Enter goes; a note says where, or why not.
#[derive(IntoElement)]
pub struct GoToLine {
id: ElementId,
state: Entity<TextInput>,
lines: usize,
on_jump: Option<OnJump>,
}
impl GoToLine {
pub fn new(id: impl Into<ElementId>, state: &Entity<TextInput>, lines: usize) -> Self {
assert!(lines >= 1, "a file to jump in has a line");
Self {
id: id.into(),
state: state.clone(),
lines,
on_jump: None,
}
}
pub fn on_jump(
mut self,
handler: impl Fn(usize, Option<usize>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_jump = Some(Rc::new(handler));
self
}
}
impl RenderOnce for GoToLine {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let lines = self.lines;
let aim = target(self.state.read(cx).text(), lines);
let theme = cx.theme();
let colors = &theme.colors;
let (note, color): (SharedString, _) = match &aim {
Ok(None) => (format!("Lines 1 to {lines}").into(), colors.fg_subtle),
Ok(Some((line, None))) => (format!("Go to line {line}").into(), colors.fg_muted),
Ok(Some((line, Some(column)))) => (
format!("Go to line {line}, column {column}").into(),
colors.fg_muted,
),
Err(problem) => (problem.clone().into(), colors.danger),
};
let (id, on_jump, state) = (self.id.clone(), self.on_jump, self.state.clone());
div()
.id(self.id)
.flex()
.flex_col()
.gap_1()
.w_full()
.capture_action(move |_: &Enter, window, cx| {
cx.stop_propagation();
match target(state.read(cx).text(), lines) {
Ok(Some((line, column))) => {
log::info!("go to line {id:?}: {line}:{column:?}");
if let Some(on_jump) = &on_jump {
on_jump(line, column, window, cx);
}
}
Ok(None) => {}
Err(problem) => log::info!("go to line {id:?}: {problem}"),
}
})
.child(
Input::new(&self.state).invalid(aim.is_err()).prefix(
Icon::new(IconName::Hash)
.size(IconSize::Sm)
.color(colors.fg_subtle),
),
)
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(color)
.child(note),
)
}
}
#[cfg(test)]
mod tests {
use super::target;
#[test]
fn targets_read_line_and_column_and_refuse_the_rest() {
assert_eq!(target("", 100), Ok(None));
assert_eq!(target(" 12 ", 100), Ok(Some((12, None))));
assert_eq!(target("12:4", 100), Ok(Some((12, Some(4)))));
assert!(target("0", 100).is_err());
assert!(target("12:", 100).is_err());
assert!(target("abc", 100).is_err());
assert_eq!(
target("300", 240),
Err("Line 300 is past the last line, 240".to_string())
);
}
}
+112
View File
@@ -0,0 +1,112 @@
use std::rc::Rc;
use gpui::{App, ElementId, IntoElement, ParentElement, RenderOnce, Styled, Window, div};
use super::overflow::list_button;
use crate::{
buttons::{ButtonVariant, IconButton},
forms::Choice,
primitives::IconName,
theme::ControlSize,
};
type OnGo = Rc<dyn Fn(usize, &mut Window, &mut App)>;
/// Back and forward through visited places, and a list of every one of them, newest first.
#[derive(IntoElement)]
pub struct BackForwardNavigation {
id: ElementId,
places: Vec<Choice>,
current: usize,
on_go: Option<OnGo>,
}
impl BackForwardNavigation {
/// `places` run oldest first; `current` is where you stand among them.
pub fn new(
id: impl Into<ElementId>,
places: impl IntoIterator<Item = Choice>,
current: usize,
) -> Self {
let places: Vec<Choice> = places.into_iter().collect();
assert!(
current < places.len(),
"place {current} is past the last of {}",
places.len()
);
Self {
id: id.into(),
places,
current,
on_go: None,
}
}
/// Runs with the index of the place to go to.
pub fn on_go(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_go = Some(Rc::new(handler));
self
}
}
impl RenderOnce for BackForwardNavigation {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (current, last) = (self.current, self.places.len() - 1);
let go: OnGo = {
let (id, on_go) = (self.id.clone(), self.on_go);
Rc::new(move |to, window, cx| {
log::info!("history {id:?}: go to {to}");
if let Some(on_go) = &on_go {
on_go(to, window, cx);
}
})
};
let rows: Vec<Choice> = self
.places
.iter()
.enumerate()
.rev()
.map(|(ix, place)| {
let row = Choice::new(ix.to_string(), place.label.clone());
match place.icon {
Some(icon) => row.icon(icon),
None => row,
}
})
.collect();
let (back, ahead, listed) = (go.clone(), go.clone(), go);
let list = list_button(
&(self.id.clone(), "history").into(),
IconName::History,
Rc::new(rows),
Some(&current.to_string().into()),
move |value, window, cx| {
let to = value.parse().expect("history rows carry their index");
listed(to, window, cx);
},
window,
cx,
);
div()
.flex()
.items_center()
.gap_0p5()
.child(
IconButton::new((self.id.clone(), "back"), IconName::ArrowLeft)
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.tooltip("Back")
.disabled(current == 0)
.on_click(move |_, window, cx| back(current - 1, window, cx)),
)
.child(
IconButton::new((self.id, "forward"), IconName::ArrowRight)
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.tooltip("Forward")
.disabled(current == last)
.on_click(move |_, window, cx| ahead(current + 1, window, cx)),
)
.child(list)
}
}
+276
View File
@@ -0,0 +1,276 @@
use std::rc::Rc;
use gpui::{
App, Bounds, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, MouseButton,
ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window,
canvas, div, prelude::*,
};
use crate::{
forms::{Choice, OnValue, float, step, surface},
primitives::{Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
/// Which entry is open, the link under the keyboard, and where each entry sits.
#[derive(Default)]
struct Open {
entry: Option<usize>,
link: usize,
anchors: Vec<Bounds<Pixels>>,
}
type PickLink = Rc<dyn Fn(usize, usize, &mut Window, &mut App)>;
type Entries = [(SharedString, Vec<Choice>)];
/// The open entry and its marked link, checked against the lists as they are now.
fn marked(open: &Open, entries: &Entries) -> Option<(usize, usize)> {
let entry = open.entry.filter(|&entry| entry < entries.len())?;
let links = &entries[entry].1;
let link = if links.get(open.link).is_some_and(|link| !link.disabled) {
open.link
} else {
step(links, links.len() - 1, 1)
};
Some((entry, link))
}
fn show(state: &Entity<Open>, entry: Option<usize>, cx: &mut App) {
state.update(cx, |open, cx| {
if open.entry != entry {
open.link = 0;
}
open.entry = entry;
cx.notify();
});
}
/// A row of entries; each opens a panel of links. While one is open, hovering another switches to it.
#[derive(IntoElement)]
pub struct NavigationMenu {
id: ElementId,
entries: Vec<(SharedString, Vec<Choice>)>,
on_select: Option<OnValue>,
}
impl NavigationMenu {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
entries: Vec::new(),
on_select: None,
}
}
/// An entry and its links: each link's label is its title, its note a line of detail.
pub fn entry(
mut self,
label: impl Into<SharedString>,
links: impl IntoIterator<Item = Choice>,
) -> Self {
let links: Vec<Choice> = links.into_iter().collect();
assert!(!links.is_empty(), "a navigation menu entry needs links");
self.entries.push((label.into(), links));
self
}
pub fn on_select(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
}
impl RenderOnce for NavigationMenu {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let count = self.entries.len();
let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
let focused = focus.is_focused(window);
let state = window.use_keyed_state((self.id.clone(), "open"), cx, |_, _| Open::default());
if state.read(cx).anchors.len() != count {
state.update(cx, |open, _| open.anchors = vec![Bounds::default(); count]);
}
let current = marked(state.read(cx), &self.entries);
if state.read(cx).entry.is_some() && (!focused || current.is_none()) {
log::info!("navigation menu {:?}: closed, focused {focused}", self.id);
state.update(cx, |open, _| open.entry = None);
}
let open = current.filter(|_| focused).map(|(entry, _)| entry);
let link = current.map_or(0, |(_, link)| link);
let entries = Rc::new(self.entries);
let pick: PickLink = {
let (id, entries, state, on_select) = (
self.id.clone(),
entries.clone(),
state.clone(),
self.on_select,
);
Rc::new(move |entry, link, window, cx| {
let choice = &entries[entry].1[link];
if choice.disabled {
return;
}
let value = choice.value.clone();
log::info!("navigation menu {id:?}: {value}");
show(&state, None, cx);
if let Some(on_select) = &on_select {
on_select(&value, window, cx);
}
})
};
let theme = cx.theme();
let colors = &theme.colors;
let triggers = entries.iter().enumerate().map(|(ix, (label, _))| {
let on = open == Some(ix);
let (press, hover, measure, into) =
(state.clone(), state.clone(), state.clone(), focus.clone());
div()
.id(("entry", ix))
.relative()
.flex()
.items_center()
.gap_1()
.h(theme.control_height(ControlSize::Md))
.px_3()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(if focused && on {
colors.focus
} else {
gpui::transparent_black()
})
.text_size(theme.text_size(TextSize::Sm))
.text_color(if on { colors.fg } else { colors.fg_muted })
.when(on, |entry| entry.bg(colors.hover))
.cursor_pointer()
.hover(|style| style.text_color(colors.fg))
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
window.prevent_default();
window.focus(&into);
show(&press, if on { None } else { Some(ix) }, cx);
})
.on_hover(move |hovered, _, cx| {
let current = hover.read(cx).entry;
if *hovered && current.is_some() && current != Some(ix) {
show(&hover, Some(ix), cx);
}
})
.child(label.clone())
.child(
Icon::new(IconName::ChevronDown)
.size(IconSize::Xs)
.color(colors.fg_subtle),
)
.child(
canvas(
move |bounds, _, cx| {
if measure.read(cx).anchors.get(ix) != Some(&bounds) {
measure.update(cx, |open, _| open.anchors[ix] = bounds);
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
});
let panel = open.map(|entry| {
let links = entries[entry].1.iter().enumerate().map(|(ix, choice)| {
let pick = pick.clone();
let (fg, muted) = if choice.disabled {
(colors.fg_disabled, colors.fg_disabled)
} else {
(colors.fg, colors.fg_muted)
};
div()
.id(("link", ix))
.flex()
.gap_3()
.p_2()
.rounded(theme.radius(Radius::Md))
.when(!choice.disabled, |row| {
row.when(ix == link, |row| row.bg(colors.hover))
.cursor_pointer()
.hover(|style| style.bg(colors.hover))
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
pick(entry, ix, window, cx);
})
})
.when_some(choice.icon, |row, icon| {
row.child(Icon::new(icon).size(IconSize::Md).color(muted))
})
.child(
div()
.flex()
.flex_col()
.gap_0p5()
.child(
div()
.font_weight(FontWeight::MEDIUM)
.text_color(fg)
.child(choice.label.clone()),
)
.when_some(choice.note.clone(), |words, note| {
words.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(note),
)
}),
)
});
let close = state.clone();
let content = surface((self.id.clone(), "panel"), cx)
.w(theme.nav_panel_width())
.p_2()
.grid()
.grid_cols(2)
.gap_1()
.on_mouse_down_out(move |_, _, cx| show(&close, None, cx))
.children(links);
let rows = entries[entry].1.len().div_ceil(2) * 2;
float(state.read(cx).anchors[entry], rows, content, window, cx)
});
let (keys, key_entries, enter) = (state.clone(), entries.clone(), pick);
div()
.id(self.id)
.track_focus(&focus)
.flex()
.items_center()
.gap_1()
.on_key_down(move |event, window, cx| {
let key = event.keystroke.key.as_str();
let current = marked(keys.read(cx), &key_entries);
let entry = current.map(|(entry, _)| entry);
let next = |by: isize| {
(entry.unwrap_or(0) as isize + by).rem_euclid(count as isize) as usize
};
match (key, current) {
("right", Some(_)) => show(&keys, Some(next(1)), cx),
("left", Some(_)) => show(&keys, Some(next(-1)), cx),
("down" | "enter" | "space", None) => show(&keys, Some(0), cx),
("down" | "up", Some((at, link))) => {
let by = if key == "down" { 1 } else { -1 };
let to = step(&key_entries[at].1, link, by);
keys.update(cx, |open, cx| {
open.link = to;
cx.notify();
});
}
("enter" | "space", Some((at, link))) => enter(at, link, window, cx),
("escape", Some(_)) => show(&keys, None, cx),
_ => return,
}
cx.stop_propagation();
})
.children(triggers)
.children(panel)
}
}
+10
View File
@@ -1,15 +1,25 @@
mod breadcrumb;
mod editor_tabs;
mod goto;
mod history;
mod menu;
mod nav;
mod overflow;
mod pages;
mod steps;
mod tabs;
#[cfg(all(test, feature = "test-support"))]
mod tests;
mod toc;
pub use breadcrumb::{Breadcrumb, Crumb};
pub use editor_tabs::{EditorTab, EditorTabs};
pub use goto::GoToLine;
pub use history::BackForwardNavigation;
pub use menu::NavigationMenu;
pub use nav::{NavGroup, NavItem};
pub use overflow::TabOverflowMenu;
pub use pages::Pagination;
pub use steps::{Steps, Wizard};
pub use tabs::{TabPlacement, Tabs};
pub use toc::{Anchor, Sections, TableOfContents};
+232
View File
@@ -0,0 +1,232 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
prelude::*,
};
use smallvec::SmallVec;
use crate::{
forms::{OnFlag, Run},
layout::Collapsible,
primitives::{FocusRing, Icon, IconName, Tooltip},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
/// One place in a side navigation. A folded sidebar shows the icon alone and names it on hover.
#[derive(IntoElement)]
pub struct NavItem {
id: ElementId,
label: SharedString,
icon: IconName,
count: Option<SharedString>,
active: bool,
folded: bool,
on_click: Option<Run>,
}
impl NavItem {
pub fn new(id: impl Into<ElementId>, icon: IconName, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: label.into(),
icon,
count: None,
active: false,
folded: false,
on_click: None,
}
}
/// Quiet text at the end, such as an unread count.
pub fn count(mut self, count: impl Into<SharedString>) -> Self {
self.count = Some(count.into());
self
}
/// Marks the place you are.
pub fn active(mut self, active: bool) -> Self {
self.active = active;
self
}
/// The icon alone, for a sidebar folded to a rail.
pub fn folded(mut self, folded: bool) -> Self {
self.folded = folded;
self
}
pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
}
impl RenderOnce for NavItem {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let fg = if self.active {
colors.fg
} else {
colors.fg_muted
};
let (id, label) = (self.id.clone(), self.label.clone());
let item = div()
.id(self.id)
.relative()
.flex()
.items_center()
.gap_2()
.h(theme.control_height(ControlSize::Md))
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(gpui::transparent_black())
.text_size(theme.text_size(TextSize::Sm))
.text_color(fg)
.cursor_pointer()
.tab_index(0)
.focus_ring(cx)
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.map(|item| {
if self.active {
item.bg(colors.hover).font_weight(FontWeight::MEDIUM).child(
div()
.absolute()
.left_0()
.top_1p5()
.bottom_1p5()
.w(theme.tab_indicator())
.rounded_full()
.bg(colors.accent),
)
} else {
item.hover(|style| style.bg(colors.hover).text_color(colors.fg))
}
})
.when_some(self.on_click, |item, click| {
item.on_click(move |_, window, cx| {
log::info!("nav item {id:?}: open");
click(window, cx)
})
})
.child(Icon::new(self.icon).size(IconSize::Sm).color(fg));
if self.folded {
return item
.justify_center()
.w(theme.control_height(ControlSize::Md))
.tooltip(Tooltip::text(label));
}
item.px_2()
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_ellipsis()
.child(label),
)
.when_some(self.count, |item, count| {
item.child(div().text_color(colors.fg_subtle).child(count))
})
}
}
/// A titled run of nav items. The title folds it; a folded sidebar drops the title.
#[derive(IntoElement)]
pub struct NavGroup {
id: ElementId,
title: SharedString,
open: bool,
folded: bool,
items: SmallVec<[AnyElement; 8]>,
on_toggle: Option<OnFlag>,
}
impl NavGroup {
pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>, open: bool) -> Self {
Self {
id: id.into(),
title: title.into(),
open,
folded: false,
items: SmallVec::new(),
on_toggle: None,
}
}
/// Items only, for a sidebar folded to a rail.
pub fn folded(mut self, folded: bool) -> Self {
self.folded = folded;
self
}
/// Runs with the state the title asks for.
pub fn on_toggle(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
self.on_toggle = Some(Rc::new(handler));
self
}
}
impl ParentElement for NavGroup {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.items.extend(elements);
}
}
impl RenderOnce for NavGroup {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let items = div().flex().flex_col().gap_0p5().children(self.items);
if self.folded {
return div().id(self.id).child(items);
}
let theme = cx.theme();
let colors = &theme.colors;
let (open, id) = (self.open, self.id.clone());
let toggle = self.on_toggle;
div()
.id(self.id.clone())
.flex()
.flex_col()
.gap_0p5()
.child(
div()
.id((self.id.clone(), "title"))
.flex()
.items_center()
.gap_1()
.h(theme.control_height(ControlSize::Sm))
.px_2()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(gpui::transparent_black())
.text_size(theme.text_size(TextSize::Xs))
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg_subtle)
.cursor_pointer()
.tab_index(0)
.focus_ring(cx)
.hover(|style| style.text_color(colors.fg_muted))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| {
log::info!("nav group {id:?}: open {}", !open);
if let Some(toggle) = &toggle {
toggle(!open, window, cx);
}
})
.child(
Icon::new(if open {
IconName::ChevronDown
} else {
IconName::ChevronRight
})
.size(IconSize::Xs)
.color(colors.fg_subtle),
)
.child(self.title),
)
.child(Collapsible::new((self.id, "items"), open).child(items))
}
}
+49 -40
View File
@@ -1,17 +1,57 @@
use std::rc::Rc;
use gpui::{
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div,
App, Div, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
Stateful, Styled, Window, div,
};
use crate::{
forms::{Choice, Listing, listing},
forms::{Choice, Listing, OnValue, listing},
primitives::{Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, Radius},
};
type OnValue = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// A small icon button that lists `rows`; picking one runs `on_pick`.
pub(crate) fn list_button(
id: &ElementId,
icon: IconName,
rows: Rc<Vec<Choice>>,
selected: Option<&SharedString>,
on_pick: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
window: &mut Window,
cx: &mut App,
) -> Stateful<Div> {
let focus = tab_stop((id.clone(), "focus").into(), true, window, cx);
let focused = focus.is_focused(window);
let theme = cx.theme();
let colors = &theme.colors;
let trigger = div()
.id((id.clone(), "button"))
.track_focus(&focus)
.relative()
.flex()
.flex_none()
.items_center()
.justify_center()
.size(theme.control_height(ControlSize::Sm))
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(if focused {
colors.focus
} else {
gpui::transparent_black()
})
.cursor_pointer()
.hover(|style| style.bg(colors.hover))
.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted));
let list = Listing {
id,
rows,
selected,
focused,
};
listing(list, trigger, on_pick, window, cx)
}
/// A button that lists every tab, for a strip too narrow to show them all.
#[derive(IntoElement)]
@@ -53,43 +93,12 @@ impl RenderOnce for TabOverflowMenu {
"tab overflow menu {:?} has no tabs",
self.id
);
let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
let focused = focus.is_focused(window);
let theme = cx.theme();
let colors = &theme.colors;
let trigger = div()
.id((self.id.clone(), "button"))
.track_focus(&focus)
.relative()
.flex()
.flex_none()
.items_center()
.justify_center()
.size(theme.control_height(ControlSize::Sm))
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(if focused {
colors.focus
} else {
gpui::transparent_black()
})
.cursor_pointer()
.hover(|style| style.bg(colors.hover))
.child(
Icon::new(IconName::ChevronDown)
.size(IconSize::Sm)
.color(colors.fg_muted),
);
let (id, on_select) = (self.id.clone(), self.on_select);
let list = Listing {
id: &self.id,
rows: Rc::new(self.tabs),
selected: self.selected.as_ref(),
focused,
};
listing(
list,
trigger,
list_button(
&self.id,
IconName::ChevronDown,
Rc::new(self.tabs),
self.selected.as_ref(),
move |value, window, cx| {
log::info!("tab overflow menu {id:?}: {value}");
if let Some(on_select) = &on_select {
+163 -5
View File
@@ -1,11 +1,17 @@
use gpui::{
Context, IntoElement, KeyUpEvent, Keystroke, Modifiers, ParentElement, Render, ScrollDelta,
ScrollWheelEvent, SharedString, Styled, TestAppContext, TouchPhase, VisualTestContext, Window,
div, point, px,
AppContext as _, Context, IntoElement, KeyUpEvent, Keystroke, Modifiers, ParentElement, Render,
ScrollDelta, ScrollWheelEvent, SharedString, Styled, TestAppContext, TouchPhase,
VisualTestContext, Window, div, point, px,
};
use super::{Breadcrumb, Crumb, EditorTab, EditorTabs, Tabs, Wizard};
use crate::{forms::Choice, theme::Theme};
use super::{
BackForwardNavigation, Breadcrumb, Crumb, EditorTab, EditorTabs, GoToLine, NavigationMenu,
Tabs, Wizard,
};
use crate::{
forms::{Choice, TextInput},
theme::Theme,
};
fn setup(cx: &mut TestAppContext) {
cx.update(|cx| {
@@ -233,3 +239,155 @@ fn the_chosen_tab_scrolls_in_once_and_a_manual_scroll_stays(cx: &mut TestAppCont
"a manual scroll stays put"
);
}
struct Site {
entries: Vec<(&'static str, Vec<Choice>)>,
went: Option<SharedString>,
}
impl Site {
fn new() -> Self {
let entries = vec![
(
"Product",
vec![
Choice::new("tour", "Tour").disabled(),
Choice::new("price", "Pricing"),
],
),
(
"Docs",
vec![Choice::new("guide", "Guide"), Choice::new("api", "API")],
),
];
Self {
entries,
went: None,
}
}
}
impl Render for Site {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
self.entries
.iter()
.fold(NavigationMenu::new("site"), |menu, (label, links)| {
menu.entry(*label, links.clone())
})
.on_select(move |value, _, cx| {
let value = value.clone();
view.update(cx, |view, _| view.went = Some(value));
})
}
}
#[gpui::test]
fn the_menu_opens_switches_and_picks_from_the_keyboard(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Site::new());
cx.update(|window, _| window.focus_next());
cx.simulate_keystrokes("down right down enter");
let went = view.read_with(cx, |view, _| view.went.clone());
assert_eq!(went, Some(SharedString::from("api")));
}
#[gpui::test]
fn menu_keys_pass_over_disabled_links(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Site::new());
cx.update(|window, _| window.focus_next());
cx.simulate_keystrokes("down enter");
let went = view.read_with(cx, |view, _| view.went.clone());
assert_eq!(went, Some(SharedString::from("price")));
cx.simulate_keystrokes("down down enter");
let went = view.read_with(cx, |view, _| view.went.clone());
assert_eq!(went, Some(SharedString::from("price")));
}
#[gpui::test]
fn an_open_menu_follows_lists_that_shrink(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Site::new());
cx.update(|window, _| window.focus_next());
cx.simulate_keystrokes("down right down");
view.update(cx, |view, cx| {
view.entries[1].1.pop();
cx.notify();
});
cx.simulate_keystrokes("enter");
let went = view.read_with(cx, |view, _| view.went.clone());
assert_eq!(went, Some(SharedString::from("guide")));
cx.simulate_keystrokes("down right");
view.update(cx, |view, cx| {
view.entries.pop();
cx.notify();
});
cx.run_until_parked();
cx.simulate_keystrokes("enter");
let went = view.read_with(cx, |view, _| view.went.clone());
assert_eq!(went, Some(SharedString::from("guide")));
}
struct Trail {
at: usize,
}
impl Render for Trail {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
let places = ["home", "docs", "api"].map(|place| Choice::new(place, place));
BackForwardNavigation::new("trail", places, self.at).on_go(move |to, _, cx| {
view.update(cx, |view, cx| {
view.at = to;
cx.notify();
})
})
}
}
#[gpui::test]
fn back_steps_once_and_the_list_jumps_anywhere(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Trail { at: 2 });
cx.update(|window, _| window.focus_next());
press("enter", cx);
assert_eq!(view.read_with(cx, |view, _| view.at), 1);
cx.update(|window, _| {
window.blur();
for _ in 0..3 {
window.focus_next();
}
});
press("down", cx);
press("up", cx);
press("enter", cx);
assert_eq!(view.read_with(cx, |view, _| view.at), 2);
}
struct Jump {
field: gpui::Entity<TextInput>,
to: Option<(usize, Option<usize>)>,
}
impl Render for Jump {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
GoToLine::new("jump", &self.field, 240).on_jump(move |line, column, _, cx| {
view.update(cx, |view, _| view.to = Some((line, column)));
})
}
}
#[gpui::test]
fn enter_jumps_to_a_line_and_column(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|window, cx| Jump {
field: cx.new(|cx| TextInput::new(window, cx)),
to: None,
});
cx.update(|window, _| window.focus_next());
cx.simulate_input("12:4");
cx.simulate_keystrokes("enter");
assert_eq!(view.read_with(cx, |view, _| view.to), Some((12, Some(4))));
}
+263
View File
@@ -0,0 +1,263 @@
use gpui::{
Animation, AnimationExt, AnyElement, App, Bounds, ElementId, Entity, FontWeight,
InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, ScrollHandle,
SharedString, Styled, Window, canvas, div, point, prelude::*,
};
use smallvec::SmallVec;
use crate::{
motion::{self, Axis, Marker, glide, measure_item, measure_origin, slide},
theme::{ActiveTheme, ControlSize, Radius, TextSize},
};
/// Where a document's sections sit: shared by its anchors and its table of contents.
pub struct Sections {
scroll: ScrollHandle,
spots: Vec<(SharedString, Bounds<Pixels>)>,
}
impl Sections {
/// `scroll` tracks the container the document scrolls in.
pub fn new(scroll: ScrollHandle) -> Self {
Self {
scroll,
spots: Vec::new(),
}
}
fn spot(&self, value: &SharedString) -> Option<Bounds<Pixels>> {
self.spots
.iter()
.find(|(name, _)| name == value)
.map(|(_, bounds)| *bounds)
}
}
/// The last entry whose section starts above `line`: a quarter down the view, or its bottom at the end.
pub(crate) fn in_view(tops: &[Option<Pixels>], line: Pixels) -> usize {
tops.iter()
.rposition(|top| top.is_some_and(|top| top <= line))
.unwrap_or(0)
}
/// Marks where a section starts, so a table of contents can follow it and bring it into view.
#[derive(IntoElement)]
pub struct Anchor {
sections: Entity<Sections>,
value: SharedString,
children: SmallVec<[AnyElement; 2]>,
}
impl Anchor {
pub fn new(sections: &Entity<Sections>, value: impl Into<SharedString>) -> Self {
Self {
sections: sections.clone(),
value: value.into(),
children: SmallVec::new(),
}
}
}
impl ParentElement for Anchor {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for Anchor {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let (sections, value) = (self.sections, self.value);
div().relative().children(self.children).child(
canvas(
move |bounds, _, cx| {
if sections.read(cx).spot(&value) == Some(bounds) {
return;
}
sections.update(cx, |sections, cx| {
match sections.spots.iter_mut().find(|(name, _)| *name == value) {
Some(spot) => spot.1 = bounds,
None => sections.spots.push((value.clone(), bounds)),
}
cx.notify();
});
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
}
}
/// A document's headings. A line marks the section in view; a click brings a section to the top.
#[derive(IntoElement)]
pub struct TableOfContents {
id: ElementId,
sections: Entity<Sections>,
entries: Vec<(SharedString, SharedString, usize)>,
}
impl TableOfContents {
pub fn new(id: impl Into<ElementId>, sections: &Entity<Sections>) -> Self {
Self {
id: id.into(),
sections: sections.clone(),
entries: Vec::new(),
}
}
/// A heading: its anchor's value, its text, and how deep it sits, from 0.
pub fn entry(
mut self,
value: impl Into<SharedString>,
label: impl Into<SharedString>,
depth: usize,
) -> Self {
self.entries.push((value.into(), label.into(), depth));
self
}
}
impl RenderOnce for TableOfContents {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
assert!(
!self.entries.is_empty(),
"table of contents {:?} is empty",
self.id
);
let (viewport, at_end, tops) = {
let sections = self.sections.read(cx);
let (scroll, viewport) = (&sections.scroll, sections.scroll.bounds());
let room = scroll.max_offset().height;
let at_end = room > Pixels::ZERO && -scroll.offset().y >= room;
let tops: Vec<Option<Pixels>> = self
.entries
.iter()
.map(|(value, _, _)| sections.spot(value).map(|spot| spot.top()))
.collect();
(viewport, at_end, tops)
};
let line = if at_end {
viewport.bottom()
} else {
viewport.top() + viewport.size.height / 4.0
};
let here = in_view(&tops, line);
log::debug!(
"table of contents {:?}: {here} in view, tops {tops:?} in {viewport:?}",
self.id
);
let values: Vec<SharedString> = self
.entries
.iter()
.map(|(value, ..)| value.clone())
.collect();
let (state, marker) = slide(
(self.id.clone(), "slide"),
&values,
&values[here],
window,
cx,
);
let theme = cx.theme();
let colors = &theme.colors;
let duration = motion::duration(motion::BASE, cx);
let line = marker.map(
|Marker {
from,
to,
generation,
}| {
div()
.absolute()
.left_0()
.w(theme.tab_indicator())
.rounded_full()
.bg(colors.accent)
.with_animation(
("toc-line", generation),
Animation::new(duration),
move |line, t| {
let (top, height) = glide(from, to, t);
line.top(top).h(height)
},
)
},
);
let rows = self
.entries
.into_iter()
.enumerate()
.map(|(ix, (value, label, depth))| {
let sections = self.sections.clone();
let on = ix == here;
let row = div()
.id(("toc", ix))
.relative()
.flex()
.items_center()
.min_h(theme.control_height(ControlSize::Sm))
.pr_2()
.rounded(theme.radius(Radius::Sm))
.text_size(theme.text_size(TextSize::Sm))
.text_color(if on { colors.fg } else { colors.fg_muted })
.when(on, |row| row.font_weight(FontWeight::MEDIUM))
.cursor_pointer()
.hover(|style| style.text_color(colors.fg))
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
let (scroll, spot) = {
let sections = sections.read(cx);
(sections.scroll.clone(), sections.spot(&value))
};
let Some(spot) = spot else {
log::error!("table of contents: no anchor {value} has drawn");
return;
};
let offset = scroll.offset();
let lift = spot.top() - scroll.bounds().top();
scroll.set_offset(point(offset.x, offset.y - lift));
log::info!(
"table of contents: to {value}, lift {lift:?} from {:?} in {:?}",
offset.y,
scroll.bounds()
);
window.refresh();
})
.child(label)
.child(measure_item(state.clone(), ix, Axis::Vertical));
match depth {
0 => row.pl_3(),
1 => row.pl_6(),
_ => row.pl_8(),
}
});
div()
.id(self.id)
.relative()
.flex()
.flex_col()
.gap_0p5()
.border_l_1()
.border_color(colors.border)
.child(measure_origin(state.clone(), Axis::Vertical))
.children(line)
.children(rows)
}
}
#[cfg(test)]
mod tests {
use gpui::px;
use super::in_view;
#[test]
fn the_section_in_view_is_the_last_one_started() {
let tops = [Some(px(-400.0)), Some(px(-20.0)), Some(px(300.0)), None];
assert_eq!(in_view(&tops, px(100.0)), 1);
assert_eq!(in_view(&tops, px(-500.0)), 0);
assert_eq!(in_view(&tops, px(900.0)), 2);
}
}
+5
View File
@@ -223,6 +223,11 @@ impl Theme {
px_to_rems(2.0)
}
/// Width of a navigation menu's panel of links.
pub fn nav_panel_width(&self) -> Rems {
px_to_rems(480.0)
}
/// Grab bar on a drawer.
pub fn grip(&self) -> Size<Rems> {
size(px_to_rems(36.0), px_to_rems(4.0))
+7 -7
View File
@@ -290,13 +290,13 @@
## T07b Navigation · Places
- [ ] Sidebar Navigation / NavItem / NavGroup
- [ ] NavigationMenu
- [ ] ActivityBar Item → `shell::ActivityBar`
- [ ] BackForwardNavigation
- [ ] History Navigation → `navigation::BackForwardNavigation`
- [ ] Anchor / TableOfContents
- [ ] JumpTo / GoToLine
- [x] Sidebar Navigation / NavItem / NavGroup → `navigation::NavItem`, `navigation::NavGroup`
- [x] NavigationMenu → `navigation::NavigationMenu`
- [x] ActivityBar Item → `shell::ActivityBar`
- [x] BackForwardNavigation → `navigation::BackForwardNavigation`
- [x] History Navigation → `navigation::BackForwardNavigation`
- [x] Anchor / TableOfContents → `navigation::Anchor`, `navigation::TableOfContents`
- [x] JumpTo / GoToLine → `navigation::GoToLine`
## T07c Navigation · Palettes & tour