Tables: focused headers scroll into view; long tables scroll sideways
A sortable header is a tracked Tab stop; once focused it sets its table's sideways offset whole from its painted bounds and asks one frame. The virtualized branch scrolls sideways too, and its list keeps a plain wheel to its rows. layout::on_axis keeps a scroll box's wheel to its axes; tables, code blocks, the thumbnail strip and the gallery's page use it. Footer figures move to tables/foot.rs; the profiler's ruler takes theme.chart().label. HumanInputRequest's answered state gets a test.
This commit is contained in:
15 files changed
+405
-254
No files matched your search
@@ -82,7 +82,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
|||||||
- A window keeps one `feedback::Toaster` and renders one `ToastViewport`; anything holding the entity pushes to it. Messages share `primitives::Severity`.
|
- A window keeps one `feedback::Toaster` and renders one `ToastViewport`; anything holding the entity pushes to it. Messages share `primitives::Severity`.
|
||||||
- Only gpui can build `ExternalPaths` with paths, so drop rules live in `forms::files::dropped`, where tests reach them.
|
- 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`.
|
- 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 against last frame's bounds and overflow, so a call before a list's first frame does nothing. Call it from a child's prepaint once the handle has bounds, then request one frame (`navigation::editor_tabs`). A floating list reveals its cursor, and a strip its focused item, through `forms::options::reveal` and `revealer`. Never poll layout with `request_animation_frame`.
|
- `ScrollHandle::scroll_to_item` runs in the container's prepaint against last frame's bounds and overflow, so a call before a list's first frame does nothing. Call it from a child's prepaint once the handle has bounds, then request one frame (`navigation::editor_tabs`). A floating list reveals its cursor, and a strip its focused item, through `forms::options::reveal` and `revealer`. A focused Tab stop deeper in a scroll box sets the box's offset whole from its own painted bounds in prepaint, once per focus (`tables::header`). Never poll layout with `request_animation_frame`.
|
||||||
- gpui animations run on the wall clock; timers run on the executor's clock, which tests advance by hand. A test that needs an animation settled turns on the theme's reduced motion. Clocks read `background_executor().now()` and wake only their own view.
|
- gpui animations run on the wall clock; timers run on the executor's clock, which tests advance by hand. A test that needs an animation settled turns on the theme's reduced motion. Clocks read `background_executor().now()` and wake only their own view.
|
||||||
- Machine-read marks, QR codes and barcodes, paint `ink` on `paper`: dark on light in both themes, so cameras read them.
|
- Machine-read marks, QR codes and barcodes, paint `ink` on `paper`: dark on light in both themes, so cameras read them.
|
||||||
- A layer over pressable content that must still pass scrolls to its ancestors uses `block_mouse_except_scroll`; `occlude` hides them from scrolls too.
|
- A layer over pressable content that must still pass scrolls to its ancestors uses `block_mouse_except_scroll`; `occlude` hides them from scrolls too.
|
||||||
@@ -137,7 +137,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
|||||||
- A flex line whose free space goes to an auto margin loses its gaps in taffy 0.9. A row that parts left and right groups its sides and uses `justify_between`; `ml_auto` is safe only beside a `flex_1` item that leaves no free space.
|
- A flex line whose free space goes to an auto margin loses its gaps in taffy 0.9. A row that parts left and right groups its sides and uses `justify_between`; `ml_auto` is safe only beside a `flex_1` item that leaves no free space.
|
||||||
- A header that holds a name beside counts and actions wraps: `flex_wrap`, the name `flex_1` with `min_w(label_width)`, the rest one `flex_none` group that drops below.
|
- A header that holds a name beside counts and actions wraps: `flex_wrap`, the name `flex_1` with `min_w(label_width)`, the rest one `flex_none` group that drops below.
|
||||||
- Every header, row and strip works at 280px: a name keeps its minimum width, the rest wraps below or scrolls, and nothing draws past its box.
|
- Every header, row and strip works at 280px: a name keeps its minimum width, the rest wraps below or scrolls, and nothing draws past its box.
|
||||||
- gpui takes a scroll box's reach from its direct children's bounds, and a block child stretches to the box. Content that scrolls sideways sets its least width on that child (`tables::body::least`). An x-only scroll box also turns a plain wheel sideways unless `restrict_scroll_to_axis` is set through `style()`; a table's sets it, so the page keeps the wheel.
|
- gpui takes a scroll box's reach from its direct children's bounds, and a block child stretches to the box. Content that scrolls sideways sets its least width on that child (`tables::body::least`).
|
||||||
|
- gpui turns a wheel along the other axis onto a box that scrolls one way, and the page around it scrolls too. A box inside a scrolling page keeps its wheel to its axis with `layout::on_axis`, and so does the page.
|
||||||
- The focus color belongs to the focus ring. A chosen or shown item takes accent, as `forms::glyphs` and `lists::ListItem` do.
|
- The focus color belongs to the focus ring. A chosen or shown item takes accent, as `forms::glyphs` and `lists::ListItem` do.
|
||||||
- A box that shows a host's picture takes the picture's shape from the host (`ratio`), so nothing is cropped and a point given as a share lands on the picture.
|
- A box that shows a host's picture takes the picture's shape from the host (`ratio`), so nothing is cropped and a point given as a share lands on the picture.
|
||||||
|
|
||||||
|
|||||||
@@ -284,3 +284,4 @@ Per-component lines live in `tasks/`. Tags there:
|
|||||||
| T24d | 3 | FAIL | reviewed by Claude Fable 5.1 max after codex ran out of quota: the shown thumbnail wore the focus color, so focus and choice looked alike; frames were forced to 16:10 and cropped, and a pointer's share missed its target. Fixed after the cap: the shown frame takes accent, and BrowserPreview and ComputerUseViewer take the host's ratio (test a_screen_takes_its_pictures_shape); no fourth review |
|
| T24d | 3 | FAIL | reviewed by Claude Fable 5.1 max after codex ran out of quota: the shown thumbnail wore the focus color, so focus and choice looked alike; frames were forced to 16:10 and cropped, and a pointer's share missed its target. Fixed after the cap: the shown frame takes accent, and BrowserPreview and ComputerUseViewer take the host's ratio (test a_screen_takes_its_pictures_shape); no fourth review |
|
||||||
| T24e | 1 | FAIL | cpu() clamped a share past 1 in silence; the registry's grouping by source had no test |
|
| T24e | 1 | FAIL | cpu() clamped a share past 1 in silence; the registry's grouping by source had no test |
|
||||||
| T24e | 2 | PASS | |
|
| T24e | 2 | PASS | |
|
||||||
|
| T24f | 1 | FAIL | Tab to a sortable header past a narrow table's edge left it out of view; a virtualized table still drew past its box at 280px |
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
use ely_gpui_component::{
|
use ely_gpui_component::{
|
||||||
buttons::{ButtonVariant, IconButton},
|
buttons::{ButtonVariant, IconButton},
|
||||||
|
layout::on_axis,
|
||||||
primitives::{FocusScope, IconName},
|
primitives::{FocusScope, IconName},
|
||||||
theme::{ActiveTheme, ControlSize, Mode, Radius, TextSize, Theme},
|
theme::{ActiveTheme, ControlSize, Mode, Radius, TextSize, Theme},
|
||||||
};
|
};
|
||||||
@@ -122,8 +123,7 @@ impl Gallery {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
on_axis(div().id("nav"))
|
||||||
.id("nav")
|
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.gap_0p5()
|
.gap_0p5()
|
||||||
@@ -208,8 +208,7 @@ impl Render for Gallery {
|
|||||||
.text_size(theme.text_size(TextSize::Base))
|
.text_size(theme.text_size(TextSize::Base))
|
||||||
.child(sidebar)
|
.child(sidebar)
|
||||||
.child(
|
.child(
|
||||||
div()
|
on_axis(div().id("page"))
|
||||||
.id("page")
|
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.h_full()
|
.h_full()
|
||||||
.overflow_y_scroll()
|
.overflow_y_scroll()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use crate::{
|
|||||||
buttons::{ButtonVariant, IconButton},
|
buttons::{ButtonVariant, IconButton},
|
||||||
documents::source,
|
documents::source,
|
||||||
forms::{Pick, Run, reveal, revealer},
|
forms::{Pick, Run, reveal, revealer},
|
||||||
|
layout::on_axis,
|
||||||
motion::{self, Spinner},
|
motion::{self, Spinner},
|
||||||
primitives::{FocusRing, Icon, IconName, Image, tab_stop},
|
primitives::{FocusRing, Icon, IconName, Image, tab_stop},
|
||||||
theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, Radius, TextSize},
|
theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, Radius, TextSize},
|
||||||
@@ -168,8 +169,7 @@ impl RenderOnce for BrowserPreview {
|
|||||||
let thumb = theme.avatar_size(AvatarSize::Lg);
|
let thumb = theme.avatar_size(AvatarSize::Lg);
|
||||||
let shown = self.shown;
|
let shown = self.shown;
|
||||||
let strip = (self.frames.len() > 1).then(|| {
|
let strip = (self.frames.len() > 1).then(|| {
|
||||||
div()
|
on_axis(div().id((self.id.clone(), "strip")))
|
||||||
.id((self.id.clone(), "strip"))
|
|
||||||
.track_scroll(&scroll)
|
.track_scroll(&scroll)
|
||||||
.overflow_x_scroll()
|
.overflow_x_scroll()
|
||||||
.flex()
|
.flex()
|
||||||
|
|||||||
+22
-13
@@ -11,7 +11,7 @@ use crate::{
|
|||||||
theme::Theme,
|
theme::Theme,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// A question with two answers to pick, and the answers given.
|
/// A question with two answers to pick, and the answers given; once answered, it shows the first.
|
||||||
struct Asked {
|
struct Asked {
|
||||||
field: Entity<TextInput>,
|
field: Entity<TextInput>,
|
||||||
answers: Vec<String>,
|
answers: Vec<String>,
|
||||||
@@ -20,17 +20,23 @@ struct Asked {
|
|||||||
impl Render for Asked {
|
impl Render for Asked {
|
||||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let view = cx.entity();
|
let view = cx.entity();
|
||||||
div().w(px(420.0)).child(
|
let request = HumanInputRequest::new(
|
||||||
HumanInputRequest::new(
|
"asked",
|
||||||
"asked",
|
"Keep the accents?",
|
||||||
"Keep the accents?",
|
&self.field,
|
||||||
&self.field,
|
move |answer, _, cx| {
|
||||||
move |answer, _, cx| {
|
view.update(cx, |asked, cx| {
|
||||||
view.update(cx, |asked, _| asked.answers.push(answer.to_string()))
|
asked.answers.push(answer.to_string());
|
||||||
},
|
cx.notify();
|
||||||
)
|
})
|
||||||
.choices(["Keep them", "Lift further"]),
|
},
|
||||||
)
|
)
|
||||||
|
.choices(["Keep them", "Lift further"]);
|
||||||
|
let request = match self.answers.first() {
|
||||||
|
Some(answer) => request.answered(answer.clone()),
|
||||||
|
None => request,
|
||||||
|
};
|
||||||
|
div().w(px(420.0)).child(request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,14 +56,17 @@ fn asked(cx: &mut TestAppContext) -> (Entity<Asked>, &mut VisualTestContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
fn a_pick_answers_the_question(cx: &mut TestAppContext) {
|
fn a_pick_answers_the_question_and_the_answer_stands_alone(cx: &mut TestAppContext) {
|
||||||
let (view, cx) = asked(cx);
|
let (view, cx) = asked(cx);
|
||||||
cx.update(|window, _| window.focus_next());
|
cx.update(|window, _| window.focus_next());
|
||||||
cx.update(|window, _| window.focus_next());
|
cx.update(|window, _| window.focus_next());
|
||||||
press("enter", cx);
|
press("enter", cx);
|
||||||
|
cx.update(|window, _| window.focus_next());
|
||||||
|
press("enter", cx);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
view.read_with(cx, |asked, _| asked.answers.clone()),
|
view.read_with(cx, |asked, _| asked.answers.clone()),
|
||||||
["Lift further"]
|
["Lift further"],
|
||||||
|
"once answered, no choice is left to press"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -10,6 +10,7 @@ use crate::{
|
|||||||
buttons::{Button, ButtonVariant, CopyButton, IconButton},
|
buttons::{Button, ButtonVariant, CopyButton, IconButton},
|
||||||
editor::code_colors,
|
editor::code_colors,
|
||||||
forms::Run,
|
forms::Run,
|
||||||
|
layout::on_axis,
|
||||||
primitives::{Disclosure, IconName},
|
primitives::{Disclosure, IconName},
|
||||||
theme::{ActiveTheme, ControlSize, Radius, TextSize},
|
theme::{ActiveTheme, ControlSize, Radius, TextSize},
|
||||||
};
|
};
|
||||||
@@ -128,8 +129,7 @@ impl RenderOnce for CodeBlock {
|
|||||||
.child(CopyButton::new((self.id.clone(), "copy"), code.clone())),
|
.child(CopyButton::new((self.id.clone(), "copy"), code.clone())),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
on_axis(div().id((self.id.clone(), "code")))
|
||||||
.id((self.id.clone(), "code"))
|
|
||||||
.overflow_x_scroll()
|
.overflow_x_scroll()
|
||||||
.px_3()
|
.px_3()
|
||||||
.py_2()
|
.py_2()
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ impl RenderOnce for TimelineProfiler {
|
|||||||
let (from, to) = self.range;
|
let (from, to) = self.range;
|
||||||
let length = to - from;
|
let length = to - from;
|
||||||
let at = move |time: f64| ((time - from) / length) as f32;
|
let at = move |time: f64| ((time - from) / length) as f32;
|
||||||
let room = (theme.label_width() * 0.4).to_pixels(window.rem_size());
|
let room = theme.chart().label.to_pixels(window.rem_size());
|
||||||
let width = lane.read(cx).size.width;
|
let width = lane.read(cx).size.width;
|
||||||
let (step, ticks) = ruler(self.range, f32::from(width), f32::from(room));
|
let (step, ticks) = ruler(self.range, f32::from(width), f32::from(room));
|
||||||
let wheel_lane = lane.clone();
|
let wheel_lane = lane.clone();
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ pub use floating::FloatingPanel;
|
|||||||
pub use grid::{Masonry, SimpleGrid};
|
pub use grid::{Masonry, SimpleGrid};
|
||||||
pub use page::{AppShell, MasterDetail, Page};
|
pub use page::{AppShell, MasterDetail, Page};
|
||||||
pub use panes::{PaneGroup, PaneId, PaneLayout};
|
pub use panes::{PaneGroup, PaneId, PaneLayout};
|
||||||
pub use scroll::{ScrollArea, Scrollbar};
|
pub use scroll::{ScrollArea, Scrollbar, on_axis};
|
||||||
pub use scroll_aids::{ScrollShadow, ScrollToTop, StickyHeader};
|
pub use scroll_aids::{ScrollShadow, ScrollToTop, StickyHeader};
|
||||||
pub use sheet::{Drawer, Sheet};
|
pub use sheet::{Drawer, Sheet};
|
||||||
pub use sidebar::Sidebar;
|
pub use sidebar::Sidebar;
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ use crate::{motion, theme::ActiveTheme};
|
|||||||
|
|
||||||
const IDLE: Duration = Duration::from_millis(900);
|
const IDLE: Duration = Duration::from_millis(900);
|
||||||
|
|
||||||
|
/// Keeps a scroll box's wheel to its own axes; gpui turns a wheel along the other axis onto a box that scrolls one way, while the page scrolls too.
|
||||||
|
pub fn on_axis<E: Styled>(mut element: E) -> E {
|
||||||
|
element.style().restrict_scroll_to_axis = Some(true);
|
||||||
|
element
|
||||||
|
}
|
||||||
|
|
||||||
struct Activity {
|
struct Activity {
|
||||||
handle: ScrollHandle,
|
handle: ScrollHandle,
|
||||||
last: Point<Pixels>,
|
last: Point<Pixels>,
|
||||||
|
|||||||
+11
-7
@@ -2,13 +2,14 @@ use std::{collections::HashSet, rc::Rc};
|
|||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Div, ElementId, Entity, InteractiveElement, IntoElement, MouseButton,
|
AnyElement, App, Div, ElementId, Entity, InteractiveElement, IntoElement, MouseButton,
|
||||||
ParentElement, Pixels, Rems, SharedString, Stateful, StatefulInteractiveElement, Styled,
|
ParentElement, Pixels, Rems, ScrollHandle, SharedString, Stateful, StatefulInteractiveElement,
|
||||||
Window, div, prelude::*, transparent_black,
|
Styled, Window, div, prelude::*, transparent_black,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{Align, Column, Row, cell::draw, model::tint, table::View};
|
use super::{Align, Column, Row, cell::draw, model::tint, table::View};
|
||||||
use crate::{
|
use crate::{
|
||||||
forms::{CheckState, Editing, Input, check_mark},
|
forms::{CheckState, Editing, Input, check_mark},
|
||||||
|
layout::on_axis,
|
||||||
lists::Pick,
|
lists::Pick,
|
||||||
primitives::Disclosure,
|
primitives::Disclosure,
|
||||||
theme::{ActiveTheme, ControlSize, TextSize},
|
theme::{ActiveTheme, ControlSize, TextSize},
|
||||||
@@ -32,11 +33,14 @@ pub(super) fn sized<E: Styled>(cell: E, column: &Column, narrowest: Rems) -> E {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A box a table's columns scroll sideways in; a plain wheel passes to the page.
|
/// A box a table's columns scroll sideways in, tracked by `handle`; a plain wheel passes to the page.
|
||||||
pub(super) fn sideways(id: &ElementId) -> Stateful<Div> {
|
pub(super) fn sideways(id: &ElementId, handle: &ScrollHandle) -> Stateful<Div> {
|
||||||
let mut sideways = div().id((id.clone(), "sideways")).overflow_x_scroll();
|
on_axis(
|
||||||
sideways.style().restrict_scroll_to_axis = Some(true);
|
div()
|
||||||
sideways
|
.id((id.clone(), "sideways"))
|
||||||
|
.overflow_x_scroll()
|
||||||
|
.track_scroll(handle),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the body draws, one after another.
|
/// What the body draws, one after another.
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
use gpui::{App, Div, FontWeight, ParentElement, Styled, div, prelude::*, transparent_black};
|
||||||
|
|
||||||
|
use super::{Aggregate, Cell, body::Body, body::sized, model::figure};
|
||||||
|
use crate::{
|
||||||
|
theme::{ActiveTheme, TextSize},
|
||||||
|
typography::format,
|
||||||
|
};
|
||||||
|
|
||||||
|
impl Body {
|
||||||
|
/// The footer's figures over the rows `order` keeps, in columns `cols`; none when no column asks for one.
|
||||||
|
pub(super) fn figures(
|
||||||
|
&self,
|
||||||
|
order: &[usize],
|
||||||
|
cols: &[usize],
|
||||||
|
lead: bool,
|
||||||
|
cx: &App,
|
||||||
|
) -> Option<Div> {
|
||||||
|
let columns = &self.columns;
|
||||||
|
columns
|
||||||
|
.iter()
|
||||||
|
.any(|column| column.aggregate.is_some())
|
||||||
|
.then(|| {
|
||||||
|
let theme = cx.theme();
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.h(self.height)
|
||||||
|
.text_size(theme.text_size(TextSize::Sm))
|
||||||
|
.font_weight(FontWeight::MEDIUM)
|
||||||
|
.when(lead, |row| {
|
||||||
|
row.child(div().flex_none().w(self.lead_width()))
|
||||||
|
})
|
||||||
|
.children(cols.iter().map(|col| {
|
||||||
|
let column = &columns[*col];
|
||||||
|
let shown = column.aggregate.map(|how| {
|
||||||
|
let shares = self
|
||||||
|
.rows
|
||||||
|
.first()
|
||||||
|
.is_some_and(|row| matches!(row.cells[*col], Cell::Progress(_)));
|
||||||
|
let value = figure(&self.rows, order, *col, how).map_or(
|
||||||
|
"—".to_string(),
|
||||||
|
|value| match how {
|
||||||
|
Aggregate::Count => format!("{}", value as usize),
|
||||||
|
_ if shares => format::percent(value, 0, false),
|
||||||
|
_ => column.reads(value),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_baseline()
|
||||||
|
.gap_1()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_size(theme.text_size(TextSize::Xs))
|
||||||
|
.text_color(theme.colors.fg_subtle)
|
||||||
|
.child(how.label()),
|
||||||
|
)
|
||||||
|
.child(value)
|
||||||
|
});
|
||||||
|
let cell = div()
|
||||||
|
.px_3()
|
||||||
|
.border_x_1()
|
||||||
|
.border_color(transparent_black())
|
||||||
|
.flex()
|
||||||
|
.items_center();
|
||||||
|
match self.widths[*col] {
|
||||||
|
Some(width) => cell.w(width).flex_none(),
|
||||||
|
None => sized(cell, column, self.narrowest),
|
||||||
|
}
|
||||||
|
.children(shown)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+111
-79
@@ -1,10 +1,11 @@
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
App, DragMoveEvent, ElementId, Entity, EntityId, FontWeight, InteractiveElement, IntoElement,
|
App, Bounds, Div, DragMoveEvent, ElementId, Entity, EntityId, FocusHandle, FontWeight,
|
||||||
MouseButton, ParentElement, Pixels, Rems, SharedString, StatefulInteractiveElement, Styled,
|
InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Rems, SharedString,
|
||||||
canvas, div, prelude::*, transparent_black,
|
Stateful, StatefulInteractiveElement, Styled, Window, canvas, div, point, prelude::*,
|
||||||
|
transparent_black,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{Column, body::sized, table::View};
|
use super::{Column, table::View};
|
||||||
use crate::layout::seeded::Seeded;
|
use crate::layout::seeded::Seeded;
|
||||||
use crate::{
|
use crate::{
|
||||||
primitives::{DragGhost, FocusRing, Icon, IconName},
|
primitives::{DragGhost, FocusRing, Icon, IconName},
|
||||||
@@ -78,16 +79,48 @@ pub(crate) fn moved(
|
|||||||
next
|
next
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One header cell: its title and sort mark; a press sorts, a drag moves it, and its right edge resizes.
|
/// Brings a focused header whole into its table's sideways box once, from its painted bounds; forgets it when focus leaves.
|
||||||
|
fn reveal(
|
||||||
|
view: &Entity<View>,
|
||||||
|
key: &SharedString,
|
||||||
|
focused: bool,
|
||||||
|
head: Bounds<Pixels>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
if focused == (view.read(cx).revealed.as_ref() == Some(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !focused {
|
||||||
|
view.update(cx, |view, _| view.revealed = None);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let scroll = view.read(cx).sideways.clone();
|
||||||
|
let (frame, offset) = (scroll.bounds(), scroll.offset());
|
||||||
|
let shift = if head.left() < frame.left() {
|
||||||
|
frame.left() - head.left()
|
||||||
|
} else if head.right() > frame.right() {
|
||||||
|
(frame.right() - head.right()).max(frame.left() - head.left())
|
||||||
|
} else {
|
||||||
|
Pixels::ZERO
|
||||||
|
};
|
||||||
|
view.update(cx, |view, _| view.revealed = Some(key.clone()));
|
||||||
|
if shift != Pixels::ZERO {
|
||||||
|
scroll.set_offset(point(offset.x + shift, offset.y));
|
||||||
|
log::info!("data table: header {key} scrolled into view");
|
||||||
|
window.request_animation_frame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One header cell, sized by its caller: its title and sort mark; a press sorts, a drag moves it, and its right edge resizes. A sortable one is a Tab stop through `focus`, and scrolls into view when focused.
|
||||||
pub(crate) fn header(
|
pub(crate) fn header(
|
||||||
id: &ElementId,
|
id: &ElementId,
|
||||||
view: &Entity<View>,
|
view: &Entity<View>,
|
||||||
sorting: &Entity<Seeded<Vec<(SharedString, bool)>>>,
|
sorting: &Entity<Seeded<Vec<(SharedString, bool)>>>,
|
||||||
column: &Column,
|
column: &Column,
|
||||||
width: Option<Pixels>,
|
focus: Option<FocusHandle>,
|
||||||
narrowest: Rems,
|
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) -> impl IntoElement + use<> {
|
) -> Stateful<Div> {
|
||||||
let sort = sorting.read(cx).value.clone();
|
let sort = sorting.read(cx).value.clone();
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let colors = &theme.colors;
|
let colors = &theme.colors;
|
||||||
@@ -116,80 +149,79 @@ pub(crate) fn header(
|
|||||||
.text_size(theme.text_size(TextSize::Xs))
|
.text_size(theme.text_size(TextSize::Xs))
|
||||||
.font_weight(FontWeight::MEDIUM)
|
.font_weight(FontWeight::MEDIUM)
|
||||||
.text_color(colors.fg_muted);
|
.text_color(colors.fg_muted);
|
||||||
let cell = match width {
|
let revealing = focus.clone().filter(|_| !column.pinned);
|
||||||
Some(width) => cell.w(width).flex_none(),
|
cell.when_some(focus, |cell, focus| cell.track_focus(&focus).focus_ring(cx))
|
||||||
None => sized(cell, column, narrowest),
|
.when(column.sortable, |cell| {
|
||||||
};
|
let key = key.clone();
|
||||||
cell.when(column.sortable, |cell| {
|
cell.cursor_pointer()
|
||||||
let key = key.clone();
|
.hover(|style| style.text_color(colors.fg))
|
||||||
cell.tab_index(0)
|
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||||
.focus_ring(cx)
|
.on_click(move |event, _, cx| {
|
||||||
.cursor_pointer()
|
let join = event.modifiers().shift;
|
||||||
.hover(|style| style.text_color(colors.fg))
|
sorter.update(cx, |sorting, cx| {
|
||||||
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
sorting.value = next_sort(&sorting.value, &key, join);
|
||||||
.on_click(move |event, _, cx| {
|
log::info!("data table: sort {:?}", sorting.value);
|
||||||
let join = event.modifiers().shift;
|
cx.notify();
|
||||||
sorter.update(cx, |sorting, cx| {
|
});
|
||||||
sorting.value = next_sort(&sorting.value, &key, join);
|
pager.update(cx, |view, _| view.page = 0);
|
||||||
log::info!("data table: sort {:?}", sorting.value);
|
|
||||||
cx.notify();
|
|
||||||
});
|
|
||||||
pager.update(cx, |view, _| view.page = 0);
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.on_drag(
|
|
||||||
Move {
|
|
||||||
owner,
|
|
||||||
key: key.clone(),
|
|
||||||
},
|
|
||||||
move |_, _, _, cx| DragGhost::new(title.clone(), None, cx),
|
|
||||||
)
|
|
||||||
.on_drop({
|
|
||||||
let key = key.clone();
|
|
||||||
move |drag: &Move, _, cx| {
|
|
||||||
if drag.owner != owner || drag.key == key {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
mover.update(cx, |view, cx| {
|
|
||||||
view.order = moved(&view.order, &drag.key, &key);
|
|
||||||
log::info!("data table: moved {} before {key}", drag.key);
|
|
||||||
cx.notify();
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.child(column.title.clone())
|
|
||||||
.children(mark.map(|(rising, rank)| {
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.child(
|
|
||||||
Icon::new(if rising {
|
|
||||||
IconName::ArrowUp
|
|
||||||
} else {
|
|
||||||
IconName::ArrowDown
|
|
||||||
})
|
})
|
||||||
.size(IconSize::Xs)
|
})
|
||||||
.color(colors.fg_muted),
|
.on_drag(
|
||||||
)
|
Move {
|
||||||
.children(rank.map(|rank| {
|
owner,
|
||||||
div()
|
key: key.clone(),
|
||||||
.text_size(theme.text_size(TextSize::Xs))
|
|
||||||
.child(rank.to_string())
|
|
||||||
}))
|
|
||||||
}))
|
|
||||||
.child(grip(id, &key, owner, sizer, theme.handle_hit(), cx))
|
|
||||||
.child(
|
|
||||||
canvas(
|
|
||||||
move |bounds, _, cx| {
|
|
||||||
if edges.read(cx).edges.get(&key) != Some(&bounds.left()) {
|
|
||||||
edges.update(cx, |view, _| view.edges.insert(key.clone(), bounds.left()));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|_, _, _, _| {},
|
move |_, _, _, cx| DragGhost::new(title.clone(), None, cx),
|
||||||
|
)
|
||||||
|
.on_drop({
|
||||||
|
let key = key.clone();
|
||||||
|
move |drag: &Move, _, cx| {
|
||||||
|
if drag.owner != owner || drag.key == key {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mover.update(cx, |view, cx| {
|
||||||
|
view.order = moved(&view.order, &drag.key, &key);
|
||||||
|
log::info!("data table: moved {} before {key}", drag.key);
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.child(column.title.clone())
|
||||||
|
.children(mark.map(|(rising, rank)| {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.child(
|
||||||
|
Icon::new(if rising {
|
||||||
|
IconName::ArrowUp
|
||||||
|
} else {
|
||||||
|
IconName::ArrowDown
|
||||||
|
})
|
||||||
|
.size(IconSize::Xs)
|
||||||
|
.color(colors.fg_muted),
|
||||||
|
)
|
||||||
|
.children(rank.map(|rank| {
|
||||||
|
div()
|
||||||
|
.text_size(theme.text_size(TextSize::Xs))
|
||||||
|
.child(rank.to_string())
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
.child(grip(id, &key, owner, sizer, theme.handle_hit(), cx))
|
||||||
|
.child(
|
||||||
|
canvas(
|
||||||
|
move |bounds, window, cx| {
|
||||||
|
if edges.read(cx).edges.get(&key) != Some(&bounds.left()) {
|
||||||
|
edges.update(cx, |view, _| view.edges.insert(key.clone(), bounds.left()));
|
||||||
|
}
|
||||||
|
if let Some(focus) = &revealing {
|
||||||
|
reveal(&edges, &key, focus.is_focused(window), bounds, window, cx);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|_, _, _, _| {},
|
||||||
|
)
|
||||||
|
.absolute()
|
||||||
|
.inset_0(),
|
||||||
)
|
)
|
||||||
.absolute()
|
|
||||||
.inset_0(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The resize grip on a header's right edge.
|
/// The resize grip on a header's right edge.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
mod body;
|
mod body;
|
||||||
mod builders;
|
mod builders;
|
||||||
mod cell;
|
mod cell;
|
||||||
|
mod foot;
|
||||||
mod formula;
|
mod formula;
|
||||||
mod grid;
|
mod grid;
|
||||||
mod gridkeys;
|
mod gridkeys;
|
||||||
|
|||||||
+94
-128
@@ -4,25 +4,26 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Div, ElementId, Entity, FontWeight, InteractiveElement, IntoElement,
|
AnyElement, App, Div, ElementId, Entity, FocusHandle, InteractiveElement, IntoElement,
|
||||||
ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement,
|
ParentElement, Pixels, RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement,
|
||||||
Styled, UniformListScrollHandle, Window, div, prelude::*, transparent_black, uniform_list,
|
StyleRefinement, Styled, UniformListScrollHandle, Window, div, prelude::*, uniform_list,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
Aggregate, Cell, Column, FilterRule,
|
Cell, Column, FilterRule,
|
||||||
body::{Body, Detail, Line, OnEdit, Select, sideways, sized},
|
body::{Body, Detail, Line, OnEdit, Select, sideways, sized},
|
||||||
header::header,
|
header::header,
|
||||||
model::{figure, filtered, page, range},
|
model::{filtered, page, range},
|
||||||
rules::{groups, kept, sorted_by},
|
rules::{groups, kept, sorted_by},
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
forms::{CheckState, Editing, check_mark},
|
forms::{CheckState, Editing, check_mark},
|
||||||
layout::seeded::use_seeded,
|
layout::{on_axis, seeded::use_seeded},
|
||||||
lists::{Pick, picked},
|
lists::{Pick, picked},
|
||||||
navigation::Pagination,
|
navigation::Pagination,
|
||||||
|
primitives::tab_stop,
|
||||||
theme::{ActiveTheme, ControlSize, Density, TextSize},
|
theme::{ActiveTheme, ControlSize, Density, TextSize},
|
||||||
typography::{Caption, format},
|
typography::Caption,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// One row: its key, which a selection names it by, and a cell for each column.
|
/// One row: its key, which a selection names it by, and a cell for each column.
|
||||||
@@ -41,12 +42,14 @@ impl Row {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A table's own state: the page, a Shift range's start, the scroll, column widths, edges and order, opened rows, folded groups, and the cell being edited.
|
/// A table's own state: the page, a Shift range's start, the scrolls down and sideways, the header last scrolled into view, column widths, edges and order, opened rows, folded groups, and the cell being edited.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub(crate) struct View {
|
pub(crate) struct View {
|
||||||
pub page: usize,
|
pub page: usize,
|
||||||
pub anchor: usize,
|
pub anchor: usize,
|
||||||
pub scroll: UniformListScrollHandle,
|
pub scroll: UniformListScrollHandle,
|
||||||
|
pub sideways: ScrollHandle,
|
||||||
|
pub revealed: Option<SharedString>,
|
||||||
pub widths: HashMap<SharedString, Pixels>,
|
pub widths: HashMap<SharedString, Pixels>,
|
||||||
pub edges: HashMap<SharedString, Pixels>,
|
pub edges: HashMap<SharedString, Pixels>,
|
||||||
pub narrowest: Pixels,
|
pub narrowest: Pixels,
|
||||||
@@ -121,6 +124,12 @@ impl RenderOnce for DataTable {
|
|||||||
window.use_keyed_state((id.clone(), "view"), cx, |_, _| View::default());
|
window.use_keyed_state((id.clone(), "view"), cx, |_, _| View::default());
|
||||||
let sorting = use_seeded((id.clone(), "sort"), self.sorts.clone(), window, cx);
|
let sorting = use_seeded((id.clone(), "sort"), self.sorts.clone(), window, cx);
|
||||||
let editor = window.use_keyed_state((id.clone(), "editor"), cx, |_, _| Editing::default());
|
let editor = window.use_keyed_state((id.clone(), "editor"), cx, |_, _| Editing::default());
|
||||||
|
let focuses: Vec<Option<FocusHandle>> = (self.columns.iter())
|
||||||
|
.map(|column| {
|
||||||
|
let key = (id.clone(), format!("head-{}", column.key)).into();
|
||||||
|
column.sortable.then(|| tab_stop(key, true, window, cx))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
let narrowest = cx.theme().label_width() * 0.5;
|
let narrowest = cx.theme().label_width() * 0.5;
|
||||||
let rem = window.rem_size();
|
let rem = window.rem_size();
|
||||||
let keys_now: Vec<SharedString> = self
|
let keys_now: Vec<SharedString> = self
|
||||||
@@ -328,72 +337,22 @@ impl RenderOnce for DataTable {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.children(cols.iter().map(|col| {
|
.children(cols.iter().map(|col| {
|
||||||
header(
|
let head = header(
|
||||||
&id,
|
&id,
|
||||||
&view,
|
&view,
|
||||||
&sorting,
|
&sorting,
|
||||||
&columns[*col],
|
&columns[*col],
|
||||||
widths[*col],
|
focuses[*col].clone(),
|
||||||
narrowest,
|
|
||||||
cx,
|
cx,
|
||||||
)
|
);
|
||||||
|
match widths[*col] {
|
||||||
|
Some(width) => head.w(width).flex_none(),
|
||||||
|
None => sized(head, &columns[*col], narrowest),
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
let figures = |cols: &[usize], lead: bool, cx: &App| {
|
let figures = |cols: &[usize], lead: bool, cx: &App| body.figures(&order, cols, lead, cx);
|
||||||
columns
|
let slide = view.read(cx).sideways.clone();
|
||||||
.iter()
|
|
||||||
.any(|column| column.aggregate.is_some())
|
|
||||||
.then(|| {
|
|
||||||
let theme = cx.theme();
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.h(height)
|
|
||||||
.text_size(theme.text_size(TextSize::Sm))
|
|
||||||
.font_weight(FontWeight::MEDIUM)
|
|
||||||
.when(lead, |row| {
|
|
||||||
row.child(div().flex_none().w(body.lead_width()))
|
|
||||||
})
|
|
||||||
.children(cols.iter().map(|col| {
|
|
||||||
let column = &columns[*col];
|
|
||||||
let shown = column.aggregate.map(|how| {
|
|
||||||
let shares = rows.first().is_some_and(|row| {
|
|
||||||
matches!(row.cells[*col], Cell::Progress(_))
|
|
||||||
});
|
|
||||||
let value = figure(&rows, &order, *col, how).map_or(
|
|
||||||
"—".to_string(),
|
|
||||||
|value| match how {
|
|
||||||
Aggregate::Count => format!("{}", value as usize),
|
|
||||||
_ if shares => format::percent(value, 0, false),
|
|
||||||
_ => column.reads(value),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.items_baseline()
|
|
||||||
.gap_1()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_size(theme.text_size(TextSize::Xs))
|
|
||||||
.text_color(theme.colors.fg_subtle)
|
|
||||||
.child(how.label()),
|
|
||||||
)
|
|
||||||
.child(value)
|
|
||||||
});
|
|
||||||
let cell = div()
|
|
||||||
.px_3()
|
|
||||||
.border_x_1()
|
|
||||||
.border_color(transparent_black())
|
|
||||||
.flex()
|
|
||||||
.items_center();
|
|
||||||
match widths[*col] {
|
|
||||||
Some(width) => cell.w(width).flex_none(),
|
|
||||||
None => sized(cell, column, narrowest),
|
|
||||||
}
|
|
||||||
.children(shown)
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
};
|
|
||||||
let part = |cols: &[usize], lead: bool, window: &mut Window, cx: &mut App| -> Div {
|
let part = |cols: &[usize], lead: bool, window: &mut Window, cx: &mut App| -> Div {
|
||||||
let lines: Vec<AnyElement> = (0..body.lines.len())
|
let lines: Vec<AnyElement> = (0..body.lines.len())
|
||||||
.map(|at| body.line(at, cols, lead, window, cx))
|
.map(|at| body.line(at, cols, lead, window, cx))
|
||||||
@@ -405,68 +364,75 @@ impl RenderOnce for DataTable {
|
|||||||
.children(lines)
|
.children(lines)
|
||||||
.children(figures(cols, lead, cx))
|
.children(figures(cols, lead, cx))
|
||||||
};
|
};
|
||||||
let table: AnyElement =
|
let table: AnyElement = if total == 0 {
|
||||||
if total == 0 {
|
let theme = cx.theme();
|
||||||
let theme = cx.theme();
|
let least = body.least(&shown_columns, true, rem);
|
||||||
let least = body.least(&shown_columns, true, rem);
|
sideways(&id, &slide)
|
||||||
sideways(&id)
|
.child(
|
||||||
.child(
|
div()
|
||||||
div()
|
.flex()
|
||||||
.flex()
|
.flex_col()
|
||||||
.flex_col()
|
.min_w(least)
|
||||||
.min_w(least)
|
.child(heads(&shown_columns, true, cx))
|
||||||
.child(heads(&shown_columns, true, cx))
|
.child(
|
||||||
.child(
|
div()
|
||||||
div()
|
.flex()
|
||||||
.flex()
|
.justify_center()
|
||||||
.justify_center()
|
.py_6()
|
||||||
.py_6()
|
.text_size(theme.text_size(TextSize::Sm))
|
||||||
.text_size(theme.text_size(TextSize::Sm))
|
.text_color(theme.colors.fg_subtle)
|
||||||
.text_color(theme.colors.fg_subtle)
|
.child("Nothing matches."),
|
||||||
.child("Nothing matches."),
|
),
|
||||||
),
|
)
|
||||||
)
|
.into_any_element()
|
||||||
.into_any_element()
|
} else if self.virtualized {
|
||||||
} else if self.virtualized {
|
let (scroll, count, cols, drawn) = (
|
||||||
let (scroll, count, cols, drawn) = (
|
view.read(cx).scroll.clone(),
|
||||||
view.read(cx).scroll.clone(),
|
body.lines.len(),
|
||||||
body.lines.len(),
|
shown_columns.clone(),
|
||||||
shown_columns.clone(),
|
body.clone(),
|
||||||
body.clone(),
|
);
|
||||||
);
|
let least = body.least(&shown_columns, true, rem);
|
||||||
div()
|
sideways(&id, &slide)
|
||||||
.flex()
|
.size_full()
|
||||||
.flex_col()
|
.child(
|
||||||
.size_full()
|
div()
|
||||||
.child(heads(&shown_columns, true, cx))
|
.flex()
|
||||||
.child(
|
.flex_col()
|
||||||
uniform_list((id.clone(), "rows"), count, move |range, window, cx| {
|
.size_full()
|
||||||
range
|
.min_w(least)
|
||||||
.map(|at| drawn.line(at, &cols, true, window, cx))
|
.child(heads(&shown_columns, true, cx))
|
||||||
.collect()
|
.child(on_axis(
|
||||||
})
|
uniform_list((id.clone(), "rows"), count, move |range, window, cx| {
|
||||||
.track_scroll(scroll)
|
range
|
||||||
.flex_1()
|
.map(|at| drawn.line(at, &cols, true, window, cx))
|
||||||
.min_h_0(),
|
.collect()
|
||||||
)
|
})
|
||||||
.children(figures(&shown_columns, true, cx))
|
.track_scroll(scroll)
|
||||||
.into_any_element()
|
.flex_1()
|
||||||
} else if pinned_any {
|
.min_h_0(),
|
||||||
let (held, moving): (Vec<usize>, Vec<usize>) =
|
))
|
||||||
shown_columns.iter().partition(|col| columns[**col].pinned);
|
.children(figures(&shown_columns, true, cx)),
|
||||||
div()
|
)
|
||||||
.flex()
|
.into_any_element()
|
||||||
.child(part(&held, true, window, cx).flex_none())
|
} else if pinned_any {
|
||||||
.child(sideways(&id).flex_1().min_w_0().child(
|
let (held, moving): (Vec<usize>, Vec<usize>) =
|
||||||
|
shown_columns.iter().partition(|col| columns[**col].pinned);
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.child(part(&held, true, window, cx).flex_none())
|
||||||
|
.child(
|
||||||
|
sideways(&id, &slide).flex_1().min_w_0().child(
|
||||||
part(&moving, false, window, cx).min_w(body.least(&moving, false, rem)),
|
part(&moving, false, window, cx).min_w(body.least(&moving, false, rem)),
|
||||||
))
|
),
|
||||||
.into_any_element()
|
)
|
||||||
} else {
|
.into_any_element()
|
||||||
let least = body.least(&shown_columns, true, rem);
|
} else {
|
||||||
sideways(&id)
|
let least = body.least(&shown_columns, true, rem);
|
||||||
.child(part(&shown_columns, true, window, cx).min_w(least))
|
sideways(&id, &slide)
|
||||||
.into_any_element()
|
.child(part(&shown_columns, true, window, cx).min_w(least))
|
||||||
};
|
.into_any_element()
|
||||||
|
};
|
||||||
let pager = pages.filter(|pages| *pages > 1).map(|pages| {
|
let pager = pages.filter(|pages| *pages > 1).map(|pages| {
|
||||||
let size = self.page_size.expect("pages come from a page size");
|
let size = self.page_size.expect("pages come from a page size");
|
||||||
let (first, last) = (page_at * size + 1, ((page_at + 1) * size).min(total));
|
let (first, last) = (page_at * size + 1, ((page_at + 1) * size).min(total));
|
||||||
|
|||||||
+73
-15
@@ -7,26 +7,41 @@ use gpui::{
|
|||||||
|
|
||||||
use super::{edit_at, settle};
|
use super::{edit_at, settle};
|
||||||
use crate::{
|
use crate::{
|
||||||
tables::{Column, DataTable, Row},
|
tables::{Cell, Column, DataTable, Row},
|
||||||
theme::Theme,
|
theme::Theme,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Three editable columns 200 wide in a table 280 wide, the first pinned or not; it keeps the column of each edit.
|
/// How a wide table lays out its rows.
|
||||||
struct Wide(Rc<RefCell<Vec<String>>>, bool);
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
enum Mode {
|
||||||
|
Plain,
|
||||||
|
Pinned,
|
||||||
|
Long,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Twenty rows of three editable columns 200 wide in a table 280 wide; it keeps each edit's row and column.
|
||||||
|
struct Wide(Rc<RefCell<Vec<String>>>, Mode);
|
||||||
|
|
||||||
impl Render for Wide {
|
impl Render for Wide {
|
||||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||||
let heard = self.0.clone();
|
let heard = self.0.clone();
|
||||||
let column = |key: &'static str| Column::new(key, key).width(gpui::rems(12.5)).editable();
|
let column = |key: &'static str| Column::new(key, key).width(gpui::rems(12.5)).editable();
|
||||||
let first = if self.1 {
|
let first = match self.1 {
|
||||||
column("a").pinned()
|
Mode::Pinned => column("a").pinned(),
|
||||||
} else {
|
_ => column("a"),
|
||||||
column("a")
|
|
||||||
};
|
};
|
||||||
DataTable::new("wide", [first, column("b"), column("c")])
|
let table = DataTable::new("wide", [first, column("b"), column("c")])
|
||||||
.rows(vec![Row::new("r", ["A".into(), "B".into(), "C".into()])])
|
.rows(
|
||||||
.on_edit(move |_, column, _, _, _| heard.borrow_mut().push(column.to_string()))
|
(0..20)
|
||||||
.w(px(280.0))
|
.map(|n| Row::new(format!("r{n}"), ["A".into(), "B".into(), "C".into()]))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.on_edit(move |row, column, _, _, _| heard.borrow_mut().push(format!("{row} {column}")))
|
||||||
|
.w(px(280.0));
|
||||||
|
match self.1 {
|
||||||
|
Mode::Long => table.virtualized().h(px(200.0)),
|
||||||
|
_ => table,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,13 +51,18 @@ fn columns_past_the_table_scroll_sideways_and_a_plain_wheel_leaves_them(cx: &mut
|
|||||||
Theme::init(cx);
|
Theme::init(cx);
|
||||||
crate::forms::bind_keys(cx);
|
crate::forms::bind_keys(cx);
|
||||||
});
|
});
|
||||||
for pinned in [false, true] {
|
for mode in [Mode::Plain, Mode::Pinned, Mode::Long] {
|
||||||
let heard = Rc::new(RefCell::new(Vec::new()));
|
let heard = Rc::new(RefCell::new(Vec::new()));
|
||||||
let seen = heard.clone();
|
let seen = heard.clone();
|
||||||
let (_, cx) = cx.add_window_view(|_, _| Wide(seen, pinned));
|
let (_, cx) = cx.add_window_view(|_, _| Wide(seen, mode));
|
||||||
settle(cx);
|
settle(cx);
|
||||||
let cell = point(px(250.0), px(54.0));
|
let cell = point(px(250.0), px(54.0));
|
||||||
for delta in [point(px(0.0), px(-2000.0)), point(px(-2000.0), px(0.0))] {
|
// A long table's rows take a plain wheel down; up leaves them at the top.
|
||||||
|
let plain = match mode {
|
||||||
|
Mode::Long => px(2000.0),
|
||||||
|
_ => px(-2000.0),
|
||||||
|
};
|
||||||
|
for delta in [point(px(0.0), plain), point(px(-2000.0), px(0.0))] {
|
||||||
cx.simulate_event(gpui::ScrollWheelEvent {
|
cx.simulate_event(gpui::ScrollWheelEvent {
|
||||||
position: cell,
|
position: cell,
|
||||||
delta: gpui::ScrollDelta::Pixels(delta),
|
delta: gpui::ScrollDelta::Pixels(delta),
|
||||||
@@ -52,7 +72,7 @@ fn columns_past_the_table_scroll_sideways_and_a_plain_wheel_leaves_them(cx: &mut
|
|||||||
settle(cx);
|
settle(cx);
|
||||||
edit_at(cell, "x", cx);
|
edit_at(cell, "x", cx);
|
||||||
}
|
}
|
||||||
assert_eq!(*heard.borrow(), ["b", "c"], "pinned {pinned}");
|
assert_eq!(*heard.borrow(), ["r0 b", "r0 c"], "{mode:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,3 +113,41 @@ fn tab_reaches_a_header_and_enter_sorts_by_it(cx: &mut TestAppContext) {
|
|||||||
edit_at(point(px(120.0), px(54.0)), "x", cx);
|
edit_at(point(px(120.0), px(54.0)), "x", cx);
|
||||||
assert_eq!(*heard.borrow(), ["ada"], "Ada rises to the top");
|
assert_eq!(*heard.borrow(), ["ada"], "Ada rises to the top");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Five sortable, editable columns 96 wide in a table 280 wide; it keeps the column of each edit.
|
||||||
|
struct Five(Rc<RefCell<Vec<String>>>);
|
||||||
|
|
||||||
|
impl Render for Five {
|
||||||
|
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let heard = self.0.clone();
|
||||||
|
let keys = ["a", "b", "c", "d", "e"];
|
||||||
|
let columns = keys.map(|key| Column::new(key, key).width(gpui::rems(6.0)).editable());
|
||||||
|
DataTable::new("five", columns)
|
||||||
|
.rows(vec![Row::new("r", keys.map(Cell::from))])
|
||||||
|
.on_edit(move |_, column, _, _, _| heard.borrow_mut().push(column.to_string()))
|
||||||
|
.w(px(280.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_focused_header_past_the_edge_scrolls_into_view(cx: &mut TestAppContext) {
|
||||||
|
cx.update(|cx| {
|
||||||
|
Theme::init(cx);
|
||||||
|
crate::forms::bind_keys(cx);
|
||||||
|
});
|
||||||
|
let heard = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
let seen = heard.clone();
|
||||||
|
let (_, cx) = cx.add_window_view(|_, _| Five(seen));
|
||||||
|
cx.update(|window, _| window.activate_window());
|
||||||
|
settle(cx);
|
||||||
|
for _ in 0..5 {
|
||||||
|
cx.update(|window, _| window.focus_next());
|
||||||
|
}
|
||||||
|
settle(cx);
|
||||||
|
edit_at(point(px(270.0), px(54.0)), "x", cx);
|
||||||
|
assert_eq!(
|
||||||
|
*heard.borrow(),
|
||||||
|
["e"],
|
||||||
|
"the last header's column sits at the box's end"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user