From 1f1166fef0449df9a7d476edc6c4659ad4e940ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 12:00:27 -0400 Subject: [PATCH] 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. --- AGENTS.md | 5 +- TASKS.md | 1 + examples/gallery/shell.rs | 7 +- src/agent/previews.rs | 4 +- src/agent/tests/control.rs | 35 +++--- src/chat/code.rs | 4 +- src/debug/timeline.rs | 2 +- src/layout/mod.rs | 2 +- src/layout/scroll.rs | 6 + src/tables/body.rs | 18 +-- src/tables/foot.rs | 74 ++++++++++++ src/tables/header.rs | 190 +++++++++++++++++------------- src/tables/mod.rs | 1 + src/tables/table.rs | 222 +++++++++++++++--------------------- src/tables/tests/columns.rs | 88 +++++++++++--- 15 files changed, 405 insertions(+), 254 deletions(-) create mode 100644 src/tables/foot.rs diff --git a/AGENTS.md b/AGENTS.md index 7d7b685..deb3cd2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. - 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 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. - 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. @@ -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 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. -- 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. - 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. diff --git a/TASKS.md b/TASKS.md index 02c2514..ce99bfc 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 | | T24e | 1 | FAIL | cpu() clamped a share past 1 in silence; the registry's grouping by source had no test | | 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 | diff --git a/examples/gallery/shell.rs b/examples/gallery/shell.rs index f30334f..08889a7 100644 --- a/examples/gallery/shell.rs +++ b/examples/gallery/shell.rs @@ -1,5 +1,6 @@ use ely_gpui_component::{ buttons::{ButtonVariant, IconButton}, + layout::on_axis, primitives::{FocusScope, IconName}, theme::{ActiveTheme, ControlSize, Mode, Radius, TextSize, Theme}, }; @@ -122,8 +123,7 @@ impl Gallery { ), ) .child( - div() - .id("nav") + on_axis(div().id("nav")) .flex() .flex_col() .gap_0p5() @@ -208,8 +208,7 @@ impl Render for Gallery { .text_size(theme.text_size(TextSize::Base)) .child(sidebar) .child( - div() - .id("page") + on_axis(div().id("page")) .flex_1() .h_full() .overflow_y_scroll() diff --git a/src/agent/previews.rs b/src/agent/previews.rs index a757903..e81f82a 100644 --- a/src/agent/previews.rs +++ b/src/agent/previews.rs @@ -10,6 +10,7 @@ use crate::{ buttons::{ButtonVariant, IconButton}, documents::source, forms::{Pick, Run, reveal, revealer}, + layout::on_axis, motion::{self, Spinner}, primitives::{FocusRing, Icon, IconName, Image, tab_stop}, theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, Radius, TextSize}, @@ -168,8 +169,7 @@ impl RenderOnce for BrowserPreview { let thumb = theme.avatar_size(AvatarSize::Lg); let shown = self.shown; let strip = (self.frames.len() > 1).then(|| { - div() - .id((self.id.clone(), "strip")) + on_axis(div().id((self.id.clone(), "strip"))) .track_scroll(&scroll) .overflow_x_scroll() .flex() diff --git a/src/agent/tests/control.rs b/src/agent/tests/control.rs index 6dfdf50..27b0f8d 100644 --- a/src/agent/tests/control.rs +++ b/src/agent/tests/control.rs @@ -11,7 +11,7 @@ use crate::{ 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 { field: Entity, answers: Vec, @@ -20,17 +20,23 @@ struct Asked { impl Render for Asked { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let view = cx.entity(); - div().w(px(420.0)).child( - HumanInputRequest::new( - "asked", - "Keep the accents?", - &self.field, - move |answer, _, cx| { - view.update(cx, |asked, _| asked.answers.push(answer.to_string())) - }, - ) - .choices(["Keep them", "Lift further"]), + let request = HumanInputRequest::new( + "asked", + "Keep the accents?", + &self.field, + move |answer, _, cx| { + view.update(cx, |asked, cx| { + asked.answers.push(answer.to_string()); + cx.notify(); + }) + }, ) + .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, &mut VisualTestContext) { } #[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); cx.update(|window, _| window.focus_next()); cx.update(|window, _| window.focus_next()); press("enter", cx); + cx.update(|window, _| window.focus_next()); + press("enter", cx); assert_eq!( view.read_with(cx, |asked, _| asked.answers.clone()), - ["Lift further"] + ["Lift further"], + "once answered, no choice is left to press" ); } diff --git a/src/chat/code.rs b/src/chat/code.rs index 69ac36e..44d98d4 100644 --- a/src/chat/code.rs +++ b/src/chat/code.rs @@ -10,6 +10,7 @@ use crate::{ buttons::{Button, ButtonVariant, CopyButton, IconButton}, editor::code_colors, forms::Run, + layout::on_axis, primitives::{Disclosure, IconName}, theme::{ActiveTheme, ControlSize, Radius, TextSize}, }; @@ -128,8 +129,7 @@ impl RenderOnce for CodeBlock { .child(CopyButton::new((self.id.clone(), "copy"), code.clone())), ) .child( - div() - .id((self.id.clone(), "code")) + on_axis(div().id((self.id.clone(), "code"))) .overflow_x_scroll() .px_3() .py_2() diff --git a/src/debug/timeline.rs b/src/debug/timeline.rs index da1490f..a4f35b0 100644 --- a/src/debug/timeline.rs +++ b/src/debug/timeline.rs @@ -136,7 +136,7 @@ impl RenderOnce for TimelineProfiler { let (from, to) = self.range; let length = to - from; 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 (step, ticks) = ruler(self.range, f32::from(width), f32::from(room)); let wheel_lane = lane.clone(); diff --git a/src/layout/mod.rs b/src/layout/mod.rs index 47a94e2..54a67be 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -21,7 +21,7 @@ pub use floating::FloatingPanel; pub use grid::{Masonry, SimpleGrid}; pub use page::{AppShell, MasterDetail, Page}; 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 sheet::{Drawer, Sheet}; pub use sidebar::Sidebar; diff --git a/src/layout/scroll.rs b/src/layout/scroll.rs index f38eb14..d3d34dc 100644 --- a/src/layout/scroll.rs +++ b/src/layout/scroll.rs @@ -16,6 +16,12 @@ use crate::{motion, theme::ActiveTheme}; 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(mut element: E) -> E { + element.style().restrict_scroll_to_axis = Some(true); + element +} + struct Activity { handle: ScrollHandle, last: Point, diff --git a/src/tables/body.rs b/src/tables/body.rs index 443ff4f..3ee5f0b 100644 --- a/src/tables/body.rs +++ b/src/tables/body.rs @@ -2,13 +2,14 @@ use std::{collections::HashSet, rc::Rc}; use gpui::{ AnyElement, App, Div, ElementId, Entity, InteractiveElement, IntoElement, MouseButton, - ParentElement, Pixels, Rems, SharedString, Stateful, StatefulInteractiveElement, Styled, - Window, div, prelude::*, transparent_black, + ParentElement, Pixels, Rems, ScrollHandle, SharedString, Stateful, StatefulInteractiveElement, + Styled, Window, div, prelude::*, transparent_black, }; use super::{Align, Column, Row, cell::draw, model::tint, table::View}; use crate::{ forms::{CheckState, Editing, Input, check_mark}, + layout::on_axis, lists::Pick, primitives::Disclosure, theme::{ActiveTheme, ControlSize, TextSize}, @@ -32,11 +33,14 @@ pub(super) fn sized(cell: E, column: &Column, narrowest: Rems) -> E { } } -/// A box a table's columns scroll sideways in; a plain wheel passes to the page. -pub(super) fn sideways(id: &ElementId) -> Stateful
{ - let mut sideways = div().id((id.clone(), "sideways")).overflow_x_scroll(); - sideways.style().restrict_scroll_to_axis = Some(true); - sideways +/// 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, handle: &ScrollHandle) -> Stateful
{ + on_axis( + div() + .id((id.clone(), "sideways")) + .overflow_x_scroll() + .track_scroll(handle), + ) } /// What the body draws, one after another. diff --git a/src/tables/foot.rs b/src/tables/foot.rs new file mode 100644 index 0000000..04c3ea7 --- /dev/null +++ b/src/tables/foot.rs @@ -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
{ + 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) + })) + }) + } +} diff --git a/src/tables/header.rs b/src/tables/header.rs index 01434d5..54b824d 100644 --- a/src/tables/header.rs +++ b/src/tables/header.rs @@ -1,10 +1,11 @@ use gpui::{ - App, DragMoveEvent, ElementId, Entity, EntityId, FontWeight, InteractiveElement, IntoElement, - MouseButton, ParentElement, Pixels, Rems, SharedString, StatefulInteractiveElement, Styled, - canvas, div, prelude::*, transparent_black, + App, Bounds, Div, DragMoveEvent, ElementId, Entity, EntityId, FocusHandle, FontWeight, + InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Rems, SharedString, + 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::{ primitives::{DragGhost, FocusRing, Icon, IconName}, @@ -78,16 +79,48 @@ pub(crate) fn moved( 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, + key: &SharedString, + focused: bool, + head: Bounds, + 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( id: &ElementId, view: &Entity, sorting: &Entity>>, column: &Column, - width: Option, - narrowest: Rems, + focus: Option, cx: &App, -) -> impl IntoElement + use<> { +) -> Stateful
{ let sort = sorting.read(cx).value.clone(); let theme = cx.theme(); let colors = &theme.colors; @@ -116,80 +149,79 @@ pub(crate) fn header( .text_size(theme.text_size(TextSize::Xs)) .font_weight(FontWeight::MEDIUM) .text_color(colors.fg_muted); - let cell = match width { - Some(width) => cell.w(width).flex_none(), - None => sized(cell, column, narrowest), - }; - cell.when(column.sortable, |cell| { - let key = key.clone(); - cell.tab_index(0) - .focus_ring(cx) - .cursor_pointer() - .hover(|style| style.text_color(colors.fg)) - .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) - .on_click(move |event, _, cx| { - let join = event.modifiers().shift; - sorter.update(cx, |sorting, cx| { - sorting.value = next_sort(&sorting.value, &key, join); - 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 + let revealing = focus.clone().filter(|_| !column.pinned); + cell.when_some(focus, |cell, focus| cell.track_focus(&focus).focus_ring(cx)) + .when(column.sortable, |cell| { + let key = key.clone(); + cell.cursor_pointer() + .hover(|style| style.text_color(colors.fg)) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |event, _, cx| { + let join = event.modifiers().shift; + sorter.update(cx, |sorting, cx| { + sorting.value = next_sort(&sorting.value, &key, join); + log::info!("data table: sort {:?}", sorting.value); + cx.notify(); + }); + pager.update(cx, |view, _| view.page = 0); }) - .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, _, cx| { - if edges.read(cx).edges.get(&key) != Some(&bounds.left()) { - edges.update(cx, |view, _| view.edges.insert(key.clone(), bounds.left())); - } + }) + .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), + ) + .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. diff --git a/src/tables/mod.rs b/src/tables/mod.rs index 4130fca..8623fb7 100644 --- a/src/tables/mod.rs +++ b/src/tables/mod.rs @@ -1,6 +1,7 @@ mod body; mod builders; mod cell; +mod foot; mod formula; mod grid; mod gridkeys; diff --git a/src/tables/table.rs b/src/tables/table.rs index b37e89e..97f4f5d 100644 --- a/src/tables/table.rs +++ b/src/tables/table.rs @@ -4,25 +4,26 @@ use std::{ }; use gpui::{ - AnyElement, App, Div, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, - ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, - Styled, UniformListScrollHandle, Window, div, prelude::*, transparent_black, uniform_list, + AnyElement, App, Div, ElementId, Entity, FocusHandle, InteractiveElement, IntoElement, + ParentElement, Pixels, RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement, + StyleRefinement, Styled, UniformListScrollHandle, Window, div, prelude::*, uniform_list, }; use super::{ - Aggregate, Cell, Column, FilterRule, + Cell, Column, FilterRule, body::{Body, Detail, Line, OnEdit, Select, sideways, sized}, header::header, - model::{figure, filtered, page, range}, + model::{filtered, page, range}, rules::{groups, kept, sorted_by}, }; use crate::{ forms::{CheckState, Editing, check_mark}, - layout::seeded::use_seeded, + layout::{on_axis, seeded::use_seeded}, lists::{Pick, picked}, navigation::Pagination, + primitives::tab_stop, 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. @@ -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)] pub(crate) struct View { pub page: usize, pub anchor: usize, pub scroll: UniformListScrollHandle, + pub sideways: ScrollHandle, + pub revealed: Option, pub widths: HashMap, pub edges: HashMap, pub narrowest: Pixels, @@ -121,6 +124,12 @@ impl RenderOnce for DataTable { window.use_keyed_state((id.clone(), "view"), cx, |_, _| View::default()); 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 focuses: Vec> = (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 rem = window.rem_size(); let keys_now: Vec = self @@ -328,72 +337,22 @@ impl RenderOnce for DataTable { }) }) .children(cols.iter().map(|col| { - header( + let head = header( &id, &view, &sorting, &columns[*col], - widths[*col], - narrowest, + focuses[*col].clone(), 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| { - columns - .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 figures = |cols: &[usize], lead: bool, cx: &App| body.figures(&order, cols, lead, cx); + let slide = view.read(cx).sideways.clone(); let part = |cols: &[usize], lead: bool, window: &mut Window, cx: &mut App| -> Div { let lines: Vec = (0..body.lines.len()) .map(|at| body.line(at, cols, lead, window, cx)) @@ -405,68 +364,75 @@ impl RenderOnce for DataTable { .children(lines) .children(figures(cols, lead, cx)) }; - let table: AnyElement = - if total == 0 { - let theme = cx.theme(); - let least = body.least(&shown_columns, true, rem); - sideways(&id) - .child( - div() - .flex() - .flex_col() - .min_w(least) - .child(heads(&shown_columns, true, cx)) - .child( - div() - .flex() - .justify_center() - .py_6() - .text_size(theme.text_size(TextSize::Sm)) - .text_color(theme.colors.fg_subtle) - .child("Nothing matches."), - ), - ) - .into_any_element() - } else if self.virtualized { - let (scroll, count, cols, drawn) = ( - view.read(cx).scroll.clone(), - body.lines.len(), - shown_columns.clone(), - body.clone(), - ); - div() - .flex() - .flex_col() - .size_full() - .child(heads(&shown_columns, true, cx)) - .child( - uniform_list((id.clone(), "rows"), count, move |range, window, cx| { - range - .map(|at| drawn.line(at, &cols, true, window, cx)) - .collect() - }) - .track_scroll(scroll) - .flex_1() - .min_h_0(), - ) - .children(figures(&shown_columns, true, cx)) - .into_any_element() - } else if pinned_any { - let (held, moving): (Vec, Vec) = - shown_columns.iter().partition(|col| columns[**col].pinned); - div() - .flex() - .child(part(&held, true, window, cx).flex_none()) - .child(sideways(&id).flex_1().min_w_0().child( + let table: AnyElement = if total == 0 { + let theme = cx.theme(); + let least = body.least(&shown_columns, true, rem); + sideways(&id, &slide) + .child( + div() + .flex() + .flex_col() + .min_w(least) + .child(heads(&shown_columns, true, cx)) + .child( + div() + .flex() + .justify_center() + .py_6() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_subtle) + .child("Nothing matches."), + ), + ) + .into_any_element() + } else if self.virtualized { + let (scroll, count, cols, drawn) = ( + view.read(cx).scroll.clone(), + body.lines.len(), + shown_columns.clone(), + body.clone(), + ); + let least = body.least(&shown_columns, true, rem); + sideways(&id, &slide) + .size_full() + .child( + div() + .flex() + .flex_col() + .size_full() + .min_w(least) + .child(heads(&shown_columns, true, cx)) + .child(on_axis( + uniform_list((id.clone(), "rows"), count, move |range, window, cx| { + range + .map(|at| drawn.line(at, &cols, true, window, cx)) + .collect() + }) + .track_scroll(scroll) + .flex_1() + .min_h_0(), + )) + .children(figures(&shown_columns, true, cx)), + ) + .into_any_element() + } else if pinned_any { + let (held, moving): (Vec, Vec) = + 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)), - )) - .into_any_element() - } else { - let least = body.least(&shown_columns, true, rem); - sideways(&id) - .child(part(&shown_columns, true, window, cx).min_w(least)) - .into_any_element() - }; + ), + ) + .into_any_element() + } else { + let least = body.least(&shown_columns, true, rem); + sideways(&id, &slide) + .child(part(&shown_columns, true, window, cx).min_w(least)) + .into_any_element() + }; let pager = pages.filter(|pages| *pages > 1).map(|pages| { 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)); diff --git a/src/tables/tests/columns.rs b/src/tables/tests/columns.rs index c8f10d7..6ad2840 100644 --- a/src/tables/tests/columns.rs +++ b/src/tables/tests/columns.rs @@ -7,26 +7,41 @@ use gpui::{ use super::{edit_at, settle}; use crate::{ - tables::{Column, DataTable, Row}, + tables::{Cell, Column, DataTable, Row}, theme::Theme, }; -/// Three editable columns 200 wide in a table 280 wide, the first pinned or not; it keeps the column of each edit. -struct Wide(Rc>>, bool); +/// How a wide table lays out its rows. +#[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>>, Mode); impl Render for Wide { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { let heard = self.0.clone(); let column = |key: &'static str| Column::new(key, key).width(gpui::rems(12.5)).editable(); - let first = if self.1 { - column("a").pinned() - } else { - column("a") + let first = match self.1 { + Mode::Pinned => column("a").pinned(), + _ => column("a"), }; - DataTable::new("wide", [first, column("b"), column("c")]) - .rows(vec![Row::new("r", ["A".into(), "B".into(), "C".into()])]) - .on_edit(move |_, column, _, _, _| heard.borrow_mut().push(column.to_string())) - .w(px(280.0)) + let table = DataTable::new("wide", [first, column("b"), column("c")]) + .rows( + (0..20) + .map(|n| Row::new(format!("r{n}"), ["A".into(), "B".into(), "C".into()])) + .collect::>(), + ) + .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); 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 seen = heard.clone(); - let (_, cx) = cx.add_window_view(|_, _| Wide(seen, pinned)); + let (_, cx) = cx.add_window_view(|_, _| Wide(seen, mode)); settle(cx); 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 { position: cell, 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); 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); 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>>); + +impl Render for Five { + fn render(&mut self, _: &mut Window, _: &mut Context) -> 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" + ); +}