T21b and T21c round 1 fixes
- a page centers only while it fits the view; a wider one starts in reach - the reveal runs after the pages' prepaint and keys on the hit itself, so a new hit at the same index and a hit current from the first frame come into view - the chapter list scrolls - zen takes focus when turned on from outside, so Escape leaves it, and hands focus back - the gallery keeps a page's due date and tags
This commit is contained in:
1 parent
9854cfa460
commit
444a95a854
8 files changed
+196
-42
No files matched your search
@@ -112,6 +112,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- Tokens live in `src/theme`; the raw `px()` scan skips that folder only.
|
||||
- `ScrollHandle` keeps its children's bounds unscrolled: a child paints at its bounds plus the offset. A press on a scrolled child subtracts both, and an offset that brings a child to a place is set whole, not added (`documents::find::reveal`).
|
||||
- A find box is `editor::FindWidget`, over code, a terminal or a document's pages. It shows the replace fold and the option toggles only when the owner handles them.
|
||||
- Taffy 0.9 hands negative free space to auto margins and centering alike, so a centered child wider than its scroll box starts past the scroll's reach. Center only what fits the measured box (`documents::DocumentViewer`).
|
||||
- A mode that leaves on Escape keeps its own focus handle: turned on while focus is elsewhere, it takes focus so Escape reaches it, and hands focus back when it leaves (`documents::ZenMode`).
|
||||
- A callback field holds a named `type` alias; clippy's type_complexity rejects `Option<Rc<dyn Fn(..)>>`. A chapter shares one alias per shape.
|
||||
- A component that fills its container says `w_full` on its root. As a window's root, or in a flex row, it would shrink to its text's narrowest width.
|
||||
- A field's `Highlight` sets color, wash, weight, slant and strike per span; measuring and painting share one set of runs, so wrapped heights match.
|
||||
|
||||
@@ -250,3 +250,5 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
| T21a | 1 | FAIL | Enter kept a selection; undo of a split left focus on a gone field; italic inside bold ate the bold; keys went into merged cells; undo kept a changed kind's highlighter |
|
||||
| T21a | 2 | FAIL | undo of typing lost the selection it replaced; Up from below a table landed in a hidden merged cell |
|
||||
| T21a | 3 | FAIL | undo after a delete or a paste lost the selection they replaced; fixed after the cap by recording the replaced selection where every TextInput edit passes, verified by a red-green test, no fourth review |
|
||||
| T21b | 1 | FAIL | a page zoomed wider than the view sat past its left edge; a new hit at the same index kept the old page in view; a hit current from the first frame only scrolled its page to the top; the chapter list did not scroll; Escape could not leave zen turned on from a button |
|
||||
| T21c | 1 | FAIL | the gallery's page properties dropped a new due date and tags |
|
||||
@@ -163,11 +163,20 @@ pub fn knowledge(window: &mut Window, cx: &mut App) -> impl IntoElement + use<>
|
||||
let icon = keep("know-icon", || SharedString::from("☀️"), window, cx);
|
||||
let status = keep("know-status", || SharedString::from("Drafting"), window, cx);
|
||||
let done = keep("know-done", || false, window, cx);
|
||||
let (now_cover, now_icon, now_status, now_done) = (
|
||||
let due = keep("know-due", || jiff::civil::date(2026, 10, 12), window, cx);
|
||||
let tags = keep(
|
||||
"know-tags",
|
||||
|| vec![SharedString::from("color"), SharedString::from("theme")],
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let (now_cover, now_icon, now_status, now_done, now_due, now_tags) = (
|
||||
cover.read(cx).clone(),
|
||||
icon.read(cx).clone(),
|
||||
status.read(cx).clone(),
|
||||
*done.read(cx),
|
||||
*due.read(cx),
|
||||
tags.read(cx).clone(),
|
||||
);
|
||||
let trash = keep(
|
||||
"know-trash",
|
||||
@@ -310,8 +319,8 @@ pub fn knowledge(window: &mut Window, cx: &mut App) -> impl IntoElement + use<>
|
||||
.selected(now_status)
|
||||
.on_change(move |next, _, cx| set(&status, next.clone(), cx)),
|
||||
)
|
||||
.row("Due", DatePicker::new("know-due", Some(jiff::civil::date(2026, 10, 12))))
|
||||
.row("Tags", TagInput::new("know-tags", ["color", "theme"]))
|
||||
.row("Due", DatePicker::new("know-due", Some(now_due)).on_change(move |next, _, cx| set(&due, next, cx)))
|
||||
.row("Tags", TagInput::new("know-tags", now_tags).on_change(move |next, _, cx| set(&tags, next, cx)))
|
||||
.row("Published", Checkbox::new("know-published", now_done).on_change(move |on, _, cx| set(&done, on, cx))),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -104,8 +104,10 @@ impl RenderOnce for EpubReader {
|
||||
let shown = &self.chapters[chapter];
|
||||
let listed = contents.then(|| {
|
||||
div()
|
||||
.id((self.id.clone(), "chapters"))
|
||||
.flex_none()
|
||||
.w(theme.sidebar_width(false))
|
||||
.overflow_y_scroll()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
|
||||
@@ -10,12 +10,11 @@ pub(crate) struct Find {
|
||||
pub current: Option<usize>,
|
||||
}
|
||||
|
||||
/// Scrolls `hit` a third of the way down the view; before the pages are laid out, its page to the top.
|
||||
/// Scrolls `hit` a third of the way down the view; runs after the pages' prepaint, so their bounds are this frame's.
|
||||
pub(crate) fn reveal(scroll: &ScrollHandle, hit: &PageHit, zoom: f32) {
|
||||
let Some(page) = scroll.bounds_for_item(hit.page) else {
|
||||
scroll.scroll_to_top_of_item(hit.page);
|
||||
return;
|
||||
};
|
||||
let page = scroll
|
||||
.bounds_for_item(hit.page)
|
||||
.expect("the hit's page is laid out");
|
||||
let view = scroll.bounds();
|
||||
let target = page.top() + Pixels::from(hit.bounds.origin.y * zoom);
|
||||
let offset = scroll.offset();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use std::{collections::BTreeMap, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, Bounds, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement,
|
||||
RenderOnce, ScrollHandle, SharedString, Styled, Window, canvas, div, fill, prelude::*, size,
|
||||
AnyElement, App, Bounds, ElementId, FocusHandle, FontWeight, InteractiveElement, IntoElement,
|
||||
ParentElement, RenderOnce, ScrollHandle, SharedString, Styled, Window, canvas, div, fill,
|
||||
prelude::*, size,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
@@ -271,12 +272,35 @@ impl ParentElement for ZenMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Zen's own focus, where focus was before zen took it, and whether zen was on last frame.
|
||||
struct Quiet {
|
||||
focus: FocusHandle,
|
||||
previous: Option<FocusHandle>,
|
||||
was_on: bool,
|
||||
}
|
||||
|
||||
impl RenderOnce for ZenMode {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let (on, exit) = (self.on, self.on_exit);
|
||||
let quiet = window.use_keyed_state((self.id.clone(), "quiet"), cx, |_, cx| Quiet {
|
||||
focus: cx.focus_handle(),
|
||||
previous: None,
|
||||
was_on: false,
|
||||
});
|
||||
let (focus, was_on) = (quiet.read(cx).focus.clone(), quiet.read(cx).was_on);
|
||||
if on && !was_on && !focus.contains_focused(window, cx) {
|
||||
log::info!("zen mode: takes focus, so Escape reaches it");
|
||||
let previous = window.focused(cx);
|
||||
window.focus(&focus);
|
||||
quiet.update(cx, |quiet, _| quiet.previous = previous);
|
||||
}
|
||||
if on != was_on {
|
||||
quiet.update(cx, |quiet, _| quiet.was_on = on);
|
||||
}
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.id(self.id.clone())
|
||||
.track_focus(&focus)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.on_key_down(move |event, window, cx| {
|
||||
@@ -286,6 +310,9 @@ impl RenderOnce for ZenMode {
|
||||
{
|
||||
cx.stop_propagation();
|
||||
log::info!("zen mode: leaves");
|
||||
if let Some(previous) = quiet.update(cx, |quiet, _| quiet.previous.take()) {
|
||||
window.focus(&previous);
|
||||
}
|
||||
exit(window, cx);
|
||||
}
|
||||
})
|
||||
|
||||
+126
-16
@@ -1,23 +1,33 @@
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
AppContext as _, Bounds, Context, Entity, IntoElement, Modifiers, ParentElement, Pixels, Point,
|
||||
Render, ScrollDelta, ScrollWheelEvent, Styled, TestAppContext, TouchPhase, VisualTestContext,
|
||||
Window, div, point, px, size,
|
||||
AppContext as _, Bounds, Context, Entity, Focusable, IntoElement, Modifiers, ParentElement,
|
||||
Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, Styled, TestAppContext, TouchPhase,
|
||||
VisualTestContext, Window, div, point, px, size,
|
||||
};
|
||||
|
||||
use super::{DocPage, DocumentViewer, PageHit};
|
||||
use super::{DocPage, DocumentViewer, PageHit, ZenMode};
|
||||
use crate::{
|
||||
editor::FindWidget,
|
||||
forms::TextInput,
|
||||
forms::{Input, TextInput},
|
||||
theme::{ActiveTheme, ControlSize, Theme},
|
||||
};
|
||||
|
||||
type Noted = Rc<RefCell<Option<(usize, Point<f32>)>>>;
|
||||
|
||||
/// Three tall pages with one hit on the second; a press while pinning records the note asked.
|
||||
/// A hit on `page`, 400 points down.
|
||||
fn hit(page: usize) -> PageHit {
|
||||
PageHit {
|
||||
page,
|
||||
bounds: Bounds::new(point(40.0, 400.0), size(60.0, 12.0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Three tall pages and the hits found; a press while pinning records the note asked.
|
||||
struct Reader {
|
||||
query: Entity<TextInput>,
|
||||
width: f32,
|
||||
hits: Vec<PageHit>,
|
||||
current: Option<usize>,
|
||||
noted: Noted,
|
||||
}
|
||||
@@ -27,20 +37,16 @@ impl Render for Reader {
|
||||
let pages: Vec<DocPage> = (0..3)
|
||||
.map(|ix| DocPage {
|
||||
source: format!("page-{ix}.png").into(),
|
||||
width: 400.0,
|
||||
width: self.width,
|
||||
height: 1000.0,
|
||||
})
|
||||
.collect();
|
||||
let hit = PageHit {
|
||||
page: 1,
|
||||
bounds: Bounds::new(point(40.0, 400.0), size(60.0, 12.0)),
|
||||
};
|
||||
let noted = self.noted.clone();
|
||||
div().size_full().child(
|
||||
DocumentViewer::new("viewer", "lift.pdf", pages)
|
||||
.find(
|
||||
FindWidget::new("find", &self.query, self.current, 1),
|
||||
vec![hit],
|
||||
FindWidget::new("find", &self.query, self.current, self.hits.len()),
|
||||
self.hits.clone(),
|
||||
self.current,
|
||||
)
|
||||
.notes(Vec::new(), move |page, at, _, _| {
|
||||
@@ -51,12 +57,23 @@ impl Render for Reader {
|
||||
}
|
||||
|
||||
fn open(cx: &mut TestAppContext) -> (Entity<Reader>, Noted, &mut VisualTestContext) {
|
||||
open_with(400.0, None, cx)
|
||||
}
|
||||
|
||||
/// Pages `width` points wide, with a hit on the second, current from the first frame when `current` says.
|
||||
fn open_with(
|
||||
width: f32,
|
||||
current: Option<usize>,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<Reader>, Noted, &mut VisualTestContext) {
|
||||
cx.update(Theme::init);
|
||||
let noted = Noted::default();
|
||||
let seen = noted.clone();
|
||||
let (reader, cx) = cx.add_window_view(move |window, cx| Reader {
|
||||
query: cx.new(|cx| TextInput::new(window, cx)),
|
||||
current: None,
|
||||
width,
|
||||
hits: vec![hit(1)],
|
||||
current,
|
||||
noted: seen,
|
||||
});
|
||||
settle(cx);
|
||||
@@ -85,8 +102,13 @@ fn wheel(down: f32, cx: &mut VisualTestContext) {
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
/// Turns pinning on with the header's last button, then presses the pages; returns the note asked.
|
||||
/// Turns pinning on with the header's last button, then presses the pages at their middle; returns the note asked.
|
||||
fn pin(cx: &mut VisualTestContext, noted: &Noted) -> (usize, Point<f32>) {
|
||||
let at = on_pages(cx);
|
||||
pin_at(at, cx, noted)
|
||||
}
|
||||
|
||||
fn pin_at(at: Point<Pixels>, cx: &mut VisualTestContext, noted: &Noted) -> (usize, Point<f32>) {
|
||||
let toggle = cx.update(|window, cx| {
|
||||
let header = cx
|
||||
.theme()
|
||||
@@ -99,7 +121,6 @@ fn pin(cx: &mut VisualTestContext, noted: &Noted) -> (usize, Point<f32>) {
|
||||
});
|
||||
cx.simulate_click(toggle, Modifiers::none());
|
||||
settle(cx);
|
||||
let at = on_pages(cx);
|
||||
cx.simulate_click(at, Modifiers::none());
|
||||
settle(cx);
|
||||
noted
|
||||
@@ -143,3 +164,92 @@ fn a_current_hit_comes_to_one_place_from_any_scroll(cx: &mut TestAppContext) {
|
||||
assert_eq!(from_top.0, 1, "the hit's page comes into view");
|
||||
assert_eq!(from_top, from_below);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_page_wider_than_the_view_starts_at_its_left_edge(cx: &mut TestAppContext) {
|
||||
let (_, noted, cx) = open_with(3000.0, None, cx);
|
||||
let (page, at) = pin_at(point(px(60.0), px(300.0)), cx, ¬ed);
|
||||
assert_eq!(page, 0);
|
||||
assert!(
|
||||
at.x < 100.0,
|
||||
"the page's left edge is in reach, not at {}",
|
||||
at.x
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_new_hit_at_the_same_index_moves_the_view(cx: &mut TestAppContext) {
|
||||
let (reader, noted, cx) = open(cx);
|
||||
reader.update(cx, |reader, cx| {
|
||||
reader.current = Some(0);
|
||||
cx.notify();
|
||||
});
|
||||
settle(cx);
|
||||
reader.update(cx, |reader, cx| {
|
||||
reader.hits = vec![hit(2)];
|
||||
cx.notify();
|
||||
});
|
||||
settle(cx);
|
||||
assert_eq!(pin(cx, ¬ed).0, 2, "the view follows the new hit");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_hit_current_from_the_first_frame_comes_into_view(cx: &mut TestAppContext) {
|
||||
let (_, noted, first) = open_with(400.0, Some(0), cx);
|
||||
let at_once = pin(first, ¬ed);
|
||||
let later = revealed(0.0, cx);
|
||||
assert_eq!(at_once, later);
|
||||
}
|
||||
|
||||
/// A field outside a zen view, and how often Escape left zen.
|
||||
struct Zen {
|
||||
outside: Entity<TextInput>,
|
||||
on: bool,
|
||||
left: usize,
|
||||
}
|
||||
|
||||
impl Render for Zen {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let me = cx.entity();
|
||||
div().size_full().child(Input::new(&self.outside)).child(
|
||||
ZenMode::new("zen", self.on)
|
||||
.on_exit(move |_, cx| {
|
||||
me.update(cx, |zen, cx| {
|
||||
zen.on = false;
|
||||
zen.left += 1;
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.child(div().child("A quiet page")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn escape_leaves_zen_turned_on_from_outside_and_hands_focus_back(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
crate::forms::bind_keys(cx);
|
||||
});
|
||||
let (zen, cx) = cx.add_window_view(|window, cx| Zen {
|
||||
outside: cx.new(|cx| TextInput::new(window, cx)),
|
||||
on: false,
|
||||
left: 0,
|
||||
});
|
||||
cx.update(|window, cx| {
|
||||
window.activate_window();
|
||||
let outside = zen.read(cx).outside.focus_handle(cx);
|
||||
window.focus(&outside);
|
||||
});
|
||||
settle(cx);
|
||||
zen.update(cx, |zen, cx| {
|
||||
zen.on = true;
|
||||
cx.notify();
|
||||
});
|
||||
settle(cx);
|
||||
cx.simulate_keystrokes("escape");
|
||||
settle(cx);
|
||||
assert_eq!(zen.read_with(cx, |zen, _| zen.left), 1);
|
||||
let back = cx.update(|window, cx| zen.read(cx).outside.focus_handle(cx).is_focused(window));
|
||||
assert!(back, "focus returns to where it was");
|
||||
}
|
||||
+17
-14
@@ -146,6 +146,10 @@ impl DocumentViewer {
|
||||
"hit {current:?} of {}",
|
||||
hits.len()
|
||||
);
|
||||
assert!(
|
||||
hits.iter().all(|hit| hit.page < self.pages.len()),
|
||||
"a hit lies past the last page"
|
||||
);
|
||||
self.find = Some(Find {
|
||||
widget,
|
||||
hits,
|
||||
@@ -173,7 +177,7 @@ struct Desk {
|
||||
page: usize,
|
||||
width: f32,
|
||||
pinning: bool,
|
||||
shown_hit: Option<usize>,
|
||||
shown_hit: Option<PageHit>,
|
||||
}
|
||||
|
||||
impl RenderOnce for DocumentViewer {
|
||||
@@ -184,18 +188,6 @@ impl RenderOnce for DocumentViewer {
|
||||
(desk.scroll.clone(), desk.page, desk.width, desk.pinning)
|
||||
};
|
||||
let zoom = self.zoom;
|
||||
if let Some(Find {
|
||||
hits,
|
||||
current: Some(current),
|
||||
..
|
||||
}) = &self.find
|
||||
&& desk.read(cx).shown_hit != Some(*current)
|
||||
{
|
||||
let hit = &hits[*current];
|
||||
log::info!("document viewer: hit {current} on page {}", hit.page);
|
||||
reveal(&scroll, hit, zoom);
|
||||
desk.update(cx, |desk, _| desk.shown_hit = Some(*current));
|
||||
}
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let count = self.pages.len();
|
||||
@@ -294,12 +286,16 @@ impl RenderOnce for DocumentViewer {
|
||||
}) => (Some(widget), hits, current),
|
||||
None => (None, Vec::new(), None),
|
||||
};
|
||||
let target = current.map(|current| hits[current].clone());
|
||||
let pages = self.pages.iter().enumerate().map(|(ix, page)| {
|
||||
let (on_note, scroll) = (self.on_note.clone().filter(|_| pinning), scroll.clone());
|
||||
div()
|
||||
.id((self.id.clone(), format!("page-{ix}")))
|
||||
.relative()
|
||||
.flex_none()
|
||||
.when(width == 0.0 || page.width * zoom <= width, |page| {
|
||||
page.mx_auto()
|
||||
})
|
||||
.w(Pixels::from(page.width * zoom))
|
||||
.h(Pixels::from(page.height * zoom))
|
||||
.bg(colors.paper)
|
||||
@@ -406,7 +402,6 @@ impl RenderOnce for DocumentViewer {
|
||||
.track_scroll(&scroll)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.gap_4()
|
||||
.p_6()
|
||||
.children(pages),
|
||||
@@ -430,6 +425,14 @@ impl RenderOnce for DocumentViewer {
|
||||
});
|
||||
window.request_animation_frame();
|
||||
}
|
||||
if tracked.read(cx).shown_hit != target {
|
||||
if let Some(hit) = &target {
|
||||
log::info!("document viewer: hit on page {}", hit.page);
|
||||
reveal(&tracked.read(cx).scroll, hit, zoom);
|
||||
window.request_animation_frame();
|
||||
}
|
||||
measured.update(cx, |desk, _| desk.shown_hit = target.clone());
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user