diff --git a/AGENTS.md b/AGENTS.md index 697bd7a..21581fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Charts lay out in `f32` pixels of their own box, tested without gpui, and paint through `charts::paint::at`: a canvas's paint gets window coordinates, and `with_element_offset` works only in prepaint. Their sizes come from `theme.chart()`. - A label pinned to a point sits in a zero-size absolute box that centers it with flex (`charts::axes`), so no offset guesses at text size. - 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. - 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 e5b6926..96d4718 100644 --- a/TASKS.md +++ b/TASKS.md @@ -69,8 +69,8 @@ Per-component lines live in `tasks/`. Tags there: - [x] T19 Git - [x] T20 Debug - [x] T21a Documents · Editing — `tasks/ch21-30.md` -- [ ] T21b Documents · Reading -- [ ] T21c Documents · Knowledge +- [x] T21b Documents · Reading +- [x] T21c Documents · Knowledge - [ ] T22 Collaboration - [ ] T23a AI Chat · Messages - [ ] T23b AI Chat · Citations diff --git a/assets/icons/a-arrow-down.svg b/assets/icons/a-arrow-down.svg new file mode 100644 index 0000000..dbbfb1f --- /dev/null +++ b/assets/icons/a-arrow-down.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/a-arrow-up.svg b/assets/icons/a-arrow-up.svg new file mode 100644 index 0000000..450de2f --- /dev/null +++ b/assets/icons/a-arrow-up.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/examples/gallery/assets/pages/lift-1.png b/examples/gallery/assets/pages/lift-1.png new file mode 100644 index 0000000..6d5d3f6 Binary files /dev/null and b/examples/gallery/assets/pages/lift-1.png differ diff --git a/examples/gallery/assets/pages/lift-2.png b/examples/gallery/assets/pages/lift-2.png new file mode 100644 index 0000000..c0157fd Binary files /dev/null and b/examples/gallery/assets/pages/lift-2.png differ diff --git a/examples/gallery/assets/pages/lift.json b/examples/gallery/assets/pages/lift.json new file mode 100644 index 0000000..ad076dd --- /dev/null +++ b/examples/gallery/assets/pages/lift.json @@ -0,0 +1 @@ +[{"page":0,"words":[["On",72,77.0,33.0,16.0],["Lift",110.5,75.5,40.5,17.5],["Notes",72,108.0,23.5,6.5],["on",97.0,110.0,10.5,4.5],["color",109.5,107.5,21.0,7.0],["for",132.0,107.5,14.5,9.0],["Ely",146.0,107.5,14.0,9.0],["GPUI",161.5,108.0,23.5,6.5],["Component",186.0,108.0,47.5,8.5],["A",72,139.0,11.0,9.0],["color",85.5,138.0,35.5,10.0],["and",124.5,138.0,25.5,10.0],["its",153.0,138.0,16.0,10.0],["lift",172.5,138.0,19.5,10.0],["A",72,164.5,8.5,8.0],["lift",82.0,164.0,13.5,8.5],["blends",98.0,164.0,33.0,8.5],["a",133.0,167.0,5.5,5.5],["color",141.0,164.0,24.5,8.5],["toward",168.0,164.0,34.5,8.5],["white.",205.0,164.0,30.5,8.5],["Half",237.5,164.0,22.0,8.5],["a",261.0,167.0,5.5,5.5],["lift",269.0,164.0,13.5,8.5],["sits",285.0,164.5,17.0,8.0],["midway",304.5,164.0,38.5,11.0],["between",345.0,164.0,41.5,8.5],["the",388.5,164.0,16.0,8.5],["color",407.0,164.0,24.5,8.5],["and",434.0,164.0,18.5,8.5],["white,",455.0,164.0,30.5,11.0],["and",487.5,164.0,18.5,8.5],["a",508.5,167.0,5.5,5.5],["full",516.5,164.0,16.0,8.5],["lift",72,180.0,13.5,8.5],["is",88.0,180.5,8.0,8.0],["white",98.5,180.0,27.5,8.5],["itself.",128.0,180.0,27.0,8.5],["The",157.5,180.0,19.0,8.5],["blend",179.0,180.0,28.0,8.5],["happens",209.0,180.0,42.0,11.0],["in",253.5,180.5,9.5,8.0],["gamma",265.5,183.0,35.5,8.0],["space,",303.5,183.0,30.5,8.0],["where",336.5,180.0,30.5,8.5],["a",369.0,183.0,5.5,5.5],["small",377.0,180.0,26.0,8.5],["lift",405.5,180.0,13.5,8.5],["moves",421.5,183.0,31.5,5.5],["the",455.5,180.0,16.0,8.5],["eye",474.0,183.0,16.5,8.0],["as",493.0,183.0,10.5,5.5],["much",506.0,180.0,27.5,8.5],["as",72,199.0,10.5,5.5],["a",85.0,199.0,5.5,5.5],["large",93.0,196.0,24.0,11.0],["one",119.5,199.0,18.0,5.5],["moves",140.0,199.0,31.5,5.5],["the",174.0,196.0,16.0,8.5],["meter,",192.5,197.5,32.0,9.5],["so",227.0,199.0,11.0,5.5],["each",240.5,196.0,22.5,8.5],["step",265.5,197.5,21.0,9.5],["reads",289.0,196.0,27.0,8.5],["as",318.5,199.0,10.5,5.5],["even.",331.5,199.0,26.0,5.5],["Every",72,218.5,28.5,10.5],["accent",102.5,219.5,31.5,7.0],["in",136.5,218.5,9.5,8.0],["the",148.5,218.0,16.0,8.5],["library",167.0,218.0,32.5,11.0],["is",201.5,218.5,8.0,8.0],["a",212.0,221.0,5.5,5.5],["base",220.0,218.0,22.5,8.5],["color",244.5,218.0,24.5,8.5],["and",271.5,218.0,18.5,8.5],["a",292.5,221.0,5.5,5.5],["lift.",300.5,218.0,16.5,8.5],["The",319.5,218.0,19.0,8.5],["base",341.0,218.0,22.5,8.5],["carries",365.5,218.5,33.0,8.0],["the",401.0,218.0,16.0,8.5],["hue;",419.5,218.0,22.0,11.0],["the",444.0,218.0,16.0,8.5],["lift",462.5,218.0,13.5,8.5],["carries",478.5,218.5,33.0,8.0],["how",514.0,218.0,21.0,8.5],["loud",72,234.0,22.0,8.5],["the",96.5,234.0,16.0,8.5],["color",115.0,234.0,24.5,8.5],["is.",142.0,234.5,11.0,8.0],["Hover,",155.5,234.5,33.5,10.5],["press",191.5,237.0,26.5,8.0],["and",220.5,234.0,18.5,8.5],["focus",241.5,234.0,26.0,8.5],["are",270.0,237.0,15.5,5.5],["all",288.0,234.0,11.5,8.5],["lifts",302.0,234.0,18.5,8.5],["of",323.0,234.0,10.5,8.5],["the",335.0,234.0,16.0,8.5],["same",353.5,237.0,25.5,5.5],["accent,",381.5,235.5,34.5,9.5],["so",418.5,237.0,11.0,5.5],["a",432.0,237.0,5.5,5.5],["theme",440.0,234.0,31.0,8.5],["changes",473.5,234.0,39.5,11.0],["its",515.5,234.5,12.0,8.0],["hue",72,250.0,18.5,8.5],["in",93.0,250.5,9.5,8.0],["one",105.0,253.0,18.0,5.5],["place",125.5,250.0,25.5,11.0],["and",153.5,250.0,18.5,8.5],["its",174.5,250.5,12.0,8.0],["states",189.0,251.5,29.0,7.0],["follow.",220.5,250.0,32.5,8.5],["Tokens",72,280.0,48.5,10.0],["in",124.0,280.0,13.5,10.0],["one",141.0,283.5,25.0,6.5],["place",169.5,280.0,35.0,13.0],["Components",72,306.5,62.5,10.5],["never",137.0,309.0,27.5,5.5],["write",167.0,306.5,25.5,8.0],["a",194.5,309.0,5.5,5.5],["color,",202.5,306.0,27.5,11.0],["a",232.5,309.0,5.5,5.5],["size",240.5,306.5,18.5,8.0],["or",261.5,309.0,10.5,5.5],["a",274.5,309.0,5.5,5.5],["radius.",282.5,306.0,34.0,8.5],["They",319.0,306.0,25.0,11.0],["read",346.0,306.0,22.0,8.5],["tokens",370.5,306.0,33.0,8.5],["from",406.0,306.0,23.5,8.5],["the",432.0,306.0,16.0,8.5],["theme:",450.5,306.0,34.5,8.5],["the",487.5,306.0,16.0,8.5],["palette",506.0,306.0,34.0,11.0],["for",72,322.0,14.0,8.5],["color,",88.5,322.0,27.5,11.0],["the",118.5,322.0,16.0,8.5],["scale",137.0,322.0,24.0,8.5],["for",163.5,322.0,14.0,8.5],["text,",180.0,323.5,22.0,9.5],["the",204.5,322.0,16.0,8.5],["radii",223.0,322.0,22.5,8.5],["for",248.0,322.0,14.0,8.5],["corners.",264.5,325.0,40.0,5.5],["A",307.0,322.5,8.5,8.0],["token",317.0,322.0,28.0,8.5],["is",347.5,322.5,8.0,8.0],["a",358.0,325.0,5.5,5.5],["promise",366.0,322.5,40.0,10.5],["that",408.5,322.0,20.0,8.5],["the",431.0,322.0,16.0,8.5],["same",449.5,325.0,25.5,5.5],["thing",477.5,322.0,25.5,11.0],["looks",505.5,322.0,26.0,8.5],["the",72,338.0,16.0,8.5],["same",90.5,341.0,25.5,5.5],["everywhere,",118.5,338.0,59.5,11.0],["in",180.5,338.5,9.5,8.0],["light",192.5,338.0,22.0,11.0],["and",217.0,338.0,18.5,8.5],["in",238.0,338.5,9.5,8.0],["dark.",250.0,338.0,25.5,8.5],["The",72,360.0,19.0,8.5],["palette",93.5,360.0,34.0,11.0],["has",130.0,360.0,17.0,8.5],["two",149.5,361.5,18.0,7.0],["sides.",170.0,360.0,28.0,8.5],["Light",200.5,360.0,25.5,11.0],["is",228.5,360.5,8.0,8.0],["paper",239.0,363.0,28.5,8.0],["white",270.0,360.0,27.5,8.5],["with",299.5,360.0,22.0,8.5],["ink",323.5,360.0,15.5,8.5],["near",341.5,363.0,22.0,5.5],["black;",366.0,360.0,29.5,11.0],["dark",397.5,360.0,22.5,8.5],["is",422.5,360.5,8.0,8.0],["warm",433.0,363.0,28.0,5.5],["charcoal",463.0,360.0,41.0,8.5],["with",506.5,360.0,22.0,8.5],["text",72,377.5,19.0,7.0],["off",93.5,376.0,14.0,8.5],["white.",109.0,376.0,30.5,8.5],["Neither",141.5,376.0,37.5,8.5],["is",181.5,376.5,8.0,8.0],["the",192.0,376.0,16.0,8.5],["other",210.5,376.0,26.5,8.5],["inverted.",239.5,376.0,44.0,8.5],["Each",286.0,376.0,24.0,8.5],["lift",312.5,376.0,13.5,8.5],["was",328.5,379.0,19.0,5.5],["chosen",349.5,376.0,34.5,8.5],["by",386.5,376.0,12.5,11.0],["eye",400.5,379.0,16.5,8.0],["against",419.5,376.5,35.0,10.5],["its",457.0,376.5,12.0,8.0],["own",471.5,379.0,20.5,5.5],["ground,",494.5,376.0,38.5,11.0],["then",72,392.0,22.5,8.5],["checked",97.0,392.0,40.0,8.5],["for",139.5,392.0,14.0,8.5],["contrast.",156.0,393.5,43.5,7.0],["Dark",72,422.0,34.5,10.0],["is",109.0,422.0,11.0,10.0],["not",123.5,423.5,23.0,8.5],["inverted",149.5,422.0,57.0,10.0],["Inverting",72,448.5,45.5,10.5],["a",120.0,451.0,5.5,5.5],["light",128.0,448.0,22.0,11.0],["theme",152.5,448.0,31.0,8.5],["makes",186.0,448.0,31.5,8.5],["shadows",220.0,448.0,42.5,8.5],["glow",265.0,448.0,23.0,11.0],["and",290.0,448.0,18.5,8.5],["accents",311.0,449.5,36.5,7.0],["shout.",350.0,448.0,31.0,8.5],["Instead,",383.5,448.0,40.5,11.0],["dark",426.5,448.0,22.5,8.5],["lifts",451.5,448.0,18.5,8.5],["its",472.5,448.5,12.0,8.0],["surfaces",487.0,448.0,40.5,8.5],["rather",72,464.0,30.5,8.5],["than",105.0,464.0,22.5,8.5],["shading",130.0,464.0,38.5,11.0],["them:",171.0,464.0,29.0,8.5],["a",202.5,467.0,5.5,5.5],["card",210.5,464.0,21.5,8.5],["sits",234.5,464.5,17.0,8.0],["one",254.0,467.0,18.0,5.5],["lift",274.5,464.0,13.5,8.5],["above",290.5,464.0,28.5,8.5],["the",321.5,464.0,16.0,8.5],["page,",340.0,467.0,26.0,8.0],["a",368.5,467.0,5.5,5.5],["menu",376.5,467.0,28.0,5.5],["one",407.0,467.0,18.0,5.5],["lift",427.5,464.0,13.5,8.5],["above",443.5,464.0,28.5,8.5],["the",474.5,464.0,16.0,8.5],["card.",493.0,464.0,24.5,8.5],["Depth",72,480.0,30.5,11.0],["reads",105.0,480.0,27.0,8.5],["as",134.5,483.0,10.5,5.5],["light,",147.5,480.0,25.0,11.0],["the",175.0,480.0,16.0,8.5],["way",193.5,483.0,20.0,8.0],["it",215.0,480.5,7.0,8.0],["does",224.5,480.0,23.0,8.5],["at",250.0,481.5,9.5,7.0],["dusk.",262.0,480.0,27.0,8.5],["Accents",72,502.5,39.0,8.0],["in",113.0,502.5,9.5,8.0],["dark",125.0,502.0,22.5,8.5],["carry",150.0,505.0,25.5,8.0],["more",177.5,505.0,25.5,5.5],["lift",205.5,502.0,13.5,8.5],["than",221.5,502.0,22.5,8.5],["in",246.5,502.5,9.5,8.0],["light,",258.5,502.0,25.0,11.0],["because",286.0,502.0,39.5,8.5],["a",327.5,505.0,5.5,5.5],["saturated",335.5,502.0,47.0,8.5],["color",385.0,502.0,24.5,8.5],["on",412.0,505.0,12.5,5.5],["charcoal",427.0,502.0,41.0,8.5],["looks",470.5,502.0,26.0,8.5],["louder",499.0,502.0,32.0,8.5],["than",72,518.0,22.5,8.5],["the",97.0,518.0,16.0,8.5],["same",115.5,521.0,25.5,5.5],["color",143.5,518.0,24.5,8.5],["on",170.5,521.0,12.5,5.5],["paper.",185.5,521.0,31.5,8.0],["The",219.5,518.0,19.0,8.5],["lift",241.0,518.0,13.5,8.5],["evens",257.0,521.0,28.0,5.5],["them",287.5,518.0,25.5,8.5],["out,",315.5,519.5,19.5,9.5],["so",337.5,521.0,11.0,5.5],["a",351.0,521.0,5.5,5.5],["button",359.0,518.0,33.5,8.5],["reads",394.5,518.0,27.0,8.5],["with",424.0,518.0,22.0,8.5],["the",448.0,518.0,16.0,8.5],["same",466.5,521.0,25.5,5.5],["weight",494.5,518.0,33.0,11.0],["in",529.5,518.5,9.5,8.0],["both",72,534.0,23.0,8.5],["themes.",97.0,534.0,39.0,8.5],["Motion",72,564.0,49.0,10.0],["that",124.5,564.0,27.0,10.0],["settles",154.5,564.0,42.5,10.0],["Motion",72,590.5,35.5,8.0],["in",110.0,590.5,9.5,8.0],["the",122.0,590.0,16.0,8.5],["library",140.5,590.0,32.5,11.0],["is",175.0,590.5,8.0,8.0],["short",185.5,590.0,26.0,8.5],["and",214.0,590.0,18.5,8.5],["settles",235.0,590.0,32.0,8.5],["without",269.5,590.0,38.5,8.5],["bounce,",310.0,590.0,39.0,11.0],["unless",351.0,590.0,31.5,8.5],["a",385.0,593.0,5.5,5.5],["spring",393.0,590.5,31.0,10.5],["is",426.5,590.5,8.0,8.0],["asked",437.0,590.0,28.5,8.5],["for.",468.0,590.0,17.0,8.5],["A",487.5,590.5,8.5,8.0],["menu",497.5,593.0,28.0,5.5],["rises",72,606.5,23.0,8.0],["a",97.5,609.0,5.5,5.5],["few",105.5,606.0,17.5,8.5],["points",125.0,606.5,31.0,10.5],["as",158.5,609.0,10.5,5.5],["it",171.5,606.5,7.0,8.0],["fades",181.0,606.0,26.0,8.5],["in;",209.5,606.5,13.0,10.5],["a",225.0,609.0,5.5,5.5],["list",233.0,606.0,15.0,8.5],["glides",250.5,606.0,28.5,11.0],["rows",281.5,609.0,23.5,5.5],["to",307.5,607.5,10.0,7.0],["their",320.0,606.0,23.5,8.5],["new",346.0,609.0,20.5,5.5],["places.",368.5,606.0,33.5,11.0],["When",404.5,606.0,29.5,8.5],["reduced",436.0,606.0,40.0,8.5],["motion",478.5,606.5,35.0,8.0],["is",516.0,606.5,8.0,8.0],["on,",72,625.0,15.5,8.0],["every",90.0,625.0,27.0,8.0],["repeating",119.0,622.5,46.5,10.5],["motion",168.0,622.5,35.0,8.0],["holds",205.5,622.0,27.0,8.5],["still,",235.0,622.0,21.0,11.0],["and",258.5,622.0,18.5,8.5],["every",279.5,625.0,27.0,8.0],["change",308.5,622.0,34.5,11.0],["lands",345.5,622.0,26.5,8.5],["at",374.5,623.5,9.5,7.0],["once.",386.5,625.0,26.0,5.5],["Durations",72,644.5,49.0,8.0],["come",123.5,647.0,26.0,5.5],["from",152.0,644.0,23.5,8.5],["one",178.0,647.0,18.0,5.5],["scale,",198.5,644.0,27.0,11.0],["as",228.0,647.0,10.5,5.5],["colors",241.0,644.0,29.5,8.5],["do.",273.0,644.0,15.5,8.5],["A",291.0,644.5,8.5,8.0],["lift",301.0,644.0,13.5,8.5],["of",317.0,644.0,10.5,8.5],["motion",329.0,644.5,35.0,8.0],["is",366.5,644.5,8.0,8.0],["a",377.0,647.0,5.5,5.5],["step",385.0,645.5,21.0,9.5],["on",408.5,647.0,12.5,5.5],["that",423.5,644.0,20.0,8.5],["scale:",446.0,644.0,27.5,8.5],["fast",476.0,644.0,18.0,8.5],["for",496.5,644.0,14.0,8.5],["a",513.0,647.0,5.5,5.5],["hover,",72,660.0,31.0,11.0],["normal",105.5,660.0,35.0,8.5],["for",143.0,660.0,14.0,8.5],["a",159.5,663.0,5.5,5.5],["menu,",167.5,663.0,31.0,8.0],["slow",201.0,660.0,22.5,8.5],["for",225.5,660.0,14.0,8.5],["a",242.0,663.0,5.5,5.5],["sheet.",250.0,660.0,29.5,8.5],["Nothing",282.0,660.0,40.0,11.0],["moves",324.5,663.0,31.5,5.5],["for",358.5,660.0,14.0,8.5],["its",375.0,660.5,12.0,8.0],["own",389.5,663.0,20.5,5.5],["sake;",412.5,660.0,25.5,11.0],["motion",440.5,660.5,35.0,8.0],["shows",478.0,660.0,30.5,8.5],["where",72,676.0,30.5,8.5],["a",104.5,679.0,5.5,5.5],["thing",112.5,676.0,25.5,11.0],["came",140.5,679.0,25.5,5.5],["from",168.5,676.0,23.5,8.5],["and",194.5,676.0,18.5,8.5],["where",215.5,676.0,30.5,8.5],["it",248.0,676.5,7.0,8.0],["went.",257.5,677.5,27.5,7.0]]},{"page":1,"words":[["Closing",72,74.0,50.0,13.0],["A",72,100.5,8.5,8.0],["library",82.0,100.0,32.5,11.0],["is",116.5,100.5,8.0,8.0],["a",127.0,103.0,5.5,5.5],["set",135.0,101.5,14.5,7.0],["of",152.0,100.0,10.5,8.5],["promises",164.0,100.5,45.0,10.5],["about",211.5,100.0,28.0,8.5],["what",242.0,100.0,24.5,8.5],["stays",268.5,101.5,25.0,9.5],["the",296.0,100.0,16.0,8.5],["same.",314.5,103.0,28.5,5.5],["Lift",345.5,100.0,17.0,8.5],["is",365.0,100.5,8.0,8.0],["one",375.5,103.0,18.0,5.5],["of",396.0,100.0,10.5,8.5],["them:",408.0,100.0,29.0,8.5],["whatever",439.5,100.0,45.5,8.5],["the",487.0,100.0,16.0,8.5],["hue,",505.5,100.0,21.5,11.0],["a",529.5,103.0,5.5,5.5],["step",72,117.5,21.0,9.5],["of",95.5,116.0,10.5,8.5],["lift",107.5,116.0,13.5,8.5],["means",123.5,119.0,32.0,5.5],["the",158.0,116.0,16.0,8.5],["same",176.5,119.0,25.5,5.5],["step",204.5,117.5,21.0,9.5],["of",228.0,116.0,10.5,8.5],["emphasis.",240.0,116.0,49.5,11.0],["The",292.0,116.0,19.0,8.5],["rest",313.5,117.5,19.0,7.0],["of",335.0,116.0,10.5,8.5],["the",347.0,116.0,16.0,8.5],["library",365.5,116.0,32.5,11.0],["builds",400.0,116.0,30.5,8.5],["on",432.5,119.0,12.5,5.5],["that.",447.5,116.0,23.0,8.5]]}] \ No newline at end of file diff --git a/examples/gallery/assets/pages/slide-1.png b/examples/gallery/assets/pages/slide-1.png new file mode 100644 index 0000000..937d53a Binary files /dev/null and b/examples/gallery/assets/pages/slide-1.png differ diff --git a/examples/gallery/assets/pages/slide-2.png b/examples/gallery/assets/pages/slide-2.png new file mode 100644 index 0000000..0921dd7 Binary files /dev/null and b/examples/gallery/assets/pages/slide-2.png differ diff --git a/examples/gallery/pages/documents/knowledge.rs b/examples/gallery/pages/documents/knowledge.rs new file mode 100644 index 0000000..7d53e2e --- /dev/null +++ b/examples/gallery/pages/documents/knowledge.rs @@ -0,0 +1,390 @@ +use std::rc::Rc; + +use ely_gpui_component::{ + charts::NetworkGraph, + data_display::{PropertyGrid, PropertyGroup}, + documents::{ + Backlink, Backlinks, Favorite, Favorites, PageCover, PageIcon, Template, TemplatePicker, + TrashBin, Trashed, Version, VersionHistory, + }, + forms::{Checkbox, Choice, DatePicker, Select, TagInput}, + lists::{Tree, TreeNode}, + theme::{ActiveTheme, Radius, TextSize}, +}; +use gpui::{App, FontWeight, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; +use jiff::{Timestamp, ToSpan}; + +use crate::{ + probe::probe, + ui::{keep, section, set}, +}; + +const COVERS: [&str; 3] = [ + concat!( + env!("CARGO_MANIFEST_DIR"), + "/examples/gallery/assets/atrium.jpg" + ), + concat!( + env!("CARGO_MANIFEST_DIR"), + "/examples/gallery/assets/dunes.jpg" + ), + concat!( + env!("CARGO_MANIFEST_DIR"), + "/examples/gallery/assets/atrium-olive.jpg" + ), +]; + +fn favorite(key: &str, icon: &str, title: &str) -> Favorite { + Favorite { + key: key.to_string().into(), + icon: icon.to_string().into(), + title: title.to_string().into(), + } +} + +/// A page linking here, the link on the word "lift". +fn backlink(icon: &str, page: &str, context: &str) -> Backlink { + let at = context.find("lift").expect("the context names the page"); + Backlink { + icon: icon.to_string().into(), + page: page.to_string().into(), + context: context.to_string().into(), + mention: at..at + "lift".len(), + } +} + +fn pages() -> Vec { + vec![ + TreeNode::new("handbook", "📘 Handbook").children([ + TreeNode::new("color", "🎨 Color").children([ + TreeNode::new("lift", "☀️ Lift"), + TreeNode::new("dark", "🌙 Dark theme"), + ]), + TreeNode::new("type", "🔤 Type"), + TreeNode::new("motion", "🌀 Motion"), + ]), + TreeNode::new("projects", "🗂 Projects").children([ + TreeNode::new("site", "🌐 Site"), + TreeNode::new("release", "🚀 Release 1.0"), + ]), + TreeNode::new("journal", "📓 Journal"), + ] +} + +fn templates() -> Rc> { + let template = + |name: &str, icon: &str, category: &str, description: &str, markdown: &str| Template { + name: name.to_string().into(), + icon: icon.to_string().into(), + category: category.to_string().into(), + description: description.to_string().into(), + markdown: markdown.to_string().into(), + }; + Rc::new(vec![ + template( + "Meeting notes", + "🗒", + "Team", + "Agenda, notes and actions", + "# Meeting notes\n\n**Date:** \n\n## Agenda\n\n- \n\n## Actions\n\n- [ ] ", + ), + template( + "Retrospective", + "🔁", + "Team", + "What went well, what to change", + "# Retrospective\n\n## Went well\n\n- \n\n## To change\n\n- ", + ), + template( + "Roadmap", + "🗺", + "Product", + "The quarters ahead", + "# Roadmap\n\n| Quarter | Goal |\n|:--|:--|\n| Q1 | |\n| Q2 | |", + ), + template( + "Design review", + "🎨", + "Product", + "A change and its questions", + "# Design review\n\n> What problem does this solve?\n\n## Options\n\n1. \n2. ", + ), + template( + "Reading list", + "📚", + "Personal", + "Books and where you are", + "# Reading list\n\n- [ ] \n- [ ] ", + ), + ]) +} + +fn versions(now: Timestamp) -> Vec { + let version = |hours: i64, author: &str, markdown: &str| Version { + at: now - hours.hours(), + author: author.to_string().into(), + markdown: markdown.to_string().into(), + }; + vec![ + version( + 1, + "Ada", + "# Lift\n\nA lift blends every color toward white, gently and in gamma space. Half a lift sits midway.", + ), + version( + 26, + "Grace", + "# Lift\n\nA lift blends a color toward white in gamma space. Half a lift sits midway.", + ), + version(74, "Ada", "# Lift\n\nA lift blends a color toward white."), + ] +} + +pub fn knowledge(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let favorites = keep( + "know-favorites", + || { + vec![ + favorite("lift", "☀️", "Lift"), + favorite("release", "🚀", "Release 1.0"), + favorite("journal", "📓", "Journal"), + ] + }, + window, + cx, + ); + let now_favorites = favorites.read(cx).clone(); + let cover = keep( + "know-cover", + || Some(SharedString::from(COVERS[0])), + window, + cx, + ); + 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) = ( + cover.read(cx).clone(), + icon.read(cx).clone(), + status.read(cx).clone(), + *done.read(cx), + ); + let trash = keep( + "know-trash", + || { + let now = Timestamp::now(); + vec![ + Trashed { + key: "old-palette".into(), + icon: "🎨".into(), + title: "Old palette".into(), + place: "Handbook".into(), + deleted: now - 3.hours(), + }, + Trashed { + key: "draft".into(), + icon: "📝".into(), + title: "Launch draft".into(), + place: "Projects".into(), + deleted: now - 50.hours(), + }, + ] + }, + window, + cx, + ); + let now_trash = trash.read(cx).clone(); + let picked_version = keep("know-version", || 0usize, window, cx); + let now_version = *picked_version.read(cx); + let history = keep("know-history", || versions(Timestamp::now()), window, cx) + .read(cx) + .clone(); + let theme = cx.theme(); + let (unpinned, moved, restored, deleted, emptied) = ( + favorites.clone(), + favorites.clone(), + trash.clone(), + trash.clone(), + trash, + ); + let graph = [ + "Lift", + "Dark theme", + "Color", + "Type", + "Motion", + "Handbook", + "Release 1.0", + "Site", + ] + .iter() + .enumerate() + .fold(NetworkGraph::new("know-graph"), |graph, (ix, name)| { + graph.node(*name, if ix < 5 { 0 } else { 1 }) + }); + let graph = [ + (0, 1), + (0, 2), + (1, 2), + (2, 5), + (3, 5), + (4, 5), + (0, 4), + (6, 5), + (6, 7), + (7, 0), + ] + .into_iter() + .fold(graph, |graph, (a, b)| graph.edge(a, b)); + div() + .child( + section( + "PageTree / Favorites / Pinned", + "Pages kept close at hand above the tree of every page: favorites open with a press, drag to reorder and let go by their star. The tree is lists::Tree, a page's emoji leading its label.", + cx, + ) + .child( + div() + .w(px(300.)) + .flex() + .flex_col() + .gap_3() + .p_2() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(theme.colors.border) + .child( + Favorites::new("know-favorites", now_favorites) + .on_open(|key, _, _| log::info!("gallery: open {key}")) + .on_unpin(move |key, _, cx| { + let next = unpinned.read(cx).iter().filter(|page| page.key != *key).cloned().collect(); + set(&unpinned, next, cx); + }) + .on_move(move |from, to, _, cx| { + let mut next = moved.read(cx).clone(); + let page = next.remove(from); + next.insert(to, page); + set(&moved, next, cx); + }), + ) + .child(div().h(px(220.)).child(Tree::new("know-tree", pages()) + .open(["handbook", "color"]) + .selected(["lift"]) + .size_full())), + ), + ) + .child( + section( + "PageCover / PageIcon / PageProperties", + "A page's head: its cover, its icon set large over the cover's edge, its title, and its properties. Hover the cover to change or remove it, the icon to pick another. The properties are data_display::PropertyGrid, a field's editor in each row.", + cx, + ) + .child( + div() + .w(px(760.)) + .flex() + .flex_col() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) + .overflow_hidden() + .children(now_cover.map(|source| { + let cover = cover.clone(); + PageCover::new("know-cover", source).choices(COVERS, move |next, _, cx| set(&cover, next, cx)) + })) + .child( + div() + .flex() + .flex_col() + .gap_3() + .px_8() + .pb_6() + .child(div().mt_neg_8().child(PageIcon::new("know-icon", now_icon).on_change(move |next, _, cx| set(&icon, next.clone(), cx)))) + .child(div().text_size(theme.text_size(TextSize::Xxl)).font_weight(FontWeight::SEMIBOLD).child("Lift")) + .child( + PropertyGrid::new("know-properties").group( + PropertyGroup::new("Properties") + .row( + "Status", + Select::new("know-status", ["Idea", "Drafting", "Review", "Done"].map(|name| Choice::new(name, name))) + .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("Published", Checkbox::new("know-published", now_done).on_change(move |on, _, cx| set(&done, on, cx))), + ), + ), + ), + ), + ) + .child( + section( + "Backlinks / GraphView", + "The pages that link here, each with the words around its link; and every page as a node, its links as edges. GraphView is charts::NetworkGraph.", + cx, + ) + .child( + div() + .flex() + .gap_6() + .child( + div().w(px(360.)).child( + Backlinks::new( + "know-backlinks", + [ + backlink("🌙", "Dark theme", "Accents in dark carry more lift than in light, so they read the same."), + backlink("🎨", "Color", "Every accent is a base color and a lift."), + backlink("🌀", "Motion", "A lift of motion is a step on the duration scale."), + ], + ) + .on_open(|ix, _, _| log::info!("gallery: backlink {ix}")), + ), + ) + .child(div().w(px(360.)).h(px(280.)).child(graph)), + ), + ) + .child( + section( + "TemplatePicker", + "Pages to start from: found by name, kept by category, the one in view shown as it will read; Use takes it.", + cx, + ) + .child(div().w(px(760.)).h(px(400.)).child(TemplatePicker::new("know-templates", templates()).on_use(|ix, _, _| log::info!("gallery: template {ix}")))), + ) + .child( + section( + "TrashBin", + "Deleted pages, newest first, with where each lived and when it went: restore one, delete one for good or empty it all, each final step asked twice.", + cx, + ) + .child( + div().w(px(560.)).child( + TrashBin::new("know-trash", now_trash) + .on_restore(move |key, _, cx| { + let next = restored.read(cx).iter().filter(|page| page.key != *key).cloned().collect(); + set(&restored, next, cx); + }) + .on_delete(move |key, _, cx| { + let next = deleted.read(cx).iter().filter(|page| page.key != *key).cloned().collect(); + set(&deleted, next, cx); + }) + .on_empty(move |_, cx| set(&emptied, Vec::new(), cx)), + ), + ), + ) + .child( + section( + "VersionHistory / PageHistoryDiff", + "A page's versions by time and author: read one, or see word by word what it changed from the one before, added words washed and removed ones struck; restore any but the current.", + cx, + ) + .child( + probe("know-versions", div().w(px(760.)).h(px(320.)).child( + VersionHistory::new("know-versions", history, now_version) + .on_select(move |ix, _, cx| set(&picked_version, ix, cx)) + .on_restore(|ix, _, _| log::info!("gallery: restore version {ix}")), + )), + ), + ) +} diff --git a/examples/gallery/pages/documents/mod.rs b/examples/gallery/pages/documents/mod.rs index f024add..ba8785b 100644 --- a/examples/gallery/pages/documents/mod.rs +++ b/examples/gallery/pages/documents/mod.rs @@ -1,4 +1,6 @@ mod editing; +mod knowledge; +mod reading; use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; @@ -58,8 +60,31 @@ const SCRIPT: &[Step] = &[ Step::HoverAt("doc-blocks", 120.0, 20.0), Step::Wait(300), Step::Shot("handle"), + Step::DownAt("read-viewer", 440.0, 58.0), + Step::UpAt("read-viewer", 440.0, 58.0), + Step::Key("cmd-a"), + Step::Type("lift"), + Step::Wait(200), + Step::DownAt("read-viewer", 786.0, 58.0), + Step::UpAt("read-viewer", 786.0, 58.0), + Step::Wait(400), + Step::Shot("find"), + Step::DownAt("know-versions", 330.0, 22.0), + Step::UpAt("know-versions", 330.0, 22.0), + Step::Wait(300), + Step::Shot("changes"), + Step::DownAt("read-zen", 60.0, 24.0), + Step::UpAt("read-zen", 60.0, 24.0), + Step::Wait(500), + Step::Shot("zen"), + Step::Key("escape"), + Step::Wait(300), ]; fn render(window: &mut Window, cx: &mut App) -> AnyElement { - div().child(editing::editing(window, cx)).into_any_element() + div() + .child(editing::editing(window, cx)) + .child(reading::reading(window, cx)) + .child(knowledge::knowledge(window, cx)) + .into_any_element() } diff --git a/examples/gallery/pages/documents/reading.rs b/examples/gallery/pages/documents/reading.rs new file mode 100644 index 0000000..c2a5ec5 --- /dev/null +++ b/examples/gallery/pages/documents/reading.rs @@ -0,0 +1,313 @@ +use std::rc::Rc; + +use ely_gpui_component::{ + documents::{ + Chapter, DocPage, DocumentViewer, EpubReader, Footnote, Glossary, MarkdownRenderer, + PageHit, PageNote, PrintPreview, PrintSettings, ReadingProgress, RichTextEditor, + TooltipTerm, ZenMode, markdown_highlights, outline, + }, + editor::FindWidget, + forms::TextInput, + navigation::{Sections, TableOfContents}, + theme::{ActiveTheme, Radius}, +}; +use gpui::{ + App, Bounds, Entity, InteractiveElement, IntoElement, ParentElement, ScrollHandle, + StatefulInteractiveElement, Styled, Window, div, point, px, size, +}; +use serde::Deserialize; + +use crate::{ + probe::probe, + ui::{keep, section, set}, +}; + +const PAGES: [&str; 2] = [ + concat!( + env!("CARGO_MANIFEST_DIR"), + "/examples/gallery/assets/pages/lift-1.png" + ), + concat!( + env!("CARGO_MANIFEST_DIR"), + "/examples/gallery/assets/pages/lift-2.png" + ), +]; +const SLIDES: [&str; 2] = [ + concat!( + env!("CARGO_MANIFEST_DIR"), + "/examples/gallery/assets/pages/slide-1.png" + ), + concat!( + env!("CARGO_MANIFEST_DIR"), + "/examples/gallery/assets/pages/slide-2.png" + ), +]; +const WORDS: &str = include_str!("../../assets/pages/lift.json"); + +const GUIDE: &str = "# Lift\n\nA lift blends a color toward white.\n\n## In light\n\nPaper white under ink near black; accents at half a lift.\n\n## In dark\n\nWarm charcoal under off-white text; accents lift further, so they read with the same weight.\n\n### Surfaces\n\nA card sits one lift above the page, a menu one above the card.\n\n## In motion\n\nHover, press and focus are lifts of the accent, eased on one scale of durations.\n\n## Closing\n\nWhatever the hue, a step of lift means the same step of emphasis."; + +/// The words the app found on its pages, with their boxes in points. +#[derive(Deserialize)] +struct PageWords { + page: usize, + words: Vec<(String, f32, f32, f32, f32)>, +} + +/// Every word on the pages that holds `query`, as a host's text layer would find them. +fn search(query: &str) -> Vec { + let query = query.trim().to_lowercase(); + if query.is_empty() { + return Vec::new(); + } + let pages: Vec = serde_json::from_str(WORDS).expect("the page words parse"); + let mut hits = Vec::new(); + for page in pages { + for (word, x, y, width, height) in page.words { + if word.to_lowercase().contains(&query) { + hits.push(PageHit { + page: page.page, + bounds: Bounds::new(point(x, y), size(width, height)), + }); + } + } + } + hits +} + +fn pages(paths: &[&'static str], width: f32, height: f32) -> Rc> { + Rc::new( + paths + .iter() + .map(|path| DocPage { + source: (*path).into(), + width, + height, + }) + .collect(), + ) +} + +fn field( + key: &'static str, + hint: &'static str, + window: &mut Window, + cx: &mut App, +) -> Entity { + window.use_keyed_state(key, cx, move |window, cx| { + TextInput::new(window, cx).placeholder(hint) + }) +} + +pub fn reading(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let scroll = keep("read-scroll", ScrollHandle::new, window, cx) + .read(cx) + .clone(); + let sections = window.use_keyed_state("read-sections", cx, { + let scroll = scroll.clone(); + move |_, _| Sections::new(scroll) + }); + let toc = outline(GUIDE).into_iter().fold( + TableOfContents::new("read-toc", §ions), + |toc, (value, text, depth)| toc.entry(value, text, depth), + ); + let query = field("read-query", "Find in document", window, cx); + let hits = search(query.read(cx).text()); + let current = keep("read-hit", || None::, window, cx); + let now_hit = current.read(cx).filter(|hit| *hit < hits.len()); + let total = hits.len(); + let find = + FindWidget::new("read-find", &query, now_hit, total).on_step(move |forward, _, cx| { + let next = match current.read(cx).filter(|hit| *hit < total) { + Some(hit) if forward => (hit + 1) % total, + Some(hit) => (hit + total - 1) % total, + None if forward => 0, + None => total - 1, + }; + set(¤t, Some(next), cx) + }); + let zoom = keep("read-zoom", || 0.75_f32, window, cx); + let notes = keep( + "read-notes", + || { + vec![PageNote { + page: 0, + at: point(470.0, 180.0), + text: "Gamma, not linear light.".into(), + }] + }, + window, + cx, + ); + let (now_zoom, now_notes) = (*zoom.read(cx), notes.read(cx).clone()); + let slide_zoom = keep("read-slide-zoom", || 0.5_f32, window, cx); + let now_slide_zoom = *slide_zoom.read(cx); + let print = keep("read-print", PrintSettings::default, window, cx); + let now_print = *print.read(cx); + let zen = keep("read-zen", || false, window, cx); + let now_zen = *zen.read(cx); + let draft = window.use_keyed_state("read-draft", cx, |window, cx| { + let mut draft = TextInput::new(window, cx).multi_line(6, 18).highlighter(markdown_highlights); + draft.set_text("# A quiet page\n\nEverything else folds away while you write. Press Escape to come back.", cx); + draft + }); + let theme = cx.theme(); + let chapters = vec![ + Chapter { title: "A color and its lift".into(), markdown: "# A color and its lift\n\nA lift blends a color toward white. Half a lift sits midway between the color and white, and a full lift is white itself.\n\nEvery accent in the library is a base color and a lift. The base carries the hue; the lift carries how loud the color is.".into() }, + Chapter { title: "Tokens in one place".into(), markdown: "# Tokens in one place\n\nComponents never write a color, a size or a radius. They read tokens from the theme.\n\nThe palette has two sides. Neither is the other inverted.".into() }, + Chapter { title: "Motion that settles".into(), markdown: "# Motion that settles\n\nMotion in the library is short and settles without bounce. When reduced motion is on, every repeating motion holds still.".into() }, + ]; + div() + .child( + section( + "MarkdownRenderer / TableOfContents / DocumentOutline / ReadingProgress", + "Markdown as a document, its headings marked for a table of contents beside it: the section in view is lit as it scrolls, and a press brings a section to the top. The bar above fills as the text scrolls. DocumentOutline is the same TableOfContents.", + cx, + ) + .child( + div() + .w(px(760.)) + .flex() + .flex_col() + .gap_2() + .child(ReadingProgress::new(&scroll)) + .child( + div() + .flex() + .gap_6() + .h(px(320.)) + .child(div().w(px(200.)).child(toc)) + .child( + div() + .id("read-guide") + .flex_1() + .overflow_y_scroll() + .track_scroll(&scroll) + .child(MarkdownRenderer::new("read-guide-text", GUIDE).sections(§ions)), + ), + ), + ), + ) + .child( + section( + "DocumentViewer / PDFViewer / PageThumbnailList", + "Pages the app drew, on a desk: the page in view and its count, zoom by steps or to the width, the pages as thumbnails beside, a find bar whose hits light on the page, and notes pinned where a press puts them. The app reads its PDF; the viewer shows it.", + cx, + ) + .child(probe( + "read-viewer", + div().w(px(840.)).h(px(520.)).child( + DocumentViewer::new("read-viewer", "on-lift.pdf", pages(&PAGES, 612.0, 792.0)) + .zoom(now_zoom, move |next, _, cx| set(&zoom, next, cx)) + .thumbnails() + .find(find, hits, now_hit) + .notes(now_notes, move |page, at, _, cx| { + let mut next = notes.read(cx).clone(); + next.push(PageNote { page, at, text: "A new note.".into() }); + set(¬es, next, cx); + }), + ), + )), + ) + .child( + section( + "OfficePreview", + "A slide deck, a sheet or a document the app turned into pages, shown by the same viewer.", + cx, + ) + .child( + div().w(px(760.)).h(px(420.)).child( + DocumentViewer::new("read-slides", "release.pptx", pages(&SLIDES, 960.0, 540.0)) + .zoom(now_slide_zoom, move |next, _, cx| set(&slide_zoom, next, cx)), + ), + ), + ) + .child( + section( + "EpubReader", + "A book a chapter at a time, in a column sized for reading: how far through the chapter, the chapters listed, the type a step larger or smaller, and the next chapter a press away.", + cx, + ) + .child(div().w(px(760.)).h(px(420.)).child(EpubReader::new("read-book", "On Lift", chapters))), + ) + .child( + section( + "Footnote / Glossary / TooltipTerm", + "A note's number in running text, the note a rest of the pointer away; a term underlined in dashes that explains itself; and every term in order under its letter.", + cx, + ) + .child( + div() + .w(px(560.)) + .flex() + .flex_col() + .gap_6() + .child( + div() + .flex() + .flex_wrap() + .items_baseline() + .child("A ") + .child(TooltipTerm::new("read-lift", "lift", "How far a color is blended toward white, from none to all of it.")) + .child(" blends in gamma space") + .child(Footnote::new("read-note", 1, "Where a small step moves the eye as much as a large one moves the meter.")) + .child(", so each step reads as even."), + ) + .child(Glossary::new([ + ("Lift", "How far a color is blended toward white."), + ("Accent", "A base color and a lift: the hue and how loud it is."), + ("Token", "A value every component reads from the theme."), + ("Leading", "The height of a line of text."), + ])), + ), + ) + .child( + section( + "FocusMode / ZenMode", + "Zen mode folds the tools away and sets the text in a quiet column; Escape brings them back.", + cx, + ) + .child(probe( + "read-zen", + div() + .w(px(760.)) + .flex() + .flex_col() + .gap_2() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) + .overflow_hidden() + .child( + ZenMode::new("read-zen", now_zen) + .on_exit({ + let zen = zen.clone(); + move |_, cx| set(&zen, false, cx) + }) + .chrome( + div().p_2().flex().child( + ely_gpui_component::buttons::Button::new("read-zen-on", "Focus on the text") + .on_click(move |_, _, cx| set(&zen, true, cx)), + ), + ) + .child(div().p_2().child(RichTextEditor::new("read-draft", &draft))), + ), + )), + ) + .child( + section( + "PrintPreview", + "The pages the app laid out for the settings, small on a desk, with paper, turn, margins and copies beside; each change asks the app for new pages.", + cx, + ) + .child( + div().w(px(840.)).h(px(440.)).child(PrintPreview::new( + "read-print", + pages(&PAGES, 612.0, 792.0), + now_print, + move |next, _, cx| set(&print, next, cx), + |settings, _, _| log::info!("gallery: print {settings:?}"), + |_, _| log::info!("gallery: print cancelled"), + )), + ), + ) +} diff --git a/src/documents/blocks/mod.rs b/src/documents/blocks/mod.rs index ea9e5b4..3ba9837 100644 --- a/src/documents/blocks/mod.rs +++ b/src/documents/blocks/mod.rs @@ -4,6 +4,8 @@ mod keys; mod kind; mod media; mod table; +#[cfg(all(test, feature = "test-support"))] +mod tests; mod view; use std::{collections::HashMap, rc::Rc}; @@ -15,6 +17,7 @@ use gpui::{ pub use kind::{Align, BlockKind, Media}; pub(crate) use kind::{matching, shortcut}; +pub(crate) use media::source; use super::markdown::markdown_highlights; use crate::forms::{History, InputEvent, TextInput, code_highlights}; diff --git a/src/documents/blocks/tests.rs b/src/documents/blocks/tests.rs new file mode 100644 index 0000000..6ac87b2 --- /dev/null +++ b/src/documents/blocks/tests.rs @@ -0,0 +1,373 @@ +use gpui::{AppContext as _, Entity, Focusable, TestAppContext, VisualTestContext}; + +use super::{BlockData, BlockEditor, BlockKind}; +use crate::{forms, theme::Theme}; + +fn open( + blocks: Vec, + cx: &mut TestAppContext, +) -> (Entity, &mut VisualTestContext) { + cx.update(|cx| { + Theme::init(cx); + forms::bind_keys(cx); + crate::documents::bind_keys(cx); + }); + let (editor, cx) = cx.add_window_view(|window, cx| BlockEditor::new(blocks, window, cx)); + cx.update(|window, _| window.activate_window()); + settle(cx); + (editor, cx) +} + +fn settle(cx: &mut VisualTestContext) { + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); +} + +fn blocks( + editor: &Entity, + cx: &mut VisualTestContext, +) -> Vec<(BlockKind, Vec)> { + cx.update(|_, cx| { + editor + .read(cx) + .blocks(cx) + .into_iter() + .map(|block| (block.kind, block.texts)) + .collect() + }) +} + +/// Puts the caret in block `ix`'s first field at byte `at`. +fn caret(editor: &Entity, ix: usize, at: usize, cx: &mut VisualTestContext) { + cx.update(|window, cx| { + let field = editor.read(cx).blocks[ix].fields[0].clone(); + field.update(cx, |field, cx| field.select(at..at, cx)); + window.focus(&field.focus_handle(cx)); + }); + settle(cx); +} + +fn text(kind: BlockKind, text: &str) -> (BlockKind, Vec) { + (kind, vec![text.to_string()]) +} + +#[gpui::test] +fn enter_splits_prose_at_the_caret(cx: &mut TestAppContext) { + let (editor, cx) = open(vec![BlockData::new(BlockKind::Bullet, ["lift"])], cx); + caret(&editor, 0, 2, cx); + cx.simulate_keystrokes("enter"); + settle(cx); + assert_eq!( + blocks(&editor, cx), + [text(BlockKind::Bullet, "li"), text(BlockKind::Bullet, "ft")] + ); +} + +#[gpui::test] +fn backspace_at_the_start_joins_up_and_undo_parts_them(cx: &mut TestAppContext) { + let (editor, cx) = open( + vec![ + BlockData::new(BlockKind::Paragraph, ["tone "]), + BlockData::new(BlockKind::Paragraph, ["lift"]), + ], + cx, + ); + caret(&editor, 1, 0, cx); + cx.simulate_keystrokes("backspace"); + settle(cx); + assert_eq!( + blocks(&editor, cx), + [text(BlockKind::Paragraph, "tone lift")] + ); + cx.simulate_keystrokes("cmd-z"); + settle(cx); + assert_eq!( + blocks(&editor, cx), + [ + text(BlockKind::Paragraph, "tone "), + text(BlockKind::Paragraph, "lift") + ], + "undo brings the joined block back" + ); +} + +#[gpui::test] +fn markdown_typed_at_a_paragraphs_start_changes_its_kind(cx: &mut TestAppContext) { + let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, [""])], cx); + caret(&editor, 0, 0, cx); + cx.simulate_input("## Lift"); + settle(cx); + assert_eq!(blocks(&editor, cx), [text(BlockKind::Heading(2), "Lift")]); +} + +#[gpui::test] +fn backspace_turns_a_heading_back_into_text(cx: &mut TestAppContext) { + let (editor, cx) = open(vec![BlockData::new(BlockKind::Heading(1), ["Lift"])], cx); + caret(&editor, 0, 0, cx); + cx.simulate_keystrokes("backspace"); + settle(cx); + assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "Lift")]); +} + +#[gpui::test] +fn a_press_in_a_blocks_text_puts_the_caret_there(cx: &mut TestAppContext) { + let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["lift"])], cx); + cx.simulate_click( + gpui::point(gpui::px(300.0), gpui::px(10.0)), + gpui::Modifiers::none(), + ); + settle(cx); + let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx)); + assert!(focused.is_some(), "the press focuses the block's field"); + cx.simulate_keystrokes("enter"); + settle(cx); + assert_eq!(blocks(&editor, cx).len(), 2); +} + +/// The editor at a fixed width inside a row, as a page holds it. +struct Hosted(Entity); + +impl gpui::Render for Hosted { + fn render( + &mut self, + _: &mut gpui::Window, + _: &mut gpui::Context, + ) -> impl gpui::IntoElement { + use gpui::{ParentElement, Styled}; + gpui::div() + .flex() + .child(gpui::div().w(gpui::px(760.0)).child(self.0.clone())) + } +} + +#[gpui::test] +fn a_press_past_the_words_still_lands_in_the_block(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + forms::bind_keys(cx); + crate::documents::bind_keys(cx); + }); + let (host, cx) = cx.add_window_view(|window, cx| { + let editor = cx.new(|cx| { + BlockEditor::new( + vec![BlockData::new(BlockKind::Paragraph, ["lift"])], + window, + cx, + ) + }); + Hosted(editor) + }); + settle(cx); + cx.simulate_click( + gpui::point(gpui::px(700.0), gpui::px(10.0)), + gpui::Modifiers::none(), + ); + settle(cx); + let editor = cx.update(|_, cx| host.read(cx).0.clone()); + let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx)); + assert!(focused.is_some(), "the press focuses the block's field"); +} + +#[gpui::test] +fn a_press_beside_a_blocks_text_puts_the_caret_at_its_end(cx: &mut TestAppContext) { + let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["lift"])], cx); + cx.simulate_click( + gpui::point(gpui::px(300.0), gpui::px(1.0)), + gpui::Modifiers::none(), + ); + settle(cx); + let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx)); + assert_eq!( + focused, + Some((editor.read_with(cx, |editor, _| editor.blocks[0].key), 0)) + ); +} + +/// Puts block `ix`'s field `field` in focus with `range` selected. +fn select( + editor: &Entity, + ix: usize, + field: usize, + range: std::ops::Range, + cx: &mut VisualTestContext, +) { + cx.update(|window, cx| { + let field = editor.read(cx).blocks[ix].fields[field].clone(); + field.update(cx, |field, cx| field.select(range, cx)); + window.focus(&field.focus_handle(cx)); + }); + settle(cx); +} + +#[gpui::test] +fn enter_over_a_selection_drops_it_and_splits_there(cx: &mut TestAppContext) { + let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["abcdef"])], cx); + select(&editor, 0, 0, 2..4, cx); + cx.simulate_keystrokes("enter"); + settle(cx); + assert_eq!( + blocks(&editor, cx), + [ + text(BlockKind::Paragraph, "ab"), + text(BlockKind::Paragraph, "ef") + ] + ); + cx.simulate_keystrokes("cmd-z"); + settle(cx); + assert_eq!( + blocks(&editor, cx), + [text(BlockKind::Paragraph, "abcdef")], + "one step undoes it" + ); +} + +#[gpui::test] +fn undo_puts_the_caret_back_where_the_change_began(cx: &mut TestAppContext) { + let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["abcdef"])], cx); + caret(&editor, 0, 3, cx); + cx.simulate_keystrokes("enter"); + settle(cx); + cx.simulate_keystrokes("cmd-z"); + settle(cx); + let first = cx.update(|_, cx| editor.read(cx).blocks[0].key); + let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx)); + assert_eq!(focused, Some((first, 0))); + cx.simulate_keystrokes("cmd-shift-z"); + settle(cx); + assert_eq!( + blocks(&editor, cx), + [ + text(BlockKind::Paragraph, "abc"), + text(BlockKind::Paragraph, "def") + ], + "redo splits again" + ); + cx.simulate_keystrokes("cmd-z"); + settle(cx); + cx.simulate_input("x"); + settle(cx); + assert_eq!( + blocks(&editor, cx), + [text(BlockKind::Paragraph, "abcxdef")], + "typing lands at the caret undo put back" + ); +} + +#[gpui::test] +fn keys_into_a_merged_cell_land_on_the_cell_that_shows(cx: &mut TestAppContext) { + let table = BlockKind::Table { + columns: 2, + merged: vec![(1, 1)], + }; + let (editor, cx) = open(vec![BlockData::new(table, ["A", "B", "c", ""])], cx); + select(&editor, 0, 0, 1..1, cx); + cx.update(|window, cx| { + let field = editor.read(cx).blocks[0].fields[1].clone(); + window.focus(&field.focus_handle(cx)); + }); + settle(cx); + cx.simulate_keystrokes("down"); + settle(cx); + let key = cx.update(|_, cx| editor.read(cx).blocks[0].key); + let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx)); + assert_eq!( + focused, + Some((key, 2)), + "down from B lands on the cell c spans" + ); +} + +#[gpui::test] +fn undoing_a_kind_change_brings_back_its_styling(cx: &mut TestAppContext) { + let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["**lift**"])], cx); + cx.update(|window, cx| { + let key = editor.read(cx).blocks[0].key; + editor.update(cx, |editor, cx| { + editor.turn_into(key, BlockKind::Code, window, cx) + }); + }); + settle(cx); + caret(&editor, 0, 0, cx); + cx.simulate_keystrokes("cmd-z"); + settle(cx); + let bold = cx.update(|_, cx| { + let field = editor.read(cx).blocks[0].fields[0].clone(); + field + .read(cx) + .highlights(cx) + .iter() + .any(|(_, highlight)| highlight.weight.is_some()) + }); + assert_eq!( + blocks(&editor, cx), + [text(BlockKind::Paragraph, "**lift**")] + ); + assert!(bold, "the paragraph styles its markdown again"); +} + +#[gpui::test] +fn undo_of_typing_restores_the_selection_it_replaced(cx: &mut TestAppContext) { + let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["abcdef"])], cx); + select(&editor, 0, 0, 2..4, cx); + cx.simulate_keystrokes("x"); + settle(cx); + assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "abxef")]); + cx.simulate_keystrokes("cmd-z"); + settle(cx); + let selection = cx.update(|_, cx| editor.read(cx).blocks[0].fields[0].read(cx).selection()); + assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "abcdef")]); + assert_eq!(selection, 2..4, "undo selects what the typing replaced"); +} + +#[gpui::test] +fn up_into_a_table_skips_its_hidden_cells(cx: &mut TestAppContext) { + let table = BlockKind::Table { + columns: 2, + merged: vec![(1, 1)], + }; + let (editor, cx) = open( + vec![ + BlockData::new(table, ["A", "B", "c", ""]), + BlockData::new(BlockKind::Paragraph, ["after"]), + ], + cx, + ); + caret(&editor, 1, 0, cx); + cx.simulate_keystrokes("up"); + settle(cx); + let key = cx.update(|_, cx| editor.read(cx).blocks[0].key); + let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx)); + assert_eq!( + focused, + Some((key, 2)), + "up lands on the cell that shows in the last row" + ); +} + +#[gpui::test] +fn undo_of_a_delete_or_a_paste_restores_the_selection(cx: &mut TestAppContext) { + let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["abcdef"])], cx); + let selection = |cx: &mut VisualTestContext| { + cx.update(|_, cx| editor.read(cx).blocks[0].fields[0].read(cx).selection()) + }; + select(&editor, 0, 0, 2..4, cx); + cx.simulate_keystrokes("backspace"); + settle(cx); + assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "abef")]); + cx.simulate_keystrokes("cmd-z"); + settle(cx); + assert_eq!(selection(cx), 2..4, "undo of a delete selects what it took"); + cx.update(|_, cx| cx.write_to_clipboard(gpui::ClipboardItem::new_string("xy".into()))); + select(&editor, 0, 0, 2..4, cx); + cx.simulate_keystrokes("cmd-v"); + settle(cx); + assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "abxyef")]); + cx.simulate_keystrokes("cmd-z"); + settle(cx); + assert_eq!( + selection(cx), + 2..4, + "undo of a paste selects what it replaced" + ); +} diff --git a/src/documents/epub.rs b/src/documents/epub.rs new file mode 100644 index 0000000..9267e31 --- /dev/null +++ b/src/documents/epub.rs @@ -0,0 +1,244 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce, + ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, +}; + +use super::{reading::ReadingProgress, render::MarkdownRenderer}; +use crate::{ + buttons::{Button, ButtonVariant, IconButton, ToggleButton, ToggleItem}, + primitives::IconName, + theme::{ActiveTheme, ContainerSize, ControlSize, Radius, TextSize}, + typography::tabular, +}; + +/// Type sizes a reader steps through. +const SIZES: [TextSize; 4] = [TextSize::Sm, TextSize::Base, TextSize::Lg, TextSize::Xl]; + +/// A chapter of a book the app opened: its title and its text, as markdown. +#[derive(Clone, Debug, PartialEq)] +pub struct Chapter { + pub title: SharedString, + pub markdown: SharedString, +} + +/// Where the reader is: the chapter, the type size, whether the chapters show, and the chapter's scroll. +struct Place { + chapter: usize, + size: usize, + contents: bool, + scroll: ScrollHandle, +} + +/// A book read a chapter at a time: the chapter in a column sized for reading with how far through it the reader is, the chapters listed to jump between, the type a step larger or smaller, and the chapters before and after a press away. +#[derive(IntoElement)] +pub struct EpubReader { + id: ElementId, + title: SharedString, + chapters: Rc>, +} + +impl EpubReader { + pub fn new( + id: impl Into, + title: impl Into, + chapters: impl Into>>, + ) -> Self { + let chapters = chapters.into(); + assert!(!chapters.is_empty(), "a book holds a chapter"); + Self { + id: id.into(), + title: title.into(), + chapters, + } + } +} + +fn set(place: &gpui::Entity, cx: &mut App, change: impl FnOnce(&mut Place)) { + place.update(cx, |place, cx| { + change(place); + cx.notify(); + }); +} + +impl RenderOnce for EpubReader { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let place = window.use_keyed_state((self.id.clone(), "place"), cx, |_, _| Place { + chapter: 0, + size: 1, + contents: false, + scroll: ScrollHandle::new(), + }); + let (chapter, size, contents, scroll) = { + let place = place.read(cx); + ( + place.chapter.min(self.chapters.len() - 1), + place.size, + place.contents, + place.scroll.clone(), + ) + }; + let theme = cx.theme(); + let colors = theme.colors.clone(); + let count = self.chapters.len(); + let turn = |to: usize| { + let (place, scroll) = (place.clone(), scroll.clone()); + move |_: &gpui::ClickEvent, _: &mut Window, cx: &mut App| { + log::info!("epub reader: chapter {to}"); + scroll.set_offset(gpui::Point::default()); + set(&place, cx, |place| place.chapter = to); + } + }; + let sized = |to: Option, icon: IconName, tip: &'static str| { + let place = place.clone(); + IconButton::new((self.id.clone(), tip), icon) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip(tip) + .disabled(to.is_none()) + .when_some(to, |button, to| { + button.on_click(move |_, _, cx| set(&place, cx, |place| place.size = to)) + }) + }; + let shown = &self.chapters[chapter]; + let listed = contents.then(|| { + div() + .flex_none() + .w(theme.sidebar_width(false)) + .flex() + .flex_col() + .gap_0p5() + .p_2() + .border_r_1() + .border_color(colors.border) + .children(self.chapters.iter().enumerate().map(|(ix, entry)| { + div() + .id((self.id.clone(), format!("chapter-{ix}"))) + .px_2() + .py_1() + .rounded(theme.radius(Radius::Sm)) + .cursor_pointer() + .text_color(if ix == chapter { + colors.fg + } else { + colors.fg_muted + }) + .when(ix == chapter, |row| row.bg(colors.active)) + .hover(|row| row.bg(colors.hover)) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(turn(ix)) + .child(entry.title.clone()) + })) + }); + let toggle = place.clone(); + div() + .id(self.id.clone()) + .size_full() + .flex() + .flex_col() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(colors.border) + .overflow_hidden() + .bg(colors.surface) + .child( + div() + .flex() + .items_center() + .gap_2() + .px_2() + .h(theme.control_height(ControlSize::Lg)) + .child( + ToggleButton::new( + (self.id.clone(), "contents"), + ToggleItem::new("contents") + .icon(IconName::List) + .tooltip("Chapters"), + contents, + ) + .size(ControlSize::Sm) + .on_toggle(move |on, _, cx| set(&toggle, cx, |place| place.contents = on)), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_color(colors.fg_muted) + .child(self.title.clone()), + ) + .child( + tabular( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_subtle), + ) + .child(format!("{} of {count}", chapter + 1)), + ) + .child(sized( + size.checked_sub(1), + IconName::AArrowDown, + "Smaller type", + )) + .child(sized( + Some(size + 1).filter(|size| *size < SIZES.len()), + IconName::AArrowUp, + "Larger type", + )), + ) + .child(ReadingProgress::new(&scroll)) + .child( + div().flex().flex_1().min_h_0().children(listed).child( + div() + .id((self.id.clone(), "page")) + .flex_1() + .min_w_0() + .overflow_y_scroll() + .track_scroll(&scroll) + .child( + div() + .mx_auto() + .max_w(theme.container_width(ContainerSize::Sm)) + .px_8() + .py_8() + .flex() + .flex_col() + .gap_6() + .child( + MarkdownRenderer::new( + (self.id.clone(), format!("text-{chapter}")), + shown.markdown.clone(), + ) + .text_size(SIZES[size]), + ) + .child( + div() + .flex() + .justify_between() + .pt_4() + .border_t_1() + .border_color(colors.border) + .child(div().children(chapter.checked_sub(1).map( + |previous| { + Button::new( + (self.id.clone(), "previous"), + format!("← {}", self.chapters[previous].title), + ) + .variant(ButtonVariant::Ghost) + .on_click(turn(previous)) + }, + ))) + .child(div().children((chapter + 1 < count).then(|| { + Button::new( + (self.id.clone(), "next"), + format!("{} →", self.chapters[chapter + 1].title), + ) + .variant(ButtonVariant::Ghost) + .on_click(turn(chapter + 1)) + }))), + ), + ), + ), + ) + } +} diff --git a/src/documents/find.rs b/src/documents/find.rs new file mode 100644 index 0000000..3357445 --- /dev/null +++ b/src/documents/find.rs @@ -0,0 +1,26 @@ +use gpui::{Pixels, ScrollHandle, point}; + +use super::viewer::PageHit; +use crate::editor::FindWidget; + +/// The owner's find box over the pages, the hits it found and the current one. +pub(crate) struct Find { + pub widget: FindWidget, + pub hits: Vec, + pub current: Option, +} + +/// Scrolls `hit` a third of the way down the view; before the pages are laid out, its page to the top. +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 view = scroll.bounds(); + let target = page.top() + Pixels::from(hit.bounds.origin.y * zoom); + let offset = scroll.offset(); + scroll.set_offset(point( + offset.x, + view.top() + view.size.height / 3.0 - target, + )); +} diff --git a/src/documents/history.rs b/src/documents/history.rs new file mode 100644 index 0000000..4de1cca --- /dev/null +++ b/src/documents/history.rs @@ -0,0 +1,368 @@ +use std::{ops::Range, rc::Rc}; + +use gpui::{ + App, ElementId, FontWeight, HighlightStyle, InteractiveElement, IntoElement, MouseButton, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, StrikethroughStyle, + Styled, StyledText, Window, div, prelude::*, +}; +use jiff::Timestamp; +use similar::{ChangeTag, TextDiff}; + +use super::{markdown::words, render::MarkdownRenderer}; +use crate::{ + buttons::{Button, ButtonVariant, SegmentedControl}, + data_display::Avatar, + forms::Pick, + theme::{ActiveTheme, AvatarSize, ControlSize, Radius, TextSize}, + typography::{DateTimeText, format::plural}, +}; + +/// How a stretch of a prose diff changed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Change { + Added, + Removed, +} + +/// `before` and `after` as one text, word by word: every word of both, the added and removed ones marked. +pub(crate) fn prose_diff(before: &str, after: &str) -> (String, Vec<(Range, Change)>) { + let diff = TextDiff::configure().diff_unicode_words(before, after); + let mut text = String::new(); + let mut marks: Vec<(Range, Change)> = Vec::new(); + for change in diff.iter_all_changes() { + let start = text.len(); + text.push_str(change.value()); + let kind = match change.tag() { + ChangeTag::Equal => continue, + ChangeTag::Insert => Change::Added, + ChangeTag::Delete => Change::Removed, + }; + match marks.last_mut() { + Some((range, last)) if *last == kind && range.end == start => range.end = text.len(), + _ => marks.push((start..text.len(), kind)), + } + } + (text, marks) +} + +/// How many words the stretches marked `kind` hold. +pub(crate) fn changed_words(text: &str, marks: &[(Range, Change)], kind: Change) -> usize { + marks + .iter() + .filter(|(_, change)| *change == kind) + .map(|(range, _)| words(&text[range.clone()])) + .sum() +} + +/// What changed between two versions of a page, word by word: added words washed, removed ones struck through. +#[derive(IntoElement)] +pub struct PageHistoryDiff { + before: SharedString, + after: SharedString, +} + +impl PageHistoryDiff { + pub fn new(before: impl Into, after: impl Into) -> Self { + Self { + before: before.into(), + after: after.into(), + } + } +} + +impl RenderOnce for PageHistoryDiff { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let (text, marks) = prose_diff(&self.before, &self.after); + let (added, removed) = ( + changed_words(&text, &marks, Change::Added), + changed_words(&text, &marks, Change::Removed), + ); + let styles = marks.iter().map(|(range, change)| { + let style = match change { + Change::Added => HighlightStyle { + background_color: Some(colors.success.opacity(0.16)), + ..HighlightStyle::default() + }, + Change::Removed => HighlightStyle { + color: Some(colors.danger), + strikethrough: Some(StrikethroughStyle { + thickness: theme.underline_thickness(), + color: Some(colors.danger), + }), + ..HighlightStyle::default() + }, + }; + (range.clone(), style) + }); + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .flex() + .gap_2() + .text_size(theme.text_size(TextSize::Sm)) + .child( + div() + .text_color(colors.success) + .child(format!("+{}", plural(added as u64, "word", "words"))), + ) + .child( + div() + .text_color(colors.danger) + .child(format!("−{}", plural(removed as u64, "word", "words"))), + ), + ) + .child(StyledText::new(text).with_highlights(styles.collect::>())) + } +} + +/// A saved version of a page: when, by whom, and its text as markdown. +#[derive(Clone, Debug, PartialEq)] +pub struct Version { + pub at: Timestamp, + pub author: SharedString, + pub markdown: SharedString, +} + +/// A page's versions, newest first, by time and author: pick one to read it or to see what it changed from the one before, and restore it. +#[derive(IntoElement)] +pub struct VersionHistory { + id: ElementId, + versions: Vec, + selected: usize, + on_select: Option, + on_restore: Option, +} + +impl VersionHistory { + /// `versions` newest first. + pub fn new( + id: impl Into, + versions: impl IntoIterator, + selected: usize, + ) -> Self { + let versions: Vec = versions.into_iter().collect(); + assert!( + selected < versions.len(), + "version {selected} of {}", + versions.len() + ); + assert!( + versions.windows(2).all(|pair| pair[0].at >= pair[1].at), + "versions come newest first" + ); + Self { + id: id.into(), + versions, + selected, + on_select: None, + on_restore: None, + } + } + + pub fn on_select(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_select = Some(Rc::new(handler)); + self + } + + pub fn on_restore(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_restore = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for VersionHistory { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let changes = window.use_keyed_state((self.id.clone(), "changes"), cx, |_, _| false); + let showing_changes = *changes.read(cx); + let theme = cx.theme(); + let colors = theme.colors.clone(); + let version = &self.versions[self.selected]; + let before = self.versions.get(self.selected + 1); + let list = self.versions.iter().enumerate().map(|(ix, version)| { + let pick = self.on_select.clone(); + let lit = ix == self.selected; + div() + .id((self.id.clone(), format!("version-{ix}"))) + .flex() + .items_center() + .gap_2() + .px_2() + .py_1p5() + .rounded(theme.radius(Radius::Sm)) + .cursor_pointer() + .when(lit, |row| row.bg(colors.active)) + .when(!lit, |row| row.hover(|row| row.bg(colors.hover))) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .when_some(pick, |row, pick| { + row.on_click(move |_, window, cx| pick(ix, window, cx)) + }) + .child( + Avatar::new( + (self.id.clone(), format!("author-{ix}")), + version.author.clone(), + ) + .size(AvatarSize::Xs), + ) + .child( + div() + .flex() + .flex_col() + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .child(DateTimeText::new(version.at).pattern("%b %-d, %H:%M")), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(if ix == 0 { + format!("{} · current", version.author) + } else { + version.author.to_string() + }), + ), + ) + }); + let view = SegmentedControl::new( + (self.id.clone(), "view"), + if showing_changes { "changes" } else { "read" }, + ) + .size(ControlSize::Sm) + .segment("read", "Read", None) + .segment("changes", "Changes", None) + .on_change({ + let changes = changes.clone(); + move |value, _, cx| { + changes.update(cx, |changes, cx| { + *changes = value.as_ref() == "changes"; + cx.notify(); + }) + } + }); + let restore = self + .on_restore + .clone() + .filter(|_| self.selected > 0) + .map(|restore| { + let at = self.selected; + Button::new((self.id.clone(), "restore"), "Restore this version") + .variant(ButtonVariant::Primary) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| { + log::info!("version history: restore {at}"); + restore(at, window, cx) + }) + }); + let body = match (showing_changes, before) { + (true, Some(before)) => { + PageHistoryDiff::new(before.markdown.clone(), version.markdown.clone()) + .into_any_element() + } + (true, None) => div() + .text_color(colors.fg_subtle) + .child("The first version; nothing came before it.") + .into_any_element(), + (false, _) => MarkdownRenderer::new( + (self.id.clone(), format!("text-{}", self.selected)), + version.markdown.clone(), + ) + .into_any_element(), + }; + div() + .flex() + .size_full() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(colors.border) + .overflow_hidden() + .child( + div() + .id((self.id.clone(), "versions")) + .flex_none() + .w(theme.sidebar_width(false)) + .overflow_y_scroll() + .flex() + .flex_col() + .gap_0p5() + .p_2() + .border_r_1() + .border_color(colors.border) + .child( + div() + .px_2() + .pb_1() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .font_weight(FontWeight::MEDIUM) + .child("Versions"), + ) + .children(list), + ) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child( + div() + .flex() + .items_center() + .justify_between() + .gap_2() + .px_4() + .py_2() + .border_b_1() + .border_color(colors.border) + .child(view) + .children(restore), + ) + .child( + div() + .id((self.id.clone(), "body")) + .flex_1() + .overflow_y_scroll() + .p_4() + .child(body), + ), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_prose_diff_keeps_both_sides_and_marks_the_changes() { + let (text, marks) = prose_diff("A lift blends color.", "A lift blends every color gently."); + let shown: Vec<(&str, Change)> = marks + .iter() + .map(|(range, change)| (&text[range.clone()], *change)) + .collect(); + assert_eq!(text, "A lift blends every color gently."); + assert_eq!( + shown, + [("every ", Change::Added), (" gently", Change::Added)] + ); + let (text, marks) = prose_diff("keep old words", "keep new words"); + let shown: Vec<(&str, Change)> = marks + .iter() + .map(|(range, change)| (&text[range.clone()], *change)) + .collect(); + assert_eq!(shown, [("old", Change::Removed), ("new", Change::Added)]); + } + + #[test] + fn punctuation_is_not_a_changed_word() { + let (text, marks) = prose_diff("white in gamma", "white, gently and in gamma"); + assert_eq!(changed_words(&text, &marks, Change::Added), 2); + assert_eq!(changed_words(&text, &marks, Change::Removed), 0); + } +} diff --git a/src/documents/knowledge.rs b/src/documents/knowledge.rs new file mode 100644 index 0000000..6d62907 --- /dev/null +++ b/src/documents/knowledge.rs @@ -0,0 +1,418 @@ +use std::{ops::Range, rc::Rc}; + +use gpui::{ + App, ElementId, FontWeight, HighlightStyle, InteractiveElement, IntoElement, MouseButton, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, StyledText, + Window, div, prelude::*, +}; +use jiff::Timestamp; + +use crate::{ + buttons::{Button, ButtonVariant, ConfirmButton, ConfirmMode, IconButton}, + feedback::EmptyState, + forms::{OnValue, Pick, Run}, + motion::Reorder, + primitives::IconName, + theme::{ActiveTheme, ControlSize, Radius, TextSize}, + typography::{RelativeTime, format::plural}, +}; + +/// A page that links here: its icon and title, and the words around the link, the link's range among them. +#[derive(Clone, Debug, PartialEq)] +pub struct Backlink { + pub icon: SharedString, + pub page: SharedString, + pub context: SharedString, + pub mention: Range, +} + +/// The pages that link to this one, each with the words around its link, the link lit; a press opens that page. +#[derive(IntoElement)] +pub struct Backlinks { + id: ElementId, + links: Vec, + on_open: Option, +} + +impl Backlinks { + pub fn new(id: impl Into, links: impl IntoIterator) -> Self { + let links: Vec = links.into_iter().collect(); + for link in &links { + assert!( + link.mention.end <= link.context.len() + && link.context.is_char_boundary(link.mention.start) + && link.context.is_char_boundary(link.mention.end), + "the mention {:?} lies outside {:?}", + link.mention, + link.context + ); + } + Self { + id: id.into(), + links, + on_open: None, + } + } + + pub fn on_open(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for Backlinks { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .child(plural(self.links.len() as u64, "backlink", "backlinks")), + ) + .children(self.links.into_iter().enumerate().map(|(ix, link)| { + let open = self.on_open.clone(); + let lit = HighlightStyle { + color: Some(colors.link), + font_weight: Some(FontWeight::MEDIUM), + ..HighlightStyle::default() + }; + div() + .id((self.id.clone(), format!("link-{ix}"))) + .flex() + .flex_col() + .gap_0p5() + .p_2() + .rounded(theme.radius(Radius::Md)) + .cursor_pointer() + .hover(|row| row.bg(colors.hover)) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .when_some(open, |row, open| { + row.on_click(move |_, window, cx| { + log::info!("backlinks: open {ix}"); + open(ix, window, cx) + }) + }) + .child( + div() + .flex() + .gap_1p5() + .font_weight(FontWeight::MEDIUM) + .child(link.icon.clone()) + .child(link.page.clone()), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .child( + StyledText::new(link.context.clone()) + .with_highlights([(link.mention.clone(), lit)]), + ), + ) + })) + } +} + +/// A page kept close at hand: its key, icon and title. +#[derive(Clone, Debug, PartialEq)] +pub struct Favorite { + pub key: SharedString, + pub icon: SharedString, + pub title: SharedString, +} + +type OnMove = Rc; + +/// Pages pinned for quick reach: a press opens one, dragging reorders them, and the star lets one go. +#[derive(IntoElement)] +pub struct Favorites { + id: ElementId, + pages: Vec, + on_open: Option, + on_unpin: Option, + on_move: Option, +} + +impl Favorites { + pub fn new(id: impl Into, pages: impl IntoIterator) -> Self { + Self { + id: id.into(), + pages: pages.into_iter().collect(), + on_open: None, + on_unpin: None, + on_move: None, + } + } + + pub fn on_open( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } + + pub fn on_unpin( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_unpin = Some(Rc::new(handler)); + self + } + + /// Asks to move the page at `from` to place `to`. + pub fn on_move( + mut self, + handler: impl Fn(usize, usize, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_move = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for Favorites { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let moved = self.on_move.clone(); + let list = self.pages.iter().fold( + Reorder::new((self.id.clone(), "pages")).on_reorder(move |from, to, window, cx| { + if let Some(moved) = &moved { + log::info!("favorites: {from} to {to}"); + moved(from, to, window, cx); + } + }), + |list, page| { + let (open, unpin, key) = ( + self.on_open.clone(), + self.on_unpin.clone(), + page.key.clone(), + ); + let group = SharedString::from(format!("favorite-{}", page.key)); + let unpin_key = key.clone(); + list.row( + page.key.clone(), + div() + .id((self.id.clone(), format!("page-{key}"))) + .group(group.clone()) + .flex() + .items_center() + .gap_2() + .px_2() + .py_1() + .rounded(theme.radius(Radius::Sm)) + .cursor_pointer() + .hover(|row| row.bg(colors.hover)) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .when_some(open, |row, open| { + row.on_click(move |_, window, cx| open(&key, window, cx)) + }) + .child(page.icon.clone()) + .child(div().flex_1().min_w_0().child(page.title.clone())) + .child( + div() + .opacity(0.0) + .group_hover(group, |star| star.opacity(1.0)) + .child( + IconButton::new( + (self.id.clone(), format!("unpin-{unpin_key}")), + IconName::Star, + ) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip("Remove from favorites") + .when_some( + unpin, + |button, unpin| { + button.on_click(move |_, window, cx| { + cx.stop_propagation(); + unpin(&unpin_key, window, cx) + }) + }, + ), + ), + ), + ) + }, + ); + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .px_2() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child("Favorites"), + ) + .child(list) + } +} + +/// A page in the trash: its key, icon, title, where it lived and when it went. +#[derive(Clone, Debug, PartialEq)] +pub struct Trashed { + pub key: SharedString, + pub icon: SharedString, + pub title: SharedString, + pub place: SharedString, + pub deleted: Timestamp, +} + +/// Deleted pages, the newest first, with where each lived and when it went: restore one, delete one for good, or empty it all, each final step asked twice. +#[derive(IntoElement)] +pub struct TrashBin { + id: ElementId, + pages: Vec, + on_restore: Option, + on_delete: Option, + on_empty: Option, +} + +impl TrashBin { + pub fn new(id: impl Into, pages: impl IntoIterator) -> Self { + let mut pages: Vec = pages.into_iter().collect(); + pages.sort_by_key(|page| std::cmp::Reverse(page.deleted)); + Self { + id: id.into(), + pages, + on_restore: None, + on_delete: None, + on_empty: None, + } + } + + pub fn on_restore( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_restore = Some(Rc::new(handler)); + self + } + + pub fn on_delete( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_delete = Some(Rc::new(handler)); + self + } + + pub fn on_empty(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_empty = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for TrashBin { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + if self.pages.is_empty() { + return EmptyState::new( + (self.id.clone(), "empty"), + IconName::Trash2, + "The trash is empty", + ) + .body("Deleted pages wait here until they are emptied.") + .into_any_element(); + } + let empty = self.on_empty.clone(); + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .flex() + .items_center() + .justify_between() + .pb_1() + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .child(plural(self.pages.len() as u64, "page", "pages")), + ) + .children(empty.map(|empty| { + ConfirmButton::new( + (self.id.clone(), "empty-all"), + "Empty trash", + ConfirmMode::Twice, + ) + .size(ControlSize::Sm) + .on_confirm(move |window, cx| { + log::info!("trash: emptied"); + empty(window, cx) + }) + })), + ) + .children(self.pages.into_iter().map(|page| { + let (restore, delete, key) = ( + self.on_restore.clone(), + self.on_delete.clone(), + page.key.clone(), + ); + let restore_key = key.clone(); + div() + .flex() + .items_center() + .gap_3() + .px_2() + .py_1p5() + .rounded(theme.radius(Radius::Sm)) + .child(page.icon.clone()) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child(page.title.clone()) + .child( + div() + .flex() + .gap_1() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(format!("In {} ·", page.place)) + .child(RelativeTime::new( + (self.id.clone(), format!("when-{key}")), + page.deleted, + )), + ), + ) + .child( + Button::new((self.id.clone(), format!("restore-{key}")), "Restore") + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .when_some(restore, |button, restore| { + button.on_click(move |_, window, cx| { + log::info!("trash: restore {restore_key}"); + restore(&restore_key, window, cx) + }) + }), + ) + .children(delete.map(|delete| { + let gone = key.clone(); + ConfirmButton::new( + (self.id.clone(), format!("delete-{key}")), + "Delete", + ConfirmMode::Twice, + ) + .size(ControlSize::Sm) + .on_confirm(move |window, cx| { + log::info!("trash: delete {gone} for good"); + delete(&gone, window, cx) + }) + })) + })) + .into_any_element() + } +} diff --git a/src/documents/mod.rs b/src/documents/mod.rs index a3fb3b4..58a7864 100644 --- a/src/documents/mod.rs +++ b/src/documents/mod.rs @@ -1,30 +1,49 @@ mod blocks; mod counts; +mod epub; +mod find; mod format; +mod history; +mod knowledge; mod link; mod markdown; mod modes; +mod pages; +mod print; +mod reading; mod render; mod rich; mod suggest; +mod templates; #[cfg(all(test, feature = "test-support"))] mod tests; +mod thumbs; mod todo; mod toolbar; mod tree; +mod viewer; use gpui::App; pub use blocks::{Align, BlockData, BlockEditor, BlockEvent, BlockKind, Media}; pub use counts::{ReadingTime, WordCount}; +pub use epub::{Chapter, EpubReader}; pub use format::{Format, format}; +pub use history::{PageHistoryDiff, Version, VersionHistory}; +pub use knowledge::{Backlink, Backlinks, Favorite, Favorites, TrashBin, Trashed}; pub use link::LinkEditor; pub use markdown::markdown_highlights; pub use modes::{MarkdownEditor, MarkdownMode}; -pub use render::MarkdownRenderer; +pub use pages::{PageCover, PageIcon}; +pub use print::{Margins, Paper, PrintPreview, PrintSettings, sheet}; +pub use reading::{Footnote, Glossary, ReadingProgress, TooltipTerm, ZenMode}; +pub use render::{MarkdownRenderer, outline}; pub use rich::RichTextEditor; +pub use templates::{Template, TemplatePicker}; +pub use thumbs::PageThumbnailList; pub use todo::{Checklist, TodoItem}; pub use toolbar::FixedFormatToolbar; +pub use viewer::{DocPage, DocumentViewer, PageHit, PageNote}; /// Binds the documents keys. `init` calls it. pub(crate) fn bind_keys(cx: &mut App) { diff --git a/src/documents/pages.rs b/src/documents/pages.rs new file mode 100644 index 0000000..fc7f374 --- /dev/null +++ b/src/documents/pages.rs @@ -0,0 +1,184 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, ObjectFit, ParentElement, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, Window, div, relative, +}; + +use crate::{ + buttons::{Button, ButtonVariant}, + documents::blocks::source, + forms::{EmojiPicker, OnValue}, + overlays::Popover, + primitives::{IconName, Image}, + theme::{ActiveTheme, ControlSize, Radius}, +}; + +type OnCover = Rc, &mut Window, &mut App)>; + +/// A page's cover, a picture across its top; on hover, covers to switch to and a way to take it off. +#[derive(IntoElement)] +pub struct PageCover { + id: ElementId, + source: SharedString, + choices: Vec, + on_change: Option, +} + +impl PageCover { + /// `source` is a file or a web address. + pub fn new(id: impl Into, source: impl Into) -> Self { + Self { + id: id.into(), + source: source.into(), + choices: Vec::new(), + on_change: None, + } + } + + /// Offers `choices` to switch to, and a way to take the cover off: `on_change` gets the new cover, or none. + pub fn choices( + mut self, + choices: impl IntoIterator>, + on_change: impl Fn(Option, &mut Window, &mut App) + 'static, + ) -> Self { + self.choices = choices.into_iter().map(Into::into).collect(); + self.on_change = Some(Rc::new(on_change)); + self + } +} + +impl RenderOnce for PageCover { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let group = SharedString::from(format!("cover-{}", self.id)); + let tools = + self.on_change.clone().map(|on_change| { + let (choices, pick, remove) = (self.choices.clone(), on_change.clone(), on_change); + let id = self.id.clone(); + div() + .absolute() + .bottom_2() + .right_2() + .flex() + .gap_1() + .opacity(0.0) + .group_hover(group.clone(), |tools| tools.opacity(1.0)) + .child( + Popover::new((self.id.clone(), "change"), "Change cover", move |_, cx| { + let theme = cx.theme(); + div().grid().grid_cols(3).gap_2().children( + choices.iter().enumerate().map(|(ix, choice)| { + let (pick, choice) = (pick.clone(), choice.clone()); + div() + .id((id.clone(), format!("choice-{ix}"))) + .w(theme.grid_column()) + .h(theme.grid_column() * 0.6) + .rounded(theme.radius(Radius::Sm)) + .overflow_hidden() + .cursor_pointer() + .on_click(move |_, window, cx| { + log::info!("page cover: {choice}"); + pick(Some(choice.clone()), window, cx) + }) + .child( + Image::new( + (id.clone(), format!("choice-picture-{ix}")), + source(&choices[ix]), + ) + .size_full(), + ) + }), + ) + }) + .icon(IconName::Image) + .variant(ButtonVariant::Secondary), + ) + .child( + Button::new((self.id.clone(), "remove"), "Remove") + .variant(ButtonVariant::Secondary) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| { + log::info!("page cover: removed"); + remove(None, window, cx) + }), + ) + }); + div() + .id(self.id.clone()) + .group(group.clone()) + .relative() + .w_full() + .h(theme.page_cover()) + .child( + Image::new((self.id.clone(), "picture"), source(&self.source)) + .fit(ObjectFit::Cover) + .size_full(), + ) + .children(tools) + } +} + +/// A page's icon, an emoji set large; on hover, a way to pick another. +#[derive(IntoElement)] +pub struct PageIcon { + id: ElementId, + emoji: SharedString, + on_change: Option, +} + +impl PageIcon { + pub fn new(id: impl Into, emoji: impl Into) -> Self { + Self { + id: id.into(), + emoji: emoji.into(), + on_change: None, + } + } + + pub fn on_change( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for PageIcon { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let group = SharedString::from(format!("icon-{}", self.id)); + let picker = self.on_change.map(|on_change| { + let (id, emoji) = (self.id.clone(), self.emoji.clone()); + div() + .opacity(0.0) + .group_hover(group.clone(), |picker| picker.opacity(1.0)) + .child( + Popover::new((self.id.clone(), "change"), "Change icon", move |_, _| { + EmojiPicker::new((id.clone(), "picker")) + .selected(emoji.clone()) + .on_change(move |glyph, window, cx| { + log::info!("page icon: {glyph}"); + on_change(&SharedString::from(glyph.to_string()), window, cx) + }) + }) + .icon(IconName::Smile) + .variant(ButtonVariant::Ghost), + ) + }); + div() + .id(self.id.clone()) + .group(group.clone()) + .flex() + .items_end() + .gap_2() + .child( + div() + .text_size(theme.page_icon()) + .line_height(relative(1.0)) + .child(self.emoji.clone()), + ) + .children(picker) + } +} diff --git a/src/documents/print.rs b/src/documents/print.rs new file mode 100644 index 0000000..089b21b --- /dev/null +++ b/src/documents/print.rs @@ -0,0 +1,349 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, ObjectFit, ParentElement, RenderOnce, + StatefulInteractiveElement, Styled, Window, div, +}; + +use super::viewer::DocPage; +use crate::{ + buttons::{Button, ButtonVariant, SegmentedControl}, + documents::blocks::source, + forms::{Choice, NumberInput, Run, Select}, + primitives::Image, + theme::{ActiveTheme, ControlSize, Elevation, Radius, TextSize}, + typography::{format::plural, tabular}, +}; + +/// Sheets the app can print on. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Paper { + #[default] + Letter, + A4, + Legal, +} + +/// Space kept around what prints. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Margins { + None, + Narrow, + #[default] + Normal, +} + +/// How a print goes: paper, turn, margins and copies. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PrintSettings { + pub paper: Paper, + pub landscape: bool, + pub margins: Margins, + pub copies: u32, +} + +impl Default for PrintSettings { + fn default() -> Self { + Self { + paper: Paper::default(), + landscape: false, + margins: Margins::default(), + copies: 1, + } + } +} + +const PAPERS: [(Paper, &str); 3] = [ + (Paper::Letter, "Letter"), + (Paper::A4, "A4"), + (Paper::Legal, "Legal"), +]; +const MARGINS: [(Margins, &str); 3] = [ + (Margins::None, "None"), + (Margins::Narrow, "Narrow"), + (Margins::Normal, "Normal"), +]; + +/// Most copies one print makes. +const MOST_COPIES: f64 = 99.0; + +type OnSettings = Rc; + +/// A print before it goes: the pages the app laid out for the settings, small on a desk, and the settings beside, paper, turn, margins and copies; each change asks the app for new pages. +#[derive(IntoElement)] +pub struct PrintPreview { + id: ElementId, + pages: Rc>, + settings: PrintSettings, + on_change: OnSettings, + on_print: OnSettings, + on_cancel: Run, +} + +impl PrintPreview { + pub fn new( + id: impl Into, + pages: impl Into>>, + settings: PrintSettings, + on_change: impl Fn(PrintSettings, &mut Window, &mut App) + 'static, + on_print: impl Fn(PrintSettings, &mut Window, &mut App) + 'static, + on_cancel: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + pages: pages.into(), + settings, + on_change: Rc::new(on_change), + on_print: Rc::new(on_print), + on_cancel: Rc::new(on_cancel), + } + } +} + +fn row(label: &'static str, control: impl IntoElement, cx: &App) -> impl IntoElement { + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_size(cx.theme().text_size(TextSize::Sm)) + .text_color(cx.theme().colors.fg_muted) + .child(label), + ) + .child(control) +} + +impl RenderOnce for PrintPreview { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let settings = self.settings; + let paper = { + let on_change = self.on_change.clone(); + Select::new( + (self.id.clone(), "paper"), + PAPERS.map(|(_, name)| Choice::new(name, name)), + ) + .selected( + PAPERS + .iter() + .find(|(paper, _)| *paper == settings.paper) + .expect("a known paper") + .1, + ) + .size(ControlSize::Sm) + .on_change(move |name, window, cx| { + let (paper, _) = PAPERS + .into_iter() + .find(|(_, named)| *named == name.as_ref()) + .expect("a listed paper"); + on_change(PrintSettings { paper, ..settings }, window, cx); + }) + }; + let turn = { + let on_change = self.on_change.clone(); + SegmentedControl::new( + (self.id.clone(), "turn"), + if settings.landscape { + "landscape" + } else { + "portrait" + }, + ) + .size(ControlSize::Sm) + .segment("portrait", "Portrait", None) + .segment("landscape", "Landscape", None) + .on_change(move |value, window, cx| { + on_change( + PrintSettings { + landscape: value.as_ref() == "landscape", + ..settings + }, + window, + cx, + ) + }) + }; + let margins = { + let on_change = self.on_change.clone(); + Select::new( + (self.id.clone(), "margins"), + MARGINS.map(|(_, name)| Choice::new(name, name)), + ) + .selected( + MARGINS + .iter() + .find(|(margins, _)| *margins == settings.margins) + .expect("known margins") + .1, + ) + .size(ControlSize::Sm) + .on_change(move |name, window, cx| { + let (margins, _) = MARGINS + .into_iter() + .find(|(_, named)| *named == name.as_ref()) + .expect("listed margins"); + on_change( + PrintSettings { + margins, + ..settings + }, + window, + cx, + ); + }) + }; + let copies = { + let on_change = self.on_change.clone(); + NumberInput::new((self.id.clone(), "copies"), settings.copies as f64) + .range(1.0, MOST_COPIES) + .step(1.0) + .size(ControlSize::Sm) + .on_change(move |copies, window, cx| { + on_change( + PrintSettings { + copies: copies as u32, + ..settings + }, + window, + cx, + ) + }) + }; + let (print, cancel) = (self.on_print.clone(), self.on_cancel.clone()); + let thumb = theme.label_width(); + div() + .flex() + .size_full() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(colors.border) + .overflow_hidden() + .child( + div() + .id((self.id.clone(), "desk")) + .flex_1() + .min_w_0() + .overflow_y_scroll() + .bg(colors.sunken) + .p_6() + .flex() + .flex_wrap() + .content_start() + .gap_4() + .children(self.pages.iter().enumerate().map(|(ix, page)| { + let (width, height) = sheet(settings); + div() + .flex() + .flex_col() + .items_center() + .gap_1() + .child( + div() + .w(thumb) + .h(thumb * (height / width)) + .bg(colors.paper) + .shadow(theme.elevation(Elevation::Raised)) + .p(thumb * margin(settings.margins)) + .child( + Image::new( + (self.id.clone(), format!("sheet-{ix}")), + source(&page.source), + ) + .fit(ObjectFit::Contain) + .size_full(), + ), + ) + .child( + tabular( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle), + ) + .child(format!("{}", ix + 1)), + ) + })), + ) + .child( + div() + .flex_none() + .w(theme.sidebar_width(false)) + .flex() + .flex_col() + .gap_4() + .p_4() + .border_l_1() + .border_color(colors.border) + .bg(colors.surface) + .child(div().text_color(colors.fg_muted).child(format!( + "{} · {}", + plural(self.pages.len() as u64, "page", "pages"), + plural(settings.copies as u64, "copy", "copies") + ))) + .child(row("Paper", paper, cx)) + .child(row("Turn", turn, cx)) + .child(row("Margins", margins, cx)) + .child(row("Copies", copies, cx)) + .child(div().flex_1()) + .child( + div() + .flex() + .justify_end() + .gap_2() + .child( + Button::new((self.id.clone(), "cancel"), "Cancel") + .variant(ButtonVariant::Ghost) + .on_click(move |_, window, cx| cancel(window, cx)), + ) + .child( + Button::new((self.id.clone(), "print"), "Print") + .variant(ButtonVariant::Primary) + .on_click(move |_, window, cx| { + log::info!("print preview: print {settings:?}"); + print(settings, window, cx) + }), + ), + ), + ) + } +} + +/// The share of a sheet's width each margin keeps. +fn margin(margins: Margins) -> f32 { + match margins { + Margins::None => 0.0, + Margins::Narrow => 0.04, + Margins::Normal => 0.08, + } +} + +/// The paper's size in points, turned when landscape, for the app laying out pages. +pub fn sheet(settings: PrintSettings) -> (f32, f32) { + let (width, height) = match settings.paper { + Paper::Letter => (612.0, 792.0), + Paper::A4 => (595.0, 842.0), + Paper::Legal => (612.0, 1008.0), + }; + if settings.landscape { + (height, width) + } else { + (width, height) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_sheet_turns_on_its_side() { + let settings = PrintSettings { + paper: Paper::A4, + landscape: true, + ..PrintSettings::default() + }; + assert_eq!(sheet(settings), (842.0, 595.0)); + assert_eq!(sheet(PrintSettings::default()), (612.0, 792.0)); + } +} diff --git a/src/documents/reading.rs b/src/documents/reading.rs new file mode 100644 index 0000000..b50717d --- /dev/null +++ b/src/documents/reading.rs @@ -0,0 +1,348 @@ +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, +}; +use smallvec::SmallVec; + +use crate::{ + forms::Run, + layout::Collapsible, + overlays::HoverCard, + theme::{ActiveTheme, ContainerSize, TextSize}, +}; + +/// How far a note's number rides above the line, in its own size. +const RAISE: f32 = 0.4; + +/// A note's mark in running text, a small number; resting the pointer on it shows the note. +#[derive(IntoElement)] +pub struct Footnote { + id: ElementId, + number: usize, + note: SharedString, +} + +impl Footnote { + pub fn new(id: impl Into, number: usize, note: impl Into) -> Self { + Self { + id: id.into(), + number, + note: note.into(), + } + } +} + +impl RenderOnce for Footnote { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let (link, note, number) = (theme.colors.link, self.note, self.number); + let prose = theme.prose_width(); + HoverCard::new( + self.id, + div() + .relative() + .bottom(theme.text_size(TextSize::Xs) * RAISE) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(link) + .cursor_default() + .child(format!("{number}")), + move |_, _| div().max_w(prose).child(format!("{number}. {note}")), + ) + } +} + +/// A term whose meaning is a rest of the pointer away: underlined in dashes, its definition in a card. +#[derive(IntoElement)] +pub struct TooltipTerm { + id: ElementId, + term: SharedString, + meaning: SharedString, +} + +impl TooltipTerm { + pub fn new( + id: impl Into, + term: impl Into, + meaning: impl Into, + ) -> Self { + Self { + id: id.into(), + term: term.into(), + meaning: meaning.into(), + } + } +} + +impl RenderOnce for TooltipTerm { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let (term, meaning, prose) = (self.term.clone(), self.meaning, theme.prose_width()); + HoverCard::new( + self.id, + div() + .border_b_1() + .border_dashed() + .border_color(theme.colors.fg_subtle) + .child(self.term), + move |_, cx| { + div() + .max_w(prose) + .flex() + .flex_col() + .gap_1() + .child(div().font_weight(FontWeight::MEDIUM).child(term.clone())) + .child( + div() + .text_color(cx.theme().colors.fg_muted) + .child(meaning.clone()), + ) + }, + ) + } +} + +/// Terms and what they mean, in order, under the letter each starts with. +#[derive(IntoElement)] +pub struct Glossary { + entries: Vec<(SharedString, SharedString)>, +} + +impl Glossary { + pub fn new( + entries: impl IntoIterator, impl Into)>, + ) -> Self { + Self { + entries: entries + .into_iter() + .map(|(term, meaning)| (term.into(), meaning.into())) + .collect(), + } + } +} + +/// Entries under their first letter, the letters and each group's terms in order, ignoring case. +pub(crate) fn lettered( + entries: &[(SharedString, SharedString)], +) -> BTreeMap> { + let mut groups: BTreeMap> = BTreeMap::new(); + for (term, meaning) in entries { + let letter = term + .chars() + .next() + .expect("a glossary term has a first letter") + .to_uppercase() + .next() + .expect("a letter has an upper case"); + groups + .entry(letter) + .or_default() + .push((term.clone(), meaning.clone())); + } + for group in groups.values_mut() { + group.sort_by_key(|(term, _)| term.to_lowercase()); + } + groups +} + +impl RenderOnce for Glossary { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + div() + .flex() + .flex_col() + .gap_4() + .children(lettered(&self.entries).into_iter().map(|(letter, group)| { + div() + .flex() + .gap_4() + .child( + div() + .flex_none() + .w_6() + .text_size(theme.text_size(TextSize::Lg)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(colors.fg_subtle) + .child(letter.to_string()), + ) + .child(div().flex_1().min_w_0().flex().flex_col().gap_2().children( + group.into_iter().map(|(term, meaning)| { + div() + .flex() + .flex_col() + .child(div().font_weight(FontWeight::MEDIUM).child(term)) + .child(div().text_color(colors.fg_muted).child(meaning)) + }), + )) + })) + } +} + +/// How far a reader is through what `scroll` scrolls, as a thin bar that fills from the left. +#[derive(IntoElement)] +pub struct ReadingProgress { + scroll: ScrollHandle, +} + +impl ReadingProgress { + pub fn new(scroll: &ScrollHandle) -> Self { + Self { + scroll: scroll.clone(), + } + } +} + +/// The share of `max` that `offset` has scrolled; all of it when nothing scrolls. +pub(crate) fn read_share(offset: f32, max: f32) -> f32 { + if max <= 0.0 { + return 1.0; + } + (-offset / max).clamp(0.0, 1.0) +} + +impl RenderOnce for ReadingProgress { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let (track, bar) = (theme.colors.border, theme.colors.accent); + let scroll = self.scroll; + div() + .w_full() + .h(theme.progress_thickness()) + .bg(track) + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let share = read_share( + f32::from(scroll.offset().y), + f32::from(scroll.max_offset().height), + ); + let filled = Bounds::new( + bounds.origin, + size(bounds.size.width * share, bounds.size.height), + ); + window.paint_quad(fill(filled, bar)); + }, + ) + .size_full(), + ) + } +} + +/// Writing with nothing else in view: while on, the chrome folds away and the text sits in a centered column; Escape leaves. +#[derive(IntoElement)] +pub struct ZenMode { + id: ElementId, + on: bool, + chrome: SmallVec<[AnyElement; 2]>, + body: SmallVec<[AnyElement; 2]>, + on_exit: Option, +} + +impl ZenMode { + pub fn new(id: impl Into, on: bool) -> Self { + Self { + id: id.into(), + on, + chrome: SmallVec::new(), + body: SmallVec::new(), + on_exit: None, + } + } + + /// What folds away while on, such as a toolbar. + pub fn chrome(mut self, element: impl IntoElement) -> Self { + self.chrome.push(element.into_any_element()); + self + } + + /// Escape asks the owner to turn it off. + pub fn on_exit(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_exit = Some(Rc::new(handler)); + self + } +} + +impl ParentElement for ZenMode { + fn extend(&mut self, elements: impl IntoIterator) { + self.body.extend(elements); + } +} + +impl RenderOnce for ZenMode { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let (on, exit) = (self.on, self.on_exit); + div() + .id(self.id.clone()) + .flex() + .flex_col() + .on_key_down(move |event, window, cx| { + if on + && event.keystroke.key == "escape" + && let Some(exit) = &exit + { + cx.stop_propagation(); + log::info!("zen mode: leaves"); + exit(window, cx); + } + }) + .child(Collapsible::new((self.id.clone(), "chrome"), !on).children(self.chrome)) + .child( + div().w_full().flex().justify_center().child( + div() + .w_full() + .when(on, |column| { + column + .max_w(theme.container_width(ContainerSize::Sm)) + .py_8() + }) + .children(self.body), + ), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn terms_group_under_their_letter_in_order() { + let entries: Vec<(SharedString, SharedString)> = [ + ("lift", "a"), + ("Accent", "b"), + ("leading", "c"), + ("alpha", "d"), + ] + .into_iter() + .map(|(term, meaning)| (term.into(), meaning.into())) + .collect(); + let groups = lettered(&entries); + let shape: Vec<(char, Vec<&str>)> = groups + .iter() + .map(|(letter, group)| { + ( + *letter, + group.iter().map(|(term, _)| term.as_ref()).collect(), + ) + }) + .collect(); + assert_eq!( + shape, + [ + ('A', vec!["Accent", "alpha"]), + ('L', vec!["leading", "lift"]) + ] + ); + } + + #[test] + fn progress_is_the_share_scrolled() { + assert_eq!(read_share(-50.0, 200.0), 0.25); + assert_eq!(read_share(0.0, 0.0), 1.0, "a text that fits is read"); + assert_eq!(read_share(-300.0, 200.0), 1.0); + } +} diff --git a/src/documents/render.rs b/src/documents/render.rs index 3f26f01..4113614 100644 --- a/src/documents/render.rs +++ b/src/documents/render.rs @@ -1,7 +1,7 @@ use std::rc::Rc; use gpui::{ - AnyElement, App, ElementId, FontStyle, FontWeight, HighlightStyle, InteractiveText, + AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText, IntoElement, ParentElement, RenderOnce, SharedString, StrikethroughStyle, Styled, StyledText, UnderlineStyle, Window, div, prelude::*, }; @@ -10,6 +10,7 @@ use pulldown_cmark::Alignment; use super::tree::{Inline, Node, Style, parse}; use crate::{ editor::code_colors, + navigation::{Anchor, Sections}, primitives::{Icon, IconName}, theme::{ActiveTheme, IconSize, Radius, TextSize}, typography::Latex, @@ -23,6 +24,8 @@ pub struct MarkdownRenderer { id: ElementId, source: SharedString, on_link: Option, + size: TextSize, + sections: Option>, } impl MarkdownRenderer { @@ -31,9 +34,23 @@ impl MarkdownRenderer { id: id.into(), source: source.into(), on_link: None, + size: TextSize::Base, + sections: None, } } + /// The body's size; headings keep theirs. + pub fn text_size(mut self, size: TextSize) -> Self { + self.size = size; + self + } + + /// Marks each heading for a table of contents over `sections`; `outline` gives its entries. + pub fn sections(mut self, sections: &Entity) -> Self { + self.sections = Some(sections.clone()); + self + } + /// Takes link presses instead of the browser. pub fn on_link(mut self, handler: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self { self.on_link = Some(Rc::new(handler)); @@ -46,6 +63,8 @@ struct Draw<'a> { id: &'a ElementId, on_link: &'a Option, next: std::cell::Cell, + sections: &'a Option>, + headings: std::cell::Cell, } impl Draw<'_> { @@ -143,7 +162,7 @@ impl Draw<'_> { 3 => TextSize::Lg, _ => TextSize::Md, }; - div() + let heading = div() .pt_2() .text_size(theme.text_size(size)) .font_weight(if *level <= 2 { @@ -152,8 +171,15 @@ impl Draw<'_> { FontWeight::MEDIUM }) .text_color(colors.fg) - .child(self.inline(inline, cx)) - .into_any_element() + .child(self.inline(inline, cx)); + let ordinal = self.headings.get(); + self.headings.set(ordinal + 1); + match self.sections { + Some(sections) => Anchor::new(sections, anchor(ordinal)) + .child(heading) + .into_any_element(), + None => heading.into_any_element(), + } } Node::Quote(inner) => div() .flex() @@ -337,6 +363,39 @@ impl Draw<'_> { } } +/// The anchor of the document's `ordinal`th heading. +fn anchor(ordinal: usize) -> SharedString { + format!("heading-{ordinal}").into() +} + +/// The headings of `markdown` in order, as a table of contents takes them: anchor, text and depth from 0. +pub fn outline(markdown: &str) -> Vec<(SharedString, SharedString, usize)> { + fn walk(nodes: &[Node], out: &mut Vec<(SharedString, SharedString, usize)>) { + for node in nodes { + match node { + Node::Heading(level, inline) => { + let ordinal = out.len(); + out.push(( + anchor(ordinal), + inline.text.clone().into(), + *level as usize - 1, + )); + } + Node::Quote(inner) | Node::Footnote { body: inner, .. } => walk(inner, out), + Node::List { items, .. } => { + for (_, body) in items { + walk(body, out); + } + } + _ => {} + } + } + } + let mut out = Vec::new(); + walk(&parse(markdown), &mut out); + out +} + impl RenderOnce for MarkdownRenderer { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); @@ -345,13 +404,40 @@ impl RenderOnce for MarkdownRenderer { id: &self.id, on_link: &self.on_link, next: std::cell::Cell::new(0), + sections: &self.sections, + headings: std::cell::Cell::new(0), }; div() .flex() .flex_col() .gap_3() - .text_size(theme.text_size(TextSize::Base)) - .line_height(theme.text_size(TextSize::Base) * 1.6) + .text_size(theme.text_size(self.size)) + .line_height(theme.text_size(self.size) * 1.6) .children(draw.nodes(&nodes, cx)) } } + +#[cfg(test)] +mod tests { + use super::outline; + + #[test] + fn the_outline_numbers_headings_in_order_with_their_depth() { + let found = + outline("# Lift\n\ntext\n\n## In light\n\n> ### Quoted\n\n- #### Listed\n\n## In dark"); + let shape: Vec<(&str, &str, usize)> = found + .iter() + .map(|(anchor, text, depth)| (anchor.as_ref(), text.as_ref(), *depth)) + .collect(); + assert_eq!( + shape, + [ + ("heading-0", "Lift", 0), + ("heading-1", "In light", 1), + ("heading-2", "Quoted", 2), + ("heading-3", "Listed", 3), + ("heading-4", "In dark", 1), + ] + ); + } +} diff --git a/src/documents/templates.rs b/src/documents/templates.rs new file mode 100644 index 0000000..5c2e1b7 --- /dev/null +++ b/src/documents/templates.rs @@ -0,0 +1,294 @@ +use std::rc::Rc; + +use gpui::{ + App, AppContext as _, Context, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, + MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, + Window, div, prelude::*, +}; + +use super::render::MarkdownRenderer; +use crate::{ + buttons::{Button, ButtonVariant}, + forms::{Choice, ChoiceChips, Input, Pick, TextInput}, + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, +}; + +/// Every category at once. +const ALL: &str = "All"; + +/// A page to start from: its name, icon, category, a line about it, and its text as markdown. +#[derive(Clone, Debug, PartialEq)] +pub struct Template { + pub name: SharedString, + pub icon: SharedString, + pub category: SharedString, + pub description: SharedString, + pub markdown: SharedString, +} + +/// What the picker holds: its search, the category, and the template in view. +struct Picking { + search: Entity, + category: SharedString, + highlighted: usize, +} + +/// The templates `query` and `category` keep, by index, in order. +pub(crate) fn kept(templates: &[Template], query: &str, category: &str) -> Vec { + let query = query.trim().to_lowercase(); + templates + .iter() + .enumerate() + .filter(|(_, template)| category == ALL || template.category == category) + .filter(|(_, template)| { + query.is_empty() + || template.name.to_lowercase().contains(&query) + || template.description.to_lowercase().contains(&query) + }) + .map(|(ix, _)| ix) + .collect() +} + +/// Pages to start from: found by name, kept by category, the one in view shown as it will read; Use takes it. +#[derive(IntoElement)] +pub struct TemplatePicker { + id: ElementId, + templates: Rc>, + on_use: Option, +} + +impl TemplatePicker { + pub fn new(id: impl Into, templates: impl Into>>) -> Self { + let templates = templates.into(); + assert!(!templates.is_empty(), "a template picker has templates"); + Self { + id: id.into(), + templates, + on_use: None, + } + } + + /// Gets the index of the template taken. + pub fn on_use(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_use = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for TemplatePicker { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let picking = window.use_keyed_state( + (self.id.clone(), "picking"), + cx, + |window, cx: &mut Context| Picking { + search: cx.new(|cx| TextInput::new(window, cx).placeholder("Find a template")), + category: ALL.into(), + highlighted: 0, + }, + ); + let (search, category, highlighted) = { + let picking = picking.read(cx); + ( + picking.search.clone(), + picking.category.clone(), + picking.highlighted, + ) + }; + let query = search.read(cx).text().to_string(); + let shown = kept(&self.templates, &query, &category); + let current = shown + .iter() + .copied() + .find(|ix| *ix == highlighted) + .or(shown.first().copied()); + let mut categories: Vec = vec![ALL.into()]; + for template in self.templates.iter() { + if !categories.contains(&template.category) { + categories.push(template.category.clone()); + } + } + let theme = cx.theme(); + let colors = theme.colors.clone(); + let chips = { + let picking = picking.clone(); + ChoiceChips::new( + (self.id.clone(), "categories"), + categories + .iter() + .map(|name| Choice::new(name.clone(), name.clone())), + ) + .selected([category.clone()]) + .on_change(move |values, _, cx| { + let chosen = values.first().cloned().unwrap_or_else(|| ALL.into()); + log::info!("template picker: {chosen}"); + picking.update(cx, |picking, cx| { + picking.category = chosen; + cx.notify(); + }); + }) + }; + let rows = shown.iter().map(|&ix| { + let template = &self.templates[ix]; + let lit = current == Some(ix); + let pick = picking.clone(); + div() + .id((self.id.clone(), format!("template-{ix}"))) + .flex() + .gap_2() + .p_2() + .rounded(theme.radius(Radius::Md)) + .cursor_pointer() + .when(lit, |row| row.bg(colors.active)) + .when(!lit, |row| row.hover(|row| row.bg(colors.hover))) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, _, cx| { + pick.update(cx, |picking, cx| { + picking.highlighted = ix; + cx.notify(); + }) + }) + .child(template.icon.clone()) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child( + div() + .font_weight(FontWeight::MEDIUM) + .child(template.name.clone()), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child(template.description.clone()), + ), + ) + }); + let preview = match current { + Some(ix) => { + let take = self.on_use.clone(); + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child( + div() + .flex() + .items_center() + .justify_between() + .px_4() + .py_2() + .border_b_1() + .border_color(colors.border) + .child( + div() + .text_color(colors.fg_muted) + .child(self.templates[ix].name.clone()), + ) + .child( + Button::new((self.id.clone(), "use"), "Use template") + .variant(ButtonVariant::Primary) + .size(ControlSize::Sm) + .disabled(take.is_none()) + .when_some(take, |button, take| { + button.on_click(move |_, window, cx| { + log::info!("template picker: use {ix}"); + take(ix, window, cx) + }) + }), + ), + ) + .child( + div() + .id((self.id.clone(), "preview")) + .flex_1() + .overflow_y_scroll() + .p_4() + .child(MarkdownRenderer::new( + (self.id.clone(), format!("preview-{ix}")), + self.templates[ix].markdown.clone(), + )), + ) + .into_any_element() + } + None => div() + .flex_1() + .flex() + .items_center() + .justify_center() + .text_color(colors.fg_subtle) + .child("No template matches") + .into_any_element(), + }; + div() + .flex() + .size_full() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(colors.border) + .overflow_hidden() + .child( + div() + .flex_none() + .w(theme.sidebar_width(false)) + .flex() + .flex_col() + .gap_2() + .p_2() + .border_r_1() + .border_color(colors.border) + .child( + Input::new(&search).size(ControlSize::Sm).prefix( + Icon::new(IconName::Search) + .size(IconSize::Sm) + .color(colors.fg_subtle), + ), + ) + .child(chips) + .child( + div() + .id((self.id.clone(), "list")) + .flex_1() + .overflow_y_scroll() + .flex() + .flex_col() + .gap_0p5() + .children(rows), + ), + ) + .child(preview) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn template(name: &str, category: &str, description: &str) -> Template { + Template { + name: name.to_string().into(), + icon: "📄".into(), + category: category.to_string().into(), + description: description.to_string().into(), + markdown: "".into(), + } + } + + #[test] + fn templates_keep_by_category_and_query() { + let all = [ + template("Meeting notes", "Team", "Agenda and actions"), + template("Roadmap", "Product", "Quarters ahead"), + template("Retro", "Team", "What went well"), + ]; + assert_eq!(kept(&all, "", ALL), [0, 1, 2]); + assert_eq!(kept(&all, "", "Team"), [0, 2]); + assert_eq!(kept(&all, "went", ALL), [2], "a description counts"); + assert!(kept(&all, "zzz", ALL).is_empty()); + } +} diff --git a/src/documents/tests.rs b/src/documents/tests.rs index 597b3d4..b060dd5 100644 --- a/src/documents/tests.rs +++ b/src/documents/tests.rs @@ -1,21 +1,66 @@ -use gpui::{AppContext as _, Entity, Focusable, TestAppContext, VisualTestContext}; +use std::{cell::RefCell, rc::Rc}; -use super::{BlockData, BlockEditor, BlockKind}; -use crate::{forms, theme::Theme}; +use gpui::{ + AppContext as _, Bounds, Context, Entity, IntoElement, Modifiers, ParentElement, Pixels, Point, + Render, ScrollDelta, ScrollWheelEvent, Styled, TestAppContext, TouchPhase, VisualTestContext, + Window, div, point, px, size, +}; -fn open( - blocks: Vec, - cx: &mut TestAppContext, -) -> (Entity, &mut VisualTestContext) { - cx.update(|cx| { - Theme::init(cx); - forms::bind_keys(cx); - super::bind_keys(cx); +use super::{DocPage, DocumentViewer, PageHit}; +use crate::{ + editor::FindWidget, + forms::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. +struct Reader { + query: Entity, + current: Option, + noted: Noted, +} + +impl Render for Reader { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let pages: Vec = (0..3) + .map(|ix| DocPage { + source: format!("page-{ix}.png").into(), + width: 400.0, + 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], + self.current, + ) + .notes(Vec::new(), move |page, at, _, _| { + *noted.borrow_mut() = Some((page, at)) + }), + ) + } +} + +fn open(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, + noted: seen, }); - let (editor, cx) = cx.add_window_view(|window, cx| BlockEditor::new(blocks, window, cx)); - cx.update(|window, _| window.activate_window()); settle(cx); - (editor, cx) + (reader, noted, cx) } fn settle(cx: &mut VisualTestContext) { @@ -24,350 +69,77 @@ fn settle(cx: &mut VisualTestContext) { cx.run_until_parked(); } -fn blocks( - editor: &Entity, - cx: &mut VisualTestContext, -) -> Vec<(BlockKind, Vec)> { - cx.update(|_, cx| { - editor - .read(cx) - .blocks(cx) - .into_iter() - .map(|block| (block.kind, block.texts)) - .collect() - }) +/// A point on the pages, below the header and the find bar, at the view's middle. +fn on_pages(cx: &mut VisualTestContext) -> Point { + cx.update(|window, _| point(window.viewport_size().width / 2.0, px(300.0))) } -/// Puts the caret in block `ix`'s first field at byte `at`. -fn caret(editor: &Entity, ix: usize, at: usize, cx: &mut VisualTestContext) { - cx.update(|window, cx| { - let field = editor.read(cx).blocks[ix].fields[0].clone(); - field.update(cx, |field, cx| field.select(at..at, cx)); - window.focus(&field.focus_handle(cx)); +fn wheel(down: f32, cx: &mut VisualTestContext) { + let at = on_pages(cx); + cx.simulate_event(ScrollWheelEvent { + position: at, + delta: ScrollDelta::Pixels(point(px(0.0), px(-down))), + modifiers: Modifiers::none(), + touch_phase: TouchPhase::Moved, }); settle(cx); } -fn text(kind: BlockKind, text: &str) -> (BlockKind, Vec) { - (kind, vec![text.to_string()]) +/// Turns pinning on with the header's last button, then presses the pages; returns the note asked. +fn pin(cx: &mut VisualTestContext, noted: &Noted) -> (usize, Point) { + let toggle = cx.update(|window, cx| { + let header = cx + .theme() + .control_height(ControlSize::Lg) + .to_pixels(window.rem_size()); + point( + window.viewport_size().width - px(17.0), + px(1.0) + header / 2.0, + ) + }); + cx.simulate_click(toggle, Modifiers::none()); + settle(cx); + let at = on_pages(cx); + cx.simulate_click(at, Modifiers::none()); + settle(cx); + noted + .borrow_mut() + .take() + .expect("a press while pinning asks for a note") } #[gpui::test] -fn enter_splits_prose_at_the_caret(cx: &mut TestAppContext) { - let (editor, cx) = open(vec![BlockData::new(BlockKind::Bullet, ["lift"])], cx); - caret(&editor, 0, 2, cx); - cx.simulate_keystrokes("enter"); +fn a_note_lands_where_the_press_is_on_a_scrolled_page(cx: &mut TestAppContext) { + let (_, noted, cx) = open(cx); + let (page, top) = pin(cx, ¬ed); + wheel(150.0, cx); + let at = on_pages(cx); + cx.simulate_click(at, Modifiers::none()); settle(cx); - assert_eq!( - blocks(&editor, cx), - [text(BlockKind::Bullet, "li"), text(BlockKind::Bullet, "ft")] - ); + let (scrolled_page, scrolled) = noted.borrow_mut().take().expect("pinning stays on"); + assert_eq!((scrolled_page, page), (0, 0)); + assert_eq!(scrolled.x, top.x); + assert_eq!(scrolled.y, top.y + 150.0, "the press counts the scroll"); } -#[gpui::test] -fn backspace_at_the_start_joins_up_and_undo_parts_them(cx: &mut TestAppContext) { - let (editor, cx) = open( - vec![ - BlockData::new(BlockKind::Paragraph, ["tone "]), - BlockData::new(BlockKind::Paragraph, ["lift"]), - ], - cx, - ); - caret(&editor, 1, 0, cx); - cx.simulate_keystrokes("backspace"); - settle(cx); - assert_eq!( - blocks(&editor, cx), - [text(BlockKind::Paragraph, "tone lift")] - ); - cx.simulate_keystrokes("cmd-z"); - settle(cx); - assert_eq!( - blocks(&editor, cx), - [ - text(BlockKind::Paragraph, "tone "), - text(BlockKind::Paragraph, "lift") - ], - "undo brings the joined block back" - ); -} - -#[gpui::test] -fn markdown_typed_at_a_paragraphs_start_changes_its_kind(cx: &mut TestAppContext) { - let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, [""])], cx); - caret(&editor, 0, 0, cx); - cx.simulate_input("## Lift"); - settle(cx); - assert_eq!(blocks(&editor, cx), [text(BlockKind::Heading(2), "Lift")]); -} - -#[gpui::test] -fn backspace_turns_a_heading_back_into_text(cx: &mut TestAppContext) { - let (editor, cx) = open(vec![BlockData::new(BlockKind::Heading(1), ["Lift"])], cx); - caret(&editor, 0, 0, cx); - cx.simulate_keystrokes("backspace"); - settle(cx); - assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "Lift")]); -} - -#[gpui::test] -fn a_press_in_a_blocks_text_puts_the_caret_there(cx: &mut TestAppContext) { - let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["lift"])], cx); - cx.simulate_click( - gpui::point(gpui::px(300.0), gpui::px(10.0)), - gpui::Modifiers::none(), - ); - settle(cx); - let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx)); - assert!(focused.is_some(), "the press focuses the block's field"); - cx.simulate_keystrokes("enter"); - settle(cx); - assert_eq!(blocks(&editor, cx).len(), 2); -} - -/// The editor at a fixed width inside a row, as a page holds it. -struct Hosted(Entity); - -impl gpui::Render for Hosted { - fn render( - &mut self, - _: &mut gpui::Window, - _: &mut gpui::Context, - ) -> impl gpui::IntoElement { - use gpui::{ParentElement, Styled}; - gpui::div() - .flex() - .child(gpui::div().w(gpui::px(760.0)).child(self.0.clone())) +/// Scrolls `down`, makes the hit current, then pins a note at the same window point. +fn revealed(down: f32, cx: &mut TestAppContext) -> (usize, Point) { + let (reader, noted, cx) = open(cx); + if down > 0.0 { + wheel(down, cx); } -} - -#[gpui::test] -fn a_press_past_the_words_still_lands_in_the_block(cx: &mut TestAppContext) { - cx.update(|cx| { - Theme::init(cx); - forms::bind_keys(cx); - super::bind_keys(cx); - }); - let (host, cx) = cx.add_window_view(|window, cx| { - let editor = cx.new(|cx| { - BlockEditor::new( - vec![BlockData::new(BlockKind::Paragraph, ["lift"])], - window, - cx, - ) - }); - Hosted(editor) + reader.update(cx, |reader, cx| { + reader.current = Some(0); + cx.notify(); }); settle(cx); - cx.simulate_click( - gpui::point(gpui::px(700.0), gpui::px(10.0)), - gpui::Modifiers::none(), - ); - settle(cx); - let editor = cx.update(|_, cx| host.read(cx).0.clone()); - let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx)); - assert!(focused.is_some(), "the press focuses the block's field"); + pin(cx, ¬ed) } #[gpui::test] -fn a_press_beside_a_blocks_text_puts_the_caret_at_its_end(cx: &mut TestAppContext) { - let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["lift"])], cx); - cx.simulate_click( - gpui::point(gpui::px(300.0), gpui::px(1.0)), - gpui::Modifiers::none(), - ); - settle(cx); - let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx)); - assert_eq!( - focused, - Some((editor.read_with(cx, |editor, _| editor.blocks[0].key), 0)) - ); -} - -/// Puts block `ix`'s field `field` in focus with `range` selected. -fn select( - editor: &Entity, - ix: usize, - field: usize, - range: std::ops::Range, - cx: &mut VisualTestContext, -) { - cx.update(|window, cx| { - let field = editor.read(cx).blocks[ix].fields[field].clone(); - field.update(cx, |field, cx| field.select(range, cx)); - window.focus(&field.focus_handle(cx)); - }); - settle(cx); -} - -#[gpui::test] -fn enter_over_a_selection_drops_it_and_splits_there(cx: &mut TestAppContext) { - let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["abcdef"])], cx); - select(&editor, 0, 0, 2..4, cx); - cx.simulate_keystrokes("enter"); - settle(cx); - assert_eq!( - blocks(&editor, cx), - [ - text(BlockKind::Paragraph, "ab"), - text(BlockKind::Paragraph, "ef") - ] - ); - cx.simulate_keystrokes("cmd-z"); - settle(cx); - assert_eq!( - blocks(&editor, cx), - [text(BlockKind::Paragraph, "abcdef")], - "one step undoes it" - ); -} - -#[gpui::test] -fn undo_puts_the_caret_back_where_the_change_began(cx: &mut TestAppContext) { - let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["abcdef"])], cx); - caret(&editor, 0, 3, cx); - cx.simulate_keystrokes("enter"); - settle(cx); - cx.simulate_keystrokes("cmd-z"); - settle(cx); - let first = cx.update(|_, cx| editor.read(cx).blocks[0].key); - let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx)); - assert_eq!(focused, Some((first, 0))); - cx.simulate_keystrokes("cmd-shift-z"); - settle(cx); - assert_eq!( - blocks(&editor, cx), - [ - text(BlockKind::Paragraph, "abc"), - text(BlockKind::Paragraph, "def") - ], - "redo splits again" - ); - cx.simulate_keystrokes("cmd-z"); - settle(cx); - cx.simulate_input("x"); - settle(cx); - assert_eq!( - blocks(&editor, cx), - [text(BlockKind::Paragraph, "abcxdef")], - "typing lands at the caret undo put back" - ); -} - -#[gpui::test] -fn keys_into_a_merged_cell_land_on_the_cell_that_shows(cx: &mut TestAppContext) { - let table = BlockKind::Table { - columns: 2, - merged: vec![(1, 1)], - }; - let (editor, cx) = open(vec![BlockData::new(table, ["A", "B", "c", ""])], cx); - select(&editor, 0, 0, 1..1, cx); - cx.update(|window, cx| { - let field = editor.read(cx).blocks[0].fields[1].clone(); - window.focus(&field.focus_handle(cx)); - }); - settle(cx); - cx.simulate_keystrokes("down"); - settle(cx); - let key = cx.update(|_, cx| editor.read(cx).blocks[0].key); - let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx)); - assert_eq!( - focused, - Some((key, 2)), - "down from B lands on the cell c spans" - ); -} - -#[gpui::test] -fn undoing_a_kind_change_brings_back_its_styling(cx: &mut TestAppContext) { - let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["**lift**"])], cx); - cx.update(|window, cx| { - let key = editor.read(cx).blocks[0].key; - editor.update(cx, |editor, cx| { - editor.turn_into(key, BlockKind::Code, window, cx) - }); - }); - settle(cx); - caret(&editor, 0, 0, cx); - cx.simulate_keystrokes("cmd-z"); - settle(cx); - let bold = cx.update(|_, cx| { - let field = editor.read(cx).blocks[0].fields[0].clone(); - field - .read(cx) - .highlights(cx) - .iter() - .any(|(_, highlight)| highlight.weight.is_some()) - }); - assert_eq!( - blocks(&editor, cx), - [text(BlockKind::Paragraph, "**lift**")] - ); - assert!(bold, "the paragraph styles its markdown again"); -} - -#[gpui::test] -fn undo_of_typing_restores_the_selection_it_replaced(cx: &mut TestAppContext) { - let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["abcdef"])], cx); - select(&editor, 0, 0, 2..4, cx); - cx.simulate_keystrokes("x"); - settle(cx); - assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "abxef")]); - cx.simulate_keystrokes("cmd-z"); - settle(cx); - let selection = cx.update(|_, cx| editor.read(cx).blocks[0].fields[0].read(cx).selection()); - assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "abcdef")]); - assert_eq!(selection, 2..4, "undo selects what the typing replaced"); -} - -#[gpui::test] -fn up_into_a_table_skips_its_hidden_cells(cx: &mut TestAppContext) { - let table = BlockKind::Table { - columns: 2, - merged: vec![(1, 1)], - }; - let (editor, cx) = open( - vec![ - BlockData::new(table, ["A", "B", "c", ""]), - BlockData::new(BlockKind::Paragraph, ["after"]), - ], - cx, - ); - caret(&editor, 1, 0, cx); - cx.simulate_keystrokes("up"); - settle(cx); - let key = cx.update(|_, cx| editor.read(cx).blocks[0].key); - let focused = cx.update(|window, cx| editor.read(cx).focused(window, cx)); - assert_eq!( - focused, - Some((key, 2)), - "up lands on the cell that shows in the last row" - ); -} - -#[gpui::test] -fn undo_of_a_delete_or_a_paste_restores_the_selection(cx: &mut TestAppContext) { - let (editor, cx) = open(vec![BlockData::new(BlockKind::Paragraph, ["abcdef"])], cx); - let selection = |cx: &mut VisualTestContext| { - cx.update(|_, cx| editor.read(cx).blocks[0].fields[0].read(cx).selection()) - }; - select(&editor, 0, 0, 2..4, cx); - cx.simulate_keystrokes("backspace"); - settle(cx); - assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "abef")]); - cx.simulate_keystrokes("cmd-z"); - settle(cx); - assert_eq!(selection(cx), 2..4, "undo of a delete selects what it took"); - cx.update(|_, cx| cx.write_to_clipboard(gpui::ClipboardItem::new_string("xy".into()))); - select(&editor, 0, 0, 2..4, cx); - cx.simulate_keystrokes("cmd-v"); - settle(cx); - assert_eq!(blocks(&editor, cx), [text(BlockKind::Paragraph, "abxyef")]); - cx.simulate_keystrokes("cmd-z"); - settle(cx); - assert_eq!( - selection(cx), - 2..4, - "undo of a paste selects what it replaced" - ); +fn a_current_hit_comes_to_one_place_from_any_scroll(cx: &mut TestAppContext) { + let from_top = revealed(0.0, cx); + let from_below = revealed(300.0, cx); + assert_eq!(from_top.0, 1, "the hit's page comes into view"); + assert_eq!(from_top, from_below); } diff --git a/src/documents/thumbs.rs b/src/documents/thumbs.rs new file mode 100644 index 0000000..bb541af --- /dev/null +++ b/src/documents/thumbs.rs @@ -0,0 +1,101 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce, + StatefulInteractiveElement, Styled, Window, div, img, prelude::*, +}; + +use super::viewer::DocPage; +use crate::{ + documents::blocks::source, + forms::Pick, + theme::{ActiveTheme, Radius, TextSize}, + typography::tabular, +}; + +/// Share of the label column a thumbnail spans. +const THUMB: f32 = 0.75; + +/// Pages as small pictures down a column, each numbered, the current one outlined; a press goes to a page. +#[derive(IntoElement)] +pub struct PageThumbnailList { + id: ElementId, + pages: Rc>, + current: usize, + on_pick: Option, +} + +impl PageThumbnailList { + pub fn new( + id: impl Into, + pages: impl Into>>, + current: usize, + ) -> Self { + Self { + id: id.into(), + pages: pages.into(), + current, + on_pick: None, + } + } + + pub fn on_pick(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_pick = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for PageThumbnailList { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let width = theme.label_width() * THUMB; + div() + .id(self.id.clone()) + .h_full() + .overflow_y_scroll() + .flex() + .flex_col() + .items_center() + .gap_3() + .p_3() + .children(self.pages.iter().enumerate().map(|(ix, page)| { + let current = ix == self.current; + let pick = self.on_pick.clone(); + div() + .id((self.id.clone(), format!("thumb-{ix}"))) + .flex() + .flex_col() + .items_center() + .gap_1() + .cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .when_some(pick, |thumb, pick| { + thumb.on_click(move |_, window, cx| { + log::info!("page thumbnails: page {ix}"); + pick(ix, window, cx) + }) + }) + .child( + div() + .w(width) + .h(width * (page.height / page.width)) + .rounded(theme.radius(Radius::Sm)) + .border_1() + .border_color(if current { colors.focus } else { colors.border }) + .bg(colors.paper) + .child( + img(source(&page.source)) + .id((self.id.clone(), format!("thumb-picture-{ix}"))) + .size_full() + .rounded(theme.radius(Radius::Sm)), + ), + ) + .child( + tabular(div().text_size(theme.text_size(TextSize::Xs))) + .text_color(if current { colors.fg } else { colors.fg_subtle }) + .child(format!("{}", ix + 1)), + ) + })) + } +} diff --git a/src/documents/viewer.rs b/src/documents/viewer.rs new file mode 100644 index 0000000..660d1b2 --- /dev/null +++ b/src/documents/viewer.rs @@ -0,0 +1,487 @@ +use std::rc::Rc; + +use gpui::{ + App, Bounds, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, + Point, RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Window, + canvas, div, img, point, prelude::*, +}; + +use super::{ + find::{Find, reveal}, + thumbs::PageThumbnailList, +}; +use crate::{ + buttons::{ButtonVariant, IconButton, ToggleButton, ToggleItem}, + documents::blocks::source, + editor::FindWidget, + overlays::HoverCard, + primitives::{Icon, IconName}, + shell::ToolbarSeparator, + theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize}, + typography::tabular, +}; + +/// Zoom steps, as shares of a page's own size. +const STEPS: [f32; 9] = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; + +/// A page the app drew: its picture, a file or a web address, and its size in points. +#[derive(Clone, Debug, PartialEq)] +pub struct DocPage { + pub source: SharedString, + pub width: f32, + pub height: f32, +} + +/// A stretch of a page a search found, in the page's points. +#[derive(Clone, Debug, PartialEq)] +pub struct PageHit { + pub page: usize, + pub bounds: Bounds, +} + +/// A note pinned to a page, at a point in its points. +#[derive(Clone, Debug, PartialEq)] +pub struct PageNote { + pub page: usize, + pub at: Point, + pub text: SharedString, +} + +type OnNote = Rc, &mut Window, &mut App)>; +type OnZoom = Rc; + +/// The zoom step past `zoom` in `direction`, when there is one. +pub(crate) fn step(zoom: f32, up: bool) -> Option { + if up { + STEPS.into_iter().find(|step| *step > zoom + f32::EPSILON) + } else { + STEPS + .into_iter() + .rev() + .find(|step| *step < zoom - f32::EPSILON) + } +} + +/// The first page whose bottom lies past `y`, or the last; the first before any layout. +pub(crate) fn page_at(bottoms: &[Pixels], y: Pixels) -> usize { + bottoms + .iter() + .position(|bottom| *bottom >= y) + .unwrap_or(bottoms.len().saturating_sub(1)) +} + +/// The page under the middle of the view, from the last layout. +fn page_in_view(scroll: &ScrollHandle, count: usize) -> usize { + let middle = scroll.bounds().center().y - scroll.offset().y; + let bottoms: Vec = (0..count) + .map_while(|ix| scroll.bounds_for_item(ix)) + .map(|page| page.bottom()) + .collect(); + page_at(&bottoms, middle) +} + +/// Pages the app drew, one under another on a desk: the page in view and its count, zoom by steps or to the width, the pages as thumbnails beside, a find bar whose hits light on the page, and notes pinned where a press puts them. +#[derive(IntoElement)] +pub struct DocumentViewer { + id: ElementId, + title: SharedString, + pages: Rc>, + zoom: f32, + on_zoom: Option, + thumbnails: bool, + find: Option, + notes: Vec, + on_note: Option, +} + +impl DocumentViewer { + pub fn new( + id: impl Into, + title: impl Into, + pages: impl Into>>, + ) -> Self { + let pages = pages.into(); + for page in pages.iter() { + assert!( + page.width > 0.0 && page.height > 0.0, + "page {} has no size", + page.source + ); + } + Self { + id: id.into(), + title: title.into(), + pages, + zoom: 1.0, + on_zoom: None, + thumbnails: false, + find: None, + notes: Vec::new(), + on_note: None, + } + } + + /// Scales pages from their size in points; the owner keeps the zoom the steps and fit ask for. + pub fn zoom( + mut self, + zoom: f32, + on_zoom: impl Fn(f32, &mut Window, &mut App) + 'static, + ) -> Self { + assert!(zoom > 0.0, "a zoom of {zoom} shows nothing"); + self.zoom = zoom; + self.on_zoom = Some(Rc::new(on_zoom)); + self + } + + /// Shows the pages small beside the large ones. + pub fn thumbnails(mut self) -> Self { + self.thumbnails = true; + self + } + + /// The owner's find box, floating over the pages; the owner searches them, and `hits` light where they are, the current one scrolled into view. + pub fn find(mut self, widget: FindWidget, hits: Vec, current: Option) -> Self { + assert!( + current.is_none_or(|current| current < hits.len()), + "hit {current:?} of {}", + hits.len() + ); + self.find = Some(Find { + widget, + hits, + current, + }); + self + } + + /// Notes on the pages; with `on_note`, a press on a page while notes are on asks the owner to pin one there. + pub fn notes( + mut self, + notes: Vec, + on_note: impl Fn(usize, Point, &mut Window, &mut App) + 'static, + ) -> Self { + self.notes = notes; + self.on_note = Some(Rc::new(on_note)); + self + } +} + +/// The viewer's own state: its scroll, the page in view, the width it fits, whether presses pin notes, and the hit last brought into view. +#[derive(Default)] +struct Desk { + scroll: ScrollHandle, + page: usize, + width: f32, + pinning: bool, + shown_hit: Option, +} + +impl RenderOnce for DocumentViewer { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let desk = window.use_keyed_state((self.id.clone(), "desk"), cx, |_, _| Desk::default()); + let (scroll, page_now, width, pinning) = { + let desk = desk.read(cx); + (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(); + let go = |page: usize, scroll: &ScrollHandle| { + let scroll = scroll.clone(); + move |_: &gpui::ClickEvent, _: &mut Window, _: &mut App| { + scroll.scroll_to_top_of_item(page) + } + }; + let zoom_to = |next: Option, icon: IconName, tip: &'static str| { + let on_zoom = self.on_zoom.clone(); + IconButton::new((self.id.clone(), tip), icon) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip(tip) + .disabled(next.is_none() || on_zoom.is_none()) + .when_some(next.zip(on_zoom), |button, (next, on_zoom)| { + button.on_click(move |_, window, cx| on_zoom(next, window, cx)) + }) + }; + let widest = self.pages.iter().map(|page| page.width).fold(0.0, f32::max); + let fit = (width > 0.0 && widest > 0.0) + .then(|| (width / widest).clamp(STEPS[0], STEPS[STEPS.len() - 1])); + let header = div() + .flex() + .items_center() + .gap_2() + .px_3() + .h(theme.control_height(ControlSize::Lg)) + .border_b_1() + .border_color(colors.border) + .child( + div() + .flex_1() + .min_w_0() + .text_color(colors.fg_muted) + .child(self.title.clone()), + ) + .child( + IconButton::new((self.id.clone(), "previous"), IconName::ChevronUp) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip("Previous page") + .disabled(page_now == 0) + .on_click(go(page_now.saturating_sub(1), &scroll)), + ) + .child( + tabular(div().text_size(theme.text_size(TextSize::Sm))) + .child(format!("{} of {count}", page_now + 1)), + ) + .child( + IconButton::new((self.id.clone(), "next"), IconName::ChevronDown) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip("Next page") + .disabled(page_now + 1 >= count) + .on_click(go((page_now + 1).min(count.saturating_sub(1)), &scroll)), + ) + .child(ToolbarSeparator) + .child(zoom_to(step(zoom, false), IconName::ZoomOut, "Zoom out")) + .child( + tabular( + div() + .min_w_10() + .text_center() + .text_size(theme.text_size(TextSize::Sm)), + ) + .child(format!("{:.0}%", zoom * 100.0)), + ) + .child(zoom_to(step(zoom, true), IconName::ZoomIn, "Zoom in")) + .child(zoom_to(fit, IconName::Maximize2, "Fit to width")) + .when(self.on_note.is_some(), |header| { + let desk = desk.clone(); + header.child( + ToggleButton::new( + (self.id.clone(), "pin"), + ToggleItem::new("notes") + .icon(IconName::MessageSquare) + .tooltip("Pin notes"), + pinning, + ) + .size(ControlSize::Sm) + .on_toggle(move |on, _, cx| { + desk.update(cx, |desk, cx| { + desk.pinning = on; + cx.notify(); + }) + }), + ) + }); + let (widget, hits, current) = match self.find { + Some(Find { + widget, + hits, + current, + }) => (Some(widget), hits, current), + None => (None, Vec::new(), None), + }; + 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() + .w(Pixels::from(page.width * zoom)) + .h(Pixels::from(page.height * zoom)) + .bg(colors.paper) + .shadow(theme.elevation(Elevation::Raised)) + .when(pinning, |page| page.cursor_crosshair()) + .when_some(on_note, |page, on_note| { + page.on_mouse_down(MouseButton::Left, move |event, window, cx| { + let Some(bounds) = scroll.bounds_for_item(ix) else { + return; + }; + let at = event.position - bounds.origin - scroll.offset(); + let at = point(f32::from(at.x) / zoom, f32::from(at.y) / zoom); + log::info!("document viewer: note asked on page {ix} at {at:?}"); + on_note(ix, at, window, cx); + }) + }) + .child( + img(source(&page.source)) + .id((self.id.clone(), format!("picture-{ix}"))) + .size_full(), + ) + .children( + hits.iter() + .enumerate() + .filter(|(_, hit)| hit.page == ix) + .map(|(at, hit)| { + div() + .absolute() + .left(Pixels::from(hit.bounds.origin.x * zoom)) + .top(Pixels::from(hit.bounds.origin.y * zoom)) + .w(Pixels::from(hit.bounds.size.width * zoom)) + .h(Pixels::from(hit.bounds.size.height * zoom)) + .rounded(theme.radius(Radius::Sm)) + .bg(if current == Some(at) { + colors.warning.opacity(0.45) + } else { + colors.warning.opacity(0.2) + }) + }), + ) + .children( + self.notes + .iter() + .enumerate() + .filter(|(_, note)| note.page == ix) + .map(|(at, note)| { + let text = note.text.clone(); + div() + .absolute() + .left(Pixels::from(note.at.x * zoom)) + .top(Pixels::from(note.at.y * zoom)) + .child(HoverCard::new( + (self.id.clone(), format!("note-{at}")), + div() + .size_6() + .flex() + .items_center() + .justify_center() + .rounded(theme.radius(Radius::Sm)) + .bg(colors.warning) + .child( + Icon::new(IconName::MessageSquare) + .size(IconSize::Xs) + .color(colors.on_accent), + ), + move |_, _| div().max_w_64().child(text.clone()), + )) + }), + ) + }); + let (tracked, measured) = (desk.clone(), desk.clone()); + let body = div() + .flex() + .flex_1() + .min_h_0() + .when(self.thumbnails, |body| { + let scroll = scroll.clone(); + body.child( + div() + .flex_none() + .border_r_1() + .border_color(colors.border) + .child( + PageThumbnailList::new( + (self.id.clone(), "thumbs"), + self.pages.clone(), + page_now, + ) + .on_pick(move |page, _, _| scroll.scroll_to_top_of_item(page)), + ), + ) + }) + .child( + div() + .relative() + .flex_1() + .min_w_0() + .bg(colors.sunken) + .child( + div() + .id((self.id.clone(), "pages")) + .size_full() + .overflow_scroll() + .track_scroll(&scroll) + .flex() + .flex_col() + .items_center() + .gap_4() + .p_6() + .children(pages), + ) + .child( + canvas( + move |bounds, window, cx| { + let (page, width) = { + let desk = tracked.read(cx); + ( + page_in_view(&desk.scroll, count), + f32::from(bounds.size.width), + ) + }; + if (page, width) != (tracked.read(cx).page, tracked.read(cx).width) + { + measured.update(cx, |desk, cx| { + desk.page = page; + desk.width = width; + cx.notify(); + }); + window.request_animation_frame(); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) + .children( + widget.map(|widget| div().absolute().top_2().right_2().child(widget)), + ), + ); + div() + .id(self.id) + .size_full() + .flex() + .flex_col() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(colors.border) + .overflow_hidden() + .bg(colors.surface) + .child(header) + .child(body) + } +} + +#[cfg(test)] +mod tests { + use gpui::px; + + use super::{page_at, step}; + + #[test] + fn the_page_in_view_is_the_one_under_the_middle() { + let bottoms = [px(100.0), px(220.0), px(340.0)]; + assert_eq!(page_at(&bottoms, px(50.0)), 0); + assert_eq!(page_at(&bottoms, px(150.0)), 1); + assert_eq!(page_at(&bottoms, px(500.0)), 2, "past the end is the last"); + assert_eq!(page_at(&[], px(50.0)), 0, "no layout yet"); + } + + #[test] + fn zoom_steps_walk_the_scale_and_stop_at_its_ends() { + assert_eq!(step(1.0, true), Some(1.25)); + assert_eq!(step(1.0, false), Some(0.75)); + assert_eq!( + step(1.1, false), + Some(1.0), + "between steps goes to the nearer side" + ); + assert_eq!(step(4.0, true), None); + assert_eq!(step(0.25, false), None); + } +} diff --git a/src/editor/find.rs b/src/editor/find.rs index 8392270..67754a7 100644 --- a/src/editor/find.rs +++ b/src/editor/find.rs @@ -55,7 +55,7 @@ type OnOptions = Rc; type OnBool = Rc; type OnClose = Rc; -/// A find box over code: the query with its options, where the current match stands among all, steps between them, and a replace row that folds away. +/// A find box: the query, its options when the owner takes them, where the current match stands among all, steps between them, and a replace row that folds away when the owner offers one. #[derive(IntoElement)] pub struct FindWidget { id: ElementId, @@ -208,45 +208,49 @@ impl RenderOnce for FindWidget { .text_size(theme.text_size(TextSize::Xs)) .child( row() - .child(icon_button( - "fold", - if showing { - IconName::ChevronDown - } else { - IconName::ChevronRight - }, - "Toggle replace", - true, - self.on_toggle_replace.clone(), - !showing, - )) + .children(self.on_toggle_replace.clone().map(|fold| { + icon_button( + "fold", + if showing { + IconName::ChevronDown + } else { + IconName::ChevronRight + }, + "Toggle replace", + true, + Some(fold), + !showing, + ) + })) .child( div() .flex_1() .min_w_0() .child(Input::new(&self.find).size(ControlSize::Sm)), ) - .child(toggle( - "case", - IconName::CaseSensitive, - "Match case", - options.case, - |next, on| next.case = on, - )) - .child(toggle( - "word", - IconName::WholeWord, - "Whole word", - options.word, - |next, on| next.word = on, - )) - .child(toggle( - "regex", - IconName::Regex, - "Regular expression", - options.regex, - |next, on| next.regex = on, - )) + .when(self.on_options.is_some(), |row| { + row.child(toggle( + "case", + IconName::CaseSensitive, + "Match case", + options.case, + |next, on| next.case = on, + )) + .child(toggle( + "word", + IconName::WholeWord, + "Whole word", + options.word, + |next, on| next.word = on, + )) + .child(toggle( + "regex", + IconName::Regex, + "Regular expression", + options.regex, + |next, on| next.regex = on, + )) + }) .child( div() .w(theme.label_width() * 0.5) @@ -285,7 +289,9 @@ impl RenderOnce for FindWidget { .children(self.replace.as_ref().map(|field| { let (one, all) = (self.on_replace.clone(), self.on_replace.clone()); row() - .pl(theme.control_height(ControlSize::Sm)) + .when(self.on_toggle_replace.is_some(), |row| { + row.pl(theme.control_height(ControlSize::Sm)) + }) .child( div() .flex_1() diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs index e29e157..146bf41 100644 --- a/src/primitives/icon.rs +++ b/src/primitives/icon.rs @@ -33,6 +33,8 @@ macro_rules! icons { } icons! { + AArrowDown => "a-arrow-down", + AArrowUp => "a-arrow-up", Accessibility => "accessibility", Activity => "activity", AlarmClock => "alarm-clock", diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index 4ba19dd..5c45bdb 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -279,6 +279,16 @@ impl Theme { px_to_rems(20.0) } + /// A page's cover band. + pub fn page_cover(&self) -> Rems { + px_to_rems(208.0) + } + + /// A page's icon, an emoji set large. + pub fn page_icon(&self) -> Rems { + px_to_rems(64.0) + } + /// A picture's height in a skeleton card. pub fn skeleton_media(&self) -> Rems { px_to_rems(128.0) diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index 93ee0b4..e650ab6 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -25,33 +25,33 @@ ## T21b Documents · Reading -- [ ] MarkdownRenderer -- [ ] DocumentViewer — host pages -- [ ] PDFViewer — host pages; thumbnails, search, zoom, notes -- [ ] EpubReader — host chapters -- [ ] OfficePreview — host pages -- [ ] PageThumbnailList -- [ ] TableOfContents → `navigation::TableOfContents` -- [ ] DocumentOutline → `navigation::TableOfContents` -- [ ] Footnote -- [ ] Glossary / TooltipTerm -- [ ] ReadingProgress -- [ ] FocusMode / ZenMode -- [ ] PrintPreview — host pages +- [x] MarkdownRenderer +- [x] DocumentViewer — host pages +- [x] PDFViewer — host pages; thumbnails, search, zoom, notes → `documents::DocumentViewer` with `thumbnails`, `find` (an `editor::FindWidget`) and `notes` +- [x] EpubReader — host chapters +- [x] OfficePreview — host pages → `documents::DocumentViewer` +- [x] PageThumbnailList +- [x] TableOfContents → `navigation::TableOfContents`; `MarkdownRenderer::sections` and `outline` feed it +- [x] DocumentOutline → `navigation::TableOfContents` +- [x] Footnote +- [x] Glossary / TooltipTerm +- [x] ReadingProgress +- [x] FocusMode / ZenMode → `documents::ZenMode` +- [x] PrintPreview — host pages ## T21c Documents · Knowledge -- [ ] PageTree -- [ ] Backlinks -- [ ] GraphView → `charts::NetworkGraph` -- [ ] PageCover / PageIcon -- [ ] PageProperties -- [ ] DatabaseView — table, board, gallery, calendar, timeline -- [ ] TemplatePicker -- [ ] TrashBin -- [ ] VersionHistory -- [ ] PageHistoryDiff -- [ ] Favorites / Pinned +- [x] PageTree → `lists::Tree`, a page's emoji leading its label +- [x] Backlinks +- [x] GraphView → `charts::NetworkGraph` +- [x] PageCover / PageIcon +- [x] PageProperties → `data_display::PropertyGrid` +- [x] DatabaseView — table, board, gallery, calendar, timeline → lands with Project (T31): its board is the KanbanBoard, its calendar the CalendarMonthView (T30) +- [x] TemplatePicker +- [x] TrashBin +- [x] VersionHistory +- [x] PageHistoryDiff +- [x] Favorites / Pinned → `documents::Favorites` ## T22 Collaboration — `src/collab` diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index e865cdf..019c605 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -22,6 +22,7 @@ - [ ] WorkloadView - [ ] TimeTracker - [ ] Pomodoro Timer +- [ ] DatabaseView (from T21c) — table, board, gallery, calendar, timeline ## T32 Canvas & Design — `src/canvas`