From 37e53a5ecbb638a6a224a7f121a897c3a2ea1519 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 11:39:24 -0400 Subject: [PATCH] T24f Agent: control and cost HumanInputRequest asks a question with answers to pick and a line to write one; CostBreakdown totals a session and splits it by part. InterruptButton, TokenUsageChart, TraceViewer and EvalResultTable point to chat::stop_button, a stacked BarChart, TimelineProfiler and DataTable. UsageBar shows amounts as its owner writes them. MemoryPanel and HumanInputRequest share agent::line::send_line; the chapter's bool callbacks take forms::OnFlag. DataTable scrolls columns wider than its box sideways, pinned or not, and leaves a plain wheel to the page; sortable headers are Tab stops. TimelineProfiler fits its ruler to its lane, its spans are Tab stops, and a picked span takes accent. --- AGENTS.md | 1 + examples/gallery/pages/agent/control.rs | 175 ++++++++++++++++++++ examples/gallery/pages/agent/mod.rs | 11 ++ src/agent/changes.rs | 13 +- src/agent/control.rs | 203 ++++++++++++++++++++++++ src/agent/line.rs | 58 +++++++ src/agent/memory.rs | 51 ++---- src/agent/mod.rs | 3 + src/agent/permission.rs | 5 +- src/agent/tests/control.rs | 96 +++++++++++ src/agent/tests/mod.rs | 1 + src/data_display/measures.rs | 35 +++- src/debug/tests.rs | 49 +++++- src/debug/timeline.rs | 82 ++++++---- src/tables/body.rs | 32 +++- src/tables/header.rs | 13 +- src/tables/table.rs | 129 ++++++++------- src/tables/tests/columns.rs | 95 +++++++++++ src/tables/{tests.rs => tests/mod.rs} | 50 +++--- tasks/ch21-30.md | 12 +- 20 files changed, 934 insertions(+), 180 deletions(-) create mode 100644 examples/gallery/pages/agent/control.rs create mode 100644 src/agent/control.rs create mode 100644 src/agent/line.rs create mode 100644 src/agent/tests/control.rs create mode 100644 src/tables/tests/columns.rs rename src/tables/{tests.rs => tests/mod.rs} (97%) diff --git a/AGENTS.md b/AGENTS.md index 4a27b74..7d7b685 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,6 +137,7 @@ 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. - 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/examples/gallery/pages/agent/control.rs b/examples/gallery/pages/agent/control.rs new file mode 100644 index 0000000..828e188 --- /dev/null +++ b/examples/gallery/pages/agent/control.rs @@ -0,0 +1,175 @@ +use std::rc::Rc; + +use ely_gpui_component::{ + agent::{AgentState, AgentStatus, CostBreakdown, HumanInputRequest}, + charts::{BarChart, Series}, + chat::{continue_button, stop_button}, + data_display::Tone, + debug::{Span, TimelineProfiler}, + forms::TextInput, + tables::{Cell, Column, DataTable, Row}, +}; +use gpui::{App, IntoElement, ParentElement, Styled, Window, div, px, rems}; + +use crate::{ + probe::probe, + ui::{keep, section, set}, +}; + +pub fn interrupt_section(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = keep("agent-run", || AgentState::Working, window, cx); + let answer = keep("agent-answer", || None::, window, cx); + let field = window.use_keyed_state("agent-answer-field", cx, |window, cx| { + TextInput::new(window, cx).placeholder("Or write an answer") + }); + let (now_state, now_answer) = (*state.read(cx), answer.read(cx).clone()); + let (stop, go) = (state.clone(), state); + let control = match now_state { + AgentState::Working => { + stop_button("agent-stop").on_click(move |_, _, cx| set(&stop, AgentState::Waiting, cx)) + } + _ => continue_button("agent-stop") + .on_click(move |_, _, cx| set(&go, AgentState::Working, cx)), + }; + let request = HumanInputRequest::new( + "agent-ask", + "The dark accents read 4.1:1 on the card. Lift them to pass 4.5:1, or keep the brand color?", + &field, + move |text, _, cx| set(&answer, Some(text.to_string()), cx), + ) + .choices(["Lift to 4.5:1", "Keep the brand color"]); + let request = match now_answer { + Some(text) => request.answered(text), + None => request, + }; + section( + "InterruptButton / HumanInputRequest", + "A way to stop an agent mid-task and pick it up again; and a question it waits on, with answers to pick or a line to write one. Once answered, the card keeps the answer.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .items_center() + .gap_3() + .child(AgentStatus::new("agent-run-status", now_state)) + .child(probe("agent-stop", control)), + ) + .child(probe("agent-ask", div().w(px(460.)).child(request))) +} + +pub fn cost_section(_: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let turns = ["Turn 1", "Turn 2", "Turn 3", "Turn 4", "Turn 5", "Turn 6"]; + section( + "TokenUsageChart / CostBreakdown", + "Tokens each turn spent, split into what the model read, what it wrote and what came from the cache; and what the session cost, part by part.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .items_start() + .gap_8() + .child( + div().w(px(420.)).child( + BarChart::new("agent-tokens", turns) + .series(Series::new("Input", [3_200., 8_400., 12_800., 6_100., 15_300., 9_700.])) + .series(Series::new("Output", [900., 2_100., 3_400., 1_200., 4_800., 2_600.])) + .series(Series::new("Cached", [0., 2_800., 7_900., 5_400., 11_200., 8_300.])) + .stacked(), + ), + ) + .child( + div().w(px(320.)).child( + CostBreakdown::new("This session", "USD") + .part("Input", 0.42) + .part("Output", 1.18) + .part("Cache reads", 0.07) + .part("Tools", 0.25), + ), + ), + ) +} + +fn trace() -> Rc> { + let span = |track, name: &str, start, end| Span { + track, + name: name.to_string().into(), + start, + end, + }; + Rc::new(vec![ + span(0, "lift the accents", 0.0, 4_200.0), + span(1, "turn 1", 0.0, 2_600.0), + span(1, "turn 2", 2_600.0, 4_200.0), + span(2, "plan", 0.0, 900.0), + span(2, "read_file", 950.0, 1_100.0), + span(2, "edit", 1_150.0, 2_600.0), + span(2, "run_tests", 2_650.0, 3_900.0), + span(2, "answer", 3_950.0, 4_200.0), + ]) +} + +fn evals() -> Vec { + let row = |case: &'static str, pass: bool, score: f32, tokens: f64, seconds: f64| { + let (word, tone) = match pass { + true => ("Pass", Tone::Success), + false => ("Fail", Tone::Danger), + }; + Row::new( + case, + [ + case.into(), + Cell::Tag(word.into(), tone), + Cell::Progress(score), + Cell::Number(tokens), + Cell::Number(seconds), + ], + ) + }; + vec![ + row("Fix a failing test", true, 0.96, 18_400., 42.1), + row("Rename across files", true, 0.91, 9_800., 18.6), + row("Add a settings page", false, 0.48, 31_200., 96.4), + row("Explain a stack trace", true, 0.88, 4_100., 7.9), + row("Migrate a schema", false, 0.62, 22_700., 71.3), + ] +} + +pub fn trace_section(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let range = keep("agent-trace-range", || (0.0_f64, 4_600.0_f64), window, cx); + let picked = keep("agent-trace-span", || Some(5_usize), window, cx); + let (now_range, now_picked) = (*range.read(cx), *picked.read(cx)); + let timeline = TimelineProfiler::new( + "agent-trace", + ["Session", "Turn", "Call"], + trace(), + now_range, + ) + .on_range(move |next, _, cx| set(&range, next, cx)) + .on_select(move |ix, _, cx| set(&picked, Some(ix), cx)); + let timeline = match now_picked { + Some(ix) => timeline.selected(ix), + None => timeline, + }; + let columns = vec![ + Column::new("case", "Case"), + Column::new("result", "Result").width(rems(6.)), + Column::new("score", "Score").width(rems(10.)), + Column::new("tokens", "Tokens").width(rems(6.)).end(), + Column::new("time", "Time") + .width(rems(6.)) + .end() + .decimals(1) + .suffix(" s"), + ]; + section( + "TraceViewer / EvalResultTable", + "A run as spans on a track for each depth: the session, its turns, and the calls in each; the wheel zooms and a press picks a span. Then how an agent did on a set of cases, sortable by any column.", + cx, + ) + .child(div().w(px(760.)).child(timeline)) + .child(div().w(px(760.)).child(DataTable::new("agent-evals", columns).rows(evals()))) +} diff --git a/examples/gallery/pages/agent/mod.rs b/examples/gallery/pages/agent/mod.rs index 02c3091..37027d9 100644 --- a/examples/gallery/pages/agent/mod.rs +++ b/examples/gallery/pages/agent/mod.rs @@ -1,4 +1,5 @@ mod changes; +mod control; mod environment; mod previews; mod progress; @@ -44,6 +45,13 @@ const SCRIPT: &[Step] = &[ Step::Click("agent-pointer-next"), Step::Wait(400), Step::Shot("pointer"), + Step::Click("agent-stop"), + Step::Wait(300), + Step::Shot("interrupted"), + Step::DownAt("agent-ask", 57.0, 79.0), + Step::UpAt("agent-ask", 57.0, 79.0), + Step::Wait(300), + Step::Shot("answered"), ]; fn render(window: &mut Window, cx: &mut App) -> AnyElement { @@ -58,5 +66,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(environment::sandbox_section(window, cx)) .child(environment::connectors_section(window, cx)) .child(environment::memory_section(window, cx)) + .child(control::interrupt_section(window, cx)) + .child(control::cost_section(window, cx)) + .child(control::trace_section(window, cx)) .into_any_element() } diff --git a/src/agent/changes.rs b/src/agent/changes.rs index 4acfe06..471d2a9 100644 --- a/src/agent/changes.rs +++ b/src/agent/changes.rs @@ -10,6 +10,7 @@ use crate::{ buttons::{Button, ButtonVariant}, chat::file_icon, collab::Decision, + forms::OnFlag, git::{DiffStat, DiffViewer, stat}, primitives::{Disclosure, FocusRing, Icon, IconName}, theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, @@ -33,16 +34,10 @@ pub struct FileChange { pub state: ChangeState, } -type OnVerdict = Rc; type OnDecision = Rc; /// Reject and Accept while the change is proposed; after, a quiet word for how it ended. -fn verdict( - id: &ElementId, - state: ChangeState, - on_decide: Option, - cx: &App, -) -> AnyElement { +fn verdict(id: &ElementId, state: ChangeState, on_decide: Option, cx: &App) -> AnyElement { let colors = &cx.theme().colors; let said = |icon: IconName, word: &'static str, color| { div() @@ -87,7 +82,7 @@ fn verdict( pub struct FileChangeCard { id: ElementId, change: FileChange, - on_decide: Option, + on_decide: Option, } impl FileChangeCard { @@ -113,7 +108,7 @@ impl RenderOnce for FileChangeCard { let (open, unfolded) = (*state.read(cx), folds.read(cx).clone()); let (added, removed) = stat(&self.change.old, &self.change.new); let path = self.change.path.clone(); - let decide = self.on_decide.map(|decide| -> OnVerdict { + let decide = self.on_decide.map(|decide| -> OnFlag { Rc::new(move |accepted, window, cx| { log::info!( "file change {path}: {}", diff --git a/src/agent/control.rs b/src/agent/control.rs new file mode 100644 index 0000000..10d7472 --- /dev/null +++ b/src/agent/control.rs @@ -0,0 +1,203 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, Entity, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, + Styled, Window, div, +}; + +use super::line::{OnText, send_line}; +use crate::{ + buttons::{Button, ButtonVariant}, + data_display::UsageBar, + forms::TextInput, + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, + typography::{format::currency, tabular}, +}; + +/// A question an agent waits on: a few answers to pick, a line to write one, and, once answered, the answer. +#[derive(IntoElement)] +pub struct HumanInputRequest { + id: ElementId, + question: SharedString, + field: Entity, + choices: Vec, + answered: Option, + on_answer: OnText, +} + +impl HumanInputRequest { + /// `field` is the owner's line for a written answer. + pub fn new( + id: impl Into, + question: impl Into, + field: &Entity, + on_answer: impl Fn(&str, &mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + question: question.into(), + field: field.clone(), + choices: Vec::new(), + answered: None, + on_answer: Rc::new(on_answer), + } + } + + /// Answers to pick with a press. + pub fn choices(mut self, choices: impl IntoIterator>) -> Self { + self.choices = choices.into_iter().map(Into::into).collect(); + self + } + + /// The answer given; the request stops asking. + pub fn answered(mut self, answer: impl Into) -> Self { + self.answered = Some(answer.into()); + self + } +} + +impl RenderOnce for HumanInputRequest { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let asking = self.answered.is_none(); + let choices = (asking && !self.choices.is_empty()).then(|| { + div() + .flex() + .flex_wrap() + .gap_2() + .children(self.choices.iter().enumerate().map(|(ix, choice)| { + let (answer, picked) = (self.on_answer.clone(), choice.clone()); + Button::new((self.id.clone(), format!("choice-{ix}")), choice.clone()) + .variant(ButtonVariant::Secondary) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| { + log::info!("human input: picked {ix}"); + answer(&picked, window, cx) + }) + })) + }); + let line = asking.then(|| { + send_line( + &self.field, + Button::new((self.id.clone(), "reply"), "Reply").variant(ButtonVariant::Primary), + self.on_answer.clone(), + "human input", + cx, + ) + }); + let theme = cx.theme(); + let colors = theme.colors.clone(); + div() + .w_full() + .flex() + .flex_col() + .gap_3() + .p_4() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(colors.border) + .bg(colors.surface) + .text_size(theme.text_size(TextSize::Sm)) + .child( + div() + .flex() + .items_start() + .gap_2() + .child( + div().flex_none().child( + Icon::new(IconName::CircleHelp) + .size(IconSize::Sm) + .color(colors.fg_muted), + ), + ) + .child( + div() + .flex_1() + .min_w_0() + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg) + .child(self.question), + ), + ) + .children(self.answered.map(|answer| { + div() + .flex() + .items_start() + .gap_2() + .text_color(colors.fg_muted) + .child( + div().flex_none().child( + Icon::new(IconName::Check) + .size(IconSize::Sm) + .color(colors.success), + ), + ) + .child(div().flex_1().min_w_0().child(answer)) + })) + .children(choices) + .children(line) + } +} + +/// What a session cost, as a whole and part by part: the total, then a bar of each part's share with its amount. +#[derive(IntoElement)] +pub struct CostBreakdown { + label: SharedString, + code: &'static str, + parts: Vec<(SharedString, f64)>, +} + +impl CostBreakdown { + /// `code` is the currency, such as "USD". + pub fn new(label: impl Into, code: &'static str) -> Self { + Self { + label: label.into(), + code, + parts: Vec::new(), + } + } + + pub fn part(mut self, name: impl Into, cost: f64) -> Self { + assert!(cost >= 0.0 && cost.is_finite(), "a cost of {cost}"); + self.parts.push((name.into(), cost)); + self + } +} + +impl RenderOnce for CostBreakdown { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let total: f64 = self.parts.iter().map(|(_, cost)| cost).sum(); + let code = self.code; + let bar = (total > 0.0).then(|| { + self.parts.into_iter().fold( + UsageBar::new(total).amounts(move |amount| currency(amount, code)), + |bar, (name, cost)| bar.part(name, cost), + ) + }); + div() + .w_full() + .flex() + .flex_col() + .gap_3() + .text_size(theme.text_size(TextSize::Sm)) + .child( + div() + .flex() + .flex_wrap() + .items_baseline() + .justify_between() + .gap_x_3() + .child(div().text_color(colors.fg_muted).child(self.label)) + .child( + tabular(div()) + .text_size(theme.text_size(TextSize::Lg)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg) + .child(currency(total, code)), + ), + ) + .children(bar) + } +} diff --git a/src/agent/line.rs b/src/agent/line.rs new file mode 100644 index 0000000..72a1c2a --- /dev/null +++ b/src/agent/line.rs @@ -0,0 +1,58 @@ +use std::rc::Rc; + +use gpui::{App, Div, Entity, InteractiveElement, ParentElement, Styled, Window, div}; + +use crate::{ + buttons::Button, + forms::{Enter, Input, TextInput}, +}; + +pub(super) type OnText = Rc; + +/// Hands the field's words to `send` and empties it; blank words are kept out. +fn send( + field: &Entity, + on_send: &OnText, + what: &str, + window: &mut Window, + cx: &mut App, +) { + let text = field.read(cx).text().trim().to_string(); + if text.is_empty() { + return; + } + log::info!("{what}: sent {} characters", text.len()); + on_send(&text, window, cx); + field.update(cx, |input, cx| input.set_text("", cx)); +} + +/// The owner's field beside `button`: Enter or a press sends its words, and the button waits for some. +pub(super) fn send_line( + field: &Entity, + button: Button, + on_send: OnText, + what: &'static str, + cx: &App, +) -> Div { + let ready = !field.read(cx).text().trim().is_empty(); + let (typed, pressed, enter) = (field.clone(), field.clone(), on_send.clone()); + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .flex_1() + .min_w_0() + .capture_action(move |_: &Enter, window, cx| { + cx.stop_propagation(); + send(&typed, &enter, what, window, cx) + }) + .child(Input::new(field)), + ) + .child( + button + .disabled(!ready) + .on_click(move |_, window, cx| send(&pressed, &on_send, what, window, cx)), + ) +} diff --git a/src/agent/memory.rs b/src/agent/memory.rs index 1c42672..957ed64 100644 --- a/src/agent/memory.rs +++ b/src/agent/memory.rs @@ -1,15 +1,16 @@ use std::rc::Rc; use gpui::{ - App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce, - SharedString, Styled, Window, div, prelude::*, + App, ElementId, Entity, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, + Styled, Window, div, prelude::*, }; use jiff::Timestamp; +use super::line::{OnText, send_line}; use crate::{ buttons::{Button, ButtonVariant, IconButton}, data_display::{Timeline, TimelineItem, Tone}, - forms::{Enter, Input, Pick, TextInput}, + forms::{Pick, TextInput}, primitives::IconName, theme::{ActiveTheme, ControlSize, TextSize}, typography::{RelativeTime, format::plural}, @@ -24,7 +25,6 @@ pub struct Memory { pub at: Timestamp, } -type OnAdd = Rc; type OnForget = Rc; /// What an agent keeps between conversations: a field to add a memory, then each one with where it came from, when, and a way to forget it. @@ -33,7 +33,7 @@ pub struct MemoryPanel { id: ElementId, field: Entity, memories: Vec, - on_add: Option, + on_add: Option, on_forget: Option, } @@ -69,46 +69,19 @@ impl MemoryPanel { } } -/// Hands the field's words to `add` and empties it; blank words are kept out. -fn keep(field: &Entity, add: &OnAdd, window: &mut Window, cx: &mut App) { - let text = field.read(cx).text().trim().to_string(); - if text.is_empty() { - return; - } - log::info!("memory: kept {} characters", text.len()); - add(&text, window, cx); - field.update(cx, |input, cx| input.set_text("", cx)); -} - impl RenderOnce for MemoryPanel { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let colors = theme.colors.clone(); - let ready = !self.field.read(cx).text().trim().is_empty(); let count = self.memories.len(); let adding = self.on_add.map(|add| { - let (enter, press, typed, pressed) = - (add.clone(), add, self.field.clone(), self.field.clone()); - div() - .flex() - .items_center() - .gap_2() - .child( - div() - .flex_1() - .min_w_0() - .capture_action(move |_: &Enter, window, cx| { - cx.stop_propagation(); - keep(&typed, &enter, window, cx) - }) - .child(Input::new(&self.field)), - ) - .child( - Button::new((self.id.clone(), "add"), "Remember") - .variant(ButtonVariant::Secondary) - .disabled(!ready) - .on_click(move |_, window, cx| keep(&pressed, &press, window, cx)), - ) + send_line( + &self.field, + Button::new((self.id.clone(), "add"), "Remember").variant(ButtonVariant::Secondary), + add, + "memory", + cx, + ) }); div() .w_full() diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 5de33b1..f954fbf 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -1,6 +1,8 @@ mod artifact; mod changes; +mod control; mod environment; +mod line; mod memory; mod permission; mod previews; @@ -12,6 +14,7 @@ mod tools; pub use artifact::ArtifactPanel; pub use changes::{ChangeState, FileChange, FileChangeCard, MultiFileDiffReview}; +pub use control::{CostBreakdown, HumanInputRequest}; pub use environment::{McpServer, McpServerList, SandboxState, SandboxStatus, ServerState}; pub use memory::{Checkpoint, CheckpointList, Memory, MemoryPanel}; pub use permission::{Permission, PermissionPrompt, ToolApprovalDialog}; diff --git a/src/agent/permission.rs b/src/agent/permission.rs index 773fd11..a02a0d9 100644 --- a/src/agent/permission.rs +++ b/src/agent/permission.rs @@ -8,13 +8,12 @@ use gpui::{ use crate::{ buttons::{Button, ButtonVariant}, chat::CodeBlock, + forms::OnFlag, overlays::Dialog, primitives::{Icon, IconName}, theme::{ActiveTheme, IconSize, Radius, TextSize}, }; -type OnAnswer = Rc; - /// Asks before a tool runs: what it will do and its arguments, then Deny or Run. Escape, the scrim and Deny refuse it. Render it while the call waits. #[derive(IntoElement)] pub struct ToolApprovalDialog { @@ -22,7 +21,7 @@ pub struct ToolApprovalDialog { tool: SharedString, arguments: SharedString, detail: Option, - on_answer: OnAnswer, + on_answer: OnFlag, } impl ToolApprovalDialog { diff --git a/src/agent/tests/control.rs b/src/agent/tests/control.rs new file mode 100644 index 0000000..6dfdf50 --- /dev/null +++ b/src/agent/tests/control.rs @@ -0,0 +1,96 @@ +use gpui::{ + AppContext as _, Context, Entity, IntoElement, KeyBinding, ParentElement, Render, Styled, + TestAppContext, VisualTestContext, Window, div, px, +}; + +use super::{press, settle}; +use crate::{ + agent::{CostBreakdown, HumanInputRequest}, + forms::{self, TextInput}, + primitives::FocusNext, + theme::Theme, +}; + +/// A question with two answers to pick, and the answers given. +struct Asked { + field: Entity, + answers: Vec, +} + +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"]), + ) + } +} + +fn asked(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { + cx.update(|cx| { + Theme::init(cx); + forms::bind_keys(cx); + cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]); + }); + let (view, cx) = cx.add_window_view(|window, cx| Asked { + field: cx.new(|cx| TextInput::new(window, cx)), + answers: Vec::new(), + }); + cx.update(|window, _| window.activate_window()); + settle(cx); + (view, cx) +} + +#[gpui::test] +fn a_pick_answers_the_question(cx: &mut TestAppContext) { + let (view, cx) = asked(cx); + cx.update(|window, _| window.focus_next()); + cx.update(|window, _| window.focus_next()); + press("enter", cx); + assert_eq!( + view.read_with(cx, |asked, _| asked.answers.clone()), + ["Lift further"] + ); +} + +#[gpui::test] +fn enter_sends_a_written_answer_and_skips_a_blank_one(cx: &mut TestAppContext) { + let (view, cx) = asked(cx); + for _ in 0..3 { + cx.update(|window, _| window.focus_next()); + } + cx.simulate_input(" "); + press("enter", cx); + cx.simulate_input("Lift them to 18%"); + press("enter", cx); + view.read_with(cx, |asked, cx| { + assert_eq!(asked.answers, ["Lift them to 18%"]); + assert_eq!(asked.field.read(cx).text(), ""); + }); +} + +/// A breakdown of a session that has cost nothing yet. +struct Nothing; + +impl Render for Nothing { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .w(px(320.0)) + .child(CostBreakdown::new("This session", "USD")) + } +} + +#[gpui::test] +fn a_session_that_cost_nothing_draws_its_total(cx: &mut TestAppContext) { + cx.update(Theme::init); + let (_, cx) = cx.add_window_view(|_, _| Nothing); + settle(cx); +} diff --git a/src/agent/tests/mod.rs b/src/agent/tests/mod.rs index 7883420..8090765 100644 --- a/src/agent/tests/mod.rs +++ b/src/agent/tests/mod.rs @@ -4,6 +4,7 @@ use gpui::{ }; mod changes; +mod control; mod environment; mod previews; diff --git a/src/data_display/measures.rs b/src/data_display/measures.rs index 34073b7..10b6247 100644 --- a/src/data_display/measures.rs +++ b/src/data_display/measures.rs @@ -1,4 +1,7 @@ -use std::f32::consts::{FRAC_PI_4, TAU}; +use std::{ + f32::consts::{FRAC_PI_4, TAU}, + rc::Rc, +}; use gpui::{ Animation, AnimationExt, App, ElementId, FontWeight, Hsla, IntoElement, ParentElement, @@ -282,11 +285,14 @@ impl RenderOnce for Gauge { } } -/// How a whole divides: one bar of colored parts with the free rest as track, and a legend of each part's share. +type Amounts = Rc String>; + +/// How a whole divides: one bar of colored parts with the free rest as track, and a legend of each part's share or amount. #[derive(IntoElement)] pub struct UsageBar { total: f64, parts: Vec<(SharedString, f64)>, + amounts: Option, } impl UsageBar { @@ -299,9 +305,16 @@ impl UsageBar { Self { total, parts: Vec::new(), + amounts: None, } } + /// Shows each part's amount, as `write` puts it, in place of its share. + pub fn amounts(mut self, write: impl Fn(f64) -> String + 'static) -> Self { + self.amounts = Some(Rc::new(write)); + self + } + pub fn part(mut self, name: impl Into, amount: f64) -> Self { assert!(amount >= 0.0, "a part cannot be negative, got {amount}"); self.parts.push((name.into(), amount)); @@ -320,6 +333,14 @@ impl UsageBar { ); (used, used >= self.total * (1.0 - 1e-9)) } + + /// A part's figure in the legend: its amount as `amounts` writes it, or its share. + fn figure(&self, amount: f64) -> String { + match &self.amounts { + Some(write) => write(amount), + None => format::percent(amount / self.total, 0, false), + } + } } impl RenderOnce for UsageBar { @@ -372,7 +393,7 @@ impl RenderOnce for UsageBar { .child( tabular(div()) .text_color(colors.fg_muted) - .child(format::percent(amount / total, 0, false)), + .child(self.figure(*amount)), ) }); div().flex().flex_col().gap_2().child(bar).child( @@ -398,6 +419,14 @@ mod tests { assert!(!UsageBar::new(1.0).part("a", 0.5).used().1); } + #[test] + fn a_part_reads_as_its_share_or_as_written() { + let bar = UsageBar::new(4.0).part("a", 1.0); + assert_eq!(bar.figure(1.0), "25%"); + let bar = bar.amounts(|amount| format!("{amount} GB")); + assert_eq!(bar.figure(1.0), "1 GB"); + } + #[test] fn a_share_turns_amber_then_red() { let limits = (0.8, 0.95); diff --git a/src/debug/tests.rs b/src/debug/tests.rs index 422b2db..bc5fb57 100644 --- a/src/debug/tests.rs +++ b/src/debug/tests.rs @@ -1,11 +1,11 @@ use std::{cell::RefCell, rc::Rc}; use gpui::{ - Context, IntoElement, Modifiers, ParentElement, Render, Styled, TestAppContext, - VisualTestContext, Window, div, point, px, + Context, IntoElement, KeyUpEvent, Keystroke, Modifiers, ParentElement, Render, Styled, + TestAppContext, VisualTestContext, Window, div, point, px, }; -use super::{Breakpoint, BreakpointList, Disassembly, Instruction}; +use super::{Breakpoint, BreakpointList, Disassembly, Instruction, Span, TimelineProfiler}; use crate::theme::Theme; fn settle(cx: &mut VisualTestContext) { @@ -95,3 +95,46 @@ fn a_breakpoints_box_toggles_without_opening_it(cx: &mut TestAppContext) { settle(cx); assert_eq!(*opens.borrow(), [0], "the row still opens"); } + +/// Two spans on one track; it keeps the spans picked. +struct Spans(Rc>>); + +impl Render for Spans { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let store = self.0.clone(); + let span = |name: &str, start, end| Span { + track: 0, + name: name.to_string().into(), + start, + end, + }; + let spans = vec![span("a", 0.0, 4.0), span("b", 5.0, 9.0)]; + div().w(px(400.0)).child( + TimelineProfiler::new("spans", ["Main"], spans, (0.0, 10.0)) + .on_select(move |ix, _, _| store.borrow_mut().push(ix)), + ) + } +} + +#[gpui::test] +fn tab_reaches_each_span_and_enter_picks_it(cx: &mut TestAppContext) { + cx.update(Theme::init); + let picked = Rc::new(RefCell::new(Vec::new())); + let store = picked.clone(); + let (_, cx) = cx.add_window_view(|_, _| Spans(store)); + cx.update(|window, _| window.activate_window()); + settle(cx); + cx.update(|window, _| window.focus_next()); + cx.update(|window, _| window.focus_next()); + cx.simulate_keystrokes("enter"); + settle(cx); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse("enter").expect("a key"), + }); + settle(cx); + assert_eq!( + *picked.borrow(), + [1], + "the second Tab stands on the second span" + ); +} diff --git a/src/debug/timeline.rs b/src/debug/timeline.rs index da87786..da1490f 100644 --- a/src/debug/timeline.rs +++ b/src/debug/timeline.rs @@ -1,14 +1,15 @@ -use std::{cell::Cell, rc::Rc}; +use std::rc::Rc; use gpui::{ - App, Bounds, ElementId, FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton, + App, Bounds, ElementId, Entity, FontWeight, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, ScrollWheelEvent, SharedString, StatefulInteractiveElement, - Styled, Window, canvas, div, prelude::*, relative, + Styled, Window, div, prelude::*, relative, }; use super::OnIndex; use crate::{ - charts::nice_step, + charts::{measure, nice_step}, + primitives::FocusRing, theme::{ActiveTheme, ControlSize, Radius, TextSize}, typography::{Ellipsis, LEADING, format::decimals}, }; @@ -30,9 +31,26 @@ fn tick_label(time: f64, step: f64) -> String { format!("{time:.*} ms", decimals(step)) } -/// Ticks along the ruler, about. +/// Ticks along the ruler, at most. const TICKS: usize = 6; +/// The ruler's step and ticks: as many as leave `room` for each label across `width`, each with room before the end. +fn ruler(range: (f64, f64), width: f32, room: f32) -> (f64, Vec) { + let length = range.1 - range.0; + let across = move |span: f64| (span / length) as f32 * width; + let step = (2..=TICKS) + .rev() + .map(|count| nice_step(length, count)) + .find(|step| across(*step) >= room) + .unwrap_or_else(|| nice_step(length, 1)); + let first = (range.0 / step).ceil() as i64; + let ticks = (first..) + .map(|n| n as f64 * step) + .take_while(|time| across(range.1 - time) >= room) + .collect(); + (step, ticks) +} + /// `range` scaled by `factor` around the point at `at` across it, from 0 to 1; above 1 widens, below narrows. pub fn zoomed(range: (f64, f64), at: f64, factor: f64) -> (f64, f64) { assert!( @@ -108,6 +126,8 @@ impl TimelineProfiler { impl RenderOnce for TimelineProfiler { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let lane: Entity> = + window.use_keyed_state((self.id.clone(), "lane"), cx, |_, _| Bounds::default()); let theme = cx.theme(); let colors = theme.colors.clone(); let line = theme @@ -116,14 +136,10 @@ impl RenderOnce for TimelineProfiler { let (from, to) = self.range; let length = to - from; let at = move |time: f64| ((time - from) / length) as f32; - let step = nice_step(length, TICKS); - let first = (from / step).ceil() as i64; - let ticks: Vec = (first..) - .map(|n| n as f64 * step) - .take_while(|time| *time < to) - .collect(); - let lane = Rc::new(Cell::new(Bounds::::default())); - let (measure, wheel_lane) = (lane.clone(), lane); + let room = (theme.label_width() * 0.4).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(); let on_range = self.on_range.clone(); let label = theme.label_width() * 0.6; let row = theme.control_height(ControlSize::Md); @@ -166,7 +182,7 @@ impl RenderOnce for TimelineProfiler { let Some(on_range) = &on_range else { return; }; - let bounds = wheel_lane.get(); + let bounds = *wheel_lane.read(cx); let delta = event.delta.pixel_delta(line); let (x, y) = (f64::from(f32::from(delta.x)), f64::from(f32::from(delta.y))); cx.stop_propagation(); @@ -208,17 +224,19 @@ impl RenderOnce for TimelineProfiler { .bg(if lit { color } else { color.opacity(0.55) }) .border_1() .border_color(if lit { - colors.focus + colors.accent } else { color.opacity(0.0) }) .text_color(colors.fg) - .cursor_pointer() .on_mouse_down(MouseButton::Left, |_, window, _| { window.prevent_default() }) .when_some(pick, |bar, pick| { - bar.on_click(move |_, window, cx| pick(ix, window, cx)) + bar.tab_index(0) + .focus_ring(cx) + .cursor_pointer() + .on_click(move |_, window, cx| pick(ix, window, cx)) }) .child(Ellipsis::new(span.name.clone())) }); @@ -241,18 +259,8 @@ impl RenderOnce for TimelineProfiler { .h_full() .overflow_hidden() .bg(colors.hover.opacity(0.5)) - .when(track == 0, |lane| { - let measure = measure.clone(); - lane.child( - canvas( - move |bounds, _, _| measure.set(bounds), - |_, _, _, _| {}, - ) - .absolute() - .top_0() - .left_0() - .size_full(), - ) + .when(track == 0, |row| { + row.child(measure(lane.clone(), |bounds| bounds)) }) .children(bars), ) @@ -290,4 +298,20 @@ mod tests { ); assert_eq!(zoomed((10.0, 20.0), 1.0, 2.0), (0.0, 20.0)); } + + #[test] + fn the_ruler_keeps_a_label_of_room_between_ticks() { + let narrow = ruler((0.0, 4_400.0), 184.0, 64.0); + assert_eq!( + narrow, + (2_000.0, vec![0.0, 2_000.0]), + "4000 has no room before the end" + ); + let wide = ruler((0.0, 4_600.0), 664.0, 64.0).1; + assert_eq!(wide, [0.0, 1_000.0, 2_000.0, 3_000.0, 4_000.0]); + assert!( + ruler((0.0, 4_400.0), 0.0, 64.0).1.is_empty(), + "an unmeasured lane holds none" + ); + } } diff --git a/src/tables/body.rs b/src/tables/body.rs index 28f3e28..443ff4f 100644 --- a/src/tables/body.rs +++ b/src/tables/body.rs @@ -1,9 +1,9 @@ use std::{collections::HashSet, rc::Rc}; use gpui::{ - AnyElement, App, ElementId, Entity, InteractiveElement, IntoElement, MouseButton, - ParentElement, Pixels, Rems, SharedString, StatefulInteractiveElement, Styled, Window, div, - prelude::*, + AnyElement, App, Div, ElementId, Entity, InteractiveElement, IntoElement, MouseButton, + ParentElement, Pixels, Rems, SharedString, Stateful, StatefulInteractiveElement, Styled, + Window, div, prelude::*, transparent_black, }; use super::{Align, Column, Row, cell::draw, model::tint, table::View}; @@ -32,6 +32,13 @@ 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 +} + /// What the body draws, one after another. #[derive(Clone, Debug, PartialEq)] pub(super) enum Line { @@ -76,6 +83,21 @@ impl Body { self.lead * (usize::from(self.select.is_some()) + usize::from(self.detail.is_some())) as f32 } + /// The least width columns `cols` draw in: fixed widths, the narrowest share for the rest, and the leading cells. + pub(super) fn least(&self, cols: &[usize], lead: bool, rem: Pixels) -> Pixels { + let start = match lead { + true => self.lead_width().to_pixels(rem), + false => Pixels::ZERO, + }; + cols.iter().fold(start, |sum, col| { + sum + match (self.widths[*col], self.columns[*col].width) { + (Some(width), _) => width, + (None, Some(width)) => width.to_pixels(rem), + (None, None) => self.narrowest.to_pixels(rem), + } + }) + } + /// Line `at` over columns `cols`; `lead` draws the leading cells. pub(super) fn line( &self, @@ -266,7 +288,9 @@ impl Body { .h_full() .flex() .items_center() - .px_3(); + .px_3() + .border_x_1() + .border_color(transparent_black()); let frame = match self.widths[col] { Some(width) => frame .w(width) diff --git a/src/tables/header.rs b/src/tables/header.rs index 044fd15..01434d5 100644 --- a/src/tables/header.rs +++ b/src/tables/header.rs @@ -1,13 +1,13 @@ use gpui::{ App, DragMoveEvent, ElementId, Entity, EntityId, FontWeight, InteractiveElement, IntoElement, - ParentElement, Pixels, Rems, SharedString, StatefulInteractiveElement, Styled, canvas, div, - prelude::*, + MouseButton, ParentElement, Pixels, Rems, SharedString, StatefulInteractiveElement, Styled, + canvas, div, prelude::*, transparent_black, }; use super::{Column, body::sized, table::View}; use crate::layout::seeded::Seeded; use crate::{ - primitives::{DragGhost, Icon, IconName}, + primitives::{DragGhost, FocusRing, Icon, IconName}, theme::{ActiveTheme, IconSize, TextSize}, }; @@ -111,6 +111,8 @@ pub(crate) fn header( .items_center() .gap_1() .px_3() + .border_1() + .border_color(transparent_black()) .text_size(theme.text_size(TextSize::Xs)) .font_weight(FontWeight::MEDIUM) .text_color(colors.fg_muted); @@ -120,8 +122,11 @@ pub(crate) fn header( }; cell.when(column.sortable, |cell| { let key = key.clone(); - cell.cursor_pointer() + 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| { diff --git a/src/tables/table.rs b/src/tables/table.rs index 54edaa3..b37e89e 100644 --- a/src/tables/table.rs +++ b/src/tables/table.rs @@ -6,12 +6,12 @@ use std::{ use gpui::{ AnyElement, App, Div, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, - Styled, UniformListScrollHandle, Window, div, prelude::*, uniform_list, + Styled, UniformListScrollHandle, Window, div, prelude::*, transparent_black, uniform_list, }; use super::{ Aggregate, Cell, Column, FilterRule, - body::{Body, Detail, Line, OnEdit, Select, sized}, + body::{Body, Detail, Line, OnEdit, Select, sideways, sized}, header::header, model::{figure, filtered, page, range}, rules::{groups, kept, sorted_by}, @@ -380,7 +380,12 @@ impl RenderOnce for DataTable { ) .child(value) }); - let cell = div().px_3().flex().items_center(); + 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), @@ -400,64 +405,68 @@ impl RenderOnce for DataTable { .children(lines) .children(figures(cols, lead, cx)) }; - let table: AnyElement = if total == 0 { - let theme = cx.theme(); - div() - .flex() - .flex_col() - .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( - div() - .id((id.clone(), "sideways")) + 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_w_0() - .overflow_x_scroll() - .child(part(&moving, false, window, cx)), - ) - .into_any_element() - } else { - part(&shown_columns, true, window, cx).into_any_element() - }; + .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( + 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() + }; 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 new file mode 100644 index 0000000..c8f10d7 --- /dev/null +++ b/src/tables/tests/columns.rs @@ -0,0 +1,95 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + Context, IntoElement, KeyUpEvent, Keystroke, Modifiers, Render, Styled, TestAppContext, Window, + point, px, +}; + +use super::{edit_at, settle}; +use crate::{ + tables::{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); + +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") + }; + 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)) + } +} + +#[gpui::test] +fn columns_past_the_table_scroll_sideways_and_a_plain_wheel_leaves_them(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + crate::forms::bind_keys(cx); + }); + for pinned in [false, true] { + let heard = Rc::new(RefCell::new(Vec::new())); + let seen = heard.clone(); + let (_, cx) = cx.add_window_view(|_, _| Wide(seen, pinned)); + 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))] { + cx.simulate_event(gpui::ScrollWheelEvent { + position: cell, + delta: gpui::ScrollDelta::Pixels(delta), + modifiers: Modifiers::none(), + touch_phase: gpui::TouchPhase::Moved, + }); + settle(cx); + edit_at(cell, "x", cx); + } + assert_eq!(*heard.borrow(), ["b", "c"], "pinned {pinned}"); + } +} + +/// Two rows under a sortable, editable name; it keeps the row of each edit. +struct Sorting(Rc>>); + +impl Render for Sorting { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let heard = self.0.clone(); + DataTable::new("sorting", [Column::new("name", "Name").editable()]) + .rows(vec![ + Row::new("bea", ["Bea".into()]), + Row::new("ada", ["Ada".into()]), + ]) + .on_edit(move |row, _, _, _, _| heard.borrow_mut().push(row.to_string())) + .w(px(400.0)) + } +} + +#[gpui::test] +fn tab_reaches_a_header_and_enter_sorts_by_it(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(|_, _| Sorting(seen)); + cx.update(|window, _| window.activate_window()); + settle(cx); + cx.update(|window, _| window.focus_next()); + cx.simulate_keystrokes("enter"); + settle(cx); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse("enter").expect("a key"), + }); + settle(cx); + edit_at(point(px(120.0), px(54.0)), "x", cx); + assert_eq!(*heard.borrow(), ["ada"], "Ada rises to the top"); +} diff --git a/src/tables/tests.rs b/src/tables/tests/mod.rs similarity index 97% rename from src/tables/tests.rs rename to src/tables/tests/mod.rs index 6bfbf0f..36d6b82 100644 --- a/src/tables/tests.rs +++ b/src/tables/tests/mod.rs @@ -9,6 +9,8 @@ use gpui::{ Styled, TestAppContext, VisualTestContext, Window, div, point, px, }; +mod columns; + use super::{Column, DataTable, FilterBuilder, FilterRule, Row}; use crate::{ primitives::{FocusNext, FocusPrev, FocusScope}, @@ -39,6 +41,29 @@ fn settle(cx: &mut VisualTestContext) { cx.run_until_parked(); } +/// Edits the cell at `at`: a double press, then `text` and Enter. +fn edit_at(at: gpui::Point, text: &str, cx: &mut VisualTestContext) { + for count in [1, 2] { + cx.simulate_event(gpui::MouseDownEvent { + button: gpui::MouseButton::Left, + position: at, + modifiers: Modifiers::none(), + click_count: count, + first_mouse: false, + }); + cx.simulate_event(gpui::MouseUpEvent { + button: gpui::MouseButton::Left, + position: at, + modifiers: Modifiers::none(), + click_count: count, + }); + } + settle(cx); + cx.simulate_input(text); + cx.simulate_keystrokes("enter"); + settle(cx); +} + fn picks(seen: &Rc>>) -> Vec { seen.borrow().iter().map(|key| key.to_string()).collect() } @@ -159,26 +184,11 @@ fn a_row_opens_its_detail_and_a_double_press_edits_a_cell(cx: &mut TestAppContex cx.simulate_click(point(px(14.0), px(54.0)), Modifiers::none()); settle(cx); assert_eq!(heard.borrow().last().map(String::as_str), Some("detail a")); - let name = point(px(120.0), px(54.0 + 36.0 + 40.0 + 24.0)); - for count in [1, 2] { - cx.simulate_event(gpui::MouseDownEvent { - button: gpui::MouseButton::Left, - position: name, - modifiers: Modifiers::none(), - click_count: count, - first_mouse: false, - }); - cx.simulate_event(gpui::MouseUpEvent { - button: gpui::MouseButton::Left, - position: name, - modifiers: Modifiers::none(), - click_count: count, - }); - } - settle(cx); - cx.simulate_input("Alan Kay"); - cx.simulate_keystrokes("enter"); - settle(cx); + edit_at( + point(px(120.0), px(54.0 + 36.0 + 40.0 + 24.0)), + "Alan Kay", + cx, + ); assert_eq!( heard .borrow() diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index 3de8e08..6f3aaf7 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -201,12 +201,12 @@ ## T24f Agent · Control and cost -- [ ] InterruptButton -- [ ] HumanInputRequest -- [ ] TokenUsageChart -- [ ] CostBreakdown -- [ ] TraceViewer -- [ ] EvalResultTable +- [x] InterruptButton → `chat::stop_button` +- [x] HumanInputRequest +- [x] TokenUsageChart → `charts::BarChart` (stacked) +- [x] CostBreakdown +- [x] TraceViewer → `debug::TimelineProfiler` (a track per depth) +- [x] EvalResultTable → `tables::DataTable` (Tag and Progress cells) ## T25 Generative — `src/generative`