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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user