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:
21 files changed
+1548
-56
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 | |
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(§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<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,
|
||||
))
|
||||
}
|
||||
@@ -198,6 +198,8 @@ impl RenderOnce for GradientEditor {
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
.on_mouse_down(MouseButton::Left, move |event, window, cx| {
|
||||
|
||||
@@ -191,6 +191,8 @@ fn area(
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ pub(crate) fn checker(radius: Rems, cx: &App) -> impl IntoElement + use<> {
|
||||
},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full()
|
||||
}
|
||||
|
||||
|
||||
@@ -156,6 +156,8 @@ impl RenderOnce for Knob {
|
||||
},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
.when(!self.disabled, |knob| {
|
||||
|
||||
+2
-2
@@ -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;
|
||||
@@ -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| {
|
||||
|
||||
@@ -205,6 +205,8 @@ impl Track {
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
.when(!self.disabled, |inner| {
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(¤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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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
@@ -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
@@ -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))));
|
||||
}
|
||||
@@ -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) = (§ions.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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user