diff --git a/CHANGELOG.md b/CHANGELOG.md index 97a8d7126..4afcaea81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- The crosshair on a session browser's toolbar lets you leave notes on the page for the agent. The element under the pointer is outlined and named, a click opens a note beside it, and each note pins its element with a number, following it as the page scrolls. Notes wait in a tray floating over the bottom of the page, which never resizes under you. Hovering a note there lights its element, a click edits it, and Send (or Ctrl+Enter) types them into the session as one message: which element, a selector the agent's `browser_click` takes, the React component that drew it in a development build, and your note. Ctrl+Shift+E (Cmd+Shift+E on macOS) turns the crosshair on and off, and Esc leaves it. A send the session refuses keeps the notes and says why in the tray. +- The crosshair on a session browser's toolbar lets you leave notes on the page for the agent. The element under the pointer is outlined and named, the outline sliding from one element to the next as the pointer moves, a click opens a note beside it, and each note pins its element with a number, following it as the page scrolls. Notes wait in a tray floating over the bottom of the page, which never resizes under you. Hovering a note there lights its element, and a click on it or its pin scrolls the element into view and edits the note. An element a scrolling list, sidebar or dialog cuts is outlined only where it shows, and one it hides keeps no pin over what stands beside it. Send (or Ctrl+Enter) types them into the session as one message: which element, a selector the agent's `browser_click` takes, the React component that drew it in a development build, and your note. Ctrl+Shift+E (Cmd+Shift+E on macOS) turns the crosshair on and off, and Esc leaves it. A send the session refuses keeps the notes and says why in the tray. - A session's browser downloads files. A download the page starts, from a link, a button, a new tab or a script, lands in your Downloads folder under the name the page gives it, with ` (1)` added rather than overwriting a file already there. The agent can also open a file's URL directly. The agent's click, key press or open answers with the file's path and size, and the window shows a toast naming the file, with a button that opens its folder. Before, a download button in the session's browser did nothing. ### Fixed diff --git a/api/browser.go b/api/browser.go index 1ac0e13bb..4e9317cb1 100644 --- a/api/browser.go +++ b/api/browser.go @@ -281,8 +281,11 @@ type BrowserElement struct { } // BrowserLocateOptions asks where the elements Selectors find stand in -// session ID's page; one gone from the page answers an empty box. +// session ID's page; one gone from the page, or hidden by what it scrolls +// in, answers an empty box. Reveal, when set, is a selector whose element +// the page first scrolls into view unless it already shows whole. type BrowserLocateOptions struct { ID string `json:"id"` Selectors []string `json:"selectors"` + Reveal string `json:"reveal,omitempty"` } diff --git a/api/testdata/contract.golden b/api/testdata/contract.golden index dfe014c17..2f92fdb31 100644 --- a/api/testdata/contract.golden +++ b/api/testdata/contract.golden @@ -372,6 +372,7 @@ BrowserKeyOptions { BrowserLocateOptions { id string selectors []string + reveal,omitempty string } BrowserRect { diff --git a/docs/browser-tab.md b/docs/browser-tab.md index 4f20e493c..65e3f0ba3 100644 --- a/docs/browser-tab.md +++ b/docs/browser-tab.md @@ -49,12 +49,16 @@ in the page is not a tool. ## Notes on the page The crosshair on the pane's toolbar stops the pointer reaching the page: the -element under it is outlined, and a click opens a note on it. Notes wait in a +element under it is outlined, the outline sliding to the next element in 160 +ms as the pointer moves, and a click opens a note on it. Notes wait in a tray floating over the bottom of the page, which never resizes for it, each element pinned where it stands, and Send types them into the session's prompt as one message: each element's label, page, a selector `browser_click` takes, its React component when the page is a -development build, its markup without any field's value, and the note. The +development build, its markup without any field's value, and the note. A note +opened from the tray or its pin scrolls its element into view first, unless it +already shows whole. An element inside a list, sidebar or dialog that scrolls +is outlined only where that box shows it, and one the box hides has no pin. The page only answers what is where (`browser.ElementAt`, `browser.Locate`); the outline, the pins and the note are the window's, so nothing is injected into the page. diff --git a/docs/ceilings.md b/docs/ceilings.md index e59e7a2f7..41ec96c2f 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -988,7 +988,9 @@ work when nobody knows it and that the call site never shows. The mechanism and not look. The React component comes from the page's fiber, there only in a development build, and its file and line from `_debugSource`, which React 19 dropped. Pins follow their elements by looking them up again every 400 ms while notes wait, and every 30 ms for a moment after the wheel turns, so an element the page - moves on its own is outlined late. Notes not sent yet + moves on its own is outlined late. A pin is cut to every ancestor whose overflow is not visible, up to a + fixed one, so an absolutely positioned element that escapes such an ancestor is cut anyway, and one a + sibling covers keeps its pin. Notes not sent yet live in the window: closing it, or the browser, loses them. - **A session browser's downloads go straight to the user's Downloads folder** (`internal/browser/downloads.go`, diff --git a/internal/browser/fake_test.go b/internal/browser/fake_test.go index 0a4b483ee..a50134df0 100644 --- a/internal/browser/fake_test.go +++ b/internal/browser/fake_test.go @@ -335,7 +335,7 @@ func (p *fakePage) ElementAt(_ context.Context, x, y float64) (api.BrowserElemen return p.element, nil } -func (p *fakePage) Locate(_ context.Context, selectors []string) ([]api.BrowserRect, error) { +func (p *fakePage) Locate(_ context.Context, selectors []string, _ string) ([]api.BrowserRect, error) { p.mu.Lock() defer p.mu.Unlock() p.located = append(p.located, selectors) diff --git a/internal/browser/notes.go b/internal/browser/notes.go index f76439aef..5fbaf5097 100644 --- a/internal/browser/notes.go +++ b/internal/browser/notes.go @@ -22,6 +22,7 @@ import ( // letters is a production build's minifier, which tells the agent nothing. A // point on the page's root answers null. const elementScript = `((x, y) => { + const visible = ` + visibleScript + `; const cut = (s, n) => s.length > n ? s.slice(0, n - 1) + '…' : s; let el = document.elementFromPoint(x, y); while (el && el.shadowRoot) { @@ -71,24 +72,56 @@ const elementScript = `((x, y) => { if (n.matches('input, textarea, select, option')) { n.removeAttribute('value'); n.textContent = ''; } if (n !== copy && n.matches('script, style')) n.remove(); } - const r = el.getBoundingClientRect(); return { label, selector, component, source, html: cut(copy.outerHTML.replace(/\s+/g, ' '), 600), url: location.href.split(/[?#]/)[0], - box: {x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height)}, + box: visible(el), }; })` -// locateScript is the viewport box of the element each selector finds, an -// empty one where it finds none. -const locateScript = `(selectors => selectors.map(s => { - let e = null; - try { e = document.querySelector(s); } catch {} - if (!e) return {x: 0, y: 0, w: 0, h: 0}; +// visibleScript is the viewport box of the part of an element that shows: a +// list, sidebar or dialog that scrolls cuts its rows to its own box, and a +// row it hides whole answers an empty box, so a pin is never drawn over what +// stands beside the list. A partly hidden one keeps the part that shows, +// where the person still sees it. The walk stops at a fixed ancestor, which +// no scrolling box above it cuts. +const visibleScript = `(e => { const r = e.getBoundingClientRect(); - return {x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height)}; -}))` + let x0 = r.left, y0 = r.top, x1 = r.right, y1 = r.bottom; + for (let a = e; a && a !== document.body && a !== document.documentElement; a = a.parentElement) { + const s = getComputedStyle(a); + if (a !== e && (s.overflowX !== 'visible' || s.overflowY !== 'visible')) { + const c = a.getBoundingClientRect(), left = c.left + a.clientLeft, top = c.top + a.clientTop; + x0 = Math.max(x0, left); y0 = Math.max(y0, top); + x1 = Math.min(x1, left + a.clientWidth); y1 = Math.min(y1, top + a.clientHeight); + } + if (s.position === 'fixed') break; + } + if (x1 <= x0 || y1 <= y0) return {x: 0, y: 0, w: 0, h: 0}; + return {x: Math.round(x0), y: Math.round(y0), w: Math.round(x1 - x0), h: Math.round(y1 - y0)}; +})` + +// locateScript is the visible box of the element each selector finds, an +// empty one where it finds none. It first scrolls reveal's element, when +// that does not show whole, to the middle of every box it scrolls in, or to +// their top when it is taller than the viewport. The scroll is instant so +// the boxes it answers are where the element stops. +const locateScript = `((selectors, reveal) => { + const visible = ` + visibleScript + `; + const find = s => { try { return document.querySelector(s); } catch { return null; } }; + const shown = reveal ? find(reveal) : null; + if (shown) { + const r = shown.getBoundingClientRect(), v = visible(shown); + const whole = v.w === Math.round(r.width) && v.h === Math.round(r.height) && v.w > 0 && + r.top >= 0 && r.left >= 0 && r.bottom <= innerHeight && r.right <= innerWidth; + if (!whole) shown.scrollIntoView({block: r.height > innerHeight ? 'start' : 'center', inline: 'nearest', behavior: 'instant'}); + } + return selectors.map(s => { + const e = find(s); + return e ? visible(e) : {x: 0, y: 0, w: 0, h: 0}; + }); +})` func (p *chromePage) ElementAt(ctx context.Context, x, y float64) (api.BrowserElement, error) { var el *api.BrowserElement @@ -101,13 +134,17 @@ func (p *chromePage) ElementAt(ctx context.Context, x, y float64) (api.BrowserEl return *el, nil } -func (p *chromePage) Locate(ctx context.Context, selectors []string) ([]api.BrowserRect, error) { +func (p *chromePage) Locate(ctx context.Context, selectors []string, reveal string) ([]api.BrowserRect, error) { arg, err := json.Marshal(selectors) if err != nil { return nil, err } + shown, err := json.Marshal(reveal) + if err != nil { + return nil, err + } boxes := []api.BrowserRect{} - return boxes, p.run(ctx, chromedp.Evaluate(fmt.Sprintf("%s(%s)", locateScript, arg), &boxes)) + return boxes, p.run(ctx, chromedp.Evaluate(fmt.Sprintf("%s(%s, %s)", locateScript, arg, shown), &boxes)) } // ElementAtPoint is the element under a point of this session's page. @@ -121,12 +158,13 @@ func (s *Service) ElementAtPoint(_ context.Context, opts api.BrowserPointOptions return el, err } -// LocateElements is where the elements opts.Selectors find stand now. +// LocateElements is where the elements opts.Selectors find stand now, after +// scrolling opts.Reveal's element into view when it is set. func (s *Service) LocateElements(_ context.Context, opts api.BrowserLocateOptions) ([]api.BrowserRect, error) { var boxes []api.BrowserRect _, err := s.steer(opts.ID, inputWait, func(p Page, ctx context.Context) error { var err error - boxes, err = p.Locate(ctx, opts.Selectors) + boxes, err = p.Locate(ctx, opts.Selectors, opts.Reveal) return err }) return boxes, err diff --git a/internal/browser/notes_live_test.go b/internal/browser/notes_live_test.go index b465557a8..4ab242ad5 100644 --- a/internal/browser/notes_live_test.go +++ b/internal/browser/notes_live_test.go @@ -23,11 +23,16 @@ const notesProbe = ` ` func notesPage(t *testing.T) (*chromePage, context.Context) { + t.Helper() + return notesPageOf(t, notesProbe) +} + +func notesPageOf(t *testing.T, html string) (*chromePage, context.Context) { t.Helper() p, _ := liveChrome(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/html") - fmt.Fprint(w, notesProbe) + fmt.Fprint(w, html) })) t.Cleanup(srv.Close) ctx, cancel := context.WithTimeout(context.Background(), pageWait) @@ -43,7 +48,7 @@ func notesPage(t *testing.T) (*chromePage, context.Context) { func boxOf(t *testing.T, p *chromePage, ctx context.Context, selector string) api.BrowserRect { t.Helper() - boxes, err := p.Locate(ctx, []string{selector}) + boxes, err := p.Locate(ctx, []string{selector}, "") if err != nil { t.Fatal(err) } @@ -124,6 +129,64 @@ func TestChromeLocateAnswersAnEmptyBoxForAnElementGone(t *testing.T) { } } +// scrollProbe is a list that scrolls inside a 100px box, rows of 40px, above +// a page taller than the viewport. +const scrollProbe = ` + +
+ +
` + +func TestChromeLocateHidesAnElementItsScrollingBoxHides(t *testing.T) { + p, ctx := notesPageOf(t, scrollProbe) + if got := boxOf(t, p, ctx, "#i5"); got != (api.BrowserRect{}) { + t.Errorf("row scrolled out of its list answers %+v, want an empty box", got) + } + if got, want := boxOf(t, p, ctx, "#i3"), (api.BrowserRect{X: 0, Y: 80, W: 800, H: 20}); got != want { + t.Errorf("row half out of its list answers %+v, want its visible part %+v", got, want) + } +} + +func TestChromeElementAtBoxesOnlyThePartItsScrollingBoxShows(t *testing.T) { + p, ctx := notesPageOf(t, scrollProbe) + el, err := p.ElementAt(ctx, 20, 90) + if err != nil { + t.Fatal(err) + } + if want := (api.BrowserRect{X: 0, Y: 80, W: 800, H: 20}); el.Box != want { + t.Fatalf("picked %q at %+v, want its visible part %+v", el.Label, el.Box, want) + } +} + +func TestChromeLocateRevealsAnElementOutOfView(t *testing.T) { + p, ctx := notesPageOf(t, scrollProbe) + boxes, err := p.Locate(ctx, []string{"#i5", "#far"}, "#i5") + if err != nil { + t.Fatal(err) + } + if got := boxes[0]; got.H != 40 || got.Y < 0 || got.Y+got.H > 100 { + t.Errorf("revealed row stands at %+v, want whole inside its list", got) + } + far, err := p.Locate(ctx, []string{"#far"}, "#far") + if err != nil { + t.Fatal(err) + } + if got := far[0]; got.H != 40 || got.Y < 250 || got.Y > 310 { + t.Errorf("revealed button stands at %+v, want centered in the 600px viewport", got) + } + again, err := p.Locate(ctx, []string{"#far"}, "#far") + if err != nil { + t.Fatal(err) + } + if again[0] != far[0] { + t.Errorf("revealing a button already in view moved it from %+v to %+v", far[0], again[0]) + } +} + // reactProbe gives two elements a React fiber, as a development build and a // production build leave them: a named component with its source, and one // whose name the minifier shortened. diff --git a/internal/browser/page.go b/internal/browser/page.go index 622d8e4aa..602a1d9cf 100644 --- a/internal/browser/page.go +++ b/internal/browser/page.go @@ -113,9 +113,10 @@ type Page interface { // Selection is the text selected in the page, "" in a password field. Selection(ctx context.Context) (string, error) // ElementAt is the element under x,y, zero when the point holds none; - // Locate is the box of the element each selector finds, zero for none. + // Locate is the box of the element each selector finds, zero for none, + // after scrolling reveal's element into view when reveal is set. ElementAt(ctx context.Context, x, y float64) (api.BrowserElement, error) - Locate(ctx context.Context, selectors []string) ([]api.BrowserRect, error) + Locate(ctx context.Context, selectors []string, reveal string) ([]api.BrowserRect, error) Alive() bool Close() error } diff --git a/native/backend/browser.go b/native/backend/browser.go index ab1e94eed..ebbfa3e51 100644 --- a/native/backend/browser.go +++ b/native/backend/browser.go @@ -78,7 +78,8 @@ func (c *Client) BrowserElementAt(ctx context.Context, id string, x, y float64) } // BrowserLocate is where the elements selectors find stand in session id's -// page, an empty box for one gone. -func (c *Client) BrowserLocate(ctx context.Context, id string, selectors []string) ([]api.BrowserRect, error) { - return callMethod(ctx, c, api.BrowserLocate, api.BrowserLocateOptions{ID: id, Selectors: selectors}) +// page, an empty box for one gone, after scrolling reveal's element into +// view when reveal is set. +func (c *Client) BrowserLocate(ctx context.Context, id string, selectors []string, reveal string) ([]api.BrowserRect, error) { + return callMethod(ctx, c, api.BrowserLocate, api.BrowserLocateOptions{ID: id, Selectors: selectors, Reveal: reveal}) } diff --git a/native/cmd/lich-native/browser_notes.go b/native/cmd/lich-native/browser_notes.go index b48de1914..4fade3933 100644 --- a/native/cmd/lich-native/browser_notes.go +++ b/native/cmd/lich-native/browser_notes.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "image" + "math" "slices" "time" @@ -41,6 +42,11 @@ const ( noteFollowFor = 700 * time.Millisecond ) +// noteHoverGlide is how long the pick's outline takes to slide from one +// element to the next under the pointer, the variant the maintainer chose +// over a jump, a 100 ms slide and a cross-fade. +const noteHoverGlide = 160 * time.Millisecond + // browserNote is one note: the element as it was picked, what the person // wrote, and where the element stands now (empty when it left the page). type browserNote struct { @@ -55,8 +61,11 @@ type browserNote struct { type browserNotes struct { picking bool pick iconActionState - // hover is the element under the pointer while picking. - hover api.BrowserElement + // hover is the element under the pointer while picking; its outline + // glides from hoverFrom, where it stood at hoverAt. + hover api.BrowserElement + hoverFrom api.BrowserRect + hoverAt time.Time // draft is the element the note popover is open on; editing is the note // it edits, -1 for a new one. draft api.BrowserElement @@ -79,6 +88,9 @@ type browserNotes struct { // failure is why the last send failed, shown in the tray until the next. failure string nextLocate time.Time + // reveal is the selector of a note opened from the tray or its pin, + // which the next lookup scrolls into view first. + reveal string // followUntil is when the fast lookups after a scroll end. followUntil time.Time } @@ -130,6 +142,41 @@ func (n *browserNotes) setPicking(on bool) { } } +// setHover outlines el, the element under the pointer at now. A new element +// starts the outline's glide from where it stands; the first one under the +// pointer, or the same one answered again, is outlined where it is. +func (n *browserNotes) setHover(el api.BrowserElement, now time.Time) { + if el.Selector != n.hover.Selector { + from := el.Box + if n.hover.Label != "" { + from = n.hoverBox(now) + } + n.hoverFrom, n.hoverAt = from, now + } + n.hover = el +} + +// hoverBox is where the outline of the element under the pointer stands at +// now, on its glide from the element before. +func (n *browserNotes) hoverBox(now time.Time) api.BrowserRect { + f := easeInOutQuad(progress(now.Sub(n.hoverAt), noteHoverGlide)) + at := func(from, to int) int { return from + int(math.Round(float64(f)*float64(to-from))) } + a, b := n.hoverFrom, n.hover.Box + return api.BrowserRect{X: at(a.X, b.X), Y: at(a.Y, b.Y), W: at(a.W, b.W), H: at(a.H, b.H)} +} + +// gliding is whether the outline is still on its way at now. +func (n *browserNotes) gliding(now time.Time) bool { + return now.Before(n.hoverAt.Add(noteHoverGlide)) +} + +func easeInOutQuad(t float32) float32 { + if t < 0.5 { + return 2 * t * t + } + return 1 - (2-2*t)*(2-2*t)/2 +} + // openNote opens the popover on el, for note i or a new one (-1). func (n *browserNotes) openNote(el api.BrowserElement, i int) { if !n.open { @@ -145,6 +192,17 @@ func (n *browserNotes) openNote(el api.BrowserElement, i int) { n.editor.SetCaret(len([]rune(text)), len([]rune(text))) } +// openListed opens the popover on note i and has the next lookup, right +// away, scroll its element into view: the box the tray knows may stand off +// the page's viewport, where the popover would open far from it. +func (n *browserNotes) openListed(gtx C, i int) { + el := n.list[i].el + el.Box = n.list[i].box + n.openNote(el, i) + n.reveal, n.nextLocate = el.Selector, time.Time{} + gtx.Execute(op.InvalidateCmd{}) +} + // draftBox is where the element the popover is open on stands now. func (n *browserNotes) draftBox() api.BrowserRect { if n.editing >= 0 && n.editing < len(n.list) { @@ -189,7 +247,7 @@ func (u *window) probeElement(p *browserPaneUI, session string, x, y float64, op } // relocateNotes looks the noted elements up again, and the one a new note -// is open on. +// is open on, first scrolling the one opened from the tray into view. func (u *window) relocateNotes(p *browserPaneUI, session string) { selectors := make([]string, 0, len(p.notes.list)+1) for _, n := range p.notes.list { @@ -199,8 +257,15 @@ func (u *window) relocateNotes(p *browserPaneUI, session string) { if draft { selectors = append(selectors, p.notes.draft.Selector) } - p.queue.push(browserCall{merge: "locate", send: func(ctx context.Context) error { - boxes, err := u.client.BrowserLocate(ctx, session, selectors) + reveal := p.notes.reveal + p.notes.reveal = "" + merge := "locate" + if reveal != "" { + // The lookups queued behind a reveal must not merge it away. + merge = "" + } + p.queue.push(browserCall{merge: merge, send: func(ctx context.Context) error { + boxes, err := u.client.BrowserLocate(ctx, session, selectors, reveal) if err != nil { return fmt.Errorf("locate notes: %w", err) } @@ -222,7 +287,7 @@ func (u *window) takeFound(gtx C, p *browserPaneUI, session string) { case f.open && f.el.Label != "": n.openNote(f.el, -1) case !f.open: - n.hover = f.el + n.setHover(f.el, gtx.Now) } case l := <-n.located: n.applyBoxes(l) diff --git a/native/cmd/lich-native/browser_notes_test.go b/native/cmd/lich-native/browser_notes_test.go index 09cd67989..6c3d531cc 100644 --- a/native/cmd/lich-native/browser_notes_test.go +++ b/native/cmd/lich-native/browser_notes_test.go @@ -3,8 +3,11 @@ package main import ( "math" "testing" + "time" "gioui.org/f32" + "gioui.org/layout" + "gioui.org/op" "github.com/lichdotdev/lich/api" ) @@ -109,6 +112,62 @@ func TestAnOpenNoteFollowsItsElementAsThePageScrolls(t *testing.T) { } } +func TestANoteOpenedFromTheTrayIsRevealedByTheNextLookup(t *testing.T) { + n := newBrowserNotes() + n.list = []*browserNote{{el: api.BrowserElement{Selector: "li.far"}, box: api.BrowserRect{Y: 2100, W: 150, H: 40}}} + n.nextLocate = time.Now().Add(time.Hour) + n.openListed(layout.Context{Ops: new(op.Ops)}, 0) + if n.reveal != "li.far" || !n.nextLocate.IsZero() { + t.Fatalf("reveal %q, next lookup %v: want li.far looked up and scrolled to right away", n.reveal, n.nextLocate) + } + if got := n.draftBox(); got.Y != 2100 { + t.Fatalf("the popover opened at y %d, want the box the tray knew until the lookup answers", got.Y) + } + n.applyBoxes(locatedNotes{selectors: []string{"li.far"}, boxes: []api.BrowserRect{{Y: 280, W: 150, H: 40}}}) + if got := n.draftBox(); got.Y != 280 { + t.Fatalf("the popover stayed at y %d after the reveal scrolled its element to 280", got.Y) + } +} + +func TestANotePickedOnThePageIsNotScrolledTo(t *testing.T) { + n := newBrowserNotes() + n.openNote(api.BrowserElement{Label: "a", Selector: "a"}, -1) + if n.reveal != "" { + t.Fatalf("a note picked under the pointer asks to reveal %q", n.reveal) + } +} + +func TestTheHoverOutlineGlidesToTheNextElement(t *testing.T) { + n := newBrowserNotes() + t0 := time.Now() + a := api.BrowserRect{X: 0, Y: 0, W: 100, H: 40} + b := api.BrowserRect{X: 200, Y: 100, W: 300, H: 80} + n.setHover(api.BrowserElement{Label: "a", Selector: "a", Box: a}, t0) + if got := n.hoverBox(t0); got != a { + t.Fatalf("the first element under the pointer is outlined at %+v, want its own box %+v at once", got, a) + } + t1 := t0.Add(time.Second) + n.setHover(api.BrowserElement{Label: "b", Selector: "b", Box: b}, t1) + if got := n.hoverBox(t1); got != a { + t.Fatalf("on the move to b the outline jumped to %+v, want it to leave from a %+v", got, a) + } + if got := n.hoverBox(t1.Add(noteHoverGlide / 2)); got != (api.BrowserRect{X: 100, Y: 50, W: 200, H: 60}) { + t.Fatalf("half way the outline stands at %+v, want half way between a and b", got) + } + if got := n.hoverBox(t1.Add(noteHoverGlide)); got != b { + t.Fatalf("after the glide the outline stands at %+v, want b %+v", got, b) + } + c := api.BrowserRect{X: 400, Y: 300, W: 10, H: 10} + n.setHover(api.BrowserElement{Label: "c", Selector: "c", Box: c}, t1.Add(noteHoverGlide/2)) + if got := n.hoverBox(t1.Add(noteHoverGlide / 2)); got != (api.BrowserRect{X: 100, Y: 50, W: 200, H: 60}) { + t.Fatalf("a move mid glide restarted from %+v, want from where the outline stood", got) + } + n.setHover(api.BrowserElement{Label: "c", Selector: "c", Box: c}, t1.Add(noteHoverGlide)) + if got := n.hoverBox(t1.Add(noteHoverGlide * 3 / 2)); got != c { + t.Fatalf("the probe answering c again restarted the glide: outline at %+v, want c %+v", got, c) + } +} + func TestTheRingsOutlineWalksItsSidesAndCorners(t *testing.T) { r := roundedRect{max: f32.Pt(100, 50), radius: 10} if got, want := r.perimeter(), float32(2*80+2*30+2*math.Pi*10); math.Abs(float64(got-want)) > 1e-3 { diff --git a/native/cmd/lich-native/browser_notes_view.go b/native/cmd/lich-native/browser_notes_view.go index 061169d9c..3c6565f3d 100644 --- a/native/cmd/lich-native/browser_notes_view.go +++ b/native/cmd/lich-native/browser_notes_view.go @@ -103,7 +103,12 @@ func (u *window) notesOverlay(gtx C, p *browserPaneUI, session, pageURL string) dashedRing(gtx, ring, notePick, 1.5, false) } if n.picking && !n.open && n.hover.Label != "" { - ring := noteRing(gtx, cssRect(n.hover.Box, scale)) + ring := noteRing(gtx, cssRect(n.hoverBox(gtx.Now), scale)) + if n.gliding(gtx.Now) { + // Every frame for the glide's moment: the march's 20 a second + // would step it. + gtx.Execute(op.InvalidateCmd{}) + } ringFill(gtx, ring, withAlpha(notePick, 0x1a)) dashedRing(gtx, ring, notePick, 1.5, true) u.hoverTag(gtx, ring, n.hover) @@ -154,9 +159,7 @@ func (u *window) hoverTag(gtx C, r image.Rectangle, el api.BrowserElement) { func (u *window) notePin(gtx C, n *browserNotes, i int, r image.Rectangle, lit bool) { note := n.list[i] if note.pin.Clicked(gtx) { - el := note.el - el.Box = note.box - n.openNote(el, i) + n.openListed(gtx, i) } macro := op.Record(gtx.Ops) tab := gtx @@ -386,9 +389,7 @@ func (u *window) notesTray(gtx C, p *browserPaneUI, session string) { continue } if note.row.Clicked(gtx) { - el := note.el - el.Box = note.box - n.openNote(el, i) + n.openListed(gtx, i) } } height := notesTrayHeight(gtx, len(n.list))