T07a navigation tabs and paths: tabs, editor tabs, tab overflow menu, breadcrumb, pagination, steps and wizard; shared slide, list trigger and tab stop

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 11:37:38 -04:00
1 parent 05ff67ad9f
commit 0883a999f4
50 files changed
+2490 -330

No files matched your search

+5 -1
View File
@@ -54,8 +54,10 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- The harness types with `dispatch_keystroke` through `AnyWindowHandle`, which leaves the root view free to redraw.
- The macOS open panel runs out of process too. Escape does not reach it; the harness sends it `cancel:`.
- gpui's `test-support` swaps its executor: `block` fails on a real dispatcher. It stays behind this crate's `test-support` feature, never on for the gallery.
- A tracked `FocusHandle` is a Tab stop only when built with `.tab_stop(true)`. `tab_index` on the div reaches only handles gpui makes itself.
- A tracked `FocusHandle` is a Tab stop only when built with `.tab_stop(true)`. `tab_index` on the div reaches only handles gpui makes itself. `primitives::tab_stop` keeps one per id.
- Floating lists go below their anchor, or above when only above has room (`forms::options::float`). gpui's own switch keeps the anchor point, so a flipped list would cover its trigger.
- A trigger that opens a list goes through `forms::select::listing`: toggle, keys, blur and popup live there, for Select, TabOverflowMenu and Breadcrumb alike.
- A marker that slides between items (segment thumb, tab line) measures them with `motion::slide` and eases with `glide`.
- A picker hands focus to its popup on open and back to its trigger on pick or Escape; it closes once focus leaves both (`forms::date::picker`).
- Motion for a value change keys its animation on `motion::changes`, so it replays per change and stays still on first paint.
- Thumbs stay inside their component's box: the track is padded by half a thumb (`Slider`, `ColorPicker`, `GradientEditor`).
@@ -63,6 +65,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- A rounded box does not clip its children. Each layer inside takes the radius itself; `checker` takes one.
- Only gpui can build `ExternalPaths` with paths, so drop rules live in `forms::files::dropped`, where tests reach them.
- 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.
- 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.
+6 -1
View File
@@ -29,7 +29,9 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T06e Forms · Files
- [x] T06f Forms · Other
- [x] T06g Forms · Structure
- [ ] T07 Navigation
- [x] T07a Navigation · Tabs & paths
- [ ] T07b Navigation · Places
- [ ] T07c Navigation · Palettes & tour
- [ ] T08 Menus
- [ ] T09 Overlays
- [ ] T10 Feedback
@@ -136,3 +138,6 @@ Per-component lines live in `tasks/`. Tags there:
| T06g | 1 | FAIL | form errors shared one animation id, so a later error skipped its entrance; the demo left links out of its saved snapshot; the demo let a lone @ pass as an email |
| T06g | 2 | FAIL | a field's error still keyed its entrance by the label, so twin labels in one form shared it |
| T06g | 3 | PASS | |
| 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) |
+2 -18
View File
@@ -4,29 +4,13 @@ use ely_gpui_component::{
},
primitives::IconName,
};
use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use crate::{
probe::probe,
ui::{code, section, specimen},
ui::{code, keep, section, set, specimen},
};
pub(super) fn keep<T: 'static>(
key: &'static str,
init: impl FnOnce() -> T,
window: &mut Window,
cx: &mut App,
) -> Entity<T> {
window.use_keyed_state(key, cx, move |_, _| init())
}
pub(super) fn set<T: 'static>(state: &Entity<T>, value: T, cx: &mut App) {
state.update(cx, |state, cx| {
*state = value;
cx.notify();
});
}
fn values(list: &[&'static str]) -> Vec<SharedString> {
list.iter()
.map(|value| SharedString::from(*value))
+1 -2
View File
@@ -4,10 +4,9 @@ use ely_gpui_component::{
};
use gpui::{App, Hsla, IntoElement, ParentElement, Styled, Window, div, px, rgb, rgba};
use super::choices::{keep, set};
use crate::{
probe::probe,
ui::{code, section, specimen, specimens},
ui::{code, keep, section, set, specimen, specimens},
};
fn hex(color: Hsla) -> String {
+2 -5
View File
@@ -8,13 +8,10 @@ use ely_gpui_component::forms::{
use gpui::{App, Div, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use jiff::civil::{Date, DateTime, Time, date, time};
use super::{
choices::{keep, set},
text::field,
};
use super::text::field;
use crate::{
probe::probe,
ui::{code, section, specimen, specimens},
ui::{code, keep, section, set, specimen, specimens},
};
/// Today, fixed so captures stay the same from day to day.
+1 -2
View File
@@ -3,10 +3,9 @@ use std::path::PathBuf;
use ely_gpui_component::forms::{DropZone, FileInput};
use gpui::{App, IntoElement, ParentElement, Styled, Window, div, px};
use super::choices::{keep, set};
use crate::{
probe::probe,
ui::{section, specimen, specimens},
ui::{keep, section, set, specimen, specimens},
};
fn names(paths: &[PathBuf]) -> String {
+2 -5
View File
@@ -8,13 +8,10 @@ use ely_gpui_component::{
};
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use super::{
choices::{keep, set},
text::field,
};
use super::text::field;
use crate::{
probe::probe,
ui::{section, specimen, specimens},
ui::{keep, section, set, specimen, specimens},
};
pub fn signature(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+2 -5
View File
@@ -4,13 +4,10 @@ use ely_gpui_component::{
};
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use super::{
choices::{keep, set},
text::field,
};
use super::text::field;
use crate::{
probe::probe,
ui::{blocked, section, specimen, specimens},
ui::{blocked, keep, section, set, specimen, specimens},
};
fn cities() -> Vec<Choice> {
+2 -5
View File
@@ -9,13 +9,10 @@ use gpui::{
App, AppContext, Entity, IntoElement, ParentElement, Styled, Window, div, prelude::*, px,
};
use super::{
choices::{keep, set},
text::field,
};
use super::text::field;
use crate::{
probe::probe,
ui::{section, specimen},
ui::{keep, section, set, specimen},
};
type Saved = (String, String, String, Vec<String>);
+1 -2
View File
@@ -1,10 +1,9 @@
use ely_gpui_component::forms::{Choice, ChoiceChips, Knob, RangeSlider, Rating, Slider, Stepper};
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use super::choices::{keep, set};
use crate::{
probe::probe,
ui::{section, specimen, specimens},
ui::{keep, section, set, specimen, specimens},
};
pub fn chips(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+2
View File
@@ -1,6 +1,7 @@
mod buttons;
mod forms;
mod layout;
mod navigation;
mod primitives;
mod shell;
mod theme;
@@ -27,6 +28,7 @@ pub const ALL: &[Page] = &[
shell::PAGE,
buttons::PAGE,
forms::PAGE,
navigation::PAGE,
theme::PAGE,
];
+58
View File
@@ -0,0 +1,58 @@
mod paths;
mod tabs;
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
use super::Page;
use crate::script::Step;
pub const PAGE: Page = Page {
number: 7,
slug: "navigation",
title: "Navigation",
summary: "Tabs, paths, pages and steps: where you are, and the ways out.",
render,
script: SCRIPT,
};
const SCRIPT: &[Step] = &[
Step::Rest,
Step::DownAt("tabs-top", 128.0, 14.0),
Step::UpAt("tabs-top", 128.0, 14.0),
Step::Wait(500),
Step::Shot("tabs"),
Step::Hover("editor-tabs"),
Step::Wait(300),
Step::Shot("editor-hover"),
Step::DownAt("editor-tabs", 544.0, 16.0),
Step::UpAt("editor-tabs", 544.0, 16.0),
Step::Wait(300),
Step::Shot("editor-overflow"),
Step::Key("escape"),
Step::DownAt("breadcrumb", 173.0, 10.0),
Step::UpAt("breadcrumb", 173.0, 10.0),
Step::Wait(300),
Step::Key("down"),
Step::Wait(200),
Step::Shot("breadcrumb-open"),
Step::Key("enter"),
Step::DownAt("pagination", 150.0, 10.0),
Step::UpAt("pagination", 150.0, 10.0),
Step::Wait(300),
Step::Shot("pagination"),
Step::DownAt("wizard", 531.0, 173.0),
Step::UpAt("wizard", 531.0, 173.0),
Step::Wait(500),
Step::Shot("wizard"),
Step::Rest,
];
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
div()
.child(tabs::tabs(window, cx))
.child(tabs::editor_tabs(window, cx))
.child(paths::breadcrumb(window, cx))
.child(paths::pagination(window, cx))
.child(paths::steps(window, cx))
.into_any_element()
}
+118
View File
@@ -0,0 +1,118 @@
use ely_gpui_component::{
forms::Choice,
navigation::{Breadcrumb, Crumb, Pagination, Steps, Wizard},
primitives::IconName,
typography::Paragraph,
};
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use crate::{
probe::probe,
ui::{keep, section, set, specimen, specimens},
};
pub fn breadcrumb(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let project = keep("crumb-project", || SharedString::from("ely"), window, cx);
let went = keep("crumb-went", || None::<String>, window, cx);
let now = project.read(cx).clone();
let caption = went
.read(cx)
.clone()
.unwrap_or_else(|| "click a level; one with a chevron lists its siblings".into());
section(
"Breadcrumb",
"Each level is a place. A level with siblings opens them, the way an editor's path bar does.",
cx,
)
.child(specimen(
caption,
probe(
"breadcrumb",
Breadcrumb::new(
"breadcrumb",
[
Crumb::new("home", "Home").icon(IconName::House),
Crumb::new("projects", "Projects"),
Crumb::new(now.clone(), now.clone()).siblings([
Choice::new("ely", "ely"),
Choice::new("zed", "zed"),
Choice::new("helix", "helix"),
]),
Crumb::new("src", "src"),
Crumb::new("main", "main.rs"),
],
)
.on_select(move |level, value, _, cx| {
if level == 2 {
set(&project, value.clone(), cx);
}
set(&went, Some(format!("went to {value} at level {level}")), cx);
}),
),
cx,
))
}
pub fn pagination(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let page = keep("pagination-page", || 6usize, window, cx);
let now = *page.read(cx);
section(
"Pagination",
"The first, the last, and the pages around this one. A gap stands for two or more pages.",
cx,
)
.child(specimen(
format!("page {now} of 20"),
probe(
"pagination",
Pagination::new("pagination", now, 20).on_change(move |to, _, cx| set(&page, to, cx)),
),
cx,
))
}
fn setup_steps() -> [Choice; 3] {
[
Choice::new("account", "Account").note("Name and email"),
Choice::new("plan", "Plan").note("Seats and billing"),
Choice::new("confirm", "Confirm").note("Check and start"),
]
}
pub fn steps(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let step = keep("wizard-step", || 0usize, window, cx);
let finished = keep("wizard-finished", || false, window, cx);
let (now, done) = (*step.read(cx), *finished.read(cx));
let body = match now {
0 => "Who runs this workspace? One name, one address; both can change later.",
1 => "Ten seats to start. Billing runs monthly and stops when you do.",
_ => "Ready. The workspace opens as soon as you finish.",
};
let (walk, finish) = (step.clone(), finished);
section(
"Steps / Wizard",
"Finished steps carry a check and take you back. The line into the current step fills as you advance.",
cx,
)
.child(
specimens()
.child(specimen(
"vertical",
div().w(px(240.0)).child(Steps::new("steps-vertical", setup_steps(), 1).vertical()),
cx,
))
.child(specimen(
if done { "finished" } else { "wizard" },
probe(
"wizard",
div().w(px(560.0)).child(
Wizard::new("wizard", setup_steps(), now)
.content(div().h(px(72.0)).child(Paragraph::new(body)))
.on_step(move |to, _, cx| set(&walk, to, cx))
.on_finish(move |_, cx| set(&finish, true, cx)),
),
),
cx,
)),
)
}
+195
View File
@@ -0,0 +1,195 @@
use ely_gpui_component::{
forms::Choice,
navigation::{EditorTab, EditorTabs, TabPlacement, Tabs},
primitives::IconName,
typography::Paragraph,
};
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use crate::{
probe::probe,
ui::{keep, section, set, specimen, specimens},
};
fn panel(text: &'static str) -> impl IntoElement {
div().py_2().child(Paragraph::new(text))
}
pub fn tabs(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let top = keep("tabs-top", || SharedString::from("overview"), window, cx);
let left = keep("tabs-left", || SharedString::from("general"), window, cx);
let bottom = keep("tabs-bottom", || SharedString::from("console"), window, cx);
let (top_now, left_now, bottom_now) = (
top.read(cx).clone(),
left.read(cx).clone(),
bottom.read(cx).clone(),
);
let top_panel = match top_now.as_ref() {
"overview" => "Three services, all healthy. The last deploy went out an hour ago.",
"activity" => "Twelve events since noon: four deploys, eight config reads.",
_ => "Alerts go to the on-call channel. Deploys need one review.",
};
section(
"Tabs",
"A line slides to the chosen tab. Arrows move it; disabled tabs are skipped.",
cx,
)
.child(
specimens()
.child(specimen(
"top",
probe(
"tabs-top",
div().w(px(420.0)).child(
Tabs::new(
"tabs-top",
[
Choice::new("overview", "Overview"),
Choice::new("activity", "Activity").note("12"),
Choice::new("settings", "Settings"),
Choice::new("archived", "Archived").disabled(),
],
top_now,
)
.panel(panel(top_panel))
.on_change(move |next, _, cx| set(&top, next.clone(), cx)),
),
),
cx,
))
.child(specimen(
"left",
div().w(px(420.0)).child(
Tabs::new(
"tabs-left",
[
Choice::new("general", "General").icon(IconName::Settings),
Choice::new("appearance", "Appearance").icon(IconName::Palette),
Choice::new("keys", "Keyboard").icon(IconName::Keyboard),
],
left_now,
)
.placement(TabPlacement::Left)
.panel(panel(
"Settings for this section sit here, beside their tab.",
))
.on_change(move |next, _, cx| set(&left, next.clone(), cx)),
),
cx,
))
.child(specimen(
"bottom",
div().w(px(420.0)).child(
Tabs::new(
"tabs-bottom",
[
Choice::new("console", "Console"),
Choice::new("output", "Output"),
Choice::new("problems", "Problems").note("3"),
],
bottom_now,
)
.placement(TabPlacement::Bottom)
.panel(panel("Logs stream above their tabs, as a panel dock does."))
.on_change(move |next, _, cx| set(&bottom, next.clone(), cx)),
),
cx,
)),
)
}
/// An open document, as the demo app keeps it.
#[derive(Clone)]
struct Doc {
id: &'static str,
title: &'static str,
dirty: bool,
pinned: bool,
preview: bool,
}
fn open_docs() -> Vec<Doc> {
let doc = |id, title| Doc {
id,
title,
dirty: false,
pinned: false,
preview: false,
};
vec![
Doc {
pinned: true,
..doc("cargo", "Cargo.toml")
},
doc("main", "main.rs"),
Doc {
dirty: true,
..doc("lib", "lib.rs")
},
doc("theme", "theme.rs"),
doc("tokens", "tokens.rs"),
Doc {
preview: true,
..doc("readme", "README.md")
},
]
}
fn edit(docs: &gpui::Entity<Vec<Doc>>, id: &SharedString, cx: &mut App, change: impl Fn(&mut Doc)) {
docs.update(cx, |docs, cx| {
docs.iter_mut()
.filter(|doc| doc.id == id.as_ref())
.for_each(change);
cx.notify();
})
}
pub fn editor_tabs(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let docs = keep("editor-docs", open_docs, window, cx);
let chosen = keep("editor-chosen", || SharedString::from("main"), window, cx);
let tabs: Vec<EditorTab> = docs
.read(cx)
.iter()
.map(|doc| {
let tab = EditorTab::new(doc.id, doc.title)
.dirty(doc.dirty)
.pinned(doc.pinned)
.preview(doc.preview);
if doc.title.ends_with(".rs") {
tab.icon(IconName::FileText)
} else {
tab
}
})
.collect();
let (closing, pinning, keeping, moving) = (docs.clone(), docs.clone(), docs.clone(), docs);
let (picked, select) = (chosen.read(cx).clone(), chosen);
section(
"EditorTabs / TabOverflowMenu",
"Close, pin, drag to reorder. A dot marks unsaved work until you hover; italics mark a preview. The chevron lists every tab.",
cx,
)
.child(probe(
"editor-tabs",
div().w(px(560.0)).child(
EditorTabs::new("editor-tabs", tabs)
.selected(picked)
.on_select(move |id, _, cx| set(&select, id.clone(), cx))
.on_close(move |id, _, cx| {
closing.update(cx, |docs, cx| {
docs.retain(|doc| doc.id != id.as_ref());
cx.notify();
})
})
.on_pin(move |id, _, cx| edit(&pinning, id, cx, |doc| doc.pinned = false))
.on_keep(move |id, _, cx| edit(&keeping, id, cx, |doc| doc.preview = false))
.on_reorder(move |from, to, _, cx| {
moving.update(cx, |docs, cx| {
let doc = docs.remove(from);
docs.insert(to, doc);
cx.notify();
})
}),
),
))
}
+21 -1
View File
@@ -1,5 +1,7 @@
use ely_gpui_component::theme::{ActiveTheme, TextSize};
use gpui::{App, Div, FontWeight, IntoElement, ParentElement, SharedString, Styled, div};
use gpui::{
App, Div, Entity, FontWeight, IntoElement, ParentElement, SharedString, Styled, Window, div,
};
/// Titled block of demos.
pub fn section(title: impl Into<SharedString>, note: impl Into<SharedString>, cx: &App) -> Div {
@@ -74,3 +76,21 @@ pub fn blocked(text: impl Into<SharedString>, cx: &App) -> impl IntoElement {
.child(div().size_1p5().rounded_full().bg(theme.colors.warning))
.child(text.into())
}
/// A demo's state, kept across frames under `key`.
pub fn keep<T: 'static>(
key: &'static str,
init: impl FnOnce() -> T,
window: &mut Window,
cx: &mut App,
) -> Entity<T> {
window.use_keyed_state(key, cx, move |_, _| init())
}
/// Replaces a demo's state and redraws.
pub fn set<T: 'static>(state: &Entity<T>, value: T, cx: &mut App) {
state.update(cx, |state, cx| {
*state = value;
cx.notify();
});
}
+1 -1
View File
@@ -12,7 +12,7 @@ for f in $files AGENTS.md README.md TASKS.md tasks/*.md; do
n=$(wc -l < "$f")
[ "$n" -le 500 ] || { echo "$f has $n lines"; fail=1; }
done
components=$(find src -name '*.rs' ! -path 'src/theme/tokens.rs' ! -path 'src/motion/*' ! -path '*/tests/*')
components=$(find src -name '*.rs' ! -path 'src/theme/tokens.rs' ! -path 'src/motion/*' ! -path '*/tests/*' ! -name 'tests.rs')
raw=$(awk '/#\[cfg\(test\)\]/{nextfile} /(^|[^.[:alnum:]_])px\(/{print FILENAME":"FNR": "$0}' $components)
[ -z "$raw" ] || { echo "raw px in components:"; echo "$raw"; fail=1; }
exit $fail
+38 -119
View File
@@ -1,30 +1,18 @@
use std::rc::Rc;
use gpui::{
Animation, AnimationExt, App, Bounds, ElementId, FontWeight, InteractiveElement, IntoElement,
MouseButton, ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement,
Styled, Window, canvas, div, prelude::*,
Animation, AnimationExt, App, ElementId, FontWeight, InteractiveElement, IntoElement,
MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled,
Window, div, prelude::*,
};
use super::button::label_size;
use crate::{
motion,
motion::{self, Axis, Marker, glide, measure_item, measure_origin, slide},
primitives::{FocusRing, Icon, IconName},
theme::{ActiveTheme, ControlSize, Elevation, Radius},
};
/// Left edge and width of a segment, inside the control.
type Span = (Pixels, Pixels);
#[derive(Default)]
struct Slide {
origin: Pixels,
spans: Vec<Option<Span>>,
from: Option<Span>,
last: Option<SharedString>,
generation: u64,
}
type OnChange = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// Mutually exclusive segments. A thumb slides to the chosen one.
@@ -72,65 +60,47 @@ impl SegmentedControl {
}
}
/// Position along a slide from `from` to `to`, overshooting a little.
fn glide(from: Span, to: Span, t: f32) -> Span {
let s = motion::spring(t);
let lerp = |a: Pixels, b: Pixels| a + (b - a) * s;
(lerp(from.0, to.0), lerp(from.1, to.1))
}
impl RenderOnce for SegmentedControl {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let chosen = self
let values: Vec<SharedString> = self
.segments
.iter()
.position(|(value, ..)| *value == self.selected)
.map(|(value, ..)| value.clone())
.collect();
let chosen = values
.iter()
.position(|value| *value == self.selected)
.unwrap_or_else(|| panic!("segmented control has no segment {}", self.selected));
let count = self.segments.len();
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Slide::default());
if state.read(cx).spans.len() != count {
state.update(cx, |slide, _| slide.spans = vec![None; count]);
}
if state.read(cx).last.as_ref() != Some(&self.selected) {
state.update(cx, |slide, _| {
let previous = slide
.last
.as_ref()
.and_then(|last| self.segments.iter().position(|(value, ..)| value == last));
slide.from = previous.and_then(|ix| slide.spans[ix]);
slide.last = Some(self.selected.clone());
slide.generation += 1;
});
}
let (to, from, generation) = {
let slide = state.read(cx);
(slide.spans[chosen], slide.from, slide.generation)
};
let (state, marker) = slide(self.id.clone(), &values, &self.selected, window, cx);
let theme = cx.theme();
let colors = &theme.colors;
let (text, icon_size) = label_size(self.size);
let duration = motion::duration(motion::SLOW, cx);
let thumb = to.map(|to| {
let from = from.unwrap_or(to);
div()
.absolute()
.top_0p5()
.bottom_0p5()
.rounded(theme.radius(Radius::Md))
.bg(colors.surface)
.border_1()
.border_color(colors.border)
.shadow(theme.elevation(Elevation::Raised))
.with_animation(
("segment-thumb", generation),
Animation::new(duration),
move |thumb, t| {
let (left, width) = glide(from, to, t);
thumb.left(left).w(width)
},
)
});
let origin_state = state.clone();
let thumb = marker.map(
|Marker {
from,
to,
generation,
}| {
div()
.absolute()
.top_0p5()
.bottom_0p5()
.rounded(theme.radius(Radius::Md))
.bg(colors.surface)
.border_1()
.border_color(colors.border)
.shadow(theme.elevation(Elevation::Raised))
.with_animation(
("segment-thumb", generation),
Animation::new(duration),
move |thumb, t| {
let (left, width) = glide(from, to, t);
thumb.left(left).w(width)
},
)
},
);
let segments = self
.segments
.into_iter()
@@ -176,27 +146,7 @@ impl RenderOnce for SegmentedControl {
segment.child(Icon::new(icon).size(icon_size).color(fg))
})
.child(label)
.child(
canvas(
move |bounds: Bounds<Pixels>, _, cx| {
let span = Some((
bounds.left() - measure.read(cx).origin,
bounds.size.width,
));
if measure.read(cx).spans.get(ix) != Some(&span) {
measure.update(cx, |slide, cx| {
slide.spans[ix] = span;
cx.notify();
});
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.child(measure_item(measure, ix, Axis::Horizontal))
});
div()
.id(self.id)
@@ -208,39 +158,8 @@ impl RenderOnce for SegmentedControl {
.bg(colors.sunken)
.border_1()
.border_color(colors.border)
.child(
canvas(
move |bounds: Bounds<Pixels>, _, cx| {
if origin_state.read(cx).origin != bounds.left() {
origin_state.update(cx, |slide, cx| {
slide.origin = bounds.left();
cx.notify();
});
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.child(measure_origin(state.clone(), Axis::Horizontal))
.children(thumb)
.children(segments)
}
}
#[cfg(test)]
mod tests {
use gpui::px;
use super::glide;
#[test]
fn glide_starts_at_from_and_settles_on_to() {
let (from, to) = ((px(0.0), px(40.0)), (px(100.0), px(60.0)));
assert_eq!(glide(from, to, 0.0), from);
let (left, width) = glide(from, to, 1.0);
assert!((f32::from(left) - 100.0).abs() < 0.5 && (f32::from(width) - 60.0).abs() < 0.5);
}
}
+2 -2
View File
@@ -8,13 +8,13 @@ use gpui::{
use super::{
CheckState,
check::{check_mark, tab_stop},
check::check_mark,
options::{OnFlag, Run},
radio::radio_mark,
};
use crate::{
motion,
primitives::{Icon, IconName},
primitives::{Icon, IconName, tab_stop},
theme::{ActiveTheme, IconSize, Radius, TextSize},
};
+1 -2
View File
@@ -8,12 +8,11 @@ use gpui::{
use super::{
Choice,
check::tab_stop,
options::{OnValues, float, option_row, surface},
select::{field_button, field_text},
};
use crate::{
primitives::{Icon, IconName},
primitives::{Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize},
};
+4 -18
View File
@@ -1,9 +1,9 @@
use std::rc::Rc;
use gpui::{
Animation, AnimationExt, AnyElement, App, ElementId, FocusHandle, InteractiveElement,
IntoElement, MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement,
Styled, Window, div, prelude::*,
Animation, AnimationExt, AnyElement, App, ElementId, InteractiveElement, IntoElement,
MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled,
Window, div, prelude::*,
};
use super::{
@@ -12,7 +12,7 @@ use super::{
};
use crate::{
motion,
primitives::{Icon, IconName},
primitives::{Icon, IconName, tab_stop},
theme::{ActiveTheme, IconSize, Mix, Radius, TextSize},
};
@@ -31,20 +31,6 @@ impl From<bool> for CheckState {
}
}
/// A focus handle kept for `id`, a Tab stop while enabled.
pub(crate) fn tab_stop(
id: ElementId,
enabled: bool,
window: &mut Window,
cx: &mut App,
) -> FocusHandle {
window
.use_keyed_state(id, cx, |_, cx| cx.focus_handle())
.read(cx)
.clone()
.tab_stop(enabled)
}
/// The box alone, shared by checkboxes, cards and lists. `changes` replays its motion.
pub(crate) fn check_mark(
state: CheckState,
+2 -1
View File
@@ -6,9 +6,10 @@ use gpui::{
Styled, Window, canvas, div, linear_color_stop, linear_gradient, prelude::*, relative,
};
use super::{super::check::tab_stop, ColorPicker, swatch::checker};
use super::{ColorPicker, swatch::checker};
use crate::{
layout::seeded::{Seeded, use_seeded},
primitives::tab_stop,
theme::{ActiveTheme, ControlSize, Mix, Radius},
};
+2 -5
View File
@@ -7,15 +7,12 @@ use gpui::{
};
use jiff::{ToSpan, civil::Date};
use super::{
super::{check::tab_stop, options::Run},
zoned_now,
};
use super::{super::options::Run, zoned_now};
use crate::{
buttons::{ButtonVariant, IconButton},
layout::seeded::use_seeded,
motion,
primitives::IconName,
primitives::{IconName, tab_stop},
theme::{ActiveTheme, ControlSize, Radius, TextSize},
};
+1 -2
View File
@@ -9,7 +9,6 @@ use jiff::civil::Date;
use super::{
super::{
check::tab_stop,
options::{Run, float, surface},
select::{field_button, field_text},
},
@@ -18,7 +17,7 @@ use super::{
show_date, show_span,
};
use crate::{
primitives::{Icon, IconName},
primitives::{Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize},
};
+1 -2
View File
@@ -10,13 +10,12 @@ use gpui::{
};
use super::{
check::tab_stop,
path::choose,
select::{field_button, field_text},
};
use crate::{
buttons::{Button, ButtonVariant, IconButton},
primitives::{Icon, IconName},
primitives::{Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
+1 -2
View File
@@ -9,11 +9,10 @@ use gpui::{
use super::{
Input, TextInput,
check::tab_stop,
text::{Down, Enter},
};
use crate::{
primitives::{Icon, IconName, Tooltip},
primitives::{Icon, IconName, Tooltip, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
typography::Emoji,
};
+4 -2
View File
@@ -7,11 +7,13 @@ use gpui::{
};
use super::{
check::tab_stop,
options::OnNumber,
slider::{fraction, keyed, value_at},
};
use crate::theme::{ActiveTheme, Elevation};
use crate::{
primitives::tab_stop,
theme::{ActiveTheme, Elevation},
};
/// Where the dial starts, at lower left, and how far it turns.
const START: f32 = 0.75 * PI;
+5 -2
View File
@@ -7,10 +7,13 @@ use gpui::{
use super::{
Choice,
check::{tab_stop, toggled},
check::toggled,
options::{OnValues, Pick, option_row, step},
};
use crate::theme::{ActiveTheme, Radius, TextSize};
use crate::{
primitives::tab_stop,
theme::{ActiveTheme, Radius, TextSize},
};
/// `selected` after choosing row `ix`: one row replaces, several toggle.
pub(crate) fn chosen(
+2
View File
@@ -70,6 +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 use pairs::{FieldArray, KeyValueInput, ListInput};
pub use path::PathInput;
pub use pattern::{RegexInput, regex_highlights};
@@ -78,6 +79,7 @@ pub use radio::{Radio, RadioGroup};
pub use rating::Rating;
pub use search::SearchInput;
pub use select::Select;
pub(crate) use select::{Listing, listing};
pub use signature::{SignaturePad, Stroke};
pub use slider::{RangeSlider, Slider};
pub use stepper::Stepper;
+1 -2
View File
@@ -7,14 +7,13 @@ use gpui::{
use super::{
Choice,
check::tab_stop,
listbox::chosen,
options::{OnValues, Pick, Popup, step},
select::{Picker, measure_anchor, moved},
tags::chip,
};
use crate::{
primitives::{Icon, IconName},
primitives::{Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
+1 -1
View File
@@ -8,11 +8,11 @@ use gpui::{
use super::{
Choice,
check::tab_stop,
options::{OnValue, Run, step},
};
use crate::{
motion,
primitives::tab_stop,
theme::{ActiveTheme, TextSize},
};
+4 -2
View File
@@ -9,8 +9,10 @@ use gpui::{
point, prelude::*, transparent_black,
};
use super::check::tab_stop;
use crate::theme::{ActiveTheme, IconSize, Radius};
use crate::{
primitives::tab_stop,
theme::{ActiveTheme, IconSize, Radius},
};
/// A five-point star inside `bounds`, filled or outlined with Lucide's stroke ratio.
fn star(bounds: Bounds<Pixels>, fill: bool) -> Path<Pixels> {
+143 -105
View File
@@ -8,12 +8,11 @@ use gpui::{
use super::{
Choice,
check::tab_stop,
input::text_size,
options::{OnValue, Pick, Popup, step},
};
use crate::{
primitives::{Icon, IconName},
primitives::{Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, Radius},
};
@@ -125,6 +124,114 @@ pub(crate) fn field_text(
.child(text.unwrap_or(placeholder))
}
/// The list a trigger opens: its rows, the one chosen, and whether the trigger holds focus.
pub(crate) struct Listing<'a> {
pub id: &'a ElementId,
pub rows: Rc<Vec<Choice>>,
pub selected: Option<&'a SharedString>,
pub focused: bool,
}
/// Opens `list` from `trigger`: a press or Down toggles it, arrows walk it, Enter picks, Escape or blur closes.
pub(crate) fn listing(
list: Listing,
trigger: Stateful<Div>,
on_pick: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
window: &mut Window,
cx: &mut App,
) -> Stateful<Div> {
let Listing {
id,
rows,
selected,
focused,
} = list;
let picker = window.use_keyed_state((id.clone(), "picker"), cx, |_, _| Picker::default());
if picker.read(cx).open && !focused {
log::info!("list {id:?}: closed on blur");
picker.update(cx, |picker, _| picker.open = false);
}
let current = selected.and_then(|value| rows.iter().position(|row| row.value == *value));
let start = current.unwrap_or_else(|| step(&rows, rows.len() - 1, 1));
if picker.read(cx).highlighted >= rows.len() {
picker.update(cx, |picker, _| picker.highlighted = start);
}
let (open, highlighted, anchor, scroll) = {
let picker = picker.read(cx);
(
picker.open,
picker.highlighted,
picker.anchor,
picker.scroll.clone(),
)
};
let pick: Pick = {
let (rows, picker) = (rows.clone(), picker.clone());
Rc::new(move |ix, window, cx| {
if rows[ix].disabled {
return;
}
Picker::show(&picker, false, ix, cx);
on_pick(&rows[ix].value, window, cx);
})
};
let chosen: Vec<SharedString> = current
.map(|ix| rows[ix].value.clone())
.into_iter()
.collect();
let (toggle, keys, close, keyed_rows, enter) = (
picker.clone(),
picker.clone(),
picker.clone(),
rows.clone(),
pick.clone(),
);
trigger
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
Picker::show(&toggle, !open, start, cx)
})
.on_key_down(move |event, window, cx| {
let at = keys.read(cx).highlighted;
let key = event.keystroke.key.as_str();
if !open {
if matches!(key, "down" | "up" | "enter" | "space") {
cx.stop_propagation();
Picker::show(&keys, true, start, cx);
}
return;
}
if let Some(to) = moved(event, &keyed_rows, at) {
cx.stop_propagation();
Picker::show(&keys, true, to, cx);
} else if matches!(key, "enter" | "space") {
cx.stop_propagation();
enter(at, window, cx);
} else if key == "escape" {
cx.stop_propagation();
Picker::show(&keys, false, at, cx);
}
})
.child(measure_anchor(picker))
.when(open, |trigger| {
trigger.child(
Popup {
id: (id.clone(), "list").into(),
anchor,
rows: &rows,
highlighted: Some(highlighted),
checked: Some(&chosen),
pick,
dismiss: Some(Rc::new(move |_, cx| {
let at = close.read(cx).highlighted;
Picker::show(&close, false, at, cx)
})),
scroll: Some(&scroll),
}
.render(window, cx),
)
})
}
/// A button that opens a list and shows the one chosen.
#[derive(IntoElement)]
pub struct Select {
@@ -192,62 +299,14 @@ impl RenderOnce for Select {
window,
cx,
);
let focused = focus.is_focused(window);
let picker =
window.use_keyed_state((self.id.clone(), "picker"), cx, |_, _| Picker::default());
if picker.read(cx).open && !focused {
log::info!("select {:?}: closed on blur", self.id);
picker.update(cx, |picker, _| picker.open = false);
}
let choices = Rc::new(self.choices);
let current = self
let shown = self
.selected
.as_ref()
.and_then(|value| choices.iter().position(|choice| choice.value == *value));
let start = current.unwrap_or_else(|| step(&choices, choices.len() - 1, 1));
if picker.read(cx).highlighted >= choices.len() {
picker.update(cx, |picker, _| picker.highlighted = start);
}
let (open, highlighted, anchor, scroll) = {
let picker = picker.read(cx);
(
picker.open,
picker.highlighted,
picker.anchor,
picker.scroll.clone(),
)
};
let pick: Pick = {
let (id, choices, picker, on_change) = (
self.id.clone(),
choices.clone(),
picker.clone(),
self.on_change,
);
Rc::new(move |ix, window, cx| {
if choices[ix].disabled {
return;
}
let value = choices[ix].value.clone();
log::info!("select {id:?}: {value}");
Picker::show(&picker, false, ix, cx);
if let Some(on_change) = &on_change {
on_change(&value, window, cx);
}
})
};
let theme = cx.theme();
let colors = &theme.colors;
let shown = current.map(|ix| &choices[ix]);
let (toggle, keys, rows, close) = (
picker.clone(),
picker.clone(),
choices.clone(),
picker.clone(),
);
let enter = pick.clone();
let chosen: Vec<SharedString> = shown.iter().map(|choice| choice.value.clone()).collect();
field_button(
.and_then(|value| choices.iter().find(|choice| choice.value == *value))
.cloned();
let colors = &cx.theme().colors;
let trigger = field_button(
self.id.clone(),
&focus,
self.size,
@@ -255,38 +314,14 @@ impl RenderOnce for Select {
window,
cx,
)
.when(!self.disabled, |trigger| {
trigger
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
Picker::show(&toggle, !open, start, cx)
})
.on_key_down(move |event, window, cx| {
let at = keys.read(cx).highlighted;
let key = event.keystroke.key.as_str();
if !open {
if matches!(key, "down" | "up" | "enter" | "space") {
cx.stop_propagation();
Picker::show(&keys, true, start, cx);
}
return;
}
if let Some(to) = moved(event, &rows, at) {
cx.stop_propagation();
Picker::show(&keys, true, to, cx);
} else if matches!(key, "enter" | "space") {
cx.stop_propagation();
enter(at, window, cx);
} else if key == "escape" {
cx.stop_propagation();
Picker::show(&keys, false, at, cx);
}
})
})
.when_some(shown.and_then(|choice| choice.icon), |trigger, icon| {
trigger.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted))
})
.when_some(
shown.as_ref().and_then(|choice| choice.icon),
|trigger, icon| {
trigger.child(Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted))
},
)
.child(field_text(
shown.map(|choice| choice.label.clone()),
shown.map(|choice| choice.label),
self.placeholder,
self.disabled,
cx,
@@ -295,25 +330,28 @@ impl RenderOnce for Select {
Icon::new(IconName::ChevronsUpDown)
.size(IconSize::Xs)
.color(colors.fg_subtle),
)
.child(measure_anchor(picker))
.when(open, |trigger| {
trigger.child(
Popup {
id: (self.id, "list").into(),
anchor,
rows: &choices,
highlighted: Some(highlighted),
checked: Some(&chosen),
pick,
dismiss: Some(Rc::new(move |_, cx| {
let at = close.read(cx).highlighted;
Picker::show(&close, false, at, cx)
})),
scroll: Some(&scroll),
);
if self.disabled {
return trigger;
}
let (id, on_change) = (self.id.clone(), self.on_change);
let list = Listing {
id: &self.id,
rows: choices,
selected: self.selected.as_ref(),
focused: focus.is_focused(window),
};
listing(
list,
trigger,
move |value, window, cx| {
log::info!("select {id:?}: {value}");
if let Some(on_change) = &on_change {
on_change(value, window, cx);
}
.render(window, cx),
)
})
},
window,
cx,
)
}
}
+5 -2
View File
@@ -6,8 +6,11 @@ use gpui::{
Styled, Window, canvas, div, prelude::*, relative,
};
use super::{check::tab_stop, options::OnNumber};
use crate::theme::{ActiveTheme, Elevation};
use super::options::OnNumber;
use crate::{
primitives::tab_stop,
theme::{ActiveTheme, Elevation},
};
/// `value` as a fraction of `min..=max`.
pub(crate) fn fraction(value: f64, min: f64, max: f64) -> f32 {
+2 -1
View File
@@ -6,9 +6,10 @@ use gpui::{
prelude::*, transparent_black,
};
use super::{check::tab_stop, options::OnFlag};
use super::options::OnFlag;
use crate::{
motion,
primitives::tab_stop,
theme::{ActiveTheme, Elevation, Mix, TextSize},
};
+1
View File
@@ -4,6 +4,7 @@ pub mod buttons;
pub mod forms;
pub mod layout;
pub mod motion;
pub mod navigation;
pub mod primitives;
pub mod shell;
pub mod theme;
+2
View File
@@ -1,5 +1,7 @@
mod changes;
mod curve;
mod slide;
pub(crate) use changes::changes;
pub use curve::*;
pub(crate) use slide::{Axis, Marker, glide, measure_item, measure_origin, slide};
+147
View File
@@ -0,0 +1,147 @@
use gpui::{
App, Bounds, ElementId, Entity, IntoElement, Pixels, SharedString, Styled, Window, canvas,
};
use super::spring;
/// Start and length of an item along the axis a marker slides on.
pub(crate) type Span = (Pixels, Pixels);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Axis {
Horizontal,
Vertical,
}
impl Axis {
fn span(self, bounds: Bounds<Pixels>, origin: Pixels) -> Span {
match self {
Axis::Horizontal => (bounds.left() - origin, bounds.size.width),
Axis::Vertical => (bounds.top() - origin, bounds.size.height),
}
}
fn start(self, bounds: Bounds<Pixels>) -> Pixels {
match self {
Axis::Horizontal => bounds.left(),
Axis::Vertical => bounds.top(),
}
}
}
/// Where a sliding marker's items sit, and where it last stood.
#[derive(Default)]
pub(crate) struct Slide {
origin: Pixels,
spans: Vec<Option<Span>>,
from: Option<Span>,
last: Option<SharedString>,
generation: u64,
}
/// A marker's trip: from the last choice to this one, keyed by `generation`.
pub(crate) struct Marker {
pub from: Span,
pub to: Span,
pub generation: u64,
}
/// The slide over `values` with `selected` chosen; no marker until that item is measured.
pub(crate) fn slide(
id: impl Into<ElementId>,
values: &[SharedString],
selected: &SharedString,
window: &mut Window,
cx: &mut App,
) -> (Entity<Slide>, Option<Marker>) {
let chosen = values
.iter()
.position(|value| value == selected)
.unwrap_or_else(|| panic!("no item {selected} to slide to"));
let state = window.use_keyed_state(id, cx, |_, _| Slide::default());
if state.read(cx).spans.len() != values.len() {
state.update(cx, |slide, _| slide.spans = vec![None; values.len()]);
}
if state.read(cx).last.as_ref() != Some(selected) {
state.update(cx, |slide, _| {
let previous = slide
.last
.as_ref()
.and_then(|last| values.iter().position(|value| value == last));
slide.from = previous.and_then(|ix| slide.spans[ix]);
slide.last = Some(selected.clone());
slide.generation += 1;
});
}
let marker = {
let slide = state.read(cx);
slide.spans[chosen].map(|to| Marker {
from: slide.from.unwrap_or(to),
to,
generation: slide.generation,
})
};
(state, marker)
}
/// Records item `ix`'s span; place it as an absolute child that fills the item.
pub(crate) fn measure_item(state: Entity<Slide>, ix: usize, axis: Axis) -> impl IntoElement {
canvas(
move |bounds: Bounds<Pixels>, _, cx| {
let span = Some(axis.span(bounds, state.read(cx).origin));
if state.read(cx).spans.get(ix) != Some(&span) {
state.update(cx, |slide, cx| {
slide.spans[ix] = span;
cx.notify();
});
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full()
}
/// Records where the container starts; place it as an absolute child that fills it.
pub(crate) fn measure_origin(state: Entity<Slide>, axis: Axis) -> impl IntoElement {
canvas(
move |bounds: Bounds<Pixels>, _, cx| {
let start = axis.start(bounds);
if state.read(cx).origin != start {
state.update(cx, |slide, cx| {
slide.origin = start;
cx.notify();
});
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full()
}
/// A marker's span at `t` of its trip, overshooting a little.
pub(crate) fn glide(from: Span, to: Span, t: f32) -> Span {
let s = spring(t);
let lerp = |a: Pixels, b: Pixels| a + (b - a) * s;
(lerp(from.0, to.0), lerp(from.1, to.1))
}
#[cfg(test)]
mod tests {
use gpui::px;
use super::glide;
#[test]
fn glide_starts_at_from_and_settles_on_to() {
let (from, to) = ((px(0.0), px(40.0)), (px(100.0), px(60.0)));
assert_eq!(glide(from, to, 0.0), from);
let (left, width) = glide(from, to, 1.0);
assert!((f32::from(left) - 100.0).abs() < 0.5 && (f32::from(width) - 60.0).abs() < 0.5);
}
}
+183
View File
@@ -0,0 +1,183 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
};
use crate::{
forms::{Choice, Listing, listing},
primitives::{Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
/// One level of a path, and the siblings a click on it lists.
#[derive(Clone, Debug)]
pub struct Crumb {
value: SharedString,
label: SharedString,
icon: Option<IconName>,
siblings: Vec<Choice>,
}
impl Crumb {
pub fn new(value: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
value: value.into(),
label: label.into(),
icon: None,
siblings: Vec::new(),
}
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
/// The level's neighbors, itself among them; a click lists them.
pub fn siblings(mut self, siblings: impl IntoIterator<Item = Choice>) -> Self {
self.siblings = siblings.into_iter().collect();
assert!(
self.siblings
.iter()
.any(|sibling| sibling.value == self.value),
"crumb {} is not among its siblings",
self.value
);
self
}
}
type OnLevel = Rc<dyn Fn(usize, &SharedString, &mut Window, &mut App)>;
/// A path of levels. A level with siblings lists them on click; an earlier level goes to itself.
#[derive(IntoElement)]
pub struct Breadcrumb {
id: ElementId,
crumbs: Vec<Crumb>,
on_select: Option<OnLevel>,
}
impl Breadcrumb {
pub fn new(id: impl Into<ElementId>, crumbs: impl IntoIterator<Item = Crumb>) -> Self {
Self {
id: id.into(),
crumbs: crumbs.into_iter().collect(),
on_select: None,
}
}
/// Runs with the level and the value chosen there.
pub fn on_select(
mut self,
handler: impl Fn(usize, &SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Breadcrumb {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
assert!(
!self.crumbs.is_empty(),
"breadcrumb {:?} has no levels",
self.id
);
let last = self.crumbs.len() - 1;
let mut levels: Vec<AnyElement> = Vec::new();
for (level, crumb) in self.crumbs.into_iter().enumerate() {
let here: ElementId = (self.id.clone(), format!("crumb-{level}")).into();
let listed = !crumb.siblings.is_empty();
let active = listed || level < last;
let focus = tab_stop((here.clone(), "focus").into(), active, window, cx);
let focused = focus.is_focused(window);
let theme = cx.theme();
let colors = &theme.colors;
let fg = if level == last {
colors.fg
} else {
colors.fg_muted
};
let trigger = div()
.id(here.clone())
.track_focus(&focus)
.relative()
.flex()
.items_center()
.gap_1()
.h(theme.control_height(ControlSize::Sm))
.px_1p5()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(if focused {
colors.focus
} else {
gpui::transparent_black()
})
.text_size(theme.text_size(TextSize::Sm))
.font_weight(if level == last {
FontWeight::MEDIUM
} else {
FontWeight::NORMAL
})
.text_color(fg)
.when(active, |crumb| {
crumb
.cursor_pointer()
.hover(|style| style.bg(colors.hover).text_color(colors.fg))
})
.when_some(crumb.icon, |el, icon| {
el.child(Icon::new(icon).size(IconSize::Sm).color(fg))
})
.child(crumb.label.clone())
.when(listed, |el| {
el.child(
Icon::new(IconName::ChevronDown)
.size(IconSize::Xs)
.color(colors.fg_subtle),
)
});
let on_select = self.on_select.clone();
let (id, value) = (self.id.clone(), crumb.value.clone());
let go = move |value: &SharedString, window: &mut Window, cx: &mut App| {
log::info!("breadcrumb {id:?}: level {level} -> {value}");
if let Some(on_select) = &on_select {
on_select(level, value, window, cx);
}
};
let element = if listed {
let list = Listing {
id: &here,
rows: Rc::new(crumb.siblings),
selected: Some(&crumb.value),
focused,
};
listing(list, trigger, go, window, cx).into_any_element()
} else if active {
trigger
.on_click(move |_, window, cx| go(&value, window, cx))
.into_any_element()
} else {
trigger.into_any_element()
};
if level > 0 {
levels.push(
Icon::new(IconName::ChevronRight)
.size(IconSize::Xs)
.color(cx.theme().colors.fg_subtle)
.into_any_element(),
);
}
levels.push(element);
}
div()
.id(self.id)
.flex()
.flex_wrap()
.items_center()
.gap_0p5()
.children(levels)
}
}
+380
View File
@@ -0,0 +1,380 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, EntityId, InteractiveElement, IntoElement, MouseButton,
ParentElement, Pixels, RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement,
Styled, Window, canvas, div, prelude::*,
};
use super::TabOverflowMenu;
use crate::{
buttons::{ButtonVariant, IconButton},
forms::Choice,
primitives::{DragGhost, Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
};
/// One open document: its id, title and state.
#[derive(Clone, Debug)]
pub struct EditorTab {
id: SharedString,
title: SharedString,
icon: Option<IconName>,
dirty: bool,
pinned: bool,
preview: bool,
}
impl EditorTab {
pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
title: title.into(),
icon: None,
dirty: false,
pinned: false,
preview: false,
}
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
/// Unsaved work: a dot that turns into the close button on hover.
pub fn dirty(mut self, dirty: bool) -> Self {
self.dirty = dirty;
self
}
/// A pin in place of the close button.
pub fn pinned(mut self, pinned: bool) -> Self {
self.pinned = pinned;
self
}
/// A tab the next preview replaces, shown in italics.
pub fn preview(mut self, preview: bool) -> Self {
self.preview = preview;
self
}
}
struct TabDrag {
owner: EntityId,
from: usize,
}
type OnTab = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
type OnReorder = Rc<dyn Fn(usize, usize, &mut Window, &mut App)>;
/// Document tabs: close, pin, drag to reorder, keep a preview. Every tab is listed at the end.
#[derive(IntoElement)]
pub struct EditorTabs {
id: ElementId,
tabs: Vec<EditorTab>,
selected: Option<SharedString>,
on_select: Option<OnTab>,
on_close: Option<OnTab>,
on_pin: Option<OnTab>,
on_keep: Option<OnTab>,
on_reorder: Option<OnReorder>,
}
impl EditorTabs {
pub fn new(id: impl Into<ElementId>, tabs: impl IntoIterator<Item = EditorTab>) -> Self {
Self {
id: id.into(),
tabs: tabs.into_iter().collect(),
selected: None,
on_select: None,
on_close: None,
on_pin: None,
on_keep: None,
on_reorder: None,
}
}
pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
self.selected = Some(id.into());
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
}
/// The close button, a middle click, or the button over a dirty dot.
pub fn on_close(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_close = Some(Rc::new(handler));
self
}
/// The pin on a pinned tab, which unpins it.
pub fn on_pin(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_pin = Some(Rc::new(handler));
self
}
/// A double click on a preview tab, which keeps it open.
pub fn on_keep(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_keep = Some(Rc::new(handler));
self
}
/// Reports `(from, to)` when a tab is dropped on another.
pub fn on_reorder(
mut self,
handler: impl Fn(usize, usize, &mut Window, &mut App) + 'static,
) -> Self {
self.on_reorder = Some(Rc::new(handler));
self
}
}
fn run(handler: &Option<OnTab>, what: &str, id: &SharedString, window: &mut Window, cx: &mut App) {
log::info!("editor tabs: {what} {id}");
if let Some(handler) = handler {
handler(id, window, cx);
}
}
impl RenderOnce for EditorTabs {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let owner = window
.use_keyed_state((self.id.clone(), "drag"), cx, |_, _| ())
.entity_id();
let scroll = window
.use_keyed_state((self.id.clone(), "scroll"), cx, |_, _| ScrollHandle::new())
.read(cx)
.clone();
let chosen = self
.selected
.as_ref()
.and_then(|id| self.tabs.iter().position(|tab| tab.id == *id));
let followed = window.use_keyed_state((self.id.clone(), "followed"), cx, |_, _| {
None::<Option<SharedString>>
});
let follow = (followed.read(cx).as_ref() != Some(&self.selected))
.then(|| (followed, self.selected.clone(), scroll.clone()));
let theme = cx.theme();
let colors = &theme.colors;
let wide = theme.tab_width().1;
let tabs: Vec<AnyElement> = self
.tabs
.iter()
.enumerate()
.map(|(ix, tab)| {
let on = chosen == Some(ix);
let group = SharedString::from(format!("editor-tab-{}", tab.id));
let fg = if on { colors.fg } else { colors.fg_muted };
let (select, keep, close) = (
self.on_select.clone(),
self.on_keep.clone(),
self.on_close.clone(),
);
let (id, middle_id, preview) = (tab.id.clone(), tab.id.clone(), tab.preview);
let slot_id = tab.id.clone();
let slot: AnyElement = if tab.pinned {
let pin = self.on_pin.clone();
IconButton::new(("pin", ix), IconName::Pin)
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.tooltip("Unpin")
.on_click(move |_, window, cx| {
cx.stop_propagation();
run(&pin, "unpin", &slot_id, window, cx);
})
.into_any_element()
} else {
let close_button = {
let close = self.on_close.clone();
IconButton::new(("close", ix), IconName::X)
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.tooltip("Close")
.on_click(move |_, window, cx| {
cx.stop_propagation();
run(&close, "close", &slot_id, window, cx);
})
};
let hidden = tab.dirty || !on;
div()
.relative()
.flex_none()
.size(theme.control_height(ControlSize::Sm))
.when(tab.dirty, |slot| {
slot.child(
div()
.absolute()
.size_full()
.flex()
.items_center()
.justify_center()
.group_hover(group.clone(), |style| style.invisible())
.child(div().size(theme.status_dot()).rounded_full().bg(fg)),
)
})
.child(
div()
.absolute()
.size_full()
.when(hidden, |cover| {
cover
.invisible()
.group_hover(group.clone(), |style| style.visible())
})
.child(close_button),
)
.into_any_element()
};
let reorder = self.on_reorder.clone();
let (title, icon, edge) = (tab.title.clone(), tab.icon, colors.focus);
div()
.id(tab.id.clone())
.group(group)
.relative()
.flex()
.flex_none()
.items_center()
.gap_1p5()
.max_w(wide)
.h(theme.control_height(ControlSize::Lg))
.pl_3()
.pr_1()
.border_r_1()
.border_color(colors.border)
.text_size(theme.text_size(TextSize::Sm))
.text_color(fg)
.map(|el| {
if on {
el.bg(colors.bg).child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.h(theme.tab_indicator())
.bg(colors.accent),
)
} else {
el.hover(|style| style.text_color(colors.fg))
}
})
.when(preview, |el| el.italic())
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_mouse_down(MouseButton::Middle, move |_, window, cx| {
run(&close, "close", &middle_id, window, cx)
})
.on_click(move |event, window, cx| {
if preview && event.click_count() == 2 {
run(&keep, "keep", &id, window, cx);
} else {
run(&select, "select", &id, window, cx);
}
})
.when_some(reorder, |el, reorder| {
el.on_drag(TabDrag { owner, from: ix }, move |_, _, _, cx| {
DragGhost::new(title.clone(), icon, cx)
})
.drag_over::<TabDrag>(move |style, drag, _, _| {
if drag.owner == owner {
style.border_color(edge)
} else {
style
}
})
.on_drop(move |drag: &TabDrag, window, cx| {
if drag.owner == owner && drag.from != ix {
log::info!("editor tabs: move {} -> {ix}", drag.from);
reorder(drag.from, ix, window, cx);
}
})
})
.when_some(tab.icon, |el, icon| {
el.child(Icon::new(icon).size(IconSize::Sm).color(fg))
})
.child(
div()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_ellipsis()
.child(tab.title.clone()),
)
.child(slot)
.into_any_element()
})
.collect();
let listed: Vec<Choice> = self
.tabs
.iter()
.map(|tab| {
let choice = Choice::new(tab.id.clone(), tab.title.clone());
match tab.icon {
Some(icon) => choice.icon(icon),
None => choice,
}
})
.collect();
let overflow = (!listed.is_empty()).then(|| {
let select = self.on_select.clone();
let menu = TabOverflowMenu::new((self.id.clone(), "overflow"), listed)
.on_select(move |id, window, cx| run(&select, "select", id, window, cx));
match &self.selected {
Some(id) => menu.selected(id.clone()),
None => menu,
}
});
div()
.id(self.id)
.flex()
.items_center()
.w_full()
.bg(colors.sunken)
.border_b_1()
.border_color(colors.border)
.child(
div()
.id("strip")
.flex()
.flex_1()
.min_w_0()
.overflow_x_scroll()
.track_scroll(&scroll)
.children(tabs)
.children(follow.map(|(followed, selected, scroll)| {
canvas(
move |_, window, cx| {
if scroll.bounds().size.width <= Pixels::ZERO {
return;
}
followed.update(cx, |followed, _| *followed = Some(selected));
if let Some(ix) = chosen {
scroll.scroll_to_item(ix);
window.request_animation_frame();
}
},
|_, _, _, _| {},
)
.absolute()
})),
)
.children(overflow.map(|menu| div().flex_none().px_1().child(menu)))
}
}
+15
View File
@@ -0,0 +1,15 @@
mod breadcrumb;
mod editor_tabs;
mod overflow;
mod pages;
mod steps;
mod tabs;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use breadcrumb::{Breadcrumb, Crumb};
pub use editor_tabs::{EditorTab, EditorTabs};
pub use overflow::TabOverflowMenu;
pub use pages::Pagination;
pub use steps::{Steps, Wizard};
pub use tabs::{TabPlacement, Tabs};
+103
View File
@@ -0,0 +1,103 @@
use std::rc::Rc;
use gpui::{
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div,
};
use crate::{
forms::{Choice, Listing, listing},
primitives::{Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, Radius},
};
type OnValue = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// A button that lists every tab, for a strip too narrow to show them all.
#[derive(IntoElement)]
pub struct TabOverflowMenu {
id: ElementId,
tabs: Vec<Choice>,
selected: Option<SharedString>,
on_select: Option<OnValue>,
}
impl TabOverflowMenu {
pub fn new(id: impl Into<ElementId>, tabs: impl IntoIterator<Item = Choice>) -> Self {
Self {
id: id.into(),
tabs: tabs.into_iter().collect(),
selected: None,
on_select: None,
}
}
pub fn selected(mut self, value: impl Into<SharedString>) -> Self {
self.selected = Some(value.into());
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 TabOverflowMenu {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
assert!(
!self.tabs.is_empty(),
"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,
move |value, window, cx| {
log::info!("tab overflow menu {id:?}: {value}");
if let Some(on_select) = &on_select {
on_select(value, window, cx);
}
},
window,
cx,
)
}
}
+166
View File
@@ -0,0 +1,166 @@
use std::rc::Rc;
use gpui::{
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled, Window, div,
};
use crate::{
buttons::{Button, ButtonVariant, IconButton},
primitives::IconName,
theme::{ActiveTheme, ControlSize},
};
/// The first, the last, `current` and `near` pages either side; `None` marks a gap of two or more.
pub(crate) fn page_list(current: usize, total: usize, near: usize) -> Vec<Option<usize>> {
assert!(
total >= 1 && (1..=total).contains(&current),
"page {current} is outside 1 to {total}"
);
if total == 1 {
return vec![Some(1)];
}
let start = current.saturating_sub(near).max(2);
let end = (current + near).min(total - 1);
let mut pages = vec![Some(1)];
if start > 3 {
pages.push(None);
} else {
pages.extend((2..start).map(Some));
}
pages.extend((start..=end).map(Some));
if end + 2 < total {
pages.push(None);
} else {
pages.extend((end + 1..total).map(Some));
}
pages.push(Some(total));
pages
}
type OnPage = Rc<dyn Fn(usize, &mut Window, &mut App)>;
/// Numbered pages between previous and next; long runs fold into gaps.
#[derive(IntoElement)]
pub struct Pagination {
id: ElementId,
page: usize,
pages: usize,
near: usize,
on_change: Option<OnPage>,
}
impl Pagination {
/// `page` counts from 1.
pub fn new(id: impl Into<ElementId>, page: usize, pages: usize) -> Self {
Self {
id: id.into(),
page,
pages,
near: 1,
on_change: None,
}
}
/// Pages shown on each side of the current one.
pub fn near(mut self, near: usize) -> Self {
self.near = near;
self
}
pub fn on_change(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Pagination {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let list = page_list(self.page, self.pages, self.near);
let (page, pages) = (self.page, self.pages);
let go = {
let (id, on_change) = (self.id.clone(), self.on_change);
Rc::new(move |to: usize, window: &mut Window, cx: &mut App| {
log::info!("pagination {id:?}: page {to}");
if let Some(on_change) = &on_change {
on_change(to, window, cx);
}
})
};
let subtle = cx.theme().colors.fg_subtle;
let (back, ahead) = (go.clone(), go.clone());
let cells = list
.into_iter()
.enumerate()
.map(move |(ix, entry)| match entry {
Some(number) => {
let go = go.clone();
Button::new(("page", number), number.to_string())
.size(ControlSize::Sm)
.variant(if number == page {
ButtonVariant::Secondary
} else {
ButtonVariant::Ghost
})
.on_click(move |_, window, cx| {
if number != page {
go(number, window, cx);
}
})
.into_any_element()
}
None => div()
.id(("gap", ix))
.px_1()
.text_color(subtle)
.child("…")
.into_any_element(),
});
div()
.id(self.id)
.flex()
.items_center()
.gap_1()
.child(
IconButton::new("previous", IconName::ChevronLeft)
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.tooltip("Previous page")
.disabled(page == 1)
.on_click(move |_, window, cx| back(page - 1, window, cx)),
)
.children(cells)
.child(
IconButton::new("next", IconName::ChevronRight)
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.tooltip("Next page")
.disabled(page == pages)
.on_click(move |_, window, cx| ahead(page + 1, window, cx)),
)
}
}
#[cfg(test)]
mod tests {
use super::page_list;
fn shown(current: usize, total: usize) -> String {
page_list(current, total, 1)
.into_iter()
.map(|page| page.map_or("…".to_string(), |page| page.to_string()))
.collect::<Vec<_>>()
.join(" ")
}
#[test]
fn gaps_fold_long_runs_and_never_hide_one_page() {
assert_eq!(shown(6, 20), "1 … 5 6 7 … 20");
assert_eq!(shown(1, 20), "1 2 … 20");
assert_eq!(shown(4, 20), "1 2 3 4 5 … 20");
assert_eq!(shown(17, 20), "1 … 16 17 18 19 20");
assert_eq!(shown(20, 20), "1 … 19 20");
assert_eq!(shown(1, 1), "1");
assert_eq!(shown(2, 2), "1 2");
assert_eq!(shown(3, 5), "1 2 3 4 5");
}
}
+336
View File
@@ -0,0 +1,336 @@
use std::rc::Rc;
use gpui::{
Animation, AnimationExt, AnyElement, App, ElementId, FontWeight, InteractiveElement,
IntoElement, ParentElement, RenderOnce, StatefulInteractiveElement, Styled, Window, div,
prelude::*, relative,
};
use crate::{
buttons::{Button, ButtonVariant},
forms::{Choice, Run},
motion,
primitives::{FocusRing, Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
};
type OnStep = Rc<dyn Fn(usize, &mut Window, &mut App)>;
/// Numbered steps joined by lines. Finished steps carry a check and can be revisited.
#[derive(IntoElement)]
pub struct Steps {
id: ElementId,
steps: Vec<Choice>,
current: usize,
vertical: bool,
on_select: Option<OnStep>,
}
impl Steps {
/// Each step's label is its title, its note a line of detail.
pub fn new(
id: impl Into<ElementId>,
steps: impl IntoIterator<Item = Choice>,
current: usize,
) -> Self {
let steps: Vec<Choice> = steps.into_iter().collect();
assert!(
current < steps.len(),
"step {current} is past the last of {}",
steps.len()
);
Self {
id: id.into(),
steps,
current,
vertical: false,
on_select: None,
}
}
pub fn vertical(mut self) -> Self {
self.vertical = true;
self
}
/// Runs when a finished step is clicked.
pub fn on_select(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Steps {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let turn = motion::changes((self.id.clone(), "current"), self.current, window, cx);
let (current, vertical, count) = (self.current, self.vertical, self.steps.len());
let duration = motion::duration(motion::SLOW, cx);
let theme = cx.theme();
let colors = &theme.colors;
let mark = theme.control_height(ControlSize::Sm);
let line = |ix: usize| {
let filled = ix < current;
let fill = div().bg(colors.accent).map(|fill| {
if vertical {
fill.w_full()
} else {
fill.h_full()
}
});
let fill = if filled && ix + 1 == current && turn > 0 {
fill.with_animation(
("step-fill", turn),
Animation::new(duration).with_easing(motion::ease_out_cubic),
move |fill, t| {
if vertical {
fill.h(relative(t))
} else {
fill.w(relative(t))
}
},
)
.into_any_element()
} else {
let size = relative(if filled { 1.0 } else { 0.0 });
(if vertical { fill.h(size) } else { fill.w(size) }).into_any_element()
};
div()
.flex_1()
.bg(colors.border)
.map(|track| if vertical { track.w_px() } else { track.h_px() })
.child(fill)
};
let steps = self.steps.into_iter().enumerate().map(|(ix, step)| {
let (done, now) = (ix < current, ix == current);
let circle = div()
.flex()
.flex_none()
.items_center()
.justify_center()
.size(mark)
.rounded_full()
.text_size(theme.text_size(TextSize::Xs))
.font_weight(FontWeight::SEMIBOLD)
.map(|circle| {
if done {
circle.bg(colors.accent).child(
Icon::new(IconName::Check)
.size(IconSize::Xs)
.color(colors.on_accent),
)
} else if now {
circle
.border_2()
.border_color(colors.accent)
.text_color(colors.fg)
.child((ix + 1).to_string())
} else {
circle
.border_1()
.border_color(colors.border_strong)
.text_color(colors.fg_subtle)
.child((ix + 1).to_string())
}
});
let words = div()
.flex()
.flex_col()
.gap_0p5()
.text_size(theme.text_size(TextSize::Sm))
.text_color(if done || now {
colors.fg
} else {
colors.fg_muted
})
.when(now, |words| words.font_weight(FontWeight::MEDIUM))
.child(step.label)
.when_some(step.note, |words, note| {
words.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.font_weight(FontWeight::NORMAL)
.child(note),
)
});
let select = self.on_select.clone().filter(|_| done);
let head = div()
.id(("step", ix))
.flex()
.items_center()
.gap_2()
.rounded_full()
.border_1()
.border_color(gpui::transparent_black())
.when_some(select, |head, select| {
head.cursor_pointer().tab_index(0).focus_ring(cx).on_click(
move |_, window, cx| {
log::info!("steps: back to {ix}");
select(ix, window, cx)
},
)
});
let after = (ix + 1 < count).then(|| line(ix));
if vertical {
div()
.flex()
.gap_3()
.child(
div()
.flex()
.flex_col()
.items_center()
.gap_1()
.child(head.child(circle))
.children(after),
)
.child(div().pb_5().child(words))
.into_any_element()
} else {
div()
.flex()
.flex_1()
.items_center()
.gap_3()
.when(ix + 1 == count, |step| step.flex_none())
.child(head.child(circle).child(words))
.children(after)
.into_any_element()
}
});
div()
.id(self.id)
.flex()
.when(vertical, |steps| steps.flex_col())
.w_full()
.children(steps)
}
}
/// Steps, the current step's content, then Back and Next. Next becomes Finish on the last step.
#[derive(IntoElement)]
pub struct Wizard {
id: ElementId,
steps: Vec<Choice>,
current: usize,
content: Option<AnyElement>,
ready: bool,
on_step: Option<OnStep>,
on_finish: Option<Run>,
}
impl Wizard {
pub fn new(
id: impl Into<ElementId>,
steps: impl IntoIterator<Item = Choice>,
current: usize,
) -> Self {
Self {
id: id.into(),
steps: steps.into_iter().collect(),
current,
content: None,
ready: true,
on_step: None,
on_finish: None,
}
}
/// The current step's content.
pub fn content(mut self, content: impl IntoElement) -> Self {
self.content = Some(content.into_any_element());
self
}
/// Whether Next may run; the step's fields decide.
pub fn ready(mut self, ready: bool) -> Self {
self.ready = ready;
self
}
/// Runs with the step Back, Next or a finished step leads to.
pub fn on_step(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_step = Some(Rc::new(handler));
self
}
pub fn on_finish(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_finish = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Wizard {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (current, last) = (self.current, self.steps.len().saturating_sub(1));
let turn = motion::changes((self.id.clone(), "content"), current, window, cx);
let step: OnStep = {
let (id, on_step) = (self.id.clone(), self.on_step);
Rc::new(move |to, window, cx| {
log::info!("wizard {id:?}: step {to}");
if let Some(on_step) = &on_step {
on_step(to, window, cx);
}
})
};
let (back, next, jump) = (step.clone(), step.clone(), step);
let finish = {
let (id, on_finish) = (self.id.clone(), self.on_finish);
move |window: &mut Window, cx: &mut App| {
log::info!("wizard {id:?}: finished");
if let Some(on_finish) = &on_finish {
on_finish(window, cx);
}
}
};
let content = self.content.map(|content| {
div().child(content).with_animation(
("wizard-step", turn),
Animation::new(motion::duration(motion::BASE, cx))
.with_easing(motion::ease_out_cubic),
move |content, t| {
if turn == 0 {
content
} else {
content.opacity(t).mt(motion::NUDGE * (1.0 - t))
}
},
)
});
let advance = if current == last {
Button::new("wizard-finish", "Finish")
.primary()
.disabled(!self.ready)
.on_click(move |_, window, cx| finish(window, cx))
} else {
Button::new("wizard-next", "Next")
.primary()
.disabled(!self.ready)
.on_click(move |_, window, cx| next(current + 1, window, cx))
};
div()
.id(self.id.clone())
.flex()
.flex_col()
.gap_6()
.w_full()
.child(
Steps::new((self.id, "steps"), self.steps, current)
.on_select(move |to, window, cx| jump(to, window, cx)),
)
.children(content)
.child(
div()
.flex()
.items_center()
.justify_between()
.child(
Button::new("wizard-back", "Back")
.variant(ButtonVariant::Ghost)
.disabled(current == 0)
.on_click(move |_, window, cx| back(current - 1, window, cx)),
)
.child(advance),
)
}
}
+247
View File
@@ -0,0 +1,247 @@
use std::rc::Rc;
use gpui::{
Animation, AnimationExt, AnyElement, App, ElementId, FontWeight, InteractiveElement,
IntoElement, MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement,
Styled, Window, div, prelude::*,
};
use crate::{
forms::{Choice, Pick, step},
motion::{self, Axis, Marker, glide, measure_item, measure_origin, slide},
primitives::{Icon, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
/// Where a tab strip sits against its panel.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TabPlacement {
#[default]
Top,
Left,
Bottom,
}
type OnValue = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// A strip of tabs and the chosen tab's panel. A line slides to the chosen tab; arrows move it.
#[derive(IntoElement)]
pub struct Tabs {
id: ElementId,
tabs: Vec<Choice>,
selected: SharedString,
placement: TabPlacement,
panel: Option<AnyElement>,
on_change: Option<OnValue>,
}
impl Tabs {
pub fn new(
id: impl Into<ElementId>,
tabs: impl IntoIterator<Item = Choice>,
selected: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
tabs: tabs.into_iter().collect(),
selected: selected.into(),
placement: TabPlacement::default(),
panel: None,
on_change: None,
}
}
pub fn placement(mut self, placement: TabPlacement) -> Self {
self.placement = placement;
self
}
/// The chosen tab's content.
pub fn panel(mut self, panel: impl IntoElement) -> Self {
self.panel = Some(panel.into_any_element());
self
}
pub fn on_change(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Tabs {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let values: Vec<SharedString> = self.tabs.iter().map(|tab| tab.value.clone()).collect();
let chosen = values
.iter()
.position(|value| *value == self.selected)
.unwrap_or_else(|| panic!("tabs {:?} have no tab {}", self.id, self.selected));
let placement = self.placement;
let axis = match placement {
TabPlacement::Left => Axis::Vertical,
TabPlacement::Top | TabPlacement::Bottom => Axis::Horizontal,
};
let (state, marker) = slide(
(self.id.clone(), "slide"),
&values,
&self.selected,
window,
cx,
);
let turn = motion::changes(
(self.id.clone(), "panel"),
self.selected.clone(),
window,
cx,
);
let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
let focused = focus.is_focused(window);
let tabs = Rc::new(self.tabs);
let pick: Pick = {
let (id, tabs, on_change) = (self.id.clone(), tabs.clone(), self.on_change);
Rc::new(move |ix, window, cx| {
let value = &tabs[ix].value;
log::info!("tabs {id:?}: {value}");
if let Some(on_change) = &on_change {
on_change(value, window, cx);
}
})
};
let theme = cx.theme();
let colors = &theme.colors;
let (thickness, duration) = (theme.tab_indicator(), motion::duration(motion::SLOW, cx));
let indicator = marker.map(
|Marker {
from,
to,
generation,
}| {
let bar = div().absolute().bg(colors.accent);
let bar = match placement {
TabPlacement::Top => bar.bottom_0().h(thickness),
TabPlacement::Bottom => bar.top_0().h(thickness),
TabPlacement::Left => bar.right_0().w(thickness),
};
bar.with_animation(
("tab-indicator", generation),
Animation::new(duration),
move |bar, t| {
let (start, length) = glide(from, to, t);
match axis {
Axis::Horizontal => bar.left(start).w(length),
Axis::Vertical => bar.top(start).h(length),
}
},
)
},
);
let items = tabs
.iter()
.enumerate()
.map(|(ix, tab)| {
let on = ix == chosen;
let fg = match (tab.disabled, on) {
(true, _) => colors.fg_disabled,
(false, true) => colors.fg,
(false, false) => colors.fg_muted,
};
let pick = pick.clone();
div()
.id(("tab", ix))
.relative()
.flex()
.items_center()
.gap_1p5()
.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))
.font_weight(if on {
FontWeight::MEDIUM
} else {
FontWeight::NORMAL
})
.text_color(fg)
.when(!tab.disabled, |item| {
item.cursor_pointer()
.hover(|style| style.text_color(colors.fg))
.on_mouse_down(MouseButton::Left, |_, window, _| {
window.prevent_default()
})
.on_click(move |_, window, cx| {
if !on {
pick(ix, window, cx);
}
})
})
.when_some(tab.icon, |item, icon| {
item.child(Icon::new(icon).size(IconSize::Sm).color(fg))
})
.child(tab.label.clone())
.when_some(tab.note.clone(), |item, note| {
item.child(div().text_color(colors.fg_subtle).child(note))
})
.child(measure_item(state.clone(), ix, axis))
})
.collect::<Vec<_>>();
let (keys, key_pick) = (tabs.clone(), pick.clone());
let strip = div()
.id((self.id.clone(), "strip"))
.track_focus(&focus)
.relative()
.flex()
.when(axis == Axis::Vertical, |strip| strip.flex_col())
.gap_1()
.border_color(colors.border)
.map(|strip| match placement {
TabPlacement::Top => strip.border_b_1(),
TabPlacement::Bottom => strip.border_t_1(),
TabPlacement::Left => strip.border_r_1().pr_1(),
})
.on_key_down(move |event, window, cx| {
let back = if axis == Axis::Vertical { "up" } else { "left" };
let ahead = if axis == Axis::Vertical {
"down"
} else {
"right"
};
let key = event.keystroke.key.as_str();
let to = match key {
key if key == back => step(&keys, chosen, -1),
key if key == ahead => step(&keys, chosen, 1),
"home" => step(&keys, keys.len() - 1, 1),
"end" => step(&keys, 0, -1),
_ => return,
};
cx.stop_propagation();
if to != chosen {
key_pick(to, window, cx);
}
})
.child(measure_origin(state, axis))
.children(indicator)
.children(items);
let panel = self.panel.map(|panel| {
div().flex_1().min_w_0().child(panel).with_animation(
("tab-panel", turn),
Animation::new(motion::duration(motion::BASE, cx))
.with_easing(motion::ease_out_cubic),
move |panel, t| if turn == 0 { panel } else { panel.opacity(t) },
)
});
let frame = div().id(self.id).flex().gap_4().w_full();
match placement {
TabPlacement::Top => frame.flex_col().child(strip).children(panel),
TabPlacement::Bottom => frame.flex_col().children(panel).child(strip),
TabPlacement::Left => frame.child(strip).children(panel),
}
}
}
+235
View File
@@ -0,0 +1,235 @@
use gpui::{
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};
fn setup(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
crate::forms::bind_keys(cx);
});
}
fn press(key: &str, cx: &mut VisualTestContext) {
cx.simulate_keystrokes(key);
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse(key).unwrap(),
});
}
struct Strip {
chosen: SharedString,
}
impl Render for Strip {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
let tabs = [
Choice::new("a", "A"),
Choice::new("b", "B").disabled(),
Choice::new("c", "C"),
];
Tabs::new("tabs", tabs, self.chosen.clone()).on_change(move |value, _, cx| {
view.update(cx, |view, cx| {
view.chosen = value.clone();
cx.notify();
})
})
}
}
#[gpui::test]
fn tab_arrows_skip_disabled_tabs_and_wrap(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Strip { chosen: "a".into() });
cx.update(|window, _| window.focus_next());
cx.simulate_keystrokes("right");
assert_eq!(view.read_with(cx, |view, _| view.chosen.clone()), "c");
cx.simulate_keystrokes("right");
assert_eq!(view.read_with(cx, |view, _| view.chosen.clone()), "a");
}
struct Path {
picked: Option<(usize, SharedString)>,
}
impl Render for Path {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
Breadcrumb::new(
"path",
[
Crumb::new("home", "Home"),
Crumb::new("docs", "Docs").siblings([
Choice::new("docs", "Docs"),
Choice::new("music", "Music"),
Choice::new("photos", "Photos"),
]),
Crumb::new("notes", "notes.md"),
],
)
.on_select(move |level, value, _, cx| {
let value = value.clone();
view.update(cx, |view, _| view.picked = Some((level, value)));
})
}
}
#[gpui::test]
fn a_level_lists_its_siblings_and_picks_one(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Path { picked: None });
cx.update(|window, _| {
window.focus_next();
window.focus_next();
});
press("down", cx);
press("down", cx);
press("enter", cx);
let picked = view.read_with(cx, |view, _| view.picked.clone());
assert_eq!(picked, Some((1, SharedString::from("music"))));
}
struct Flow {
step: usize,
done: bool,
}
impl Render for Flow {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (stepper, finisher) = (cx.entity(), cx.entity());
let steps = [
Choice::new("account", "Account"),
Choice::new("plan", "Plan"),
Choice::new("confirm", "Confirm"),
];
Wizard::new("flow", steps, self.step)
.on_step(move |to, _, cx| {
stepper.update(cx, |view, cx| {
view.step = to;
cx.notify();
})
})
.on_finish(move |_, cx| finisher.update(cx, |view, _| view.done = true))
}
}
#[gpui::test]
fn the_wizard_walks_forward_back_and_finishes(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Flow {
step: 0,
done: false,
});
cx.update(|window, _| window.focus_next());
press("enter", cx);
assert_eq!(view.read_with(cx, |view, _| view.step), 1);
cx.update(|window, _| {
window.blur();
window.focus_next();
window.focus_next();
});
press("enter", cx);
assert_eq!(view.read_with(cx, |view, _| view.step), 0);
view.update(cx, |view, cx| {
view.step = 2;
cx.notify();
});
cx.update(|window, _| {
window.blur();
for _ in 0..4 {
window.focus_next();
}
});
press("enter", cx);
assert!(view.read_with(cx, |view, _| view.done));
}
struct Docs {
chosen: SharedString,
clicked: Option<SharedString>,
closed: Option<SharedString>,
}
impl Render for Docs {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (picker, closer) = (cx.entity(), cx.entity());
let tabs = (0..10).map(|ix| EditorTab::new(format!("t{ix}"), format!("document-{ix}.rs")));
div().w(px(200.0)).child(
EditorTabs::new("docs", tabs)
.selected(self.chosen.clone())
.on_select(move |id, _, cx| {
let id = id.clone();
picker.update(cx, |view, _| view.clicked = Some(id));
})
.on_close(move |id, _, cx| {
let id = id.clone();
closer.update(cx, |view, _| view.closed = Some(id));
}),
)
}
}
/// The test platform never runs next-frame callbacks; a refresh stands in for the display link.
fn settle(cx: &mut VisualTestContext) {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
fn click_at(x: f32, cx: &mut VisualTestContext) {
let at = point(px(x), px(16.0));
cx.simulate_mouse_move(at, None, Modifiers::none());
cx.simulate_click(at, Modifiers::none());
}
#[gpui::test]
fn the_chosen_tab_scrolls_in_once_and_a_manual_scroll_stays(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Docs {
chosen: "t9".into(),
clicked: None,
closed: None,
});
settle(cx);
let strip_end = cx
.update(|window, _| window.viewport_size().width)
.min(px(200.0))
- cx.update(|_, cx| {
crate::theme::ActiveTheme::theme(cx)
.control_height(crate::theme::ControlSize::Sm)
.to_pixels(px(16.0))
})
- px(8.0);
click_at(f32::from(strip_end) - 16.0, cx);
let closed = view.read_with(cx, |view, _| view.closed.clone());
assert_eq!(
closed,
Some(SharedString::from("t9")),
"the chosen tab's close button sits at the strip's end"
);
view.update(cx, |view, cx| {
view.chosen = "t8".into();
cx.notify();
});
settle(cx);
cx.simulate_event(ScrollWheelEvent {
position: point(px(60.0), px(16.0)),
delta: ScrollDelta::Pixels(point(px(5000.0), px(0.0))),
modifiers: Modifiers::none(),
touch_phase: TouchPhase::Moved,
});
view.update(cx, |_, cx| cx.notify());
settle(cx);
click_at(6.0, cx);
let clicked = view.read_with(cx, |view, _| view.clicked.clone());
assert_eq!(
clicked,
Some(SharedString::from("t0")),
"a manual scroll stays put"
);
}
+16 -2
View File
@@ -1,6 +1,6 @@
use gpui::{
AnyElement, App, Div, Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement,
RenderOnce, StyleRefinement, Styled, Window, actions, div,
AnyElement, App, Div, ElementId, Entity, FocusHandle, InteractiveElement, IntoElement,
ParentElement, RenderOnce, StyleRefinement, Styled, Window, actions, div,
};
use crate::theme::ActiveTheme;
@@ -134,3 +134,17 @@ pub(crate) fn give_back(state: &Entity<Takeover>, window: &mut Window, cx: &App)
None => log::info!("focus: overlay closed, nothing was focused before"),
}
}
/// A focus handle kept for `id`, a Tab stop while enabled.
pub(crate) fn tab_stop(
id: ElementId,
enabled: bool,
window: &mut Window,
cx: &mut App,
) -> FocusHandle {
window
.use_keyed_state(id, cx, |_, cx| cx.focus_handle())
.read(cx)
.clone()
.tab_stop(enabled)
}
+1 -1
View File
@@ -11,7 +11,7 @@ mod tooltip;
pub use backdrop::{Backdrop, Place};
pub use divider::Divider;
pub use focus::{FocusNext, FocusPrev, FocusRing, FocusScope};
pub(crate) use focus::{give_back, take_focus};
pub(crate) use focus::{give_back, tab_stop, take_focus};
pub use ghost::DragGhost;
pub use icon::{Icon, IconName};
pub use image::Image;
+5
View File
@@ -218,6 +218,11 @@ impl Theme {
(px_to_rems(96.0), px_to_rems(200.0))
}
/// The line that marks a chosen tab.
pub fn tab_indicator(&self) -> Rems {
px_to_rems(2.0)
}
/// Grab bar on a drawer.
pub fn grip(&self) -> Size<Rems> {
size(px_to_rems(36.0), px_to_rems(4.0))
+14 -8
View File
@@ -278,15 +278,18 @@
- [x] WizardForm / MultiStepForm → lands with Navigation (T07): its frame is the Wizard
- [x] DirtyIndicator → `forms::DirtyIndicator`
## T07 Navigation — `src/navigation`
## T07a Navigation · Tabs & paths — `src/navigation`
- [x] Tabs — top, left, bottom → `navigation::Tabs`
- [x] EditorTabs → `navigation::EditorTabs`
- [x] TabOverflowMenu → `navigation::TabOverflowMenu`
- [x] Breadcrumb → `navigation::Breadcrumb`
- [x] Pagination → `navigation::Pagination`
- [x] LoadMore → lands with Loading (T11a): its busy state is a Spinner
- [x] Stepper / Steps / Wizard → `navigation::Steps`, `navigation::Wizard`; also T06g WizardForm
## T07b Navigation · Places
- [ ] Tabs — top, left, bottom
- [ ] EditorTabs
- [ ] TabOverflowMenu
- [ ] Breadcrumb
- [ ] Pagination
- [ ] LoadMore
- [ ] Stepper / Steps / Wizard — as `Steps`; also T06g WizardForm
- [ ] Sidebar Navigation / NavItem / NavGroup
- [ ] NavigationMenu
- [ ] ActivityBar Item → `shell::ActivityBar`
@@ -294,6 +297,9 @@
- [ ] History Navigation → `navigation::BackForwardNavigation`
- [ ] Anchor / TableOfContents
- [ ] JumpTo / GoToLine
## T07c Navigation · Palettes & tour
- [ ] CommandPalette
- [ ] QuickOpen
- [ ] QuickSwitcher
+1 -1
View File
@@ -2,7 +2,7 @@
## T11a Loading — `src/motion`
- [ ] Spinner — ring, dots, bars, pulse, orbit, wave; also the Button loading state (T05 LoadingButton)
- [ ] Spinner — ring, dots, bars, pulse, orbit, wave; also the Button loading state (T05 LoadingButton) and T07a LoadMore
- [ ] DotsLoader → `motion::Spinner`
- [ ] BarsLoader → `motion::Spinner`
- [ ] PulseLoader → `motion::Spinner`