diff --git a/AGENTS.md b/AGENTS.md index c8ce407..a8044dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/TASKS.md b/TASKS.md index db9b761..3a67bab 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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) | diff --git a/examples/gallery/pages/forms/choices.rs b/examples/gallery/pages/forms/choices.rs index 89d7bb1..be420ba 100644 --- a/examples/gallery/pages/forms/choices.rs +++ b/examples/gallery/pages/forms/choices.rs @@ -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( - key: &'static str, - init: impl FnOnce() -> T, - window: &mut Window, - cx: &mut App, -) -> Entity { - window.use_keyed_state(key, cx, move |_, _| init()) -} - -pub(super) fn set(state: &Entity, value: T, cx: &mut App) { - state.update(cx, |state, cx| { - *state = value; - cx.notify(); - }); -} - fn values(list: &[&'static str]) -> Vec { list.iter() .map(|value| SharedString::from(*value)) diff --git a/examples/gallery/pages/forms/colors.rs b/examples/gallery/pages/forms/colors.rs index 36d95bd..753293e 100644 --- a/examples/gallery/pages/forms/colors.rs +++ b/examples/gallery/pages/forms/colors.rs @@ -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 { diff --git a/examples/gallery/pages/forms/dates.rs b/examples/gallery/pages/forms/dates.rs index 30f5352..91f559d 100644 --- a/examples/gallery/pages/forms/dates.rs +++ b/examples/gallery/pages/forms/dates.rs @@ -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. diff --git a/examples/gallery/pages/forms/files.rs b/examples/gallery/pages/forms/files.rs index 62dd457..ed39b96 100644 --- a/examples/gallery/pages/forms/files.rs +++ b/examples/gallery/pages/forms/files.rs @@ -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 { diff --git a/examples/gallery/pages/forms/other.rs b/examples/gallery/pages/forms/other.rs index 0c482f6..f035285 100644 --- a/examples/gallery/pages/forms/other.rs +++ b/examples/gallery/pages/forms/other.rs @@ -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<> { diff --git a/examples/gallery/pages/forms/pickers.rs b/examples/gallery/pages/forms/pickers.rs index e90a0d7..7e51361 100644 --- a/examples/gallery/pages/forms/pickers.rs +++ b/examples/gallery/pages/forms/pickers.rs @@ -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 { diff --git a/examples/gallery/pages/forms/structure.rs b/examples/gallery/pages/forms/structure.rs index 3c0c715..964a1f7 100644 --- a/examples/gallery/pages/forms/structure.rs +++ b/examples/gallery/pages/forms/structure.rs @@ -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); diff --git a/examples/gallery/pages/forms/values.rs b/examples/gallery/pages/forms/values.rs index 97cd216..47eb440 100644 --- a/examples/gallery/pages/forms/values.rs +++ b/examples/gallery/pages/forms/values.rs @@ -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<> { diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index ef7f06e..7c53c37 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -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, ]; diff --git a/examples/gallery/pages/navigation/mod.rs b/examples/gallery/pages/navigation/mod.rs new file mode 100644 index 0000000..653cd17 --- /dev/null +++ b/examples/gallery/pages/navigation/mod.rs @@ -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() +} diff --git a/examples/gallery/pages/navigation/paths.rs b/examples/gallery/pages/navigation/paths.rs new file mode 100644 index 0000000..d6ff983 --- /dev/null +++ b/examples/gallery/pages/navigation/paths.rs @@ -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::, 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, + )), + ) +} diff --git a/examples/gallery/pages/navigation/tabs.rs b/examples/gallery/pages/navigation/tabs.rs new file mode 100644 index 0000000..54a5597 --- /dev/null +++ b/examples/gallery/pages/navigation/tabs.rs @@ -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 { + 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>, 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 = 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(); + }) + }), + ), + )) +} diff --git a/examples/gallery/ui.rs b/examples/gallery/ui.rs index 1cfb7f9..effddb1 100644 --- a/examples/gallery/ui.rs +++ b/examples/gallery/ui.rs @@ -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, note: impl Into, cx: &App) -> Div { @@ -74,3 +76,21 @@ pub fn blocked(text: impl Into, 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( + key: &'static str, + init: impl FnOnce() -> T, + window: &mut Window, + cx: &mut App, +) -> Entity { + window.use_keyed_state(key, cx, move |_, _| init()) +} + +/// Replaces a demo's state and redraws. +pub fn set(state: &Entity, value: T, cx: &mut App) { + state.update(cx, |state, cx| { + *state = value; + cx.notify(); + }); +} diff --git a/scripts/check.sh b/scripts/check.sh index 4270d67..56c8a82 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -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 diff --git a/src/buttons/segmented.rs b/src/buttons/segmented.rs index 3bba12a..5f91e0a 100644 --- a/src/buttons/segmented.rs +++ b/src/buttons/segmented.rs @@ -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>, - from: Option, - last: Option, - generation: u64, -} - type OnChange = Rc; /// 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 = 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, _, 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, _, 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); - } -} diff --git a/src/forms/card.rs b/src/forms/card.rs index fe41d3d..fca3670 100644 --- a/src/forms/card.rs +++ b/src/forms/card.rs @@ -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}, }; diff --git a/src/forms/cascade.rs b/src/forms/cascade.rs index 7936d69..7bd8e14 100644 --- a/src/forms/cascade.rs +++ b/src/forms/cascade.rs @@ -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}, }; diff --git a/src/forms/check.rs b/src/forms/check.rs index e1d90ea..444782c 100644 --- a/src/forms/check.rs +++ b/src/forms/check.rs @@ -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 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, diff --git a/src/forms/color/gradient.rs b/src/forms/color/gradient.rs index 027bc57..6177252 100644 --- a/src/forms/color/gradient.rs +++ b/src/forms/color/gradient.rs @@ -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}, }; diff --git a/src/forms/date/calendar.rs b/src/forms/date/calendar.rs index 6f5fb5f..110dc86 100644 --- a/src/forms/date/calendar.rs +++ b/src/forms/date/calendar.rs @@ -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}, }; diff --git a/src/forms/date/picker.rs b/src/forms/date/picker.rs index 95a1268..62f38a2 100644 --- a/src/forms/date/picker.rs +++ b/src/forms/date/picker.rs @@ -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}, }; diff --git a/src/forms/files.rs b/src/forms/files.rs index 55edd78..2d01a66 100644 --- a/src/forms/files.rs +++ b/src/forms/files.rs @@ -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}, }; diff --git a/src/forms/glyphs.rs b/src/forms/glyphs.rs index 425abf0..6878f68 100644 --- a/src/forms/glyphs.rs +++ b/src/forms/glyphs.rs @@ -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, }; diff --git a/src/forms/knob.rs b/src/forms/knob.rs index 85ac7d0..8e81245 100644 --- a/src/forms/knob.rs +++ b/src/forms/knob.rs @@ -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; diff --git a/src/forms/listbox.rs b/src/forms/listbox.rs index 709f005..f3d6e20 100644 --- a/src/forms/listbox.rs +++ b/src/forms/listbox.rs @@ -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( diff --git a/src/forms/mod.rs b/src/forms/mod.rs index 85d0bbe..28c8d64 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -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; diff --git a/src/forms/multi.rs b/src/forms/multi.rs index a667b97..ddd5783 100644 --- a/src/forms/multi.rs +++ b/src/forms/multi.rs @@ -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}, }; diff --git a/src/forms/radio.rs b/src/forms/radio.rs index ff093de..9df2844 100644 --- a/src/forms/radio.rs +++ b/src/forms/radio.rs @@ -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}, }; diff --git a/src/forms/rating.rs b/src/forms/rating.rs index e22ae25..67982f7 100644 --- a/src/forms/rating.rs +++ b/src/forms/rating.rs @@ -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, fill: bool) -> Path { diff --git a/src/forms/select.rs b/src/forms/select.rs index 2bd792c..10f9c84 100644 --- a/src/forms/select.rs +++ b/src/forms/select.rs @@ -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>, + 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
, + on_pick: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + window: &mut Window, + cx: &mut App, +) -> Stateful
{ + 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 = 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 = 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, + ) } } diff --git a/src/forms/slider.rs b/src/forms/slider.rs index b739c37..7588b8e 100644 --- a/src/forms/slider.rs +++ b/src/forms/slider.rs @@ -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 { diff --git a/src/forms/switch.rs b/src/forms/switch.rs index 3c93fdd..db5700c 100644 --- a/src/forms/switch.rs +++ b/src/forms/switch.rs @@ -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}, }; diff --git a/src/lib.rs b/src/lib.rs index f5d088b..c60e595 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/motion/mod.rs b/src/motion/mod.rs index 2a7a26a..4899fdf 100644 --- a/src/motion/mod.rs +++ b/src/motion/mod.rs @@ -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}; diff --git a/src/motion/slide.rs b/src/motion/slide.rs new file mode 100644 index 0000000..615962a --- /dev/null +++ b/src/motion/slide.rs @@ -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, 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 { + 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>, + from: Option, + last: Option, + 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, + values: &[SharedString], + selected: &SharedString, + window: &mut Window, + cx: &mut App, +) -> (Entity, Option) { + 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, ix: usize, axis: Axis) -> impl IntoElement { + canvas( + move |bounds: Bounds, _, 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, axis: Axis) -> impl IntoElement { + canvas( + move |bounds: Bounds, _, 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); + } +} diff --git a/src/navigation/breadcrumb.rs b/src/navigation/breadcrumb.rs new file mode 100644 index 0000000..0af8127 --- /dev/null +++ b/src/navigation/breadcrumb.rs @@ -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, + siblings: Vec, +} + +impl Crumb { + pub fn new(value: impl Into, label: impl Into) -> 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) -> 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; + +/// 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, + on_select: Option, +} + +impl Breadcrumb { + pub fn new(id: impl Into, crumbs: impl IntoIterator) -> 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 = 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) + } +} diff --git a/src/navigation/editor_tabs.rs b/src/navigation/editor_tabs.rs new file mode 100644 index 0000000..4a783a4 --- /dev/null +++ b/src/navigation/editor_tabs.rs @@ -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, + dirty: bool, + pinned: bool, + preview: bool, +} + +impl EditorTab { + pub fn new(id: impl Into, title: impl Into) -> 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; +type OnReorder = Rc; + +/// 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, + selected: Option, + on_select: Option, + on_close: Option, + on_pin: Option, + on_keep: Option, + on_reorder: Option, +} + +impl EditorTabs { + pub fn new(id: impl Into, tabs: impl IntoIterator) -> 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) -> 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, 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::> + }); + 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 = 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::(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 = 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))) + } +} diff --git a/src/navigation/mod.rs b/src/navigation/mod.rs new file mode 100644 index 0000000..debd491 --- /dev/null +++ b/src/navigation/mod.rs @@ -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}; diff --git a/src/navigation/overflow.rs b/src/navigation/overflow.rs new file mode 100644 index 0000000..5870520 --- /dev/null +++ b/src/navigation/overflow.rs @@ -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; + +/// A button that lists every tab, for a strip too narrow to show them all. +#[derive(IntoElement)] +pub struct TabOverflowMenu { + id: ElementId, + tabs: Vec, + selected: Option, + on_select: Option, +} + +impl TabOverflowMenu { + pub fn new(id: impl Into, tabs: impl IntoIterator) -> Self { + Self { + id: id.into(), + tabs: tabs.into_iter().collect(), + selected: None, + on_select: None, + } + } + + pub fn selected(mut self, value: impl Into) -> 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, + ) + } +} diff --git a/src/navigation/pages.rs b/src/navigation/pages.rs new file mode 100644 index 0000000..c770daa --- /dev/null +++ b/src/navigation/pages.rs @@ -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> { + 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; + +/// 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, +} + +impl Pagination { + /// `page` counts from 1. + pub fn new(id: impl Into, 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::>() + .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"); + } +} diff --git a/src/navigation/steps.rs b/src/navigation/steps.rs new file mode 100644 index 0000000..6ed1064 --- /dev/null +++ b/src/navigation/steps.rs @@ -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; + +/// Numbered steps joined by lines. Finished steps carry a check and can be revisited. +#[derive(IntoElement)] +pub struct Steps { + id: ElementId, + steps: Vec, + current: usize, + vertical: bool, + on_select: Option, +} + +impl Steps { + /// Each step's label is its title, its note a line of detail. + pub fn new( + id: impl Into, + steps: impl IntoIterator, + current: usize, + ) -> Self { + let steps: Vec = 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, + current: usize, + content: Option, + ready: bool, + on_step: Option, + on_finish: Option, +} + +impl Wizard { + pub fn new( + id: impl Into, + steps: impl IntoIterator, + 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), + ) + } +} diff --git a/src/navigation/tabs.rs b/src/navigation/tabs.rs new file mode 100644 index 0000000..a0136a3 --- /dev/null +++ b/src/navigation/tabs.rs @@ -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; + +/// 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, + selected: SharedString, + placement: TabPlacement, + panel: Option, + on_change: Option, +} + +impl Tabs { + pub fn new( + id: impl Into, + tabs: impl IntoIterator, + selected: impl Into, + ) -> 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 = 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::>(); + 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), + } + } +} diff --git a/src/navigation/tests.rs b/src/navigation/tests.rs new file mode 100644 index 0000000..4f27fa3 --- /dev/null +++ b/src/navigation/tests.rs @@ -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) -> 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) -> 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) -> 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, + closed: Option, +} + +impl Render for Docs { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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" + ); +} diff --git a/src/primitives/focus.rs b/src/primitives/focus.rs index 2a2a922..2dde958 100644 --- a/src/primitives/focus.rs +++ b/src/primitives/focus.rs @@ -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, 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) +} diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs index 1c5dd46..a1a5bb2 100644 --- a/src/primitives/mod.rs +++ b/src/primitives/mod.rs @@ -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; diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index 7356894..f9ee8eb 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -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 { size(px_to_rems(36.0), px_to_rems(4.0)) diff --git a/tasks/ch01-10.md b/tasks/ch01-10.md index 09aca41..4b75639 100644 --- a/tasks/ch01-10.md +++ b/tasks/ch01-10.md @@ -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 diff --git a/tasks/ch11-20.md b/tasks/ch11-20.md index ce7112a..1fa30dc 100644 --- a/tasks/ch11-20.md +++ b/tasks/ch11-20.md @@ -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`