diff --git a/AGENTS.md b/AGENTS.md index 21581fb..e64b16b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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>`. 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. diff --git a/TASKS.md b/TASKS.md index 96d4718..9748c1e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 | diff --git a/examples/gallery/pages/documents/knowledge.rs b/examples/gallery/pages/documents/knowledge.rs index 7d53e2e..f06df07 100644 --- a/examples/gallery/pages/documents/knowledge.rs +++ b/examples/gallery/pages/documents/knowledge.rs @@ -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))), ), ), diff --git a/src/documents/epub.rs b/src/documents/epub.rs index 9267e31..0d2c39c 100644 --- a/src/documents/epub.rs +++ b/src/documents/epub.rs @@ -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() diff --git a/src/documents/find.rs b/src/documents/find.rs index 3357445..f144d55 100644 --- a/src/documents/find.rs +++ b/src/documents/find.rs @@ -10,12 +10,11 @@ pub(crate) struct Find { pub current: Option, } -/// 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(); diff --git a/src/documents/reading.rs b/src/documents/reading.rs index b50717d..f8dcef4 100644 --- a/src/documents/reading.rs +++ b/src/documents/reading.rs @@ -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, + 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); } }) diff --git a/src/documents/tests.rs b/src/documents/tests.rs index b060dd5..4db53b2 100644 --- a/src/documents/tests.rs +++ b/src/documents/tests.rs @@ -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)>>>; -/// 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, + width: f32, + hits: Vec, current: Option, noted: Noted, } @@ -27,20 +37,16 @@ impl Render for Reader { let pages: Vec = (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, 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, + cx: &mut TestAppContext, +) -> (Entity, 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) { + let at = on_pages(cx); + pin_at(at, cx, noted) +} + +fn pin_at(at: Point, cx: &mut VisualTestContext, noted: &Noted) -> (usize, Point) { let toggle = cx.update(|window, cx| { let header = cx .theme() @@ -99,7 +121,6 @@ fn pin(cx: &mut VisualTestContext, noted: &Noted) -> (usize, Point) { }); 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, + on: bool, + left: usize, +} + +impl Render for Zen { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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"); +} diff --git a/src/documents/viewer.rs b/src/documents/viewer.rs index 660d1b2..f290015 100644 --- a/src/documents/viewer.rs +++ b/src/documents/viewer.rs @@ -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, + shown_hit: Option, } 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()); + } }, |_, _, _, _| {}, )