Project · a focused card scrolls into view

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.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 05:28:35 -04:00
1 parent 8cbd389d72
commit e630e61a63
4 files changed
+82 -6

No files matched your search

+45 -4
View File
@@ -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<Revealed>,
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::<Hold>);
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()
+15
View File
@@ -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"
);
}
}
+21 -2
View File
@@ -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<Desk>) -> 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<Desk>) -> AnyElement {
let (renamed, made) = (owner.clone(), owner);
LabelManager::new(