|
| 1 | +(in-ns 'cljfx.dev) |
| 2 | +(import '[javafx.scene.input KeyEvent] |
| 3 | + '[javafx.stage Popup] |
| 4 | + '[javafx.scene Node]) |
| 5 | +(require '[cljfx.ext.list-view :as fx.ext.list-view] |
| 6 | + '[cljfx.prop :as prop] |
| 7 | + '[cljfx.mutator :as mutator] |
| 8 | + '[cljfx.component :as component] |
| 9 | + '[cljfx.css :as css]) |
| 10 | + |
| 11 | +(def ^:private help-ui-css |
| 12 | + (css/register ::css |
| 13 | + {".list-view" |
| 14 | + {:-fx-background-color :transparent |
| 15 | + :-fx-border-width [0 1 0 0] |
| 16 | + :-fx-border-color "#aaa" |
| 17 | + ":focused > .virtual-flow > .clipped-container > .sheet > .list-cell:focused" |
| 18 | + {:-fx-background-color "#4E84E0"}} |
| 19 | + ".popup-root" {:-fx-background-color "#ddd" |
| 20 | + :-fx-effect "dropshadow(gaussian, #0006, 8, 0, 0, 2)"} |
| 21 | + ".filter-term" {:-fx-background-color "#42B300" |
| 22 | + :-fx-background-radius 2 |
| 23 | + :-fx-effect "dropshadow(gaussian, #0006, 4, 0, 0, 2)" |
| 24 | + :-fx-padding [2 4]} |
| 25 | + ".list-cell" {:-fx-background-color :transparent |
| 26 | + :-fx-text-fill "#000" |
| 27 | + :-fx-font-size 14 |
| 28 | + :-fx-padding [2 4] |
| 29 | + ":selected" {:-fx-background-color "#4E84E033"}} |
| 30 | + ".text-area" {:-fx-background-color :transparent |
| 31 | + :-fx-focus-traversable false |
| 32 | + :-fx-text-fill "#000" |
| 33 | + " .content" {:-fx-background-color :transparent}} |
| 34 | + ".scroll-pane" {:-fx-background-color :transparent |
| 35 | + :-fx-padding 0 |
| 36 | + "> .viewport" {:-fx-background-color :transparent}} |
| 37 | + ".scroll-bar" {:-fx-background-color :transparent |
| 38 | + "> .thumb" {:-fx-background-color "#999" |
| 39 | + :-fx-background-insets 0 |
| 40 | + :-fx-background-radius 4 |
| 41 | + ":hover" {:-fx-background-color "#9c9c9c"} |
| 42 | + ":pressed" {:-fx-background-color "#aaa"}} |
| 43 | + ":horizontal" {"> .increment-button > .increment-arrow" {:-fx-pref-height 7} |
| 44 | + "> .decrement-button > .decrement-arrow" {:-fx-pref-height 7}} |
| 45 | + ":vertical" {"> .increment-button > .increment-arrow" {:-fx-pref-width 7} |
| 46 | + "> .decrement-button > .decrement-arrow" {:-fx-pref-width 7}} |
| 47 | + "> .decrement-button" {:-fx-padding 0 |
| 48 | + "> .decrement-arrow" {:-fx-shape nil |
| 49 | + :-fx-padding 0}} |
| 50 | + "> .increment-button" {:-fx-padding 0 |
| 51 | + "> .increment-arrow" {:-fx-shape nil |
| 52 | + :-fx-padding 0}}} |
| 53 | + ".corner" {:-fx-background-color :transparent}})) |
| 54 | + |
| 55 | +(defn- set-help-ui-selection [state {:keys [key fx/event]}] |
| 56 | + (update state key assoc :selection event)) |
| 57 | + |
| 58 | +(defn- set-help-ui-filter-term [state {:keys [key ^KeyEvent fx/event]}] |
| 59 | + (let [ch (.getCharacter event)] |
| 60 | + (cond |
| 61 | + (and (= 1 (count ch)) (#{127 27} (int (first ch)))) |
| 62 | + (update state key assoc :filter-term "") |
| 63 | + |
| 64 | + (= ch "\b") |
| 65 | + (update-in state [key :filter-term] #(cond-> % (pos? (count %)) (subs 0 (dec (count %))))) |
| 66 | + |
| 67 | + (= ch "\t") |
| 68 | + state |
| 69 | + |
| 70 | + (re-matches #"^[a-zA-Z0-9:/\-.+!@#$%^&*()-={}\[\]<>?,/\\'\"]$" ch) |
| 71 | + (update-in state [key :filter-term] str (.getCharacter event)) |
| 72 | + |
| 73 | + :else |
| 74 | + state))) |
| 75 | + |
| 76 | +(defn- help-list-view [{:keys [filter-term selection items key]}] |
| 77 | + {:fx/type :stack-pane |
| 78 | + :children [{:fx/type fx.ext.list-view/with-selection-props |
| 79 | + :props {:selected-item selection |
| 80 | + :on-selected-item-changed {:fn #'set-help-ui-selection :key key}} |
| 81 | + :desc {:fx/type :list-view |
| 82 | + :on-key-typed {:fn #'set-help-ui-filter-term :key key} |
| 83 | + :items items}} |
| 84 | + {:fx/type :label |
| 85 | + :visible (boolean (seq filter-term)) |
| 86 | + :style-class ["label" "filter-term"] |
| 87 | + :stack-pane/margin 7 |
| 88 | + :stack-pane/alignment :bottom-right |
| 89 | + :text filter-term}]}) |
| 90 | + |
| 91 | +(defn- process-filter-selection [{:keys [filter-term selection] :as m} items] |
| 92 | + (let [items (cond->> items |
| 93 | + (not (str/blank? filter-term)) |
| 94 | + (filter #(str/includes? % filter-term))) |
| 95 | + items (sort-by str items) |
| 96 | + selection (or (and selection (some #{selection} items)) |
| 97 | + (first items))] |
| 98 | + (assoc m :items items :selection selection))) |
| 99 | + |
| 100 | +(def ^:private ext-recreate-on-key-changed |
| 101 | + (reify lifecycle/Lifecycle |
| 102 | + (create [_ {:keys [key desc]} opts] |
| 103 | + (with-meta {:key key |
| 104 | + :child (lifecycle/create lifecycle/dynamic desc opts)} |
| 105 | + {`component/instance #(-> % :child component/instance)})) |
| 106 | + (advance [this component {:keys [key desc] :as this-desc} opts] |
| 107 | + (if (= (:key component) key) |
| 108 | + (update component :child #(lifecycle/advance lifecycle/dynamic % desc opts)) |
| 109 | + (do (lifecycle/delete this component opts) |
| 110 | + (lifecycle/create this this-desc opts)))) |
| 111 | + (delete [_ component opts] |
| 112 | + (lifecycle/delete lifecycle/dynamic (:child component) opts)))) |
| 113 | + |
| 114 | +(def ^:private ext-with-shown-on |
| 115 | + (fx/make-ext-with-props |
| 116 | + {:shown-on (prop/make |
| 117 | + (mutator/adder-remover |
| 118 | + (fn [^Popup popup ^Node node] |
| 119 | + (let [bounds (.getBoundsInLocal node) |
| 120 | + node-pos (.localToScreen node |
| 121 | + -8 |
| 122 | + (- (.getHeight bounds) 6))] |
| 123 | + (.show popup node |
| 124 | + (.getX node-pos) |
| 125 | + (.getY node-pos)))) |
| 126 | + (fn [^Popup popup _] |
| 127 | + (.hide popup))) |
| 128 | + lifecycle/dynamic)})) |
| 129 | + |
| 130 | +(defn- hover-help-syntax-element [state {:keys [path key]}] |
| 131 | + (assoc state key path)) |
| 132 | + |
| 133 | +(defn- hide-hover-help-popup [state {:keys [key]}] |
| 134 | + (update state key pop)) |
| 135 | + |
| 136 | +(defn- help-ui-syntax-view [{:keys [syntax key hover] :as props}] |
| 137 | + (letfn [(apply-syntax [syntax path] |
| 138 | + (cond |
| 139 | + (string? syntax) |
| 140 | + [{:fx/type :text |
| 141 | + :font {:family "monospace" :size 14} |
| 142 | + :text syntax}] |
| 143 | + |
| 144 | + (vector? syntax) |
| 145 | + (map-indexed |
| 146 | + (fn [i x] |
| 147 | + (cond |
| 148 | + (string? x) |
| 149 | + {:fx/type :text |
| 150 | + :font {:family "monospace" :size 14} |
| 151 | + :text x} |
| 152 | + |
| 153 | + (vector? x) |
| 154 | + (let [[text syntax] x |
| 155 | + current-path (conj path i)] |
| 156 | + {:fx/type fx/ext-let-refs |
| 157 | + :refs |
| 158 | + {::view |
| 159 | + {:fx/type :label |
| 160 | + :underline true |
| 161 | + :font {:family "monospace" :size 14} |
| 162 | + :text-fill "#000e26" |
| 163 | + :on-mouse-entered |
| 164 | + {:fn #'hover-help-syntax-element |
| 165 | + :path current-path |
| 166 | + :key key} |
| 167 | + :text text}} |
| 168 | + :desc |
| 169 | + {:fx/type fx/ext-let-refs |
| 170 | + :refs |
| 171 | + {::popup |
| 172 | + {:fx/type ext-with-shown-on |
| 173 | + :props (if (= current-path (take (count current-path) hover)) |
| 174 | + {:shown-on |
| 175 | + {:fx/type fx/ext-get-ref |
| 176 | + :ref ::view}} |
| 177 | + {}) |
| 178 | + :desc {:fx/type :popup |
| 179 | + :anchor-location :window-top-left |
| 180 | + :auto-hide true |
| 181 | + :hide-on-escape true |
| 182 | + :on-auto-hide {:fn #'hide-hover-help-popup |
| 183 | + :key key} |
| 184 | + :content [{:fx/type :stack-pane |
| 185 | + :max-width 960 |
| 186 | + :pref-height :use-computed-size |
| 187 | + :max-height 600 |
| 188 | + :style-class "popup-root" |
| 189 | + :children |
| 190 | + [{:fx/type :scroll-pane |
| 191 | + :content {:fx/type :text-flow |
| 192 | + :padding 5 |
| 193 | + :children (apply-syntax syntax current-path)}}]}]}}} |
| 194 | + :desc {:fx/type fx/ext-get-ref :ref ::view}}}) |
| 195 | + |
| 196 | + :else |
| 197 | + (throw (ex-info "Invalid syntax" {:syntax x})))) |
| 198 | + |
| 199 | + syntax) |
| 200 | + |
| 201 | + :else |
| 202 | + (throw (ex-info "Invalid syntax" {:syntax syntax}))))] |
| 203 | + (-> props |
| 204 | + (dissoc :syntax :key :hover) |
| 205 | + (assoc |
| 206 | + :fx/type :scroll-pane |
| 207 | + :content |
| 208 | + {:fx/type :text-flow |
| 209 | + :padding 5 |
| 210 | + :children (apply-syntax syntax [])})))) |
| 211 | + |
| 212 | +(defn- help-ui-view [{:keys [registry type prop type-hover prop-hover]}] |
| 213 | + (let [filtered-type-map (process-filter-selection type (keys (:types registry))) |
| 214 | + selected-type (:selection filtered-type-map) |
| 215 | + selected-props (-> registry :props (get selected-type)) |
| 216 | + filtered-prop-map (process-filter-selection prop (keys selected-props)) |
| 217 | + selected-prop-id (:selection filtered-prop-map) |
| 218 | + selected-prop (get selected-props selected-prop-id)] |
| 219 | + {:fx/type :stage |
| 220 | + :showing true |
| 221 | + :width 900 |
| 222 | + :scene |
| 223 | + {:fx/type :scene |
| 224 | + :stylesheets [(::css/url help-ui-css)] |
| 225 | + :root {:fx/type :grid-pane |
| 226 | + :style {:-fx-background-color "#ccc"} |
| 227 | + :column-constraints [{:fx/type :column-constraints |
| 228 | + :min-width 150 |
| 229 | + :max-width 150} |
| 230 | + {:fx/type :column-constraints |
| 231 | + :min-width 150 |
| 232 | + :max-width 150} |
| 233 | + {:fx/type :column-constraints |
| 234 | + :hgrow :always}] |
| 235 | + :row-constraints [{:fx/type :row-constraints |
| 236 | + :min-height 100 |
| 237 | + :max-height 100} |
| 238 | + {:fx/type :row-constraints |
| 239 | + :vgrow :always}] |
| 240 | + :children [(assoc filtered-type-map |
| 241 | + :grid-pane/column 0 |
| 242 | + :grid-pane/row 0 |
| 243 | + :grid-pane/row-span 2 |
| 244 | + :fx/type help-list-view |
| 245 | + :key :type) |
| 246 | + {:fx/type help-ui-syntax-view |
| 247 | + :style {:-fx-border-width [0 0 1 0] |
| 248 | + :-fx-border-color "#aaa"} |
| 249 | + :grid-pane/column 1 |
| 250 | + :grid-pane/column-span 2 |
| 251 | + :grid-pane/row 0 |
| 252 | + :key :type-hover |
| 253 | + :hover type-hover |
| 254 | + :syntax (if selected-type |
| 255 | + (let [type-map (get (:types registry) selected-type)] |
| 256 | + (str "Cljfx type: " selected-type |
| 257 | + (when (symbol? (:of type-map)) |
| 258 | + (str "\nInstance class: " (:of type-map))) |
| 259 | + (when (:req type-map) |
| 260 | + (if (set? (:req type-map)) |
| 261 | + (str "\nRequired props, either:\n" |
| 262 | + (str/join "\n" (for [req (:req type-map)] |
| 263 | + (str "- " (str/join ", " (sort req)))))))) |
| 264 | + (when (and (not selected-props) (:spec type-map)) |
| 265 | + (str "\nSpec: " (pr-str (s/describe (:spec type-map))))))) |
| 266 | + "")} |
| 267 | + {:fx/type ext-recreate-on-key-changed |
| 268 | + :grid-pane/row 1 |
| 269 | + :grid-pane/column 1 |
| 270 | + :key selected-type |
| 271 | + :desc (assoc filtered-prop-map :fx/type help-list-view :key :prop)} |
| 272 | + {:fx/type help-ui-syntax-view |
| 273 | + :grid-pane/row 1 |
| 274 | + :grid-pane/column 2 |
| 275 | + :key :prop-hover |
| 276 | + :hover prop-hover |
| 277 | + :syntax (if selected-prop |
| 278 | + (long-prop-help-syntax selected-prop) |
| 279 | + "")}]}}})) |
| 280 | + |
| 281 | +(defn- launch-help-ui! [] |
| 282 | + (let [state (atom {:registry @registry |
| 283 | + :type {:selection nil |
| 284 | + :filter-term ""} |
| 285 | + :prop {:selection nil |
| 286 | + :filter-term ""}}) |
| 287 | + render (fx/create-renderer |
| 288 | + :opts {:fx.opt/type->lifecycle type->lifecycle |
| 289 | + :fx.opt/map-event-handler #(swap! state (:fn %) %)} |
| 290 | + :middleware (fx/wrap-map-desc #(assoc % :fx/type help-ui-view)))] |
| 291 | + (fx/mount-renderer state render))) |
0 commit comments