From c02dd7d1c36cac114098b33840acf422537cd782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 25 Sep 2026 12:14:58 -0400 Subject: [PATCH] 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 --- AGENTS.md | 1 + TASKS.md | 4 +- examples/gallery/pages/navigation/mod.rs | 41 ++- examples/gallery/pages/navigation/places.rs | 240 +++++++++++++++++ src/forms/color/gradient.rs | 2 + src/forms/color/picker.rs | 2 + src/forms/color/swatch.rs | 2 + src/forms/knob.rs | 2 + src/forms/mod.rs | 4 +- src/forms/signature.rs | 2 + src/forms/slider.rs | 2 + src/navigation/goto.rs | 133 ++++++++++ src/navigation/history.rs | 112 ++++++++ src/navigation/menu.rs | 276 ++++++++++++++++++++ src/navigation/mod.rs | 10 + src/navigation/nav.rs | 232 ++++++++++++++++ src/navigation/overflow.rs | 89 ++++--- src/navigation/tests.rs | 168 +++++++++++- src/navigation/toc.rs | 263 +++++++++++++++++++ src/theme/tokens.rs | 5 + tasks/ch01-10.md | 14 +- 21 files changed, 1548 insertions(+), 56 deletions(-) create mode 100644 examples/gallery/pages/navigation/places.rs create mode 100644 src/navigation/goto.rs create mode 100644 src/navigation/history.rs create mode 100644 src/navigation/menu.rs create mode 100644 src/navigation/nav.rs create mode 100644 src/navigation/toc.rs diff --git a/AGENTS.md b/AGENTS.md index a8044dd..1db0d12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/TASKS.md b/TASKS.md index 3a67bab..0ab21a5 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 | | diff --git a/examples/gallery/pages/navigation/mod.rs b/examples/gallery/pages/navigation/mod.rs index 653cd17..193be96 100644 --- a/examples/gallery/pages/navigation/mod.rs +++ b/examples/gallery/pages/navigation/mod.rs @@ -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() } diff --git a/examples/gallery/pages/navigation/places.rs b/examples/gallery/pages/navigation/places.rs new file mode 100644 index 0000000..c952729 --- /dev/null +++ b/examples/gallery/pages/navigation/places.rs @@ -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::, 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 = { + 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(§ions, *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", §ions), + |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 = 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)>, 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, + )) +} diff --git a/src/forms/color/gradient.rs b/src/forms/color/gradient.rs index 6177252..b8d117d 100644 --- a/src/forms/color/gradient.rs +++ b/src/forms/color/gradient.rs @@ -198,6 +198,8 @@ impl RenderOnce for GradientEditor { |_, _, _, _| {}, ) .absolute() + .top_0() + .left_0() .size_full(), ) .on_mouse_down(MouseButton::Left, move |event, window, cx| { diff --git a/src/forms/color/picker.rs b/src/forms/color/picker.rs index bcba330..97b3720 100644 --- a/src/forms/color/picker.rs +++ b/src/forms/color/picker.rs @@ -191,6 +191,8 @@ fn area( |_, _, _, _| {}, ) .absolute() + .top_0() + .left_0() .size_full(), ) } diff --git a/src/forms/color/swatch.rs b/src/forms/color/swatch.rs index 0ccbbdd..f18152b 100644 --- a/src/forms/color/swatch.rs +++ b/src/forms/color/swatch.rs @@ -58,6 +58,8 @@ pub(crate) fn checker(radius: Rems, cx: &App) -> impl IntoElement + use<> { }, ) .absolute() + .top_0() + .left_0() .size_full() } diff --git a/src/forms/knob.rs b/src/forms/knob.rs index 8e81245..b7ec5d1 100644 --- a/src/forms/knob.rs +++ b/src/forms/knob.rs @@ -156,6 +156,8 @@ impl RenderOnce for Knob { }, ) .absolute() + .top_0() + .left_0() .size_full(), ) .when(!self.disabled, |knob| { diff --git a/src/forms/mod.rs b/src/forms/mod.rs index 28c8d64..6f7dec1 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -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; diff --git a/src/forms/signature.rs b/src/forms/signature.rs index 6fdc329..9ad27e4 100644 --- a/src/forms/signature.rs +++ b/src/forms/signature.rs @@ -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| { diff --git a/src/forms/slider.rs b/src/forms/slider.rs index 7588b8e..1ee122f 100644 --- a/src/forms/slider.rs +++ b/src/forms/slider.rs @@ -205,6 +205,8 @@ impl Track { |_, _, _, _| {}, ) .absolute() + .top_0() + .left_0() .size_full(), ) .when(!self.disabled, |inner| { diff --git a/src/navigation/goto.rs b/src/navigation/goto.rs new file mode 100644 index 0000000..21dbcc4 --- /dev/null +++ b/src/navigation/goto.rs @@ -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)>, String> { + let text = text.trim(); + if text.is_empty() { + return Ok(None); + } + let number = |part: &str| part.trim().parse::().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, &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, + lines: usize, + on_jump: Option, +} + +impl GoToLine { + pub fn new(id: impl Into, state: &Entity, 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, &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()) + ); + } +} diff --git a/src/navigation/history.rs b/src/navigation/history.rs new file mode 100644 index 0000000..3427118 --- /dev/null +++ b/src/navigation/history.rs @@ -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; + +/// 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, + current: usize, + on_go: Option, +} + +impl BackForwardNavigation { + /// `places` run oldest first; `current` is where you stand among them. + pub fn new( + id: impl Into, + places: impl IntoIterator, + current: usize, + ) -> Self { + let places: Vec = 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 = 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(¤t.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) + } +} diff --git a/src/navigation/menu.rs b/src/navigation/menu.rs new file mode 100644 index 0000000..028d495 --- /dev/null +++ b/src/navigation/menu.rs @@ -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, + link: usize, + anchors: Vec>, +} + +type PickLink = Rc; +type Entries = [(SharedString, Vec)]; + +/// 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, entry: Option, 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)>, + on_select: Option, +} + +impl NavigationMenu { + pub fn new(id: impl Into) -> 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, + links: impl IntoIterator, + ) -> Self { + let links: Vec = 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) + } +} diff --git a/src/navigation/mod.rs b/src/navigation/mod.rs index debd491..0ec460d 100644 --- a/src/navigation/mod.rs +++ b/src/navigation/mod.rs @@ -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}; diff --git a/src/navigation/nav.rs b/src/navigation/nav.rs new file mode 100644 index 0000000..5f05111 --- /dev/null +++ b/src/navigation/nav.rs @@ -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, + active: bool, + folded: bool, + on_click: Option, +} + +impl NavItem { + pub fn new(id: impl Into, icon: IconName, label: impl Into) -> 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) -> 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, +} + +impl NavGroup { + pub fn new(id: impl Into, title: impl Into, 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) { + 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)) + } +} diff --git a/src/navigation/overflow.rs b/src/navigation/overflow.rs index 5870520..a69dd3e 100644 --- a/src/navigation/overflow.rs +++ b/src/navigation/overflow.rs @@ -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; +/// A small icon button that lists `rows`; picking one runs `on_pick`. +pub(crate) fn list_button( + id: &ElementId, + icon: IconName, + rows: Rc>, + selected: Option<&SharedString>, + on_pick: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + window: &mut Window, + cx: &mut App, +) -> Stateful
{ + 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 { diff --git a/src/navigation/tests.rs b/src/navigation/tests.rs index 4f27fa3..5b3a8aa 100644 --- a/src/navigation/tests.rs +++ b/src/navigation/tests.rs @@ -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)>, + went: Option, +} + +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) -> 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) -> 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, + to: Option<(usize, Option)>, +} + +impl Render for Jump { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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)))); +} diff --git a/src/navigation/toc.rs b/src/navigation/toc.rs new file mode 100644 index 0000000..6b5bc2c --- /dev/null +++ b/src/navigation/toc.rs @@ -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)>, +} + +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> { + 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], 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, + value: SharedString, + children: SmallVec<[AnyElement; 2]>, +} + +impl Anchor { + pub fn new(sections: &Entity, value: impl Into) -> Self { + Self { + sections: sections.clone(), + value: value.into(), + children: SmallVec::new(), + } + } +} + +impl ParentElement for Anchor { + fn extend(&mut self, elements: impl IntoIterator) { + 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, + entries: Vec<(SharedString, SharedString, usize)>, +} + +impl TableOfContents { + pub fn new(id: impl Into, sections: &Entity) -> 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, + label: impl Into, + 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) = (§ions.scroll, sections.scroll.bounds()); + let room = scroll.max_offset().height; + let at_end = room > Pixels::ZERO && -scroll.offset().y >= room; + let tops: Vec> = 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 = 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); + } +} diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index f9ee8eb..349df3c 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -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 { size(px_to_rems(36.0), px_to_rems(4.0)) diff --git a/tasks/ch01-10.md b/tasks/ch01-10.md index 4b75639..7c809d0 100644 --- a/tasks/ch01-10.md +++ b/tasks/ch01-10.md @@ -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