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:
50 files changed
+2490
-330
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,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<> {
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
)),
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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},
|
||||
};
|
||||
|
||||
|
||||
@@ -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},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(¤t),
|
||||
"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");
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user