From e630e61a63ae499eba7243f2b1791c29a110558c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 05:28:35 -0400 Subject: [PATCH] =?UTF-8?q?Project=20=C2=B7=20a=20focused=20card=20scrolls?= =?UTF-8?q?=20into=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The board scrolled sideways without a handle, so a card that took focus past the fold stayed out of sight, ring and all, and Option-Right could send it into a column nobody saw. The board now tracks its scroll, and a card that takes focus sets the offset through layout::bring_into_view from its row's painted box, ring included, once per focus and again when it lands in another column. The stepping rules gain tests for left and right keeping a card's place and for the end of a shorter column. --- TASKS.md | 1 + src/project/board.rs | 49 ++++++++++++++++++++++++++++++++++--- src/project/moves.rs | 15 ++++++++++++ src/project/tests/boards.rs | 23 +++++++++++++++-- 4 files changed, 82 insertions(+), 6 deletions(-) diff --git a/TASKS.md b/TASKS.md index 1cef45e..8918461 100644 --- a/TASKS.md +++ b/TASKS.md @@ -364,3 +364,4 @@ Per-component lines live in `tasks/`. Tags there: | T30d | 1 | FAIL | a chosen day outlived the owner's days, and the next render panicked | | T30d | 2 | PASS | | | T31a | 1 | PASS | | +| T31b | 1 | FAIL | a focused card past the board's fold stayed out of view; left and right steps that keep a card's place went untested | diff --git a/src/project/board.rs b/src/project/board.rs index 526c34e..dacec76 100644 --- a/src/project/board.rs +++ b/src/project/board.rs @@ -1,14 +1,14 @@ use std::{collections::HashMap, rc::Rc}; use gpui::{ - AnyElement, App, Bounds, ElementId, EmptyView, EntityId, FocusHandle, InteractiveElement, - IntoElement, ParentElement, Pixels, Point, RenderOnce, SharedString, - StatefulInteractiveElement, Styled, Window, anchored, canvas, div, prelude::*, + AnyElement, App, Axis, Bounds, ElementId, EmptyView, Entity, EntityId, FocusHandle, + InteractiveElement, IntoElement, ParentElement, Pixels, Point, RenderOnce, ScrollHandle, + SharedString, StatefulInteractiveElement, Styled, Window, anchored, canvas, div, prelude::*, }; use super::moves::{landing, stepped}; use crate::{ - layout::on_axis, + layout::{bring_into_view, on_axis}, motion::Flip, primitives::{FocusRing, raise, tab_stop}, theme::{ActiveTheme, Elevation, Radius, TextSize}, @@ -106,6 +106,39 @@ fn measure( .size_full() } +/// The card shown after focus, by key and column. +type Revealed = Option<(SharedString, SharedString)>; + +/// Scrolls the board so a card that takes focus shows, once per focus and again in a new column. +fn reveal( + scroll: &ScrollHandle, + revealed: &Entity, + focus: &FocusHandle, + place: (SharedString, SharedString), +) -> impl IntoElement { + let (scroll, revealed, focus) = (scroll.clone(), revealed.clone(), focus.clone()); + canvas( + move |bounds, window, cx| { + let focused = focus.is_focused(window); + let held = revealed.read(cx).clone(); + if focused && held.as_ref() != Some(&place) { + revealed.update(cx, |revealed, _| *revealed = Some(place.clone())); + if bring_into_view(&scroll, bounds, Axis::Horizontal) { + log::info!("board: {} scrolled into view", place.0); + window.request_animation_frame(); + } + } else if !focused && held.is_some_and(|(key, _)| key == place.0) { + revealed.update(cx, |revealed, _| *revealed = None); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full() +} + /// Columns of cards: a card drags to a place in any column, the others gliding aside, and Option with an arrow moves the focused one. Each move asks the owner through `on_move(card, column, index)`, the index counted without the card. #[derive(IntoElement)] pub struct KanbanBoard { @@ -166,6 +199,11 @@ impl RenderOnce for KanbanBoard { let hold = window.use_keyed_state((id.clone(), "hold"), cx, |_, _| None::); let measures = window.use_keyed_state((id.clone(), "measures"), cx, |_, _| Measures::default()); + let scroll = window + .use_keyed_state((id.clone(), "scroll"), cx, |_, _| ScrollHandle::new()) + .read(cx) + .clone(); + let revealed = window.use_keyed_state((id.clone(), "revealed"), cx, |_, _| None); let owner = hold.entity_id(); let stale = hold .read(cx) @@ -307,10 +345,12 @@ impl RenderOnce for KanbanBoard { measures.cards.insert(key.clone(), bounds) != Some(bounds) } })); + let place = (key.clone(), column.key.clone()); let row = div() .relative() .pb_2() .child(face) + .child(reveal(&scroll, &revealed, &handle, place)) .child(measure(&row_measures, { let key = key.clone(); move |measures, bounds| { @@ -385,6 +425,7 @@ impl RenderOnce for KanbanBoard { div() .id(id.clone()) .debug_selector(|| "kanban-board".into()) + .track_scroll(&scroll) .overflow_x_scroll(), ) .flex() diff --git a/src/project/moves.rs b/src/project/moves.rs index d3af1c1..5815549 100644 --- a/src/project/moves.rs +++ b/src/project/moves.rs @@ -54,5 +54,20 @@ mod tests { assert_eq!(stepped(&lens, 2, 1, "left"), Some((1, 0))); assert_eq!(stepped(&lens, 2, 1, "right"), None); assert_eq!(stepped(&lens, 1, 0, "home"), None); + assert_eq!( + stepped(&[3, 2], 0, 1, "right"), + Some((1, 1)), + "the same place" + ); + assert_eq!( + stepped(&[3, 2], 1, 1, "left"), + Some((0, 1)), + "the same place" + ); + assert_eq!( + stepped(&[3, 2], 0, 2, "right"), + Some((1, 2)), + "the end of a shorter column" + ); } } diff --git a/src/project/tests/boards.rs b/src/project/tests/boards.rs index 11d0955..b6fa5f8 100644 --- a/src/project/tests/boards.rs +++ b/src/project/tests/boards.rs @@ -1,6 +1,6 @@ use gpui::{ - AnyElement, Entity, IntoElement, Modifiers, MouseButton, SharedString, TestAppContext, point, - px, + AnyElement, Entity, IntoElement, Modifiers, MouseButton, ParentElement, SharedString, Styled, + TestAppContext, point, px, }; use jiff::civil::date; @@ -92,6 +92,25 @@ fn option_arrows_move_the_focused_card_and_enter_opens_it(cx: &mut TestAppContex ); } +fn narrow(owner: Entity) -> AnyElement { + gpui::div() + .w(px(300.0)) + .child(board(owner)) + .into_any_element() +} + +#[gpui::test] +fn a_focused_card_past_the_fold_scrolls_into_view(cx: &mut TestAppContext) { + let (_, cx) = desk(narrow, cx); + tab_to(3, cx); + let board = cx.debug_bounds("kanban-board").expect("the board draws"); + let card = cx.debug_bounds("card c").expect("card c draws"); + assert!( + board.left() <= card.left() && card.right() <= board.right(), + "card c at {card:?} shows inside the board at {board:?}" + ); +} + fn labels(owner: Entity) -> AnyElement { let (renamed, made) = (owner.clone(), owner); LabelManager::new(