Put the caret where the click lands, and paste into the field
A click on a text field focused it and left the caret wherever it was, so fixing a typo in the middle of a message meant arrowing to it one character at a time. And Ctrl+V did nothing: the field had no clipboard to read, so the only way anything got into it was typing. A press now places the caret at the character edge nearest the pointer — the right half of a letter means after it — read against the frame that was drawn: last frame's top line and sideways slide, because that is what the person aimed at. On a field with lines the click's height picks the line. Held as well as clicked, so a drag carries the caret along. Ctrl+V and Shift+Insert paste at the caret. Carriage returns become line breaks, and a field of one line flattens those to spaces for the same reason Shift+Enter takes nothing there. The clipboard is read through the context like the measurer, so a headless walk hands it a stub, and only when a paste is pressed. A paste that finds no text is recorded rather than dropped, and the backend fires `:on-paste-empty` — which is how frq pastes a picture: the compose box already sent that to `s/paste-image!`, and nothing on this backend had ever fired it. `sdl/clipboard` also stops leaking. It was a `:string` return, which converts the buffer and drops the pointer SDL needs freed; it reads the pointer and frees it now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c7d6ea8 parent: 48212a7 modified
glimmer-backends/glimmer-jvui/src/glimmer_jvui/core.clj +6 -1 | @@ -382,7 +382,12 @@ | ||
| 382 | 382 | ;; handing it the text is an arity error the moment somebody |
| 383 | 383 | ;; presses Enter. The text is already theirs; they got it from |
| 384 | 384 | ;; :on-change. |
| 385 | - (when (w/entry-activated? id) (fire! n :on-activate))) | |
| 385 | + (when (w/entry-activated? id) (fire! n :on-activate)) | |
| 386 | + ;; A paste that found no text on the clipboard. Also a thunk, and | |
| 387 | + ;; for frq the important one: that is how a picture is pasted — the | |
| 388 | + ;; same Ctrl+V as everything else, reaching `s/paste-image!` because | |
| 389 | + ;; the field had nothing to put in itself. libvidya's name for it. | |
| 390 | + (when (w/entry-paste-empty? id) (fire! n :on-paste-empty))) | |
| 386 | 391 | |
| 387 | 392 | :progress (w/progress (num (:value props) 0.0)) |
| 388 | 393 | :separator (w/separator) |
| @@ -382,7 +382,12 @@ | |||
| 382 | ;; handing it the text is an arity error the moment somebody | 382 | ;; handing it the text is an arity error the moment somebody |
| 383 | ;; presses Enter. The text is already theirs; they got it from | 383 | ;; presses Enter. The text is already theirs; they got it from |
| 384 | ;; :on-change. | 384 | ;; :on-change. |
| 385 | - (when (w/entry-activated? id) (fire! n :on-activate))) | 385 | + (when (w/entry-activated? id) (fire! n :on-activate)) |
| 386 | + ;; A paste that found no text on the clipboard. Also a thunk, and | ||
| 387 | + ;; for frq the important one: that is how a picture is pasted — the | ||
| 388 | + ;; same Ctrl+V as everything else, reaching `s/paste-image!` because | ||
| 389 | + ;; the field had nothing to put in itself. libvidya's name for it. | ||
| 390 | + (when (w/entry-paste-empty? id) (fire! n :on-paste-empty))) | ||
| 386 | 391 | ||
| 387 | :progress (w/progress (num (:value props) 0.0)) | 392 | :progress (w/progress (num (:value props) 0.0)) |
| 388 | :separator (w/separator) | 393 | :separator (w/separator) |
modified
glimmer-backends/glimmer-jvui/test/glimmer_jvui/tests.clj +27 -0 | @@ -151,6 +151,32 @@ | ||
| 151 | 151 | (check! (= "hi" (ra/deref text)) |
| 152 | 152 | (str "typed text reached :on-change: " (pr-str (ra/deref text)))))) |
| 153 | 153 | |
| 154 | +(defn- check-paste-reaches-the-client! [] | |
| 155 | + ;; Both halves of a paste, through the reconciler. Text lands in :on-change | |
| 156 | + ;; like typing; a clipboard with no text on it reaches :on-paste-empty, a | |
| 157 | + ;; THUNK, which is frq's `s/paste-image!` — the way a picture is pasted. | |
| 158 | + (let [root (jv/root-node) cx (ctx) | |
| 159 | + text (ra/atom "") | |
| 160 | + asked (ra/atom 0) | |
| 161 | + clip (atom "pasted")] | |
| 162 | + (swap! cx assoc :clipboard (fn [] @clip)) | |
| 163 | + (gui/mount root :page | |
| 164 | + [(fn [] [:entry {:value (ra/deref text) | |
| 165 | + :on-change #(ra/reset! text %) | |
| 166 | + :on-paste-empty #(ra/swap! asked inc)}])]) | |
| 167 | + (jv/render-once root cx) | |
| 168 | + (let [[ex ey] (centre (tagged root :entry))] | |
| 169 | + (jv/render-once root cx (click-at ex ey))) | |
| 170 | + (jv/render-once root cx [{:kind :key-down :key :v :ctrl? true}]) | |
| 171 | + (check! (= "pasted" (ra/deref text)) | |
| 172 | + (str "pasted text reached :on-change: " (pr-str (ra/deref text)))) | |
| 173 | + (check! (zero? (ra/deref asked)) "and a paste of text is not a request") | |
| 174 | + (reset! clip nil) | |
| 175 | + (jv/render-once root cx [{:kind :key-down :key :v :ctrl? true}]) | |
| 176 | + (check! (= 1 (ra/deref asked)) | |
| 177 | + "a paste with no text on the clipboard reached :on-paste-empty") | |
| 178 | + (check! (= "pasted" (ra/deref text)) "and changed nothing"))) | |
| 179 | + | |
| 154 | 180 | (defn- check-enter-sends! [] |
| 155 | 181 | ;; Enter is the one key a field must not swallow. frq sends its message |
| 156 | 182 | ;; on it, so a compose box that accepted text and never reported Enter |
| @@ -308,6 +334,7 @@ | ||
| 308 | 334 | ["a list sticks to the end" check-scroll-sticks!] |
| 309 | 335 | ["Enter sends" check-enter-sends!] |
| 310 | 336 | ["an entry round-trips" check-entry-round-trips!] |
| 337 | + ["a paste reaches the client" check-paste-reaches-the-client!] | |
| 311 | 338 | ["a checkbox round-trips" check-checkbox-round-trips!] |
| 312 | 339 | ["a patch settles before it paints" check-a-patch-settles-before-it-paints!]]) |
| 313 | 340 | |
| @@ -151,6 +151,32 @@ | |||
| 151 | (check! (= "hi" (ra/deref text)) | 151 | (check! (= "hi" (ra/deref text)) |
| 152 | (str "typed text reached :on-change: " (pr-str (ra/deref text)))))) | 152 | (str "typed text reached :on-change: " (pr-str (ra/deref text)))))) |
| 153 | 153 | ||
| 154 | +(defn- check-paste-reaches-the-client! [] | ||
| 155 | + ;; Both halves of a paste, through the reconciler. Text lands in :on-change | ||
| 156 | + ;; like typing; a clipboard with no text on it reaches :on-paste-empty, a | ||
| 157 | + ;; THUNK, which is frq's `s/paste-image!` — the way a picture is pasted. | ||
| 158 | + (let [root (jv/root-node) cx (ctx) | ||
| 159 | + text (ra/atom "") | ||
| 160 | + asked (ra/atom 0) | ||
| 161 | + clip (atom "pasted")] | ||
| 162 | + (swap! cx assoc :clipboard (fn [] @clip)) | ||
| 163 | + (gui/mount root :page | ||
| 164 | + [(fn [] [:entry {:value (ra/deref text) | ||
| 165 | + :on-change #(ra/reset! text %) | ||
| 166 | + :on-paste-empty #(ra/swap! asked inc)}])]) | ||
| 167 | + (jv/render-once root cx) | ||
| 168 | + (let [[ex ey] (centre (tagged root :entry))] | ||
| 169 | + (jv/render-once root cx (click-at ex ey))) | ||
| 170 | + (jv/render-once root cx [{:kind :key-down :key :v :ctrl? true}]) | ||
| 171 | + (check! (= "pasted" (ra/deref text)) | ||
| 172 | + (str "pasted text reached :on-change: " (pr-str (ra/deref text)))) | ||
| 173 | + (check! (zero? (ra/deref asked)) "and a paste of text is not a request") | ||
| 174 | + (reset! clip nil) | ||
| 175 | + (jv/render-once root cx [{:kind :key-down :key :v :ctrl? true}]) | ||
| 176 | + (check! (= 1 (ra/deref asked)) | ||
| 177 | + "a paste with no text on the clipboard reached :on-paste-empty") | ||
| 178 | + (check! (= "pasted" (ra/deref text)) "and changed nothing"))) | ||
| 179 | + | ||
| 154 | (defn- check-enter-sends! [] | 180 | (defn- check-enter-sends! [] |
| 155 | ;; Enter is the one key a field must not swallow. frq sends its message | 181 | ;; Enter is the one key a field must not swallow. frq sends its message |
| 156 | ;; on it, so a compose box that accepted text and never reported Enter | 182 | ;; on it, so a compose box that accepted text and never reported Enter |
| @@ -308,6 +334,7 @@ | |||
| 308 | ["a list sticks to the end" check-scroll-sticks!] | 334 | ["a list sticks to the end" check-scroll-sticks!] |
| 309 | ["Enter sends" check-enter-sends!] | 335 | ["Enter sends" check-enter-sends!] |
| 310 | ["an entry round-trips" check-entry-round-trips!] | 336 | ["an entry round-trips" check-entry-round-trips!] |
| 337 | + ["a paste reaches the client" check-paste-reaches-the-client!] | ||
| 311 | ["a checkbox round-trips" check-checkbox-round-trips!] | 338 | ["a checkbox round-trips" check-checkbox-round-trips!] |
| 312 | ["a patch settles before it paints" check-a-patch-settles-before-it-paints!]]) | 339 | ["a patch settles before it paints" check-a-patch-settles-before-it-paints!]]) |
| 313 | 340 | ||
modified
jvui/src/jvui/app.clj +3 -0 | @@ -21,6 +21,9 @@ | ||
| 21 | 21 | :painter painter |
| 22 | 22 | :measure (fn [s sz] (font/size-of fonts s sz)) |
| 23 | 23 | :line-height (fn [sz] (font/line-height fonts sz)) |
| 24 | + ;; A function for the reason `:measure` is one: a field asks the | |
| 25 | + ;; context, and a headless walk hands it a stub instead. | |
| 26 | + :clipboard host/clipboard-text | |
| 24 | 27 | :draw? false |
| 25 | 28 | :data {} :events [] :handled #{} :stack [] |
| 26 | 29 | :mouse [-1 -1] :mouse-down? false |
| @@ -21,6 +21,9 @@ | |||
| 21 | :painter painter | 21 | :painter painter |
| 22 | :measure (fn [s sz] (font/size-of fonts s sz)) | 22 | :measure (fn [s sz] (font/size-of fonts s sz)) |
| 23 | :line-height (fn [sz] (font/line-height fonts sz)) | 23 | :line-height (fn [sz] (font/line-height fonts sz)) |
| 24 | + ;; A function for the reason `:measure` is one: a field asks the | ||
| 25 | + ;; context, and a headless walk hands it a stub instead. | ||
| 26 | + :clipboard host/clipboard-text | ||
| 24 | :draw? false | 27 | :draw? false |
| 25 | :data {} :events [] :handled #{} :stack [] | 28 | :data {} :events [] :handled #{} :stack [] |
| 26 | :mouse [-1 -1] :mouse-down? false | 29 | :mouse [-1 -1] :mouse-down? false |
modified
jvui/src/jvui/host.clj +6 -0 | @@ -80,6 +80,12 @@ | ||
| 80 | 80 | [url] |
| 81 | 81 | (boolean (and (string? url) (seq url) (sdl/open-url! url)))) |
| 82 | 82 | |
| 83 | +(defn clipboard-text | |
| 84 | + "The clipboard's text, or nil — with no window, or when what is on it is not | |
| 85 | + text. What a field pastes; see `clipboard-image-png!` for the other kind." | |
| 86 | + [] | |
| 87 | + (when @win (sdl/clipboard))) | |
| 88 | + | |
| 83 | 89 | (defn clipboard-image-png! |
| 84 | 90 | "Write the clipboard's picture to `path` as PNG; true when there was one. |
| 85 | 91 | |
| @@ -80,6 +80,12 @@ | |||
| 80 | [url] | 80 | [url] |
| 81 | (boolean (and (string? url) (seq url) (sdl/open-url! url)))) | 81 | (boolean (and (string? url) (seq url) (sdl/open-url! url)))) |
| 82 | 82 | ||
| 83 | +(defn clipboard-text | ||
| 84 | + "The clipboard's text, or nil — with no window, or when what is on it is not | ||
| 85 | + text. What a field pastes; see `clipboard-image-png!` for the other kind." | ||
| 86 | + [] | ||
| 87 | + (when @win (sdl/clipboard))) | ||
| 88 | + | ||
| 83 | (defn clipboard-image-png! | 89 | (defn clipboard-image-png! |
| 84 | "Write the clipboard's picture to `path` as PNG; true when there was one. | 90 | "Write the clipboard's picture to `path` as PNG; true when there was one. |
| 85 | 91 | ||
modified
jvui/src/jvui/sdl.clj +21 -2 | @@ -51,7 +51,7 @@ | ||
| 51 | 51 | (ffi/defcfn ^:private raw-poll "SDL_PollEvent" [:pointer] :bool) |
| 52 | 52 | (ffi/defcfn start-text-input! "SDL_StartTextInput" [:pointer] :bool) |
| 53 | 53 | (ffi/defcfn stop-text-input! "SDL_StopTextInput" [:pointer] :bool) |
| 54 | -(ffi/defcfn clipboard "SDL_GetClipboardText" [] :string) | |
| 54 | +(ffi/defcfn ^:private raw-clipboard-text "SDL_GetClipboardText" [] :pointer) | |
| 55 | 55 | (ffi/defcfn clipboard! "SDL_SetClipboardText" [:string] :bool) |
| 56 | 56 | |
| 57 | 57 | (ffi/defcfn texture-from-surface "SDL_CreateTextureFromSurface" [:pointer :pointer] :pointer) |
| @@ -118,7 +118,11 @@ | ||
| 118 | 118 | (def keycodes |
| 119 | 119 | {13 :return 27 :escape 8 :backspace 9 :tab 32 :space 127 :delete |
| 120 | 120 | 1073741903 :right 1073741904 :left 1073741905 :down 1073741906 :up |
| 121 | - 1073741901 :end 1073741898 :home 1073741899 :page-up 1073741902 :page-down}) | |
| 121 | + 1073741901 :end 1073741898 :home 1073741899 :page-up 1073741902 :page-down | |
| 122 | + ;; The two keys a paste is pressed with. A letter is otherwise left as its | |
| 123 | + ;; code: a field takes what it types from text events, and only the ones a | |
| 124 | + ;; shortcut is built on need a name. | |
| 125 | + 118 :v 1073741897 :insert}) | |
| 122 | 126 | |
| 123 | 127 | (def ^:private MOD-SHIFT 3) (def ^:private MOD-CTRL 192) |
| 124 | 128 | |
| @@ -310,6 +314,21 @@ | ||
| 310 | 314 | (when (raw-display-bounds (display-for-window window) r) |
| 311 | 315 | [(ffi/read (+ r 8) :int) (ffi/read (+ r 12) :int)]))) |
| 312 | 316 | |
| 317 | +(defn clipboard | |
| 318 | + "The clipboard's text, or nil when it holds none. | |
| 319 | + | |
| 320 | + Through a pointer rather than a `:string` return because the buffer is | |
| 321 | + SDL's to allocate and ours to free, and a converted return has already | |
| 322 | + dropped the pointer that would free it — a leak per paste. SDL answers an | |
| 323 | + empty string rather than NULL when there is no text, and that is nil here: | |
| 324 | + a paste of nothing is a different request from a paste of \"\"." | |
| 325 | + [] | |
| 326 | + (let [p (raw-clipboard-text)] | |
| 327 | + (when (and p (not (ffi/null? p))) | |
| 328 | + (try | |
| 329 | + (let [t (ffi/ptr->string p)] (when (seq t) t)) | |
| 330 | + (finally (sdl-free! p)))))) | |
| 331 | + | |
| 313 | 332 | (defn clipboard-data |
| 314 | 333 | "The clipboard's contents for `mime`, as [pointer length], or nil. |
| 315 | 334 | |
| @@ -51,7 +51,7 @@ | |||
| 51 | (ffi/defcfn ^:private raw-poll "SDL_PollEvent" [:pointer] :bool) | 51 | (ffi/defcfn ^:private raw-poll "SDL_PollEvent" [:pointer] :bool) |
| 52 | (ffi/defcfn start-text-input! "SDL_StartTextInput" [:pointer] :bool) | 52 | (ffi/defcfn start-text-input! "SDL_StartTextInput" [:pointer] :bool) |
| 53 | (ffi/defcfn stop-text-input! "SDL_StopTextInput" [:pointer] :bool) | 53 | (ffi/defcfn stop-text-input! "SDL_StopTextInput" [:pointer] :bool) |
| 54 | -(ffi/defcfn clipboard "SDL_GetClipboardText" [] :string) | 54 | +(ffi/defcfn ^:private raw-clipboard-text "SDL_GetClipboardText" [] :pointer) |
| 55 | (ffi/defcfn clipboard! "SDL_SetClipboardText" [:string] :bool) | 55 | (ffi/defcfn clipboard! "SDL_SetClipboardText" [:string] :bool) |
| 56 | 56 | ||
| 57 | (ffi/defcfn texture-from-surface "SDL_CreateTextureFromSurface" [:pointer :pointer] :pointer) | 57 | (ffi/defcfn texture-from-surface "SDL_CreateTextureFromSurface" [:pointer :pointer] :pointer) |
| @@ -118,7 +118,11 @@ | |||
| 118 | (def keycodes | 118 | (def keycodes |
| 119 | {13 :return 27 :escape 8 :backspace 9 :tab 32 :space 127 :delete | 119 | {13 :return 27 :escape 8 :backspace 9 :tab 32 :space 127 :delete |
| 120 | 1073741903 :right 1073741904 :left 1073741905 :down 1073741906 :up | 120 | 1073741903 :right 1073741904 :left 1073741905 :down 1073741906 :up |
| 121 | - 1073741901 :end 1073741898 :home 1073741899 :page-up 1073741902 :page-down}) | 121 | + 1073741901 :end 1073741898 :home 1073741899 :page-up 1073741902 :page-down |
| 122 | + ;; The two keys a paste is pressed with. A letter is otherwise left as its | ||
| 123 | + ;; code: a field takes what it types from text events, and only the ones a | ||
| 124 | + ;; shortcut is built on need a name. | ||
| 125 | + 118 :v 1073741897 :insert}) | ||
| 122 | 126 | ||
| 123 | (def ^:private MOD-SHIFT 3) (def ^:private MOD-CTRL 192) | 127 | (def ^:private MOD-SHIFT 3) (def ^:private MOD-CTRL 192) |
| 124 | 128 | ||
| @@ -310,6 +314,21 @@ | |||
| 310 | (when (raw-display-bounds (display-for-window window) r) | 314 | (when (raw-display-bounds (display-for-window window) r) |
| 311 | [(ffi/read (+ r 8) :int) (ffi/read (+ r 12) :int)]))) | 315 | [(ffi/read (+ r 8) :int) (ffi/read (+ r 12) :int)]))) |
| 312 | 316 | ||
| 317 | +(defn clipboard | ||
| 318 | + "The clipboard's text, or nil when it holds none. | ||
| 319 | + | ||
| 320 | + Through a pointer rather than a `:string` return because the buffer is | ||
| 321 | + SDL's to allocate and ours to free, and a converted return has already | ||
| 322 | + dropped the pointer that would free it — a leak per paste. SDL answers an | ||
| 323 | + empty string rather than NULL when there is no text, and that is nil here: | ||
| 324 | + a paste of nothing is a different request from a paste of \"\"." | ||
| 325 | + [] | ||
| 326 | + (let [p (raw-clipboard-text)] | ||
| 327 | + (when (and p (not (ffi/null? p))) | ||
| 328 | + (try | ||
| 329 | + (let [t (ffi/ptr->string p)] (when (seq t) t)) | ||
| 330 | + (finally (sdl-free! p)))))) | ||
| 331 | + | ||
| 313 | (defn clipboard-data | 332 | (defn clipboard-data |
| 314 | "The clipboard's contents for `mime`, as [pointer length], or nil. | 333 | "The clipboard's contents for `mime`, as [pointer length], or nil. |
| 315 | 334 | ||
modified
jvui/src/jvui/widgets.clj +72 -1 | @@ -388,6 +388,34 @@ | ||
| 388 | 388 | (or (first (keep-indexed (fn [i [_ hi]] (when (<= caret hi) i)) spans)) |
| 389 | 389 | (max 0 (dec (count spans))))) |
| 390 | 390 | |
| 391 | +(defn entry-paste-empty? | |
| 392 | + "Did the field under `id` see a paste with no text in it, on the frame just | |
| 393 | + walked? | |
| 394 | + | |
| 395 | + A paste is a keystroke asking for whatever is on the clipboard, and when | |
| 396 | + that is not text it is still a request — for the picture, the file, the | |
| 397 | + thing a text field cannot hold. The field records it rather than dropping | |
| 398 | + it, so the client can go and fetch what the person meant." | |
| 399 | + [id] | |
| 400 | + (boolean (c/state id :paste-empty false))) | |
| 401 | + | |
| 402 | +(defn- caret-near | |
| 403 | + "The offset between `lo` and `hi` in `s` whose edge is nearest `x` points | |
| 404 | + from the start of that line. | |
| 405 | + | |
| 406 | + Nearest EDGE, not the character under the pointer: a click on the right | |
| 407 | + half of a letter means after it. Widths only grow along a line, so the walk | |
| 408 | + stops the moment the distance starts growing again." | |
| 409 | + [s lo hi x size] | |
| 410 | + (loop [j lo best lo best-d Double/MAX_VALUE] | |
| 411 | + (if (> j hi) | |
| 412 | + best | |
| 413 | + (let [d (Math/abs (- (double (first (c/measure (subs s lo j) size))) | |
| 414 | + (double x)))] | |
| 415 | + (if (< d best-d) | |
| 416 | + (recur (inc j) j d) | |
| 417 | + best))))) | |
| 418 | + | |
| 391 | 419 | (defn text-entry |
| 392 | 420 | "An editable string. Answers the text after this frame. |
| 393 | 421 | |
| @@ -432,12 +460,47 @@ | ||
| 432 | 460 | shown (clamp (count spans) rows max-rows) |
| 433 | 461 | h (+ (double (c/th :control-height)) (* (dec shown) lh)) |
| 434 | 462 | rect (c/leaf [(double (or min-width 160.0)) h] expand [0.0 0.5]) |
| 435 | - {:keys [hover? focused?]} (c/interact! id rect) | |
| 463 | + {:keys [hover? focused? pressed? clicked?]} (c/interact! id rect) | |
| 436 | 464 | [rx ry rw rh] rect |
| 437 | 465 | inner (- rw (* 2 pad)) |
| 438 | 466 | spans (if grows? (wrap-spans s size inner) spans) |
| 439 | 467 | caret0 (clamp (c/state id :caret (count s)) 0 (count s)) |
| 468 | + ;; Where a press puts the caret, read against what was DRAWN: the | |
| 469 | + ;; line at the top and the sideways slide are last frame's, because | |
| 470 | + ;; last frame's is what the person was looking at when they aimed. | |
| 471 | + ;; Held as well as clicked, so dragging along a line carries the | |
| 472 | + ;; caret with the pointer — and a press with no release yet is how | |
| 473 | + ;; a real window delivers the first frame of every click. | |
| 474 | + caret0 (if (or pressed? clicked?) | |
| 475 | + (let [[mx my] (:mouse (c/ui)) | |
| 476 | + top0 (long (c/state id :top 0)) | |
| 477 | + shift0 (double (c/state id :shift 0.0)) | |
| 478 | + [_ th0] (c/measure (if (= s "") "M" s) size) | |
| 479 | + ty0 (+ ry (/ (- (double (c/th :control-height)) th0) 2.0)) | |
| 480 | + k (clamp (+ top0 (long (Math/floor (/ (- my ty0) lh)))) | |
| 481 | + 0 (dec (count spans))) | |
| 482 | + [lo hi] (nth spans k)] | |
| 483 | + (caret-near s lo hi (+ (- mx (+ rx pad)) shift0) size)) | |
| 484 | + caret0) | |
| 440 | 485 | evs (c/key-events id) |
| 486 | + ;; The clipboard comes from the context, the way the measurer does: | |
| 487 | + ;; a window reads the desktop's and a headless walk reads whatever | |
| 488 | + ;; its test put there. Asked only when a paste is actually pressed, | |
| 489 | + ;; and only on the walk that sees keys — the settling passes see | |
| 490 | + ;; none — so it is read once per paste and never per frame. | |
| 491 | + pasted-empty (atom false) | |
| 492 | + paste (fn [s caret] | |
| 493 | + (let [read* (or (:clipboard (c/ui)) (constantly nil)) | |
| 494 | + t (some-> (read*) str (str/replace "\r\n" "\n") | |
| 495 | + (str/replace "\r" "\n")) | |
| 496 | + ;; A field with no second line flattens the paste | |
| 497 | + ;; onto its one: a newline it cannot show would leave | |
| 498 | + ;; the text saying one thing and the box another — | |
| 499 | + ;; the rule Shift+Enter already follows. | |
| 500 | + t (if (or grows? (nil? t)) t (str/replace t "\n" " "))] | |
| 501 | + (if (seq t) | |
| 502 | + [(str (subs s 0 caret) t (subs s caret)) (+ caret (count t))] | |
| 503 | + (do (reset! pasted-empty true) [s caret])))) | |
| 441 | 504 | ;; Where the caret would land on the line above or below, by the |
| 442 | 505 | ;; offset it holds INTO its line rather than by how far along the |
| 443 | 506 | ;; line it looks. Two lines of proportional text never share a |
| @@ -486,6 +549,12 @@ | ||
| 486 | 549 | ;; on it. A field one line tall takes neither — a newline it |
| 487 | 550 | ;; could not show would leave the text saying one thing and |
| 488 | 551 | ;; the box another. |
| 552 | + ;; Ctrl+V, and Shift+Insert for the hands that learned it | |
| 553 | + ;; first. Both, because a paste that works by one of them and | |
| 554 | + ;; silently types nothing by the other reads as a clipboard | |
| 555 | + ;; that is empty. | |
| 556 | + :v (if (:ctrl? e) (paste s caret) [s caret]) | |
| 557 | + :insert (if (:shift? e) (paste s caret) [s caret]) | |
| 489 | 558 | :return (if (and grows? (:shift? e)) |
| 490 | 559 | [(str (subs s 0 caret) "\n" (subs s caret)) |
| 491 | 560 | (inc caret)] |
| @@ -496,6 +565,7 @@ | ||
| 496 | 565 | caret' (clamp caret' 0 (count s')) |
| 497 | 566 | spans' (if grows? (wrap-spans s' size inner) [[0 (count s')]])] |
| 498 | 567 | (c/state! id :caret caret') |
| 568 | + (c/state! id :paste-empty @pasted-empty) | |
| 499 | 569 | ;; What the NEXT frame will be tall enough for, recorded for a caller |
| 500 | 570 | ;; that has to leave room for it. A frame late by construction, which is |
| 501 | 571 | ;; the same beat everything else about this field runs on. |
| @@ -539,6 +609,7 @@ | ||
| 539 | 609 | ;; past the border and over whatever is beside it. |
| 540 | 610 | shift (if grows? 0.0 (max 0.0 (- caret-x inner)))] |
| 541 | 611 | (c/state! id :top top) |
| 612 | + (c/state! id :shift shift) | |
| 542 | 613 | (c/with-clip [(+ rx pad) ry inner rh] |
| 543 | 614 | (fn [] |
| 544 | 615 | (if (and (= s' "") placeholder (not focused?)) |
| @@ -388,6 +388,34 @@ | |||
| 388 | (or (first (keep-indexed (fn [i [_ hi]] (when (<= caret hi) i)) spans)) | 388 | (or (first (keep-indexed (fn [i [_ hi]] (when (<= caret hi) i)) spans)) |
| 389 | (max 0 (dec (count spans))))) | 389 | (max 0 (dec (count spans))))) |
| 390 | 390 | ||
| 391 | +(defn entry-paste-empty? | ||
| 392 | + "Did the field under `id` see a paste with no text in it, on the frame just | ||
| 393 | + walked? | ||
| 394 | + | ||
| 395 | + A paste is a keystroke asking for whatever is on the clipboard, and when | ||
| 396 | + that is not text it is still a request — for the picture, the file, the | ||
| 397 | + thing a text field cannot hold. The field records it rather than dropping | ||
| 398 | + it, so the client can go and fetch what the person meant." | ||
| 399 | + [id] | ||
| 400 | + (boolean (c/state id :paste-empty false))) | ||
| 401 | + | ||
| 402 | +(defn- caret-near | ||
| 403 | + "The offset between `lo` and `hi` in `s` whose edge is nearest `x` points | ||
| 404 | + from the start of that line. | ||
| 405 | + | ||
| 406 | + Nearest EDGE, not the character under the pointer: a click on the right | ||
| 407 | + half of a letter means after it. Widths only grow along a line, so the walk | ||
| 408 | + stops the moment the distance starts growing again." | ||
| 409 | + [s lo hi x size] | ||
| 410 | + (loop [j lo best lo best-d Double/MAX_VALUE] | ||
| 411 | + (if (> j hi) | ||
| 412 | + best | ||
| 413 | + (let [d (Math/abs (- (double (first (c/measure (subs s lo j) size))) | ||
| 414 | + (double x)))] | ||
| 415 | + (if (< d best-d) | ||
| 416 | + (recur (inc j) j d) | ||
| 417 | + best))))) | ||
| 418 | + | ||
| 391 | (defn text-entry | 419 | (defn text-entry |
| 392 | "An editable string. Answers the text after this frame. | 420 | "An editable string. Answers the text after this frame. |
| 393 | 421 | ||
| @@ -432,12 +460,47 @@ | |||
| 432 | shown (clamp (count spans) rows max-rows) | 460 | shown (clamp (count spans) rows max-rows) |
| 433 | h (+ (double (c/th :control-height)) (* (dec shown) lh)) | 461 | h (+ (double (c/th :control-height)) (* (dec shown) lh)) |
| 434 | rect (c/leaf [(double (or min-width 160.0)) h] expand [0.0 0.5]) | 462 | rect (c/leaf [(double (or min-width 160.0)) h] expand [0.0 0.5]) |
| 435 | - {:keys [hover? focused?]} (c/interact! id rect) | 463 | + {:keys [hover? focused? pressed? clicked?]} (c/interact! id rect) |
| 436 | [rx ry rw rh] rect | 464 | [rx ry rw rh] rect |
| 437 | inner (- rw (* 2 pad)) | 465 | inner (- rw (* 2 pad)) |
| 438 | spans (if grows? (wrap-spans s size inner) spans) | 466 | spans (if grows? (wrap-spans s size inner) spans) |
| 439 | caret0 (clamp (c/state id :caret (count s)) 0 (count s)) | 467 | caret0 (clamp (c/state id :caret (count s)) 0 (count s)) |
| 468 | + ;; Where a press puts the caret, read against what was DRAWN: the | ||
| 469 | + ;; line at the top and the sideways slide are last frame's, because | ||
| 470 | + ;; last frame's is what the person was looking at when they aimed. | ||
| 471 | + ;; Held as well as clicked, so dragging along a line carries the | ||
| 472 | + ;; caret with the pointer — and a press with no release yet is how | ||
| 473 | + ;; a real window delivers the first frame of every click. | ||
| 474 | + caret0 (if (or pressed? clicked?) | ||
| 475 | + (let [[mx my] (:mouse (c/ui)) | ||
| 476 | + top0 (long (c/state id :top 0)) | ||
| 477 | + shift0 (double (c/state id :shift 0.0)) | ||
| 478 | + [_ th0] (c/measure (if (= s "") "M" s) size) | ||
| 479 | + ty0 (+ ry (/ (- (double (c/th :control-height)) th0) 2.0)) | ||
| 480 | + k (clamp (+ top0 (long (Math/floor (/ (- my ty0) lh)))) | ||
| 481 | + 0 (dec (count spans))) | ||
| 482 | + [lo hi] (nth spans k)] | ||
| 483 | + (caret-near s lo hi (+ (- mx (+ rx pad)) shift0) size)) | ||
| 484 | + caret0) | ||
| 440 | evs (c/key-events id) | 485 | evs (c/key-events id) |
| 486 | + ;; The clipboard comes from the context, the way the measurer does: | ||
| 487 | + ;; a window reads the desktop's and a headless walk reads whatever | ||
| 488 | + ;; its test put there. Asked only when a paste is actually pressed, | ||
| 489 | + ;; and only on the walk that sees keys — the settling passes see | ||
| 490 | + ;; none — so it is read once per paste and never per frame. | ||
| 491 | + pasted-empty (atom false) | ||
| 492 | + paste (fn [s caret] | ||
| 493 | + (let [read* (or (:clipboard (c/ui)) (constantly nil)) | ||
| 494 | + t (some-> (read*) str (str/replace "\r\n" "\n") | ||
| 495 | + (str/replace "\r" "\n")) | ||
| 496 | + ;; A field with no second line flattens the paste | ||
| 497 | + ;; onto its one: a newline it cannot show would leave | ||
| 498 | + ;; the text saying one thing and the box another — | ||
| 499 | + ;; the rule Shift+Enter already follows. | ||
| 500 | + t (if (or grows? (nil? t)) t (str/replace t "\n" " "))] | ||
| 501 | + (if (seq t) | ||
| 502 | + [(str (subs s 0 caret) t (subs s caret)) (+ caret (count t))] | ||
| 503 | + (do (reset! pasted-empty true) [s caret])))) | ||
| 441 | ;; Where the caret would land on the line above or below, by the | 504 | ;; Where the caret would land on the line above or below, by the |
| 442 | ;; offset it holds INTO its line rather than by how far along the | 505 | ;; offset it holds INTO its line rather than by how far along the |
| 443 | ;; line it looks. Two lines of proportional text never share a | 506 | ;; line it looks. Two lines of proportional text never share a |
| @@ -486,6 +549,12 @@ | |||
| 486 | ;; on it. A field one line tall takes neither — a newline it | 549 | ;; on it. A field one line tall takes neither — a newline it |
| 487 | ;; could not show would leave the text saying one thing and | 550 | ;; could not show would leave the text saying one thing and |
| 488 | ;; the box another. | 551 | ;; the box another. |
| 552 | + ;; Ctrl+V, and Shift+Insert for the hands that learned it | ||
| 553 | + ;; first. Both, because a paste that works by one of them and | ||
| 554 | + ;; silently types nothing by the other reads as a clipboard | ||
| 555 | + ;; that is empty. | ||
| 556 | + :v (if (:ctrl? e) (paste s caret) [s caret]) | ||
| 557 | + :insert (if (:shift? e) (paste s caret) [s caret]) | ||
| 489 | :return (if (and grows? (:shift? e)) | 558 | :return (if (and grows? (:shift? e)) |
| 490 | [(str (subs s 0 caret) "\n" (subs s caret)) | 559 | [(str (subs s 0 caret) "\n" (subs s caret)) |
| 491 | (inc caret)] | 560 | (inc caret)] |
| @@ -496,6 +565,7 @@ | |||
| 496 | caret' (clamp caret' 0 (count s')) | 565 | caret' (clamp caret' 0 (count s')) |
| 497 | spans' (if grows? (wrap-spans s' size inner) [[0 (count s')]])] | 566 | spans' (if grows? (wrap-spans s' size inner) [[0 (count s')]])] |
| 498 | (c/state! id :caret caret') | 567 | (c/state! id :caret caret') |
| 568 | + (c/state! id :paste-empty @pasted-empty) | ||
| 499 | ;; What the NEXT frame will be tall enough for, recorded for a caller | 569 | ;; What the NEXT frame will be tall enough for, recorded for a caller |
| 500 | ;; that has to leave room for it. A frame late by construction, which is | 570 | ;; that has to leave room for it. A frame late by construction, which is |
| 501 | ;; the same beat everything else about this field runs on. | 571 | ;; the same beat everything else about this field runs on. |
| @@ -539,6 +609,7 @@ | |||
| 539 | ;; past the border and over whatever is beside it. | 609 | ;; past the border and over whatever is beside it. |
| 540 | shift (if grows? 0.0 (max 0.0 (- caret-x inner)))] | 610 | shift (if grows? 0.0 (max 0.0 (- caret-x inner)))] |
| 541 | (c/state! id :top top) | 611 | (c/state! id :top top) |
| 612 | + (c/state! id :shift shift) | ||
| 542 | (c/with-clip [(+ rx pad) ry inner rh] | 613 | (c/with-clip [(+ rx pad) ry inner rh] |
| 543 | (fn [] | 614 | (fn [] |
| 544 | (if (and (= s' "") placeholder (not focused?)) | 615 | (if (and (= s' "") placeholder (not focused?)) |
modified
jvui/test/jvui/tests.clj +86 -0 | @@ -258,6 +258,88 @@ | ||
| 258 | 258 | (check! (= "\n" @s) "plain enter still types nothing") |
| 259 | 259 | (check! (= 1 @acts) "and activates the field"))) |
| 260 | 260 | |
| 261 | +(defn- check-click-places-the-caret! [] | |
| 262 | + ;; Eight pixels a character, so the fifth edge of "hello world" is forty | |
| 263 | + ;; points in from where the text starts. A click just past it means after | |
| 264 | + ;; the "o"; typing there has to land between "hello" and " world". | |
| 265 | + (let [cx (ctx) s (atom "hello world") r (atom nil) | |
| 266 | + render (fn [] | |
| 267 | + (let [id (c/next-id nil)] | |
| 268 | + (reset! s (w/text-entry @s {:expand :none})) | |
| 269 | + (reset! r (c/rect-of id)))) | |
| 270 | + pad (double (:padding (:theme @cx)))] | |
| 271 | + (frame! cx render) | |
| 272 | + (let [[rx ry _ rh] @r | |
| 273 | + y (+ ry (/ rh 2.0))] | |
| 274 | + (frame! cx render (click-at (+ rx pad 41) y)) | |
| 275 | + (frame! cx render [{:kind :text :text "X"}]) | |
| 276 | + (check! (= "helloX world" @s) | |
| 277 | + (str "a click puts the caret where it landed: " (pr-str @s))) | |
| 278 | + ;; And a click left of the text is the start, not nowhere. | |
| 279 | + (frame! cx render (click-at (+ rx 1) y)) | |
| 280 | + (frame! cx render [{:kind :text :text "<"}]) | |
| 281 | + (check! (= "<helloX world" @s) | |
| 282 | + (str "and a click before the text is its start: " (pr-str @s)))))) | |
| 283 | + | |
| 284 | +(defn- check-click-picks-the-line! [] | |
| 285 | + ;; A field with lines takes the click's height too: the second line of two, | |
| 286 | + ;; at its head, is offset of the first line plus its break. | |
| 287 | + (let [cx (ctx) s (atom "one\ntwo") r (atom nil) | |
| 288 | + render (fn [] | |
| 289 | + (let [id (c/next-id nil)] | |
| 290 | + (reset! s (w/text-entry @s {:expand :none :max-rows 3})) | |
| 291 | + (reset! r (c/rect-of id)))) | |
| 292 | + pad (double (:padding (:theme @cx))) | |
| 293 | + ch (double (:control-height (:theme @cx)))] | |
| 294 | + (frame! cx render) | |
| 295 | + (frame! cx render) | |
| 296 | + (let [[rx ry] @r | |
| 297 | + ;; the middle of the second line: the first line's top inset is | |
| 298 | + ;; half of what the control's height leaves around one line | |
| 299 | + y (+ ry (/ (- ch 16.0) 2.0) 16.0 8.0)] | |
| 300 | + (frame! cx render (click-at (+ rx pad 1) y)) | |
| 301 | + (frame! cx render [{:kind :text :text "X"}]) | |
| 302 | + (check! (= "one\nXtwo" @s) | |
| 303 | + (str "a click on the second line lands on it: " (pr-str @s)))))) | |
| 304 | + | |
| 305 | +(defn- check-paste! [] | |
| 306 | + (let [cx (ctx) s (atom "ab") clip (atom "xyz") | |
| 307 | + render (fn [] (reset! s (w/text-entry @s {:expand :none})))] | |
| 308 | + (swap! cx assoc :clipboard (fn [] @clip)) | |
| 309 | + (frame! cx render) | |
| 310 | + ;; The click only focuses; where it put the caret is the other check's | |
| 311 | + ;; business, so the caret is set here by keys — End, then one back. | |
| 312 | + (frame! cx render (click-at 10 10)) | |
| 313 | + (frame! cx render [{:kind :key-down :key :end} | |
| 314 | + {:kind :key-down :key :left}]) | |
| 315 | + (frame! cx render [{:kind :key-down :key :v :ctrl? true}]) | |
| 316 | + (check! (= "axyzb" @s) (str "ctrl+v pastes at the caret: " (pr-str @s))) | |
| 317 | + (frame! cx render [{:kind :key-down :key :insert :shift? true}]) | |
| 318 | + (check! (= "axyzxyzb" @s) (str "and so does shift+insert: " (pr-str @s))) | |
| 319 | + (frame! cx render [{:kind :key-down :key :v}]) | |
| 320 | + (check! (= "axyzxyzb" @s) "a v with no ctrl is not a paste") | |
| 321 | + (reset! clip "one\r\ntwo") | |
| 322 | + (reset! s "") | |
| 323 | + (frame! cx render [{:kind :key-down :key :v :ctrl? true}]) | |
| 324 | + (check! (= "one two" @s) | |
| 325 | + (str "a field of one line flattens a pasted break: " (pr-str @s))))) | |
| 326 | + | |
| 327 | +(defn- check-paste-with-no-text! [] | |
| 328 | + (let [cx (ctx) s (atom "") seen (atom nil) | |
| 329 | + render (fn [] | |
| 330 | + (let [id (c/next-id nil)] | |
| 331 | + (reset! s (w/text-entry @s {:expand :none})) | |
| 332 | + (reset! seen (w/entry-paste-empty? id))))] | |
| 333 | + (swap! cx assoc :clipboard (constantly nil)) | |
| 334 | + (frame! cx render) | |
| 335 | + (frame! cx render (click-at 10 10)) | |
| 336 | + (check! (false? @seen) "no paste, no request") | |
| 337 | + (frame! cx render [{:kind :key-down :key :v :ctrl? true}]) | |
| 338 | + (check! (true? @seen) "a paste of nothing is recorded as asked for") | |
| 339 | + (check! (= "" @s) "and types nothing") | |
| 340 | + (frame! cx render) | |
| 341 | + (check! (false? @seen) "for the one frame it happened on"))) | |
| 342 | + | |
| 261 | 343 | (defn- check-unfocused-entry-ignores-keys! [] |
| 262 | 344 | (let [cx (ctx) s (atom "") |
| 263 | 345 | render (fn [] (reset! s (w/text-entry @s {:expand :none})))] |
| @@ -414,6 +496,10 @@ | ||
| 414 | 496 | ["an unfocused field does not" check-unfocused-entry-ignores-keys!] |
| 415 | 497 | ["a field grows" check-entry-grows!] |
| 416 | 498 | ["shift+enter breaks a line" check-entry-shift-enter!] |
| 499 | + ["a click places the caret" check-click-places-the-caret!] | |
| 500 | + ["a click picks the line" check-click-picks-the-line!] | |
| 501 | + ["a field pastes" check-paste!] | |
| 502 | + ["a paste of nothing asks" check-paste-with-no-text!] | |
| 417 | 503 | ["tab moves focus" check-tab-moves-focus!] |
| 418 | 504 | ["a viewport scrolls" check-scroll!] |
| 419 | 505 | ["the theme mixes" check-theme!]]) |
| @@ -258,6 +258,88 @@ | |||
| 258 | (check! (= "\n" @s) "plain enter still types nothing") | 258 | (check! (= "\n" @s) "plain enter still types nothing") |
| 259 | (check! (= 1 @acts) "and activates the field"))) | 259 | (check! (= 1 @acts) "and activates the field"))) |
| 260 | 260 | ||
| 261 | +(defn- check-click-places-the-caret! [] | ||
| 262 | + ;; Eight pixels a character, so the fifth edge of "hello world" is forty | ||
| 263 | + ;; points in from where the text starts. A click just past it means after | ||
| 264 | + ;; the "o"; typing there has to land between "hello" and " world". | ||
| 265 | + (let [cx (ctx) s (atom "hello world") r (atom nil) | ||
| 266 | + render (fn [] | ||
| 267 | + (let [id (c/next-id nil)] | ||
| 268 | + (reset! s (w/text-entry @s {:expand :none})) | ||
| 269 | + (reset! r (c/rect-of id)))) | ||
| 270 | + pad (double (:padding (:theme @cx)))] | ||
| 271 | + (frame! cx render) | ||
| 272 | + (let [[rx ry _ rh] @r | ||
| 273 | + y (+ ry (/ rh 2.0))] | ||
| 274 | + (frame! cx render (click-at (+ rx pad 41) y)) | ||
| 275 | + (frame! cx render [{:kind :text :text "X"}]) | ||
| 276 | + (check! (= "helloX world" @s) | ||
| 277 | + (str "a click puts the caret where it landed: " (pr-str @s))) | ||
| 278 | + ;; And a click left of the text is the start, not nowhere. | ||
| 279 | + (frame! cx render (click-at (+ rx 1) y)) | ||
| 280 | + (frame! cx render [{:kind :text :text "<"}]) | ||
| 281 | + (check! (= "<helloX world" @s) | ||
| 282 | + (str "and a click before the text is its start: " (pr-str @s)))))) | ||
| 283 | + | ||
| 284 | +(defn- check-click-picks-the-line! [] | ||
| 285 | + ;; A field with lines takes the click's height too: the second line of two, | ||
| 286 | + ;; at its head, is offset of the first line plus its break. | ||
| 287 | + (let [cx (ctx) s (atom "one\ntwo") r (atom nil) | ||
| 288 | + render (fn [] | ||
| 289 | + (let [id (c/next-id nil)] | ||
| 290 | + (reset! s (w/text-entry @s {:expand :none :max-rows 3})) | ||
| 291 | + (reset! r (c/rect-of id)))) | ||
| 292 | + pad (double (:padding (:theme @cx))) | ||
| 293 | + ch (double (:control-height (:theme @cx)))] | ||
| 294 | + (frame! cx render) | ||
| 295 | + (frame! cx render) | ||
| 296 | + (let [[rx ry] @r | ||
| 297 | + ;; the middle of the second line: the first line's top inset is | ||
| 298 | + ;; half of what the control's height leaves around one line | ||
| 299 | + y (+ ry (/ (- ch 16.0) 2.0) 16.0 8.0)] | ||
| 300 | + (frame! cx render (click-at (+ rx pad 1) y)) | ||
| 301 | + (frame! cx render [{:kind :text :text "X"}]) | ||
| 302 | + (check! (= "one\nXtwo" @s) | ||
| 303 | + (str "a click on the second line lands on it: " (pr-str @s)))))) | ||
| 304 | + | ||
| 305 | +(defn- check-paste! [] | ||
| 306 | + (let [cx (ctx) s (atom "ab") clip (atom "xyz") | ||
| 307 | + render (fn [] (reset! s (w/text-entry @s {:expand :none})))] | ||
| 308 | + (swap! cx assoc :clipboard (fn [] @clip)) | ||
| 309 | + (frame! cx render) | ||
| 310 | + ;; The click only focuses; where it put the caret is the other check's | ||
| 311 | + ;; business, so the caret is set here by keys — End, then one back. | ||
| 312 | + (frame! cx render (click-at 10 10)) | ||
| 313 | + (frame! cx render [{:kind :key-down :key :end} | ||
| 314 | + {:kind :key-down :key :left}]) | ||
| 315 | + (frame! cx render [{:kind :key-down :key :v :ctrl? true}]) | ||
| 316 | + (check! (= "axyzb" @s) (str "ctrl+v pastes at the caret: " (pr-str @s))) | ||
| 317 | + (frame! cx render [{:kind :key-down :key :insert :shift? true}]) | ||
| 318 | + (check! (= "axyzxyzb" @s) (str "and so does shift+insert: " (pr-str @s))) | ||
| 319 | + (frame! cx render [{:kind :key-down :key :v}]) | ||
| 320 | + (check! (= "axyzxyzb" @s) "a v with no ctrl is not a paste") | ||
| 321 | + (reset! clip "one\r\ntwo") | ||
| 322 | + (reset! s "") | ||
| 323 | + (frame! cx render [{:kind :key-down :key :v :ctrl? true}]) | ||
| 324 | + (check! (= "one two" @s) | ||
| 325 | + (str "a field of one line flattens a pasted break: " (pr-str @s))))) | ||
| 326 | + | ||
| 327 | +(defn- check-paste-with-no-text! [] | ||
| 328 | + (let [cx (ctx) s (atom "") seen (atom nil) | ||
| 329 | + render (fn [] | ||
| 330 | + (let [id (c/next-id nil)] | ||
| 331 | + (reset! s (w/text-entry @s {:expand :none})) | ||
| 332 | + (reset! seen (w/entry-paste-empty? id))))] | ||
| 333 | + (swap! cx assoc :clipboard (constantly nil)) | ||
| 334 | + (frame! cx render) | ||
| 335 | + (frame! cx render (click-at 10 10)) | ||
| 336 | + (check! (false? @seen) "no paste, no request") | ||
| 337 | + (frame! cx render [{:kind :key-down :key :v :ctrl? true}]) | ||
| 338 | + (check! (true? @seen) "a paste of nothing is recorded as asked for") | ||
| 339 | + (check! (= "" @s) "and types nothing") | ||
| 340 | + (frame! cx render) | ||
| 341 | + (check! (false? @seen) "for the one frame it happened on"))) | ||
| 342 | + | ||
| 261 | (defn- check-unfocused-entry-ignores-keys! [] | 343 | (defn- check-unfocused-entry-ignores-keys! [] |
| 262 | (let [cx (ctx) s (atom "") | 344 | (let [cx (ctx) s (atom "") |
| 263 | render (fn [] (reset! s (w/text-entry @s {:expand :none})))] | 345 | render (fn [] (reset! s (w/text-entry @s {:expand :none})))] |
| @@ -414,6 +496,10 @@ | |||
| 414 | ["an unfocused field does not" check-unfocused-entry-ignores-keys!] | 496 | ["an unfocused field does not" check-unfocused-entry-ignores-keys!] |
| 415 | ["a field grows" check-entry-grows!] | 497 | ["a field grows" check-entry-grows!] |
| 416 | ["shift+enter breaks a line" check-entry-shift-enter!] | 498 | ["shift+enter breaks a line" check-entry-shift-enter!] |
| 499 | + ["a click places the caret" check-click-places-the-caret!] | ||
| 500 | + ["a click picks the line" check-click-picks-the-line!] | ||
| 501 | + ["a field pastes" check-paste!] | ||
| 502 | + ["a paste of nothing asks" check-paste-with-no-text!] | ||
| 417 | ["tab moves focus" check-tab-moves-focus!] | 503 | ["tab moves focus" check-tab-moves-focus!] |
| 418 | ["a viewport scrolls" check-scroll!] | 504 | ["a viewport scrolls" check-scroll!] |
| 419 | ["the theme mixes" check-theme!]]) | 505 | ["the theme mixes" check-theme!]]) |