Merge jvui-for-frq: the toolkit frq's window is painted with
jvui and glimmer-jvui, brought up to what frq needs of them: every tag and prop its screens use, containers that fill the window, lists that follow what arrives in them, wrapping labels, the fallback and colour-emoji font handling, and a scroll that leaves its bar's strip to the bar. Beside them the dependency split and the JOLT_WITHOUT_MOQ guard on the Android glue, merged in from no-moq-deps. Branched rather than committed here because main was where the terminal work was going on; that one commit is in this merge's first parent and the branch was rebased onto it, so there is nothing to reconcile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
modified
android/jolt_main.c +14 -0 | @@ -29,7 +29,15 @@ | ||
| 29 | 29 | #include <android/log.h> |
| 30 | 30 | #include <pthread.h> |
| 31 | 31 | |
| 32 | +/* JOLT_WITHOUT_MOQ builds this glue for an app that does not link | |
| 33 | + * libjoltmoq. frq is one: its media plane is jolt now — MoQ through | |
| 34 | + * libmoq_ffi, Opus, H.264, V4L2 and ALSA — so the Rust one is an object it | |
| 35 | + * never opens, and seventeen megabytes of APK for nothing. Everything moq | |
| 36 | + * below is behind this, because --no-undefined turns an unreferenced symbol | |
| 37 | + * into a link error rather than a surprise on the phone. */ | |
| 38 | +#ifndef JOLT_WITHOUT_MOQ | |
| 32 | 39 | #include "joltmoq.h" |
| 40 | +#endif | |
| 33 | 41 | #include "scheme.h" |
| 34 | 42 | #include "vidya.h" |
| 35 | 43 | #include "vidya_tree.h" |
| @@ -114,6 +122,7 @@ static void register_vidya_api(void) { | ||
| 114 | 122 | REGISTER_VIDYA(vidya_tree_dump); |
| 115 | 123 | } |
| 116 | 124 | |
| 125 | +#ifndef JOLT_WITHOUT_MOQ | |
| 117 | 126 | /* |
| 118 | 127 | * The media plane's symbols, registered for the same reason the Vidya ones |
| 119 | 128 | * above are: they live in libjoltmoq.so, a third object, and Android's loader |
| @@ -164,6 +173,7 @@ static void register_joltmoq_api(void) { | ||
| 164 | 173 | REGISTER_JOLTMOQ(joltmoq_can_dial); |
| 165 | 174 | REGISTER_JOLTMOQ(joltmoq_new_instance); |
| 166 | 175 | } |
| 176 | +#endif /* JOLT_WITHOUT_MOQ */ | |
| 167 | 177 | |
| 168 | 178 | /* |
| 169 | 179 | * Chez writes to stdout/stderr, which on Android goes nowhere. Pump both into |
| @@ -204,8 +214,11 @@ int vidya_jolt_main(void) { | ||
| 204 | 214 | (iptr)(_binary_jolt_boot_end - _binary_jolt_boot_start)); |
| 205 | 215 | Sbuild_heap(NULL, NULL); |
| 206 | 216 | register_vidya_api(); |
| 217 | +#ifndef JOLT_WITHOUT_MOQ | |
| 207 | 218 | register_joltmoq_api(); |
| 219 | +#endif | |
| 208 | 220 | |
| 221 | +#ifndef JOLT_WITHOUT_MOQ | |
| 209 | 222 | /* The media plane's own handles, which it cannot get for itself: they arrive |
| 210 | 223 | in android-activity's glue, and the glue is in libvidya.so. This is the |
| 211 | 224 | only place both objects are in scope. Before Scheme starts, so no call can |
| @@ -221,6 +234,7 @@ int vidya_jolt_main(void) { | ||
| 221 | 234 | "VidyaJolt", "media plane JNI handles: vm=%p activity=%p", |
| 222 | 235 | moq_vm, moq_activity); |
| 223 | 236 | joltmoq_android_init(moq_vm, moq_activity); |
| 237 | +#endif | |
| 224 | 238 | |
| 225 | 239 | __android_log_print(ANDROID_LOG_INFO, "VidyaJolt", "starting Jolt application"); |
| 226 | 240 | int status = Sscheme_start(1, argv); |
| @@ -29,7 +29,15 @@ | |||
| 29 | #include <android/log.h> | 29 | #include <android/log.h> |
| 30 | #include <pthread.h> | 30 | #include <pthread.h> |
| 31 | 31 | ||
| 32 | +/* JOLT_WITHOUT_MOQ builds this glue for an app that does not link | ||
| 33 | + * libjoltmoq. frq is one: its media plane is jolt now — MoQ through | ||
| 34 | + * libmoq_ffi, Opus, H.264, V4L2 and ALSA — so the Rust one is an object it | ||
| 35 | + * never opens, and seventeen megabytes of APK for nothing. Everything moq | ||
| 36 | + * below is behind this, because --no-undefined turns an unreferenced symbol | ||
| 37 | + * into a link error rather than a surprise on the phone. */ | ||
| 38 | +#ifndef JOLT_WITHOUT_MOQ | ||
| 32 | #include "joltmoq.h" | 39 | #include "joltmoq.h" |
| 40 | +#endif | ||
| 33 | #include "scheme.h" | 41 | #include "scheme.h" |
| 34 | #include "vidya.h" | 42 | #include "vidya.h" |
| 35 | #include "vidya_tree.h" | 43 | #include "vidya_tree.h" |
| @@ -114,6 +122,7 @@ static void register_vidya_api(void) { | |||
| 114 | REGISTER_VIDYA(vidya_tree_dump); | 122 | REGISTER_VIDYA(vidya_tree_dump); |
| 115 | } | 123 | } |
| 116 | 124 | ||
| 125 | +#ifndef JOLT_WITHOUT_MOQ | ||
| 117 | /* | 126 | /* |
| 118 | * The media plane's symbols, registered for the same reason the Vidya ones | 127 | * The media plane's symbols, registered for the same reason the Vidya ones |
| 119 | * above are: they live in libjoltmoq.so, a third object, and Android's loader | 128 | * above are: they live in libjoltmoq.so, a third object, and Android's loader |
| @@ -164,6 +173,7 @@ static void register_joltmoq_api(void) { | |||
| 164 | REGISTER_JOLTMOQ(joltmoq_can_dial); | 173 | REGISTER_JOLTMOQ(joltmoq_can_dial); |
| 165 | REGISTER_JOLTMOQ(joltmoq_new_instance); | 174 | REGISTER_JOLTMOQ(joltmoq_new_instance); |
| 166 | } | 175 | } |
| 176 | +#endif /* JOLT_WITHOUT_MOQ */ | ||
| 167 | 177 | ||
| 168 | /* | 178 | /* |
| 169 | * Chez writes to stdout/stderr, which on Android goes nowhere. Pump both into | 179 | * Chez writes to stdout/stderr, which on Android goes nowhere. Pump both into |
| @@ -204,8 +214,11 @@ int vidya_jolt_main(void) { | |||
| 204 | (iptr)(_binary_jolt_boot_end - _binary_jolt_boot_start)); | 214 | (iptr)(_binary_jolt_boot_end - _binary_jolt_boot_start)); |
| 205 | Sbuild_heap(NULL, NULL); | 215 | Sbuild_heap(NULL, NULL); |
| 206 | register_vidya_api(); | 216 | register_vidya_api(); |
| 217 | +#ifndef JOLT_WITHOUT_MOQ | ||
| 207 | register_joltmoq_api(); | 218 | register_joltmoq_api(); |
| 219 | +#endif | ||
| 208 | 220 | ||
| 221 | +#ifndef JOLT_WITHOUT_MOQ | ||
| 209 | /* The media plane's own handles, which it cannot get for itself: they arrive | 222 | /* The media plane's own handles, which it cannot get for itself: they arrive |
| 210 | in android-activity's glue, and the glue is in libvidya.so. This is the | 223 | in android-activity's glue, and the glue is in libvidya.so. This is the |
| 211 | only place both objects are in scope. Before Scheme starts, so no call can | 224 | only place both objects are in scope. Before Scheme starts, so no call can |
| @@ -221,6 +234,7 @@ int vidya_jolt_main(void) { | |||
| 221 | "VidyaJolt", "media plane JNI handles: vm=%p activity=%p", | 234 | "VidyaJolt", "media plane JNI handles: vm=%p activity=%p", |
| 222 | moq_vm, moq_activity); | 235 | moq_vm, moq_activity); |
| 223 | joltmoq_android_init(moq_vm, moq_activity); | 236 | joltmoq_android_init(moq_vm, moq_activity); |
| 237 | +#endif | ||
| 224 | 238 | ||
| 225 | __android_log_print(ANDROID_LOG_INFO, "VidyaJolt", "starting Jolt application"); | 239 | __android_log_print(ANDROID_LOG_INFO, "VidyaJolt", "starting Jolt application"); |
| 226 | int status = Sscheme_start(1, argv); | 240 | int status = Sscheme_start(1, argv); |
modified
flake.nix +40 -4 | @@ -132,8 +132,40 @@ | ||
| 132 | 132 | buildInputs = desktopBuildInputs; |
| 133 | 133 | } // v4l2Env; |
| 134 | 134 | |
| 135 | - # Every external crate, compiled once and shared by the three objects, | |
| 136 | - # the clippy run and the test run. | |
| 135 | + # Every external crate, compiled once and shared by the objects that | |
| 136 | + # actually want it. | |
| 137 | + # | |
| 138 | + # This used to be ONE derivation for the whole workspace, and the | |
| 139 | + # sharing was the point: three objects, one dependency build. What it | |
| 140 | + # cost was invisible until a consumer wanted only some of them. | |
| 141 | + # jolt-moq brings moq-net, iroh, quinn, rustls and aws-lc-sys behind | |
| 142 | + # it -- 440 crates that nothing else here touches -- so a build of | |
| 143 | + # libvidya alone still paid for the media plane. A client that has | |
| 144 | + # stopped loading libjoltmoq paid for it too, which is the case that | |
| 145 | + # made this worth splitting. | |
| 146 | + # | |
| 147 | + # Split by CONSUMER rather than per package: vidya and tui share | |
| 148 | + # nearly everything, and giving them an artifact each would trade one | |
| 149 | + # kind of waste for another. | |
| 150 | + depsFor = { pname, packages }: | |
| 151 | + craneLib.buildDepsOnly (commonArgs // { | |
| 152 | + inherit pname; | |
| 153 | + version = "0.1.0"; | |
| 154 | + cargoExtraArgs = | |
| 155 | + "--locked " + lib.concatMapStringsSep " " (p: "-p " + p) packages; | |
| 156 | + }); | |
| 157 | + | |
| 158 | + uiArtifacts = depsFor { | |
| 159 | + pname = "jolt-native-ui-deps"; | |
| 160 | + packages = [ "vidya-ffi" "jolt-tui" ]; | |
| 161 | + }; | |
| 162 | + moqArtifacts = depsFor { | |
| 163 | + pname = "jolt-native-moq-deps"; | |
| 164 | + packages = [ "jolt-moq" ]; | |
| 165 | + }; | |
| 166 | + | |
| 167 | + # The whole workspace, for the clippy and test runs — those do build | |
| 168 | + # everything, and want the sharing this split gives up. | |
| 137 | 169 | cargoArtifacts = craneLib.buildDepsOnly (commonArgs // { |
| 138 | 170 | pname = "jolt-native-deps"; |
| 139 | 171 | version = "0.1.0"; |
| @@ -147,9 +179,10 @@ | ||
| 147 | 179 | # crates/jolt-vidya. Nothing is silenced — a header that stops being |
| 148 | 180 | # there should fail the build rather than ship an object with no ABI |
| 149 | 181 | # beside it. |
| 150 | - soPackage = { pname, package, dir, soname }: | |
| 182 | + soPackage = { pname, package, dir, soname, artifacts }: | |
| 151 | 183 | craneLib.buildPackage (commonArgs // { |
| 152 | - inherit pname cargoArtifacts; | |
| 184 | + inherit pname; | |
| 185 | + cargoArtifacts = artifacts; | |
| 153 | 186 | version = "0.1.0"; |
| 154 | 187 | cargoExtraArgs = "--locked -p ${package}"; |
| 155 | 188 | doCheck = false; |
| @@ -161,18 +194,21 @@ | ||
| 161 | 194 | }); |
| 162 | 195 | |
| 163 | 196 | libvidya = soPackage { |
| 197 | + artifacts = uiArtifacts; | |
| 164 | 198 | pname = "libvidya"; |
| 165 | 199 | package = "vidya-ffi"; |
| 166 | 200 | dir = "jolt-vidya"; |
| 167 | 201 | soname = "libvidya.so"; |
| 168 | 202 | }; |
| 169 | 203 | libjolttui = soPackage { |
| 204 | + artifacts = uiArtifacts; | |
| 170 | 205 | pname = "libjolttui"; |
| 171 | 206 | package = "jolt-tui"; |
| 172 | 207 | dir = "jolt-tui"; |
| 173 | 208 | soname = "libjolttui.so"; |
| 174 | 209 | }; |
| 175 | 210 | libjoltmoq = soPackage { |
| 211 | + artifacts = moqArtifacts; | |
| 176 | 212 | pname = "libjoltmoq"; |
| 177 | 213 | package = "jolt-moq"; |
| 178 | 214 | dir = "jolt-moq"; |
| @@ -132,8 +132,40 @@ | |||
| 132 | buildInputs = desktopBuildInputs; | 132 | buildInputs = desktopBuildInputs; |
| 133 | } // v4l2Env; | 133 | } // v4l2Env; |
| 134 | 134 | ||
| 135 | - # Every external crate, compiled once and shared by the three objects, | 135 | + # Every external crate, compiled once and shared by the objects that |
| 136 | - # the clippy run and the test run. | 136 | + # actually want it. |
| 137 | + # | ||
| 138 | + # This used to be ONE derivation for the whole workspace, and the | ||
| 139 | + # sharing was the point: three objects, one dependency build. What it | ||
| 140 | + # cost was invisible until a consumer wanted only some of them. | ||
| 141 | + # jolt-moq brings moq-net, iroh, quinn, rustls and aws-lc-sys behind | ||
| 142 | + # it -- 440 crates that nothing else here touches -- so a build of | ||
| 143 | + # libvidya alone still paid for the media plane. A client that has | ||
| 144 | + # stopped loading libjoltmoq paid for it too, which is the case that | ||
| 145 | + # made this worth splitting. | ||
| 146 | + # | ||
| 147 | + # Split by CONSUMER rather than per package: vidya and tui share | ||
| 148 | + # nearly everything, and giving them an artifact each would trade one | ||
| 149 | + # kind of waste for another. | ||
| 150 | + depsFor = { pname, packages }: | ||
| 151 | + craneLib.buildDepsOnly (commonArgs // { | ||
| 152 | + inherit pname; | ||
| 153 | + version = "0.1.0"; | ||
| 154 | + cargoExtraArgs = | ||
| 155 | + "--locked " + lib.concatMapStringsSep " " (p: "-p " + p) packages; | ||
| 156 | + }); | ||
| 157 | + | ||
| 158 | + uiArtifacts = depsFor { | ||
| 159 | + pname = "jolt-native-ui-deps"; | ||
| 160 | + packages = [ "vidya-ffi" "jolt-tui" ]; | ||
| 161 | + }; | ||
| 162 | + moqArtifacts = depsFor { | ||
| 163 | + pname = "jolt-native-moq-deps"; | ||
| 164 | + packages = [ "jolt-moq" ]; | ||
| 165 | + }; | ||
| 166 | + | ||
| 167 | + # The whole workspace, for the clippy and test runs — those do build | ||
| 168 | + # everything, and want the sharing this split gives up. | ||
| 137 | cargoArtifacts = craneLib.buildDepsOnly (commonArgs // { | 169 | cargoArtifacts = craneLib.buildDepsOnly (commonArgs // { |
| 138 | pname = "jolt-native-deps"; | 170 | pname = "jolt-native-deps"; |
| 139 | version = "0.1.0"; | 171 | version = "0.1.0"; |
| @@ -147,9 +179,10 @@ | |||
| 147 | # crates/jolt-vidya. Nothing is silenced — a header that stops being | 179 | # crates/jolt-vidya. Nothing is silenced — a header that stops being |
| 148 | # there should fail the build rather than ship an object with no ABI | 180 | # there should fail the build rather than ship an object with no ABI |
| 149 | # beside it. | 181 | # beside it. |
| 150 | - soPackage = { pname, package, dir, soname }: | 182 | + soPackage = { pname, package, dir, soname, artifacts }: |
| 151 | craneLib.buildPackage (commonArgs // { | 183 | craneLib.buildPackage (commonArgs // { |
| 152 | - inherit pname cargoArtifacts; | 184 | + inherit pname; |
| 185 | + cargoArtifacts = artifacts; | ||
| 153 | version = "0.1.0"; | 186 | version = "0.1.0"; |
| 154 | cargoExtraArgs = "--locked -p ${package}"; | 187 | cargoExtraArgs = "--locked -p ${package}"; |
| 155 | doCheck = false; | 188 | doCheck = false; |
| @@ -161,18 +194,21 @@ | |||
| 161 | }); | 194 | }); |
| 162 | 195 | ||
| 163 | libvidya = soPackage { | 196 | libvidya = soPackage { |
| 197 | + artifacts = uiArtifacts; | ||
| 164 | pname = "libvidya"; | 198 | pname = "libvidya"; |
| 165 | package = "vidya-ffi"; | 199 | package = "vidya-ffi"; |
| 166 | dir = "jolt-vidya"; | 200 | dir = "jolt-vidya"; |
| 167 | soname = "libvidya.so"; | 201 | soname = "libvidya.so"; |
| 168 | }; | 202 | }; |
| 169 | libjolttui = soPackage { | 203 | libjolttui = soPackage { |
| 204 | + artifacts = uiArtifacts; | ||
| 170 | pname = "libjolttui"; | 205 | pname = "libjolttui"; |
| 171 | package = "jolt-tui"; | 206 | package = "jolt-tui"; |
| 172 | dir = "jolt-tui"; | 207 | dir = "jolt-tui"; |
| 173 | soname = "libjolttui.so"; | 208 | soname = "libjolttui.so"; |
| 174 | }; | 209 | }; |
| 175 | libjoltmoq = soPackage { | 210 | libjoltmoq = soPackage { |
| 211 | + artifacts = moqArtifacts; | ||
| 176 | pname = "libjoltmoq"; | 212 | pname = "libjoltmoq"; |
| 177 | package = "jolt-moq"; | 213 | package = "jolt-moq"; |
| 178 | dir = "jolt-moq"; | 214 | dir = "jolt-moq"; |
modified
glimmer-backends/glimmer-jvui/src/glimmer_jvui/core.clj +285 -15 | @@ -39,7 +39,9 @@ | ||
| 39 | 39 | [jvui.app :as app] |
| 40 | 40 | [jvui.core :as c] |
| 41 | 41 | [jvui.theme :as theme] |
| 42 | - [jvui.widgets :as w])) | |
| 42 | + [jvui.widgets :as w] | |
| 43 | + [jvui.frames :as frames] | |
| 44 | + [jvui.host :as host])) | |
| 43 | 45 | |
| 44 | 46 | ;; --- the retained tree ------------------------------------------------------- |
| 45 | 47 | |
| @@ -68,15 +70,68 @@ | ||
| 68 | 70 | |
| 69 | 71 | (defn- num [v default] (if (number? v) (double v) default)) |
| 70 | 72 | |
| 73 | +(defn- fills-height? | |
| 74 | + "Does any child of `n` ask to fill the height? | |
| 75 | + | |
| 76 | + A row is only as tall as what is in it, and frq marks the PANES with | |
| 77 | + :fill-height rather than the row that holds them — egui gives a | |
| 78 | + horizontal layout the available height and the panes fill that, so | |
| 79 | + there is nothing there to mark. Here the row has to be told, and its | |
| 80 | + own children are what know: a row holding something that wants the | |
| 81 | + height wants the height. | |
| 82 | + | |
| 83 | + Asked of the tree rather than inferred from the layout, because the | |
| 84 | + layout answers a frame too late — a row that learns it should be tall | |
| 85 | + from what happened last frame is a row that is the wrong height on | |
| 86 | + the frame anybody looks at." | |
| 87 | + [n] | |
| 88 | + (boolean (some #(:fill-height (:props (deref %))) (:children (deref n))))) | |
| 89 | + | |
| 71 | 90 | (defn- box-opts |
| 72 | 91 | "The container options shared by every container tag." |
| 73 | - [props key] | |
| 92 | + ([props key] (box-opts props key false)) | |
| 93 | + ([props key fill-height?] | |
| 74 | 94 | (cond-> {:key key |
| 75 | - :dir (if (= :horizontal (:orientation props)) :horizontal :vertical)} | |
| 95 | + :dir (if (= :horizontal (:orientation props)) :horizontal :vertical) | |
| 96 | + ;; A CONTAINER fills its parent's cross axis by default. Without | |
| 97 | + ;; this every box shrink-wraps its children, and frq's chat | |
| 98 | + ;; column came out a couple of hundred points wide in a | |
| 99 | + ;; five-hundred-point window with every message wrapped to | |
| 100 | + ;; match — the tree is nested boxes, and each one only as wide | |
| 101 | + ;; as what is in it. | |
| 102 | + ;; | |
| 103 | + ;; :cross and not :horizontal: in a ROW, :horizontal means take | |
| 104 | + ;; a share of the slack, and a line of buttons would stretch to | |
| 105 | + ;; fill the window. | |
| 106 | + :expand :cross} | |
| 107 | + ;; :fill-height is frq's way of saying "this is the pane that takes | |
| 108 | + ;; what is left". It is the messages column in the row that also | |
| 109 | + ;; holds the people panel, and without it that column claims no slack | |
| 110 | + ;; at all — the backlog ends up as wide as the widest message and the | |
| 111 | + ;; scrollbar sits in the middle of the window. | |
| 112 | + ;; | |
| 113 | + ;; :both rather than :vertical, despite the name: in a ROW the space | |
| 114 | + ;; to be taken is horizontal, and a pane that fills the height of a | |
| 115 | + ;; row it does not fill the width of is not what anyone means by it. | |
| 116 | + ;; The panes that do NOT ask for it stay :cross and keep their own | |
| 117 | + ;; size, which is what leaves the slack to be taken. | |
| 118 | + (or (:fill-height props) fill-height?) (assoc :expand :both) | |
| 119 | + ;; A minimum, not a size: the messages column asks for one only while | |
| 120 | + ;; the people panel is beside it. | |
| 121 | + (:width-request props) | |
| 122 | + (assoc :min-size [(num (:width-request props) 0.0) 0.0]) | |
| 76 | 123 | (:spacing props) (assoc :spacing (num (:spacing props) 0.0)) |
| 77 | 124 | (:padding props) (assoc :padding (num (:padding props) 0.0)) |
| 78 | 125 | (:margin props) (assoc :margin (num (:margin props) 0.0)) |
| 79 | - (:expand props) (assoc :expand (:expand props)))) | |
| 126 | + (:expand props) (assoc :expand (:expand props)) | |
| 127 | + ;; A row whose children start a new line when they run out of room — | |
| 128 | + ;; a line of reaction pills is the case that needs it. | |
| 129 | + (:wrap props) (assoc :wrap true) | |
| 130 | + ;; Cross-axis placement: :start :center :end, as a gravity. | |
| 131 | + (:align props) (assoc :gravity (case (:align props) | |
| 132 | + (:center "center") [0.0 0.5] | |
| 133 | + (:end "end") [0.0 1.0] | |
| 134 | + [0.0 0.0]))))) | |
| 80 | 135 | |
| 81 | 136 | (defn- fire! [n k & args] |
| 82 | 137 | (when-let [f (get (:props @n) k)] (apply f args))) |
| @@ -117,14 +172,92 @@ | ||
| 117 | 172 | |
| 118 | 173 | (:card :frame) (w/card* (box-opts props key) (emit-children! n)) |
| 119 | 174 | |
| 120 | - :scroll (w/scroll* (assoc (box-opts props key) | |
| 121 | - :height (num (:height props) 200.0)) | |
| 175 | + ;; A list that follows what arrives in it. Everything here beyond | |
| 176 | + ;; :height is a prop frq writes and this used to drop on the floor — | |
| 177 | + ;; the chat did not follow new messages, and switching channels | |
| 178 | + ;; carried the previous one's scroll across. | |
| 179 | + ;; :expand is forced rather than left to box-opts, whose default is | |
| 180 | + ;; :cross — and a viewport that fills only the width asks its column | |
| 181 | + ;; for no height, is given none, and shows nothing at all. It is the | |
| 182 | + ;; one container that always fills both ways. | |
| 183 | + :scroll (w/scroll* (cond-> (assoc (box-opts props key) :expand :both) | |
| 184 | + (:height props) | |
| 185 | + (assoc :height (num (:height props) 200.0)) | |
| 186 | + (:reserve props) | |
| 187 | + (assoc :reserve (num (:reserve props) 0.0)) | |
| 188 | + (:scroll-key props) | |
| 189 | + (assoc :scroll-key (str (:scroll-key props))) | |
| 190 | + (:stick-to-bottom props) | |
| 191 | + (assoc :stick-to-bottom true) | |
| 192 | + (:scroll-to-bottom props) | |
| 193 | + (assoc :scroll-to-bottom (num (:scroll-to-bottom props) 0.0)) | |
| 194 | + ;; "end" or "away", the STRING libvidya emits — | |
| 195 | + ;; frq's handler is (= "end" %) and a boolean | |
| 196 | + ;; makes it permanently false. | |
| 197 | + ;; | |
| 198 | + ;; :on-scroll is deliberately not fired here. | |
| 199 | + ;; It is the channel for backends that report an | |
| 200 | + ;; OFFSET rather than a place — the terminal's — | |
| 201 | + ;; and frq turns one into the other with | |
| 202 | + ;; `scrolled!`. A window that reports where it | |
| 203 | + ;; ended up has nothing to say on it. | |
| 204 | + (:on-change props) | |
| 205 | + (assoc :on-at-end | |
| 206 | + (fn [at-end?] | |
| 207 | + (fire! n :on-change (if at-end? "end" "away"))))) | |
| 122 | 208 | (emit-children! n)) |
| 123 | 209 | |
| 124 | - :hbox (c/box* (assoc (box-opts props key) :dir :horizontal) | |
| 210 | + :hbox (c/box* (assoc (box-opts props key (fills-height? n)) :dir :horizontal) | |
| 125 | 211 | (emit-children! n)) |
| 126 | 212 | |
| 127 | - (:vbox :box) (c/box* (box-opts props key) (emit-children! n)) | |
| 213 | + (:vbox :box) (c/box* (box-opts props key (fills-height? n)) | |
| 214 | + (emit-children! n)) | |
| 215 | + | |
| 216 | + ;; ONE tag for both kinds of picture: `:feed` is live pixels pushed | |
| 217 | + ;; in under a name and re-uploaded as they arrive, `:src` is a file | |
| 218 | + ;; decoded once and kept by path. Everything downstream — the fit, the | |
| 219 | + ;; bounds, the click — is the same, which is why libvidya makes this a | |
| 220 | + ;; prop and not a second tag, and why frq writes [:image {:feed k}] | |
| 221 | + ;; for a call tile and [:image {:src p}] for an attachment. | |
| 222 | + ;; | |
| 223 | + ;; The pixels never go through the reconciler either way: a frame | |
| 224 | + ;; arrives when the network says so, and a props diff at thirty a | |
| 225 | + ;; second would be a re-render per frame per peer. | |
| 226 | + :image (let [id (c/next-id key) | |
| 227 | + rect (w/image {:feed (:feed props) :src (:src props)} | |
| 228 | + {:fit (:fit props) | |
| 229 | + :max-width (:max-width props) | |
| 230 | + :max-height (:max-height props) | |
| 231 | + :size (:size props) | |
| 232 | + :expand (:expand props)})] | |
| 233 | + (record! n id) | |
| 234 | + (when (:clicked? (c/interact! id rect)) (fire! n :on-click))) | |
| 235 | + | |
| 236 | + :title-2 (w/title-2 s) | |
| 237 | + | |
| 238 | + :status (w/status s (boolean (:live props))) | |
| 239 | + | |
| 240 | + :spinner (w/spinner s) | |
| 241 | + | |
| 242 | + :link (let [id (c/next-id key)] | |
| 243 | + (record! n id) | |
| 244 | + (when (w/link s {:key key}) (fire! n :on-click))) | |
| 245 | + | |
| 246 | + :emoji (w/emoji (or (:emoji props) s) (:size props)) | |
| 247 | + | |
| 248 | + :avatar (w/avatar (or (:label props) s) | |
| 249 | + (cond-> {} | |
| 250 | + (:src props) (assoc :src (:src props)) | |
| 251 | + (:size props) (assoc :size (:size props)))) | |
| 252 | + | |
| 253 | + :reaction (let [id (c/next-id key) | |
| 254 | + glyph (or (:emoji props) s)] | |
| 255 | + (record! n id) | |
| 256 | + (when (w/reaction glyph {:count (or (:count props) 0) | |
| 257 | + :mine? (boolean (:mine props)) | |
| 258 | + :size (:size props) | |
| 259 | + :key key}) | |
| 260 | + (fire! n :on-click))) | |
| 128 | 261 | |
| 129 | 262 | :title (w/title s) |
| 130 | 263 | |
| @@ -136,11 +269,27 @@ | ||
| 136 | 269 | (record! n id) |
| 137 | 270 | (when hit? (fire! n :on-click))) |
| 138 | 271 | |
| 139 | - :checkbox (let [was (boolean (:checked props)) | |
| 140 | - id (c/next-id key) | |
| 141 | - now (w/checkbox was s {:key key})] | |
| 142 | - (record! n id) | |
| 143 | - (when (not= now was) (fire! n :on-change now))) | |
| 272 | + ;; :checkbutton is the same widget under GTK's name for it, which is | |
| 273 | + ;; what libvidya calls it too — `"checkbutton" | "checkbox"` is one | |
| 274 | + ;; arm of its tag table. frq writes both. | |
| 275 | + ;; :active is what frq and libvidya call it — `props.bool("active")` | |
| 276 | + ;; in libvidya's tag table — and :checked is what this backend called | |
| 277 | + ;; it first. Both are read, because a client written against either | |
| 278 | + ;; should not render a permanently empty tick; :active wins where | |
| 279 | + ;; both appear. | |
| 280 | + ;; | |
| 281 | + ;; Likewise both events fire. libvidya emits "toggled"; :on-change is | |
| 282 | + ;; what the checkbox here answered to before. | |
| 283 | + (:checkbox :checkbutton) | |
| 284 | + (let [was (boolean (if (contains? props :active) | |
| 285 | + (:active props) | |
| 286 | + (:checked props))) | |
| 287 | + id (c/next-id key) | |
| 288 | + now (w/checkbox was s {:key key})] | |
| 289 | + (record! n id) | |
| 290 | + (when (not= now was) | |
| 291 | + (fire! n :on-toggled now) | |
| 292 | + (fire! n :on-change now))) | |
| 144 | 293 | |
| 145 | 294 | :slider (let [was (num (:value props) 0.0) |
| 146 | 295 | id (c/next-id key) |
| @@ -155,9 +304,27 @@ | ||
| 155 | 304 | (:entry :text-entry) |
| 156 | 305 | (let [was (str (or (:value props) (:text props) "")) |
| 157 | 306 | id (c/next-id key) |
| 158 | - now (w/text-entry was {:key key :placeholder (:placeholder props)})] | |
| 307 | + now (w/text-entry was {:key key | |
| 308 | + :placeholder (:placeholder props) | |
| 309 | + ;; :width-request is what frq and | |
| 310 | + ;; libvidya call a minimum width; | |
| 311 | + ;; :hexpand says take the rest of the | |
| 312 | + ;; row, which is this widget's default. | |
| 313 | + :min-width (:width-request props) | |
| 314 | + :expand (if (false? (:hexpand props)) | |
| 315 | + :none :horizontal)})] | |
| 159 | 316 | (record! n id) |
| 160 | - (when (not= now was) (fire! n :on-change now))) | |
| 317 | + (when (not= now was) (fire! n :on-change now)) | |
| 318 | + ;; Enter, which a field must not swallow as input: frq sends its | |
| 319 | + ;; message on it, and without this the compose box accepted text | |
| 320 | + ;; and had no way to say it was finished. | |
| 321 | + ;; | |
| 322 | + ;; NO ARGUMENT. libvidya emits activate with an empty string, and | |
| 323 | + ;; frq's handlers are thunks — `s/send-draft!` takes none, and | |
| 324 | + ;; handing it the text is an arity error the moment somebody | |
| 325 | + ;; presses Enter. The text is already theirs; they got it from | |
| 326 | + ;; :on-change. | |
| 327 | + (when (w/entry-activated? id) (fire! n :on-activate))) | |
| 161 | 328 | |
| 162 | 329 | :progress (w/progress (num (:value props) 0.0)) |
| 163 | 330 | :separator (w/separator) |
| @@ -175,7 +342,64 @@ | ||
| 175 | 342 | |
| 176 | 343 | (defn- schedule! [work] (swap! pending conj work) nil) |
| 177 | 344 | |
| 345 | +;; --- timers ----------------------------------------------------------------- | |
| 346 | +;; A client needs somewhere to run work that is not a reaction to anything: | |
| 347 | +;; frq drives its whole media plane from `(every! 16 pump!)`, and a decoded | |
| 348 | +;; frame arrives because a timer asked for it rather than because a person | |
| 349 | +;; clicked. There is no other hook of the right shape — a component body runs | |
| 350 | +;; when its state changes, which for a video feed is never. | |
| 351 | +;; | |
| 352 | +;; Run from the same `:before` as the reconciler's queue, and for the same | |
| 353 | +;; reason: a callback that patches the tree must not do it mid-walk. | |
| 354 | + | |
| 355 | +(defonce ^:private timers (atom {})) | |
| 356 | +(defonce ^:private next-timer (atom 0)) | |
| 357 | + | |
| 358 | +(defn- now-ms [] (System/currentTimeMillis)) | |
| 359 | + | |
| 360 | +(defn after! | |
| 361 | + "Run `f` once, at least `ms` from now. Answers a handle for `cancel!`." | |
| 362 | + [ms f] | |
| 363 | + (let [id (swap! next-timer inc)] | |
| 364 | + (swap! timers assoc id {:at (+ (now-ms) ms) :every nil :f f}) | |
| 365 | + id)) | |
| 366 | + | |
| 367 | +(defn every! | |
| 368 | + "Run `f` every `ms`. Answers a handle for `cancel!`. | |
| 369 | + | |
| 370 | + Every `ms` AT MOST, not exactly: it fires on the first frame after the | |
| 371 | + deadline, so a 16ms timer on a 60Hz window runs once a frame and on a | |
| 372 | + slower one runs less often. That is the right failure — a timer that tried | |
| 373 | + to catch up would run twice in a row on a stutter, and for a pump that | |
| 374 | + means two frames decoded and one shown." | |
| 375 | + [ms f] | |
| 376 | + (let [id (swap! next-timer inc)] | |
| 377 | + (swap! timers assoc id {:at (+ (now-ms) ms) :every ms :f f}) | |
| 378 | + id)) | |
| 379 | + | |
| 380 | +(defn cancel! | |
| 381 | + "Stop a timer." | |
| 382 | + [id] | |
| 383 | + (swap! timers dissoc id) | |
| 384 | + nil) | |
| 385 | + | |
| 386 | +(defn- run-timers! [] | |
| 387 | + (let [t (now-ms) | |
| 388 | + due (filter (fn [[_ v]] (<= (:at v) t)) @timers)] | |
| 389 | + (doseq [[id {:keys [every f]}] due] | |
| 390 | + (if every | |
| 391 | + (swap! timers assoc-in [id :at] (+ t every)) | |
| 392 | + (swap! timers dissoc id)) | |
| 393 | + ;; A throwing timer is cancelled rather than allowed to throw every | |
| 394 | + ;; frame for the rest of the session, which is unreadable and stops | |
| 395 | + ;; the ones behind it. | |
| 396 | + (try (f) | |
| 397 | + (catch Exception e | |
| 398 | + (swap! timers dissoc id) | |
| 399 | + (println "glimmer-jvui: timer failed, cancelled:" (ex-message e))))))) | |
| 400 | + | |
| 178 | 401 | (defn- drain-pending! [] |
| 402 | + (run-timers!) | |
| 179 | 403 | (let [[ws] (reset-vals! pending [])] |
| 180 | 404 | (doseq [w ws] (w)))) |
| 181 | 405 | |
| @@ -231,3 +455,49 @@ | ||
| 231 | 455 | (binding [*record-rects?* true] |
| 232 | 456 | (c/frame! cx (fn [] (emit! root)))) |
| 233 | 457 | cx)) |
| 458 | + | |
| 459 | +;; --- feeds ------------------------------------------------------------------ | |
| 460 | +;; The same three calls glimmer-vidya exposes, so a client that paints a call | |
| 461 | +;; does not care which backend is under it. They are not part of the | |
| 462 | +;; reconciler and deliberately so: pixels arrive between frames, and the tree | |
| 463 | +;; only ever holds the key. | |
| 464 | + | |
| 465 | +(defn frame-rgba! | |
| 466 | + "Hand feed `key` a new picture: `w` by `h` RGBA at FOREIGN pointer `px`. | |
| 467 | + | |
| 468 | + The pointer is read during this call and not kept, so a caller may reuse | |
| 469 | + or free it immediately afterwards — which is what a decoder handing out a | |
| 470 | + borrowed buffer needs." | |
| 471 | + [key w h px] | |
| 472 | + (frames/put! key w h px)) | |
| 473 | + | |
| 474 | +(defn frame-drop! | |
| 475 | + "Forget a feed and release its texture — someone left, or turned a camera | |
| 476 | + off." | |
| 477 | + [key] | |
| 478 | + (frames/drop! key)) | |
| 479 | + | |
| 480 | +(defn feed-keys | |
| 481 | + "Every feed with a picture." | |
| 482 | + [] | |
| 483 | + (frames/keys*)) | |
| 484 | + | |
| 485 | +;; --- the platform ----------------------------------------------------------- | |
| 486 | +;; The rest of what glimmer-vidya answers, so a client can ask its backend | |
| 487 | +;; about the window it is in without knowing which backend that is. Thin on | |
| 488 | +;; purpose: every one of these is jvui.host, and the indirection exists so | |
| 489 | +;; the client requires one namespace rather than two. | |
| 490 | + | |
| 491 | +(def set-title! host/set-title!) | |
| 492 | +(def window-width host/window-width) | |
| 493 | +(def screen-size host/screen-size) | |
| 494 | +(def quit! host/quit!) | |
| 495 | +(def open-url! host/open-url!) | |
| 496 | +(def clipboard-image-png! host/clipboard-image-png!) | |
| 497 | + | |
| 498 | +;; False and nil on a desktop, which is the right answer rather than a gap: | |
| 499 | +;; the chooser exists so a phone can hand back a grant for one picture, and | |
| 500 | +;; a caller reads the false and offers a file browser instead. glimmer-vidya | |
| 501 | +;; says the same thing here. | |
| 502 | +(def pick-image! host/pick-image!) | |
| 503 | +(def picked-image! host/picked-image!) | |
| @@ -39,7 +39,9 @@ | |||
| 39 | [jvui.app :as app] | 39 | [jvui.app :as app] |
| 40 | [jvui.core :as c] | 40 | [jvui.core :as c] |
| 41 | [jvui.theme :as theme] | 41 | [jvui.theme :as theme] |
| 42 | - [jvui.widgets :as w])) | 42 | + [jvui.widgets :as w] |
| 43 | + [jvui.frames :as frames] | ||
| 44 | + [jvui.host :as host])) | ||
| 43 | 45 | ||
| 44 | ;; --- the retained tree ------------------------------------------------------- | 46 | ;; --- the retained tree ------------------------------------------------------- |
| 45 | 47 | ||
| @@ -68,15 +70,68 @@ | |||
| 68 | 70 | ||
| 69 | (defn- num [v default] (if (number? v) (double v) default)) | 71 | (defn- num [v default] (if (number? v) (double v) default)) |
| 70 | 72 | ||
| 73 | +(defn- fills-height? | ||
| 74 | + "Does any child of `n` ask to fill the height? | ||
| 75 | + | ||
| 76 | + A row is only as tall as what is in it, and frq marks the PANES with | ||
| 77 | + :fill-height rather than the row that holds them — egui gives a | ||
| 78 | + horizontal layout the available height and the panes fill that, so | ||
| 79 | + there is nothing there to mark. Here the row has to be told, and its | ||
| 80 | + own children are what know: a row holding something that wants the | ||
| 81 | + height wants the height. | ||
| 82 | + | ||
| 83 | + Asked of the tree rather than inferred from the layout, because the | ||
| 84 | + layout answers a frame too late — a row that learns it should be tall | ||
| 85 | + from what happened last frame is a row that is the wrong height on | ||
| 86 | + the frame anybody looks at." | ||
| 87 | + [n] | ||
| 88 | + (boolean (some #(:fill-height (:props (deref %))) (:children (deref n))))) | ||
| 89 | + | ||
| 71 | (defn- box-opts | 90 | (defn- box-opts |
| 72 | "The container options shared by every container tag." | 91 | "The container options shared by every container tag." |
| 73 | - [props key] | 92 | + ([props key] (box-opts props key false)) |
| 93 | + ([props key fill-height?] | ||
| 74 | (cond-> {:key key | 94 | (cond-> {:key key |
| 75 | - :dir (if (= :horizontal (:orientation props)) :horizontal :vertical)} | 95 | + :dir (if (= :horizontal (:orientation props)) :horizontal :vertical) |
| 96 | + ;; A CONTAINER fills its parent's cross axis by default. Without | ||
| 97 | + ;; this every box shrink-wraps its children, and frq's chat | ||
| 98 | + ;; column came out a couple of hundred points wide in a | ||
| 99 | + ;; five-hundred-point window with every message wrapped to | ||
| 100 | + ;; match — the tree is nested boxes, and each one only as wide | ||
| 101 | + ;; as what is in it. | ||
| 102 | + ;; | ||
| 103 | + ;; :cross and not :horizontal: in a ROW, :horizontal means take | ||
| 104 | + ;; a share of the slack, and a line of buttons would stretch to | ||
| 105 | + ;; fill the window. | ||
| 106 | + :expand :cross} | ||
| 107 | + ;; :fill-height is frq's way of saying "this is the pane that takes | ||
| 108 | + ;; what is left". It is the messages column in the row that also | ||
| 109 | + ;; holds the people panel, and without it that column claims no slack | ||
| 110 | + ;; at all — the backlog ends up as wide as the widest message and the | ||
| 111 | + ;; scrollbar sits in the middle of the window. | ||
| 112 | + ;; | ||
| 113 | + ;; :both rather than :vertical, despite the name: in a ROW the space | ||
| 114 | + ;; to be taken is horizontal, and a pane that fills the height of a | ||
| 115 | + ;; row it does not fill the width of is not what anyone means by it. | ||
| 116 | + ;; The panes that do NOT ask for it stay :cross and keep their own | ||
| 117 | + ;; size, which is what leaves the slack to be taken. | ||
| 118 | + (or (:fill-height props) fill-height?) (assoc :expand :both) | ||
| 119 | + ;; A minimum, not a size: the messages column asks for one only while | ||
| 120 | + ;; the people panel is beside it. | ||
| 121 | + (:width-request props) | ||
| 122 | + (assoc :min-size [(num (:width-request props) 0.0) 0.0]) | ||
| 76 | (:spacing props) (assoc :spacing (num (:spacing props) 0.0)) | 123 | (:spacing props) (assoc :spacing (num (:spacing props) 0.0)) |
| 77 | (:padding props) (assoc :padding (num (:padding props) 0.0)) | 124 | (:padding props) (assoc :padding (num (:padding props) 0.0)) |
| 78 | (:margin props) (assoc :margin (num (:margin props) 0.0)) | 125 | (:margin props) (assoc :margin (num (:margin props) 0.0)) |
| 79 | - (:expand props) (assoc :expand (:expand props)))) | 126 | + (:expand props) (assoc :expand (:expand props)) |
| 127 | + ;; A row whose children start a new line when they run out of room — | ||
| 128 | + ;; a line of reaction pills is the case that needs it. | ||
| 129 | + (:wrap props) (assoc :wrap true) | ||
| 130 | + ;; Cross-axis placement: :start :center :end, as a gravity. | ||
| 131 | + (:align props) (assoc :gravity (case (:align props) | ||
| 132 | + (:center "center") [0.0 0.5] | ||
| 133 | + (:end "end") [0.0 1.0] | ||
| 134 | + [0.0 0.0]))))) | ||
| 80 | 135 | ||
| 81 | (defn- fire! [n k & args] | 136 | (defn- fire! [n k & args] |
| 82 | (when-let [f (get (:props @n) k)] (apply f args))) | 137 | (when-let [f (get (:props @n) k)] (apply f args))) |
| @@ -117,14 +172,92 @@ | |||
| 117 | 172 | ||
| 118 | (:card :frame) (w/card* (box-opts props key) (emit-children! n)) | 173 | (:card :frame) (w/card* (box-opts props key) (emit-children! n)) |
| 119 | 174 | ||
| 120 | - :scroll (w/scroll* (assoc (box-opts props key) | 175 | + ;; A list that follows what arrives in it. Everything here beyond |
| 121 | - :height (num (:height props) 200.0)) | 176 | + ;; :height is a prop frq writes and this used to drop on the floor — |
| 177 | + ;; the chat did not follow new messages, and switching channels | ||
| 178 | + ;; carried the previous one's scroll across. | ||
| 179 | + ;; :expand is forced rather than left to box-opts, whose default is | ||
| 180 | + ;; :cross — and a viewport that fills only the width asks its column | ||
| 181 | + ;; for no height, is given none, and shows nothing at all. It is the | ||
| 182 | + ;; one container that always fills both ways. | ||
| 183 | + :scroll (w/scroll* (cond-> (assoc (box-opts props key) :expand :both) | ||
| 184 | + (:height props) | ||
| 185 | + (assoc :height (num (:height props) 200.0)) | ||
| 186 | + (:reserve props) | ||
| 187 | + (assoc :reserve (num (:reserve props) 0.0)) | ||
| 188 | + (:scroll-key props) | ||
| 189 | + (assoc :scroll-key (str (:scroll-key props))) | ||
| 190 | + (:stick-to-bottom props) | ||
| 191 | + (assoc :stick-to-bottom true) | ||
| 192 | + (:scroll-to-bottom props) | ||
| 193 | + (assoc :scroll-to-bottom (num (:scroll-to-bottom props) 0.0)) | ||
| 194 | + ;; "end" or "away", the STRING libvidya emits — | ||
| 195 | + ;; frq's handler is (= "end" %) and a boolean | ||
| 196 | + ;; makes it permanently false. | ||
| 197 | + ;; | ||
| 198 | + ;; :on-scroll is deliberately not fired here. | ||
| 199 | + ;; It is the channel for backends that report an | ||
| 200 | + ;; OFFSET rather than a place — the terminal's — | ||
| 201 | + ;; and frq turns one into the other with | ||
| 202 | + ;; `scrolled!`. A window that reports where it | ||
| 203 | + ;; ended up has nothing to say on it. | ||
| 204 | + (:on-change props) | ||
| 205 | + (assoc :on-at-end | ||
| 206 | + (fn [at-end?] | ||
| 207 | + (fire! n :on-change (if at-end? "end" "away"))))) | ||
| 122 | (emit-children! n)) | 208 | (emit-children! n)) |
| 123 | 209 | ||
| 124 | - :hbox (c/box* (assoc (box-opts props key) :dir :horizontal) | 210 | + :hbox (c/box* (assoc (box-opts props key (fills-height? n)) :dir :horizontal) |
| 125 | (emit-children! n)) | 211 | (emit-children! n)) |
| 126 | 212 | ||
| 127 | - (:vbox :box) (c/box* (box-opts props key) (emit-children! n)) | 213 | + (:vbox :box) (c/box* (box-opts props key (fills-height? n)) |
| 214 | + (emit-children! n)) | ||
| 215 | + | ||
| 216 | + ;; ONE tag for both kinds of picture: `:feed` is live pixels pushed | ||
| 217 | + ;; in under a name and re-uploaded as they arrive, `:src` is a file | ||
| 218 | + ;; decoded once and kept by path. Everything downstream — the fit, the | ||
| 219 | + ;; bounds, the click — is the same, which is why libvidya makes this a | ||
| 220 | + ;; prop and not a second tag, and why frq writes [:image {:feed k}] | ||
| 221 | + ;; for a call tile and [:image {:src p}] for an attachment. | ||
| 222 | + ;; | ||
| 223 | + ;; The pixels never go through the reconciler either way: a frame | ||
| 224 | + ;; arrives when the network says so, and a props diff at thirty a | ||
| 225 | + ;; second would be a re-render per frame per peer. | ||
| 226 | + :image (let [id (c/next-id key) | ||
| 227 | + rect (w/image {:feed (:feed props) :src (:src props)} | ||
| 228 | + {:fit (:fit props) | ||
| 229 | + :max-width (:max-width props) | ||
| 230 | + :max-height (:max-height props) | ||
| 231 | + :size (:size props) | ||
| 232 | + :expand (:expand props)})] | ||
| 233 | + (record! n id) | ||
| 234 | + (when (:clicked? (c/interact! id rect)) (fire! n :on-click))) | ||
| 235 | + | ||
| 236 | + :title-2 (w/title-2 s) | ||
| 237 | + | ||
| 238 | + :status (w/status s (boolean (:live props))) | ||
| 239 | + | ||
| 240 | + :spinner (w/spinner s) | ||
| 241 | + | ||
| 242 | + :link (let [id (c/next-id key)] | ||
| 243 | + (record! n id) | ||
| 244 | + (when (w/link s {:key key}) (fire! n :on-click))) | ||
| 245 | + | ||
| 246 | + :emoji (w/emoji (or (:emoji props) s) (:size props)) | ||
| 247 | + | ||
| 248 | + :avatar (w/avatar (or (:label props) s) | ||
| 249 | + (cond-> {} | ||
| 250 | + (:src props) (assoc :src (:src props)) | ||
| 251 | + (:size props) (assoc :size (:size props)))) | ||
| 252 | + | ||
| 253 | + :reaction (let [id (c/next-id key) | ||
| 254 | + glyph (or (:emoji props) s)] | ||
| 255 | + (record! n id) | ||
| 256 | + (when (w/reaction glyph {:count (or (:count props) 0) | ||
| 257 | + :mine? (boolean (:mine props)) | ||
| 258 | + :size (:size props) | ||
| 259 | + :key key}) | ||
| 260 | + (fire! n :on-click))) | ||
| 128 | 261 | ||
| 129 | :title (w/title s) | 262 | :title (w/title s) |
| 130 | 263 | ||
| @@ -136,11 +269,27 @@ | |||
| 136 | (record! n id) | 269 | (record! n id) |
| 137 | (when hit? (fire! n :on-click))) | 270 | (when hit? (fire! n :on-click))) |
| 138 | 271 | ||
| 139 | - :checkbox (let [was (boolean (:checked props)) | 272 | + ;; :checkbutton is the same widget under GTK's name for it, which is |
| 140 | - id (c/next-id key) | 273 | + ;; what libvidya calls it too — `"checkbutton" | "checkbox"` is one |
| 141 | - now (w/checkbox was s {:key key})] | 274 | + ;; arm of its tag table. frq writes both. |
| 142 | - (record! n id) | 275 | + ;; :active is what frq and libvidya call it — `props.bool("active")` |
| 143 | - (when (not= now was) (fire! n :on-change now))) | 276 | + ;; in libvidya's tag table — and :checked is what this backend called |
| 277 | + ;; it first. Both are read, because a client written against either | ||
| 278 | + ;; should not render a permanently empty tick; :active wins where | ||
| 279 | + ;; both appear. | ||
| 280 | + ;; | ||
| 281 | + ;; Likewise both events fire. libvidya emits "toggled"; :on-change is | ||
| 282 | + ;; what the checkbox here answered to before. | ||
| 283 | + (:checkbox :checkbutton) | ||
| 284 | + (let [was (boolean (if (contains? props :active) | ||
| 285 | + (:active props) | ||
| 286 | + (:checked props))) | ||
| 287 | + id (c/next-id key) | ||
| 288 | + now (w/checkbox was s {:key key})] | ||
| 289 | + (record! n id) | ||
| 290 | + (when (not= now was) | ||
| 291 | + (fire! n :on-toggled now) | ||
| 292 | + (fire! n :on-change now))) | ||
| 144 | 293 | ||
| 145 | :slider (let [was (num (:value props) 0.0) | 294 | :slider (let [was (num (:value props) 0.0) |
| 146 | id (c/next-id key) | 295 | id (c/next-id key) |
| @@ -155,9 +304,27 @@ | |||
| 155 | (:entry :text-entry) | 304 | (:entry :text-entry) |
| 156 | (let [was (str (or (:value props) (:text props) "")) | 305 | (let [was (str (or (:value props) (:text props) "")) |
| 157 | id (c/next-id key) | 306 | id (c/next-id key) |
| 158 | - now (w/text-entry was {:key key :placeholder (:placeholder props)})] | 307 | + now (w/text-entry was {:key key |
| 308 | + :placeholder (:placeholder props) | ||
| 309 | + ;; :width-request is what frq and | ||
| 310 | + ;; libvidya call a minimum width; | ||
| 311 | + ;; :hexpand says take the rest of the | ||
| 312 | + ;; row, which is this widget's default. | ||
| 313 | + :min-width (:width-request props) | ||
| 314 | + :expand (if (false? (:hexpand props)) | ||
| 315 | + :none :horizontal)})] | ||
| 159 | (record! n id) | 316 | (record! n id) |
| 160 | - (when (not= now was) (fire! n :on-change now))) | 317 | + (when (not= now was) (fire! n :on-change now)) |
| 318 | + ;; Enter, which a field must not swallow as input: frq sends its | ||
| 319 | + ;; message on it, and without this the compose box accepted text | ||
| 320 | + ;; and had no way to say it was finished. | ||
| 321 | + ;; | ||
| 322 | + ;; NO ARGUMENT. libvidya emits activate with an empty string, and | ||
| 323 | + ;; frq's handlers are thunks — `s/send-draft!` takes none, and | ||
| 324 | + ;; handing it the text is an arity error the moment somebody | ||
| 325 | + ;; presses Enter. The text is already theirs; they got it from | ||
| 326 | + ;; :on-change. | ||
| 327 | + (when (w/entry-activated? id) (fire! n :on-activate))) | ||
| 161 | 328 | ||
| 162 | :progress (w/progress (num (:value props) 0.0)) | 329 | :progress (w/progress (num (:value props) 0.0)) |
| 163 | :separator (w/separator) | 330 | :separator (w/separator) |
| @@ -175,7 +342,64 @@ | |||
| 175 | 342 | ||
| 176 | (defn- schedule! [work] (swap! pending conj work) nil) | 343 | (defn- schedule! [work] (swap! pending conj work) nil) |
| 177 | 344 | ||
| 345 | +;; --- timers ----------------------------------------------------------------- | ||
| 346 | +;; A client needs somewhere to run work that is not a reaction to anything: | ||
| 347 | +;; frq drives its whole media plane from `(every! 16 pump!)`, and a decoded | ||
| 348 | +;; frame arrives because a timer asked for it rather than because a person | ||
| 349 | +;; clicked. There is no other hook of the right shape — a component body runs | ||
| 350 | +;; when its state changes, which for a video feed is never. | ||
| 351 | +;; | ||
| 352 | +;; Run from the same `:before` as the reconciler's queue, and for the same | ||
| 353 | +;; reason: a callback that patches the tree must not do it mid-walk. | ||
| 354 | + | ||
| 355 | +(defonce ^:private timers (atom {})) | ||
| 356 | +(defonce ^:private next-timer (atom 0)) | ||
| 357 | + | ||
| 358 | +(defn- now-ms [] (System/currentTimeMillis)) | ||
| 359 | + | ||
| 360 | +(defn after! | ||
| 361 | + "Run `f` once, at least `ms` from now. Answers a handle for `cancel!`." | ||
| 362 | + [ms f] | ||
| 363 | + (let [id (swap! next-timer inc)] | ||
| 364 | + (swap! timers assoc id {:at (+ (now-ms) ms) :every nil :f f}) | ||
| 365 | + id)) | ||
| 366 | + | ||
| 367 | +(defn every! | ||
| 368 | + "Run `f` every `ms`. Answers a handle for `cancel!`. | ||
| 369 | + | ||
| 370 | + Every `ms` AT MOST, not exactly: it fires on the first frame after the | ||
| 371 | + deadline, so a 16ms timer on a 60Hz window runs once a frame and on a | ||
| 372 | + slower one runs less often. That is the right failure — a timer that tried | ||
| 373 | + to catch up would run twice in a row on a stutter, and for a pump that | ||
| 374 | + means two frames decoded and one shown." | ||
| 375 | + [ms f] | ||
| 376 | + (let [id (swap! next-timer inc)] | ||
| 377 | + (swap! timers assoc id {:at (+ (now-ms) ms) :every ms :f f}) | ||
| 378 | + id)) | ||
| 379 | + | ||
| 380 | +(defn cancel! | ||
| 381 | + "Stop a timer." | ||
| 382 | + [id] | ||
| 383 | + (swap! timers dissoc id) | ||
| 384 | + nil) | ||
| 385 | + | ||
| 386 | +(defn- run-timers! [] | ||
| 387 | + (let [t (now-ms) | ||
| 388 | + due (filter (fn [[_ v]] (<= (:at v) t)) @timers)] | ||
| 389 | + (doseq [[id {:keys [every f]}] due] | ||
| 390 | + (if every | ||
| 391 | + (swap! timers assoc-in [id :at] (+ t every)) | ||
| 392 | + (swap! timers dissoc id)) | ||
| 393 | + ;; A throwing timer is cancelled rather than allowed to throw every | ||
| 394 | + ;; frame for the rest of the session, which is unreadable and stops | ||
| 395 | + ;; the ones behind it. | ||
| 396 | + (try (f) | ||
| 397 | + (catch Exception e | ||
| 398 | + (swap! timers dissoc id) | ||
| 399 | + (println "glimmer-jvui: timer failed, cancelled:" (ex-message e))))))) | ||
| 400 | + | ||
| 178 | (defn- drain-pending! [] | 401 | (defn- drain-pending! [] |
| 402 | + (run-timers!) | ||
| 179 | (let [[ws] (reset-vals! pending [])] | 403 | (let [[ws] (reset-vals! pending [])] |
| 180 | (doseq [w ws] (w)))) | 404 | (doseq [w ws] (w)))) |
| 181 | 405 | ||
| @@ -231,3 +455,49 @@ | |||
| 231 | (binding [*record-rects?* true] | 455 | (binding [*record-rects?* true] |
| 232 | (c/frame! cx (fn [] (emit! root)))) | 456 | (c/frame! cx (fn [] (emit! root)))) |
| 233 | cx)) | 457 | cx)) |
| 458 | + | ||
| 459 | +;; --- feeds ------------------------------------------------------------------ | ||
| 460 | +;; The same three calls glimmer-vidya exposes, so a client that paints a call | ||
| 461 | +;; does not care which backend is under it. They are not part of the | ||
| 462 | +;; reconciler and deliberately so: pixels arrive between frames, and the tree | ||
| 463 | +;; only ever holds the key. | ||
| 464 | + | ||
| 465 | +(defn frame-rgba! | ||
| 466 | + "Hand feed `key` a new picture: `w` by `h` RGBA at FOREIGN pointer `px`. | ||
| 467 | + | ||
| 468 | + The pointer is read during this call and not kept, so a caller may reuse | ||
| 469 | + or free it immediately afterwards — which is what a decoder handing out a | ||
| 470 | + borrowed buffer needs." | ||
| 471 | + [key w h px] | ||
| 472 | + (frames/put! key w h px)) | ||
| 473 | + | ||
| 474 | +(defn frame-drop! | ||
| 475 | + "Forget a feed and release its texture — someone left, or turned a camera | ||
| 476 | + off." | ||
| 477 | + [key] | ||
| 478 | + (frames/drop! key)) | ||
| 479 | + | ||
| 480 | +(defn feed-keys | ||
| 481 | + "Every feed with a picture." | ||
| 482 | + [] | ||
| 483 | + (frames/keys*)) | ||
| 484 | + | ||
| 485 | +;; --- the platform ----------------------------------------------------------- | ||
| 486 | +;; The rest of what glimmer-vidya answers, so a client can ask its backend | ||
| 487 | +;; about the window it is in without knowing which backend that is. Thin on | ||
| 488 | +;; purpose: every one of these is jvui.host, and the indirection exists so | ||
| 489 | +;; the client requires one namespace rather than two. | ||
| 490 | + | ||
| 491 | +(def set-title! host/set-title!) | ||
| 492 | +(def window-width host/window-width) | ||
| 493 | +(def screen-size host/screen-size) | ||
| 494 | +(def quit! host/quit!) | ||
| 495 | +(def open-url! host/open-url!) | ||
| 496 | +(def clipboard-image-png! host/clipboard-image-png!) | ||
| 497 | + | ||
| 498 | +;; False and nil on a desktop, which is the right answer rather than a gap: | ||
| 499 | +;; the chooser exists so a phone can hand back a grant for one picture, and | ||
| 500 | +;; a caller reads the false and offers a file browser instead. glimmer-vidya | ||
| 501 | +;; says the same thing here. | ||
| 502 | +(def pick-image! host/pick-image!) | ||
| 503 | +(def picked-image! host/picked-image!) | ||
added
glimmer-backends/glimmer-jvui/test/glimmer_jvui/props_check.clj +64 -0 | new file mode 100644 | ||
| @@ -0,0 +1,64 @@ | ||
| 1 | +(ns glimmer-jvui.props-check | |
| 2 | + "Two prop-level bugs the tag diff could not see, checked against pixels. | |
| 3 | + | |
| 4 | + A tag being handled is not the same as a tag reading the props a client | |
| 5 | + actually writes. Both of these rendered perfectly and said the wrong | |
| 6 | + thing: a checkbutton whose tick never appeared because frq writes | |
| 7 | + `:active` where this read `:checked`, and a field whose text ran out past | |
| 8 | + its own border because nothing clipped it." | |
| 9 | + (:require [glimmer.core :as ui] | |
| 10 | + [glimmer-jvui.core] | |
| 11 | + [jvui.sdl :as sdl] | |
| 12 | + [jolt.ffi :as ffi])) | |
| 13 | + | |
| 14 | +(defn- bmp [path] | |
| 15 | + (let [b (java.nio.file.Files/readAllBytes | |
| 16 | + (java.nio.file.Path/of path (into-array String []))) | |
| 17 | + u (fn [i] (bit-and (int (aget b i)) 255)) | |
| 18 | + le (fn [i] (+ (u i) (bit-shift-left (u (+ i 1)) 8) | |
| 19 | + (bit-shift-left (u (+ i 2)) 16) (bit-shift-left (u (+ i 3)) 24))) | |
| 20 | + off (le 10) w (le 18) h (le 22) stride (* 4 (quot (+ (* w 3) 3) 4))] | |
| 21 | + {:w w :h h :px (fn [x y] (let [i (+ off (* (- h 1 y) stride) (* 3 x))] | |
| 22 | + {:b (u i) :g (u (+ i 1)) :r (u (+ i 2))}))})) | |
| 23 | + | |
| 24 | +(defn- ink-in | |
| 25 | + "How many pixels inside [x0 y0 x1 y1] differ from the background." | |
| 26 | + [{:keys [px]} bg x0 y0 x1 y1] | |
| 27 | + (count (for [y (range y0 y1) x (range x0 x1) | |
| 28 | + :let [p (px x y)] | |
| 29 | + :when (> (+ (abs (- (:r p) (:r bg))) (abs (- (:g p) (:g bg))) | |
| 30 | + (abs (- (:b p) (:b bg)))) 40)] | |
| 31 | + 1))) | |
| 32 | + | |
| 33 | +(defn -main [& _] | |
| 34 | + (let [shot (str (System/getProperty "java.io.tmpdir") "/jvui-props.bmp") | |
| 35 | + out (atom []) ck! (fn [n ok?] (swap! out conj [n (boolean ok?)]))] | |
| 36 | + (ui/run (fn [] | |
| 37 | + [:vbox {:spacing 8} | |
| 38 | + ;; A card paints a background, so how wide it comes out is | |
| 39 | + ;; visible. Nested two deep because frq's tree is nested: | |
| 40 | + ;; every box shrink-wrapping is how its chat column ended | |
| 41 | + ;; up a couple of hundred points wide in a wider window. | |
| 42 | + [:vbox {} [:card {} [:label {:label "x"}]]] | |
| 43 | + ;; :active, the way frq writes it — the tick must appear. | |
| 44 | + [:checkbutton {:label "TLS" :active true}] | |
| 45 | + ;; A string far wider than the field it is in. | |
| 46 | + [:entry {:value "nandi-test.bsky.social-and-then-some-more-text"}]]) | |
| 47 | + {:title "props" :width 240 :height 140 :frames 6 :shot shot}) | |
| 48 | + (let [img (bmp shot) | |
| 49 | + bg ((:px img) 2 2)] | |
| 50 | + ;; The tick is inside the little box at the far left of the first row. | |
| 51 | + (ck! "an :active checkbutton draws its tick" | |
| 52 | + (> (ink-in img bg 4 4 26 30) 12)) | |
| 53 | + ;; Nothing may be drawn to the right of the field's own border. | |
| 54 | + (ck! "entry text stops at the field's edge" | |
| 55 | + (zero? (ink-in img bg 232 100 240 140))) | |
| 56 | + ;; The card's own background, out near the right edge: a | |
| 57 | + ;; shrink-wrapped one would not reach. | |
| 58 | + (ck! "a nested container fills the width" | |
| 59 | + (pos? (ink-in img bg 200 4 232 40)))) | |
| 60 | + (doseq [[n ok?] @out] (println (if ok? "- " "FAIL ") n)) | |
| 61 | + (let [bad (remove second @out)] | |
| 62 | + (println (if (seq bad) (str (count bad) " of " (count @out) " checks FAILED") | |
| 63 | + (str "all " (count @out) " checks passed"))) | |
| 64 | + (when (seq bad) (System/exit 1))))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,64 @@ | |||
| 1 | +(ns glimmer-jvui.props-check | ||
| 2 | + "Two prop-level bugs the tag diff could not see, checked against pixels. | ||
| 3 | + | ||
| 4 | + A tag being handled is not the same as a tag reading the props a client | ||
| 5 | + actually writes. Both of these rendered perfectly and said the wrong | ||
| 6 | + thing: a checkbutton whose tick never appeared because frq writes | ||
| 7 | + `:active` where this read `:checked`, and a field whose text ran out past | ||
| 8 | + its own border because nothing clipped it." | ||
| 9 | + (:require [glimmer.core :as ui] | ||
| 10 | + [glimmer-jvui.core] | ||
| 11 | + [jvui.sdl :as sdl] | ||
| 12 | + [jolt.ffi :as ffi])) | ||
| 13 | + | ||
| 14 | +(defn- bmp [path] | ||
| 15 | + (let [b (java.nio.file.Files/readAllBytes | ||
| 16 | + (java.nio.file.Path/of path (into-array String []))) | ||
| 17 | + u (fn [i] (bit-and (int (aget b i)) 255)) | ||
| 18 | + le (fn [i] (+ (u i) (bit-shift-left (u (+ i 1)) 8) | ||
| 19 | + (bit-shift-left (u (+ i 2)) 16) (bit-shift-left (u (+ i 3)) 24))) | ||
| 20 | + off (le 10) w (le 18) h (le 22) stride (* 4 (quot (+ (* w 3) 3) 4))] | ||
| 21 | + {:w w :h h :px (fn [x y] (let [i (+ off (* (- h 1 y) stride) (* 3 x))] | ||
| 22 | + {:b (u i) :g (u (+ i 1)) :r (u (+ i 2))}))})) | ||
| 23 | + | ||
| 24 | +(defn- ink-in | ||
| 25 | + "How many pixels inside [x0 y0 x1 y1] differ from the background." | ||
| 26 | + [{:keys [px]} bg x0 y0 x1 y1] | ||
| 27 | + (count (for [y (range y0 y1) x (range x0 x1) | ||
| 28 | + :let [p (px x y)] | ||
| 29 | + :when (> (+ (abs (- (:r p) (:r bg))) (abs (- (:g p) (:g bg))) | ||
| 30 | + (abs (- (:b p) (:b bg)))) 40)] | ||
| 31 | + 1))) | ||
| 32 | + | ||
| 33 | +(defn -main [& _] | ||
| 34 | + (let [shot (str (System/getProperty "java.io.tmpdir") "/jvui-props.bmp") | ||
| 35 | + out (atom []) ck! (fn [n ok?] (swap! out conj [n (boolean ok?)]))] | ||
| 36 | + (ui/run (fn [] | ||
| 37 | + [:vbox {:spacing 8} | ||
| 38 | + ;; A card paints a background, so how wide it comes out is | ||
| 39 | + ;; visible. Nested two deep because frq's tree is nested: | ||
| 40 | + ;; every box shrink-wrapping is how its chat column ended | ||
| 41 | + ;; up a couple of hundred points wide in a wider window. | ||
| 42 | + [:vbox {} [:card {} [:label {:label "x"}]]] | ||
| 43 | + ;; :active, the way frq writes it — the tick must appear. | ||
| 44 | + [:checkbutton {:label "TLS" :active true}] | ||
| 45 | + ;; A string far wider than the field it is in. | ||
| 46 | + [:entry {:value "nandi-test.bsky.social-and-then-some-more-text"}]]) | ||
| 47 | + {:title "props" :width 240 :height 140 :frames 6 :shot shot}) | ||
| 48 | + (let [img (bmp shot) | ||
| 49 | + bg ((:px img) 2 2)] | ||
| 50 | + ;; The tick is inside the little box at the far left of the first row. | ||
| 51 | + (ck! "an :active checkbutton draws its tick" | ||
| 52 | + (> (ink-in img bg 4 4 26 30) 12)) | ||
| 53 | + ;; Nothing may be drawn to the right of the field's own border. | ||
| 54 | + (ck! "entry text stops at the field's edge" | ||
| 55 | + (zero? (ink-in img bg 232 100 240 140))) | ||
| 56 | + ;; The card's own background, out near the right edge: a | ||
| 57 | + ;; shrink-wrapped one would not reach. | ||
| 58 | + (ck! "a nested container fills the width" | ||
| 59 | + (pos? (ink-in img bg 200 4 232 40)))) | ||
| 60 | + (doseq [[n ok?] @out] (println (if ok? "- " "FAIL ") n)) | ||
| 61 | + (let [bad (remove second @out)] | ||
| 62 | + (println (if (seq bad) (str (count bad) " of " (count @out) " checks FAILED") | ||
| 63 | + (str "all " (count @out) " checks passed"))) | ||
| 64 | + (when (seq bad) (System/exit 1))))) | ||
added
glimmer-backends/glimmer-jvui/test/glimmer_jvui/tags_check.clj +81 -0 | new file mode 100644 | ||
| @@ -0,0 +1,81 @@ | ||
| 1 | +(ns glimmer-jvui.tags-check | |
| 2 | + "Every tag frq renders, rendered, and the ones with a colour checked. | |
| 3 | + | |
| 4 | + The layout tests already prove a tag is not an unknown container. What | |
| 5 | + they cannot see is whether it drew anything, because headless there is no | |
| 6 | + renderer — so this opens a window under SDL's dummy driver and reads the | |
| 7 | + screenshot back. | |
| 8 | + | |
| 9 | + Only the tags with a colour worth asserting on are checked by pixel: a | |
| 10 | + decoded PNG must be the green it was written as, and an avatar with no | |
| 11 | + picture must be a filled disc rather than nothing. The rest are checked | |
| 12 | + for not throwing and not being swallowed as unknown, which is the failure | |
| 13 | + they would actually have." | |
| 14 | + (:require [glimmer.core :as ui] | |
| 15 | + [glimmer-jvui.core :as backend] | |
| 16 | + [jolt.ffi :as ffi])) | |
| 17 | + | |
| 18 | +(defn- bmp [path] | |
| 19 | + (let [b (java.nio.file.Files/readAllBytes | |
| 20 | + (java.nio.file.Path/of path (into-array String []))) | |
| 21 | + u (fn [i] (bit-and (int (aget b i)) 255)) | |
| 22 | + le (fn [i] (+ (u i) (bit-shift-left (u (+ i 1)) 8) | |
| 23 | + (bit-shift-left (u (+ i 2)) 16) (bit-shift-left (u (+ i 3)) 24))) | |
| 24 | + off (le 10) w (le 18) h (le 22) | |
| 25 | + stride (* 4 (quot (+ (* w 3) 3) 4))] | |
| 26 | + {:w w :h h | |
| 27 | + :px (fn [x y] (let [i (+ off (* (- h 1 y) stride) (* 3 x))] | |
| 28 | + {:b (u i) :g (u (+ i 1)) :r (u (+ i 2))}))})) | |
| 29 | + | |
| 30 | +(defn- scan | |
| 31 | + "Does any pixel in the image satisfy `p`?" | |
| 32 | + [{:keys [w h px]} p] | |
| 33 | + (boolean (some (fn [y] (some (fn [x] (p (px x y))) (range 0 w 2))) (range 0 h 2)))) | |
| 34 | + | |
| 35 | +(defn -main [& _] | |
| 36 | + (let [shot (str (System/getProperty "java.io.tmpdir") "/jvui-tags.bmp") | |
| 37 | + feed (ffi/alloc (* 4 16 16)) | |
| 38 | + out (atom []) | |
| 39 | + ck! (fn [n ok?] (swap! out conj [n (boolean ok?)]))] | |
| 40 | + ;; A live feed frame: pure red, so it cannot be confused with the file. | |
| 41 | + (dotimes [i (* 16 16)] | |
| 42 | + (ffi/write (+ feed (* 4 i) 0) :uint8 240) | |
| 43 | + (ffi/write (+ feed (* 4 i) 1) :uint8 0) | |
| 44 | + (ffi/write (+ feed (* 4 i) 2) :uint8 0) | |
| 45 | + (ffi/write (+ feed (* 4 i) 3) :uint8 255)) | |
| 46 | + (backend/every! 16 (fn [] (backend/frame-rgba! "cam" 16 16 feed))) | |
| 47 | + (try | |
| 48 | + (ui/run | |
| 49 | + (fn [] | |
| 50 | + [:vbox {:spacing 4} | |
| 51 | + [:title-2 {:label "heading"}] | |
| 52 | + [:status {:label "connected" :live true}] | |
| 53 | + [:spinner {:label "working"}] | |
| 54 | + [:link {:label "a link"}] | |
| 55 | + [:emoji {:emoji "x" :size 14}] | |
| 56 | + [:reaction {:emoji "y" :count 3 :mine true}] | |
| 57 | + [:hbox {:spacing 4} | |
| 58 | + [:avatar {:label "nandi" :size 24}] | |
| 59 | + [:image {:src "/tmp/jvui-green.png" :max-width 40}] | |
| 60 | + [:image {:feed "cam" :size [40.0 40.0]}]]]) | |
| 61 | + {:title "tags" :width 320 :height 320 :frames 6 :shot shot}) | |
| 62 | + (finally (ffi/free feed))) | |
| 63 | + (let [img (bmp shot)] | |
| 64 | + (ck! "a decoded PNG is on screen, and green" | |
| 65 | + (scan img (fn [{:keys [r g b]}] (and (> g 150) (< r 90) (< b 90))))) | |
| 66 | + (ck! "a live feed is on screen, and red" | |
| 67 | + (scan img (fn [{:keys [r g b]}] (and (> r 180) (< g 80) (< b 80))))) | |
| 68 | + (ck! "an avatar with no picture is a filled disc" | |
| 69 | + (scan img (fn [{:keys [r g b]}] | |
| 70 | + ;; any of the name palette, none of which is the | |
| 71 | + ;; background or the two above | |
| 72 | + (and (> (max r g b) 90) (< (max r g b) 230) | |
| 73 | + (> (- (max r g b) (min r g b)) 40) | |
| 74 | + (not (and (> g 150) (< r 90))) | |
| 75 | + (not (and (> r 180) (< g 80)))))))) | |
| 76 | + (doseq [[n ok?] @out] (println (if ok? "- " "FAIL ") n)) | |
| 77 | + (let [bad (remove second @out)] | |
| 78 | + (println (if (seq bad) | |
| 79 | + (str (count bad) " of " (count @out) " checks FAILED") | |
| 80 | + (str "all " (count @out) " checks passed"))) | |
| 81 | + (when (seq bad) (System/exit 1))))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,81 @@ | |||
| 1 | +(ns glimmer-jvui.tags-check | ||
| 2 | + "Every tag frq renders, rendered, and the ones with a colour checked. | ||
| 3 | + | ||
| 4 | + The layout tests already prove a tag is not an unknown container. What | ||
| 5 | + they cannot see is whether it drew anything, because headless there is no | ||
| 6 | + renderer — so this opens a window under SDL's dummy driver and reads the | ||
| 7 | + screenshot back. | ||
| 8 | + | ||
| 9 | + Only the tags with a colour worth asserting on are checked by pixel: a | ||
| 10 | + decoded PNG must be the green it was written as, and an avatar with no | ||
| 11 | + picture must be a filled disc rather than nothing. The rest are checked | ||
| 12 | + for not throwing and not being swallowed as unknown, which is the failure | ||
| 13 | + they would actually have." | ||
| 14 | + (:require [glimmer.core :as ui] | ||
| 15 | + [glimmer-jvui.core :as backend] | ||
| 16 | + [jolt.ffi :as ffi])) | ||
| 17 | + | ||
| 18 | +(defn- bmp [path] | ||
| 19 | + (let [b (java.nio.file.Files/readAllBytes | ||
| 20 | + (java.nio.file.Path/of path (into-array String []))) | ||
| 21 | + u (fn [i] (bit-and (int (aget b i)) 255)) | ||
| 22 | + le (fn [i] (+ (u i) (bit-shift-left (u (+ i 1)) 8) | ||
| 23 | + (bit-shift-left (u (+ i 2)) 16) (bit-shift-left (u (+ i 3)) 24))) | ||
| 24 | + off (le 10) w (le 18) h (le 22) | ||
| 25 | + stride (* 4 (quot (+ (* w 3) 3) 4))] | ||
| 26 | + {:w w :h h | ||
| 27 | + :px (fn [x y] (let [i (+ off (* (- h 1 y) stride) (* 3 x))] | ||
| 28 | + {:b (u i) :g (u (+ i 1)) :r (u (+ i 2))}))})) | ||
| 29 | + | ||
| 30 | +(defn- scan | ||
| 31 | + "Does any pixel in the image satisfy `p`?" | ||
| 32 | + [{:keys [w h px]} p] | ||
| 33 | + (boolean (some (fn [y] (some (fn [x] (p (px x y))) (range 0 w 2))) (range 0 h 2)))) | ||
| 34 | + | ||
| 35 | +(defn -main [& _] | ||
| 36 | + (let [shot (str (System/getProperty "java.io.tmpdir") "/jvui-tags.bmp") | ||
| 37 | + feed (ffi/alloc (* 4 16 16)) | ||
| 38 | + out (atom []) | ||
| 39 | + ck! (fn [n ok?] (swap! out conj [n (boolean ok?)]))] | ||
| 40 | + ;; A live feed frame: pure red, so it cannot be confused with the file. | ||
| 41 | + (dotimes [i (* 16 16)] | ||
| 42 | + (ffi/write (+ feed (* 4 i) 0) :uint8 240) | ||
| 43 | + (ffi/write (+ feed (* 4 i) 1) :uint8 0) | ||
| 44 | + (ffi/write (+ feed (* 4 i) 2) :uint8 0) | ||
| 45 | + (ffi/write (+ feed (* 4 i) 3) :uint8 255)) | ||
| 46 | + (backend/every! 16 (fn [] (backend/frame-rgba! "cam" 16 16 feed))) | ||
| 47 | + (try | ||
| 48 | + (ui/run | ||
| 49 | + (fn [] | ||
| 50 | + [:vbox {:spacing 4} | ||
| 51 | + [:title-2 {:label "heading"}] | ||
| 52 | + [:status {:label "connected" :live true}] | ||
| 53 | + [:spinner {:label "working"}] | ||
| 54 | + [:link {:label "a link"}] | ||
| 55 | + [:emoji {:emoji "x" :size 14}] | ||
| 56 | + [:reaction {:emoji "y" :count 3 :mine true}] | ||
| 57 | + [:hbox {:spacing 4} | ||
| 58 | + [:avatar {:label "nandi" :size 24}] | ||
| 59 | + [:image {:src "/tmp/jvui-green.png" :max-width 40}] | ||
| 60 | + [:image {:feed "cam" :size [40.0 40.0]}]]]) | ||
| 61 | + {:title "tags" :width 320 :height 320 :frames 6 :shot shot}) | ||
| 62 | + (finally (ffi/free feed))) | ||
| 63 | + (let [img (bmp shot)] | ||
| 64 | + (ck! "a decoded PNG is on screen, and green" | ||
| 65 | + (scan img (fn [{:keys [r g b]}] (and (> g 150) (< r 90) (< b 90))))) | ||
| 66 | + (ck! "a live feed is on screen, and red" | ||
| 67 | + (scan img (fn [{:keys [r g b]}] (and (> r 180) (< g 80) (< b 80))))) | ||
| 68 | + (ck! "an avatar with no picture is a filled disc" | ||
| 69 | + (scan img (fn [{:keys [r g b]}] | ||
| 70 | + ;; any of the name palette, none of which is the | ||
| 71 | + ;; background or the two above | ||
| 72 | + (and (> (max r g b) 90) (< (max r g b) 230) | ||
| 73 | + (> (- (max r g b) (min r g b)) 40) | ||
| 74 | + (not (and (> g 150) (< r 90))) | ||
| 75 | + (not (and (> r 180) (< g 80)))))))) | ||
| 76 | + (doseq [[n ok?] @out] (println (if ok? "- " "FAIL ") n)) | ||
| 77 | + (let [bad (remove second @out)] | ||
| 78 | + (println (if (seq bad) | ||
| 79 | + (str (count bad) " of " (count @out) " checks FAILED") | ||
| 80 | + (str "all " (count @out) " checks passed"))) | ||
| 81 | + (when (seq bad) (System/exit 1))))) | ||
modified
glimmer-backends/glimmer-jvui/test/glimmer_jvui/tests.clj +92 -0 | @@ -5,6 +5,7 @@ | ||
| 5 | 5 | reconciler, this backend's walk, jvui's layout and event routing — can be |
| 6 | 6 | driven with a stub that says eight pixels a character. `jolt test`." |
| 7 | 7 | (:require [glimmer.ratom :as ra] |
| 8 | + [jvui.widgets :as w] | |
| 8 | 9 | [glimmer.core :as gui] |
| 9 | 10 | [glimmer-jvui.core :as jv] |
| 10 | 11 | [jvui.core :as c])) |
| @@ -149,6 +150,94 @@ | ||
| 149 | 150 | (check! (= "hi" (ra/deref text)) |
| 150 | 151 | (str "typed text reached :on-change: " (pr-str (ra/deref text)))))) |
| 151 | 152 | |
| 153 | +(defn- check-enter-sends! [] | |
| 154 | + ;; Enter is the one key a field must not swallow. frq sends its message | |
| 155 | + ;; on it, so a compose box that accepted text and never reported Enter | |
| 156 | + ;; would take a message and have no way to say it was finished — typing | |
| 157 | + ;; works, sending does not, and nothing looks broken. | |
| 158 | + (let [root (jv/root-node) cx (ctx) | |
| 159 | + text (ra/atom "hello") | |
| 160 | + sent (ra/atom nil)] | |
| 161 | + (gui/mount root :page | |
| 162 | + [(fn [] [:entry {:value (ra/deref text) | |
| 163 | + :on-change #(ra/reset! text %) | |
| 164 | + ;; A THUNK, which is what frq's handlers | |
| 165 | + ;; are: s/send-draft! takes no arguments | |
| 166 | + ;; and the text is already the caller's | |
| 167 | + ;; from :on-change. | |
| 168 | + :on-activate #(ra/reset! sent (ra/deref text))}])]) | |
| 169 | + (jv/render-once root cx) | |
| 170 | + (let [[ex ey] (centre (tagged root :entry))] | |
| 171 | + (jv/render-once root cx (click-at ex ey))) ; take focus | |
| 172 | + (jv/render-once root cx [{:kind :key-down :key :return}]) | |
| 173 | + (check! (= "hello" (ra/deref sent)) | |
| 174 | + (str "Enter reached :on-activate: " (pr-str (ra/deref sent)))))) | |
| 175 | + | |
| 176 | +(defn- label-node [root text] | |
| 177 | + (first (filter #(and (= :label (:tag @%)) (= text (str (:label (:props @%))))) | |
| 178 | + (walk root)))) | |
| 179 | + | |
| 180 | +(defn- check-rows-do-not-stretch! [] | |
| 181 | + ;; Every box shrink-wrapping its children is how frq's chat column came | |
| 182 | + ;; out a couple of hundred points wide in a five-hundred-point window, | |
| 183 | + ;; with every message wrapped to match and the scrollbar stranded in the | |
| 184 | + ;; middle of the screen. A container fills its parent's cross axis. | |
| 185 | + ;; | |
| 186 | + ;; And a ROW's children must NOT: :cross rather than :horizontal is what | |
| 187 | + ;; keeps a line of buttons from stretching to fill the window. | |
| 188 | + (let [root (jv/root-node) cx (ctx)] ; a 400-wide context | |
| 189 | + (gui/mount root :page | |
| 190 | + [(fn [] [:vbox {:key :outer} | |
| 191 | + [:vbox {:key :inner} [:label {:label "hi"}]] | |
| 192 | + [:hbox {:key :row} [:button {:label "a"}] | |
| 193 | + [:button {:label "b"}]]])]) | |
| 194 | + (dotimes [_ 3] (jv/render-once root cx)) | |
| 195 | + ;; Only tags that register for events carry a rect here, so the | |
| 196 | + ;; filling half is checked by pixel in props-check — a card paints a | |
| 197 | + ;; background and a screenshot can measure it. What CAN be asserted | |
| 198 | + ;; from the tree is the half that would regress silently. | |
| 199 | + (let [buttons (filter #(and (= :button (:tag @%)) (:rect @%)) (walk root))] | |
| 200 | + (check! (every? #(< (nth (:rect @%) 2) 120.0) buttons) | |
| 201 | + (str "and buttons in a row do not stretch: " | |
| 202 | + (pr-str (map #(nth (:rect @%) 2) buttons))))))) | |
| 203 | + | |
| 204 | +(defn- check-scroll-sticks! [] | |
| 205 | + ;; A chat that does not follow new messages is the difference between a | |
| 206 | + ;; window you read and one you drag. | |
| 207 | + ;; | |
| 208 | + ;; Asserted on GEOMETRY and not on which labels exist: every line is in | |
| 209 | + ;; the tree whether or not it is on screen, so `labels` cannot tell a | |
| 210 | + ;; stuck list from a pinned one. What tells them apart is where the last | |
| 211 | + ;; line was PUT — inside the viewport, or far below it. | |
| 212 | + (let [root (jv/root-node) cx (ctx) | |
| 213 | + n (ra/atom 3)] | |
| 214 | + (gui/mount root :page | |
| 215 | + [(fn [] [:scroll {:height 60 :scroll-key "chat" :stick-to-bottom true} | |
| 216 | + (into [:vbox {}] | |
| 217 | + (for [i (range (ra/deref n))] | |
| 218 | + [:label {:key i :label (str "line " i)}]))])]) | |
| 219 | + (dotimes [_ 3] (jv/render-once root cx)) | |
| 220 | + (ra/reset! n 40) | |
| 221 | + (dotimes [_ 3] (jv/render-once root cx)) | |
| 222 | + ;; Forty lines of sixteen in a sixty-tall viewport leaves a long way | |
| 223 | + ;; to scroll. A stuck list is at the far end of it; a pinned one is | |
| 224 | + ;; still at zero. | |
| 225 | + (let [off (w/scroll-offset "chat")] | |
| 226 | + (check! (and off (> off 100.0)) | |
| 227 | + (str "a stuck list followed its content: offset=" off)) | |
| 228 | + ;; And it must STOP following once the reader has moved, or they can | |
| 229 | + ;; never read anything but the newest line. | |
| 230 | + ;; The mouse has to be OVER the list: a wheel is delivered to | |
| 231 | + ;; whatever is under the pointer, and the pointer only moves on a | |
| 232 | + ;; motion event. | |
| 233 | + (jv/render-once root cx [{:kind :motion :x 20 :y 30}]) | |
| 234 | + (dotimes [_ 4] | |
| 235 | + (jv/render-once root cx [{:kind :motion :x 20 :y 30} | |
| 236 | + {:kind :wheel :x 20 :y 30 :dy 3}])) | |
| 237 | + (let [off2 (w/scroll-offset "chat")] | |
| 238 | + (check! (and off2 (< off2 off)) | |
| 239 | + (str "and let go when the reader scrolled up: " off " -> " off2)))))) | |
| 240 | + | |
| 152 | 241 | (defn- check-checkbox-round-trips! [] |
| 153 | 242 | (let [root (jv/root-node) cx (ctx)] |
| 154 | 243 | (ra/reset! on? false) |
| @@ -169,6 +258,9 @@ | ||
| 169 | 258 | ["a press alone does not" check-press-alone-is-not-a-click!] |
| 170 | 259 | ["a reorder keeps identity" check-reorder-keeps-identity!] |
| 171 | 260 | ["an unknown tag is a container" check-unknown-tag-is-a-container!] |
| 261 | + ["a row does not stretch" check-rows-do-not-stretch!] | |
| 262 | + ["a list sticks to the end" check-scroll-sticks!] | |
| 263 | + ["Enter sends" check-enter-sends!] | |
| 172 | 264 | ["an entry round-trips" check-entry-round-trips!] |
| 173 | 265 | ["a checkbox round-trips" check-checkbox-round-trips!]]) |
| 174 | 266 | |
| @@ -5,6 +5,7 @@ | |||
| 5 | reconciler, this backend's walk, jvui's layout and event routing — can be | 5 | reconciler, this backend's walk, jvui's layout and event routing — can be |
| 6 | driven with a stub that says eight pixels a character. `jolt test`." | 6 | driven with a stub that says eight pixels a character. `jolt test`." |
| 7 | (:require [glimmer.ratom :as ra] | 7 | (:require [glimmer.ratom :as ra] |
| 8 | + [jvui.widgets :as w] | ||
| 8 | [glimmer.core :as gui] | 9 | [glimmer.core :as gui] |
| 9 | [glimmer-jvui.core :as jv] | 10 | [glimmer-jvui.core :as jv] |
| 10 | [jvui.core :as c])) | 11 | [jvui.core :as c])) |
| @@ -149,6 +150,94 @@ | |||
| 149 | (check! (= "hi" (ra/deref text)) | 150 | (check! (= "hi" (ra/deref text)) |
| 150 | (str "typed text reached :on-change: " (pr-str (ra/deref text)))))) | 151 | (str "typed text reached :on-change: " (pr-str (ra/deref text)))))) |
| 151 | 152 | ||
| 153 | +(defn- check-enter-sends! [] | ||
| 154 | + ;; Enter is the one key a field must not swallow. frq sends its message | ||
| 155 | + ;; on it, so a compose box that accepted text and never reported Enter | ||
| 156 | + ;; would take a message and have no way to say it was finished — typing | ||
| 157 | + ;; works, sending does not, and nothing looks broken. | ||
| 158 | + (let [root (jv/root-node) cx (ctx) | ||
| 159 | + text (ra/atom "hello") | ||
| 160 | + sent (ra/atom nil)] | ||
| 161 | + (gui/mount root :page | ||
| 162 | + [(fn [] [:entry {:value (ra/deref text) | ||
| 163 | + :on-change #(ra/reset! text %) | ||
| 164 | + ;; A THUNK, which is what frq's handlers | ||
| 165 | + ;; are: s/send-draft! takes no arguments | ||
| 166 | + ;; and the text is already the caller's | ||
| 167 | + ;; from :on-change. | ||
| 168 | + :on-activate #(ra/reset! sent (ra/deref text))}])]) | ||
| 169 | + (jv/render-once root cx) | ||
| 170 | + (let [[ex ey] (centre (tagged root :entry))] | ||
| 171 | + (jv/render-once root cx (click-at ex ey))) ; take focus | ||
| 172 | + (jv/render-once root cx [{:kind :key-down :key :return}]) | ||
| 173 | + (check! (= "hello" (ra/deref sent)) | ||
| 174 | + (str "Enter reached :on-activate: " (pr-str (ra/deref sent)))))) | ||
| 175 | + | ||
| 176 | +(defn- label-node [root text] | ||
| 177 | + (first (filter #(and (= :label (:tag @%)) (= text (str (:label (:props @%))))) | ||
| 178 | + (walk root)))) | ||
| 179 | + | ||
| 180 | +(defn- check-rows-do-not-stretch! [] | ||
| 181 | + ;; Every box shrink-wrapping its children is how frq's chat column came | ||
| 182 | + ;; out a couple of hundred points wide in a five-hundred-point window, | ||
| 183 | + ;; with every message wrapped to match and the scrollbar stranded in the | ||
| 184 | + ;; middle of the screen. A container fills its parent's cross axis. | ||
| 185 | + ;; | ||
| 186 | + ;; And a ROW's children must NOT: :cross rather than :horizontal is what | ||
| 187 | + ;; keeps a line of buttons from stretching to fill the window. | ||
| 188 | + (let [root (jv/root-node) cx (ctx)] ; a 400-wide context | ||
| 189 | + (gui/mount root :page | ||
| 190 | + [(fn [] [:vbox {:key :outer} | ||
| 191 | + [:vbox {:key :inner} [:label {:label "hi"}]] | ||
| 192 | + [:hbox {:key :row} [:button {:label "a"}] | ||
| 193 | + [:button {:label "b"}]]])]) | ||
| 194 | + (dotimes [_ 3] (jv/render-once root cx)) | ||
| 195 | + ;; Only tags that register for events carry a rect here, so the | ||
| 196 | + ;; filling half is checked by pixel in props-check — a card paints a | ||
| 197 | + ;; background and a screenshot can measure it. What CAN be asserted | ||
| 198 | + ;; from the tree is the half that would regress silently. | ||
| 199 | + (let [buttons (filter #(and (= :button (:tag @%)) (:rect @%)) (walk root))] | ||
| 200 | + (check! (every? #(< (nth (:rect @%) 2) 120.0) buttons) | ||
| 201 | + (str "and buttons in a row do not stretch: " | ||
| 202 | + (pr-str (map #(nth (:rect @%) 2) buttons))))))) | ||
| 203 | + | ||
| 204 | +(defn- check-scroll-sticks! [] | ||
| 205 | + ;; A chat that does not follow new messages is the difference between a | ||
| 206 | + ;; window you read and one you drag. | ||
| 207 | + ;; | ||
| 208 | + ;; Asserted on GEOMETRY and not on which labels exist: every line is in | ||
| 209 | + ;; the tree whether or not it is on screen, so `labels` cannot tell a | ||
| 210 | + ;; stuck list from a pinned one. What tells them apart is where the last | ||
| 211 | + ;; line was PUT — inside the viewport, or far below it. | ||
| 212 | + (let [root (jv/root-node) cx (ctx) | ||
| 213 | + n (ra/atom 3)] | ||
| 214 | + (gui/mount root :page | ||
| 215 | + [(fn [] [:scroll {:height 60 :scroll-key "chat" :stick-to-bottom true} | ||
| 216 | + (into [:vbox {}] | ||
| 217 | + (for [i (range (ra/deref n))] | ||
| 218 | + [:label {:key i :label (str "line " i)}]))])]) | ||
| 219 | + (dotimes [_ 3] (jv/render-once root cx)) | ||
| 220 | + (ra/reset! n 40) | ||
| 221 | + (dotimes [_ 3] (jv/render-once root cx)) | ||
| 222 | + ;; Forty lines of sixteen in a sixty-tall viewport leaves a long way | ||
| 223 | + ;; to scroll. A stuck list is at the far end of it; a pinned one is | ||
| 224 | + ;; still at zero. | ||
| 225 | + (let [off (w/scroll-offset "chat")] | ||
| 226 | + (check! (and off (> off 100.0)) | ||
| 227 | + (str "a stuck list followed its content: offset=" off)) | ||
| 228 | + ;; And it must STOP following once the reader has moved, or they can | ||
| 229 | + ;; never read anything but the newest line. | ||
| 230 | + ;; The mouse has to be OVER the list: a wheel is delivered to | ||
| 231 | + ;; whatever is under the pointer, and the pointer only moves on a | ||
| 232 | + ;; motion event. | ||
| 233 | + (jv/render-once root cx [{:kind :motion :x 20 :y 30}]) | ||
| 234 | + (dotimes [_ 4] | ||
| 235 | + (jv/render-once root cx [{:kind :motion :x 20 :y 30} | ||
| 236 | + {:kind :wheel :x 20 :y 30 :dy 3}])) | ||
| 237 | + (let [off2 (w/scroll-offset "chat")] | ||
| 238 | + (check! (and off2 (< off2 off)) | ||
| 239 | + (str "and let go when the reader scrolled up: " off " -> " off2)))))) | ||
| 240 | + | ||
| 152 | (defn- check-checkbox-round-trips! [] | 241 | (defn- check-checkbox-round-trips! [] |
| 153 | (let [root (jv/root-node) cx (ctx)] | 242 | (let [root (jv/root-node) cx (ctx)] |
| 154 | (ra/reset! on? false) | 243 | (ra/reset! on? false) |
| @@ -169,6 +258,9 @@ | |||
| 169 | ["a press alone does not" check-press-alone-is-not-a-click!] | 258 | ["a press alone does not" check-press-alone-is-not-a-click!] |
| 170 | ["a reorder keeps identity" check-reorder-keeps-identity!] | 259 | ["a reorder keeps identity" check-reorder-keeps-identity!] |
| 171 | ["an unknown tag is a container" check-unknown-tag-is-a-container!] | 260 | ["an unknown tag is a container" check-unknown-tag-is-a-container!] |
| 261 | + ["a row does not stretch" check-rows-do-not-stretch!] | ||
| 262 | + ["a list sticks to the end" check-scroll-sticks!] | ||
| 263 | + ["Enter sends" check-enter-sends!] | ||
| 172 | ["an entry round-trips" check-entry-round-trips!] | 264 | ["an entry round-trips" check-entry-round-trips!] |
| 173 | ["a checkbox round-trips" check-checkbox-round-trips!]]) | 265 | ["a checkbox round-trips" check-checkbox-round-trips!]]) |
| 174 | 266 | ||
added
glimmer-backends/glimmer-jvui/test/glimmer_jvui/video_check.clj +81 -0 | new file mode 100644 | ||
| @@ -0,0 +1,81 @@ | ||
| 1 | +(ns glimmer-jvui.video-check | |
| 2 | + "An :image node with a live :feed, painted, and the pixels checked. | |
| 3 | + | |
| 4 | + :image and not a :video tag of its own, because that is how libvidya has | |
| 5 | + it and how frq writes it: a feed and a file differ in where the pixels | |
| 6 | + come from and in nothing downstream of that. | |
| 7 | + | |
| 8 | + The layout tests run headless and cannot see this: with no renderer every | |
| 9 | + frame is dropped and a video tile is an empty rectangle that lays out | |
| 10 | + correctly. What is actually at stake is the CHANNEL ORDER — a buffer whose | |
| 11 | + bytes run R,G,B,A is the 32-bit word 0xAABBGGRR, so SDL wants ABGR8888 and | |
| 12 | + the obvious-looking ARGB8888 rotates every channel and turns skin blue. A | |
| 13 | + test that only asked whether a texture existed would pass either way. | |
| 14 | + | |
| 15 | + So: a window under SDL's dummy driver, a frame with a RED left half and a | |
| 16 | + BLUE right half, a screenshot, and a look at what came out." | |
| 17 | + (:require [glimmer.core :as ui] | |
| 18 | + [glimmer.ratom :as ra] | |
| 19 | + [glimmer-jvui.core :as backend] | |
| 20 | + [jvui.sdl :as sdl] | |
| 21 | + [jolt.ffi :as ffi])) | |
| 22 | + | |
| 23 | +(defn- red-blue | |
| 24 | + "w by h RGBA: red left, blue right, opaque." | |
| 25 | + [w h] | |
| 26 | + (let [p (ffi/alloc (* 4 w h))] | |
| 27 | + (dotimes [y h] | |
| 28 | + (dotimes [x w] | |
| 29 | + (let [o (* 4 (+ (* y w) x)) | |
| 30 | + left? (< x (quot w 2))] | |
| 31 | + (ffi/write (+ p o 0) :uint8 (if left? 230 10)) ; R | |
| 32 | + (ffi/write (+ p o 1) :uint8 10) ; G | |
| 33 | + (ffi/write (+ p o 2) :uint8 (if left? 10 230)) ; B | |
| 34 | + (ffi/write (+ p o 3) :uint8 255)))) ; A | |
| 35 | + p)) | |
| 36 | + | |
| 37 | +;; A BMP from SDL is bottom-up and 24bpp, with rows padded to a four-byte | |
| 38 | +;; boundary and the pixel array offset in the header at byte 10. Every one of | |
| 39 | +;; those was got wrong first time round — 32bpp and no padding — and the | |
| 40 | +;; result was not an error but plausible-looking numbers from the wrong | |
| 41 | +;; addresses, which is exactly how a pixel test lies to you. | |
| 42 | +(defn- bmp-pixel [path x y] | |
| 43 | + (let [b (java.nio.file.Files/readAllBytes (java.nio.file.Path/of path (into-array String []))) | |
| 44 | + u (fn [i] (bit-and (int (aget b i)) 255)) | |
| 45 | + le (fn [i] (+ (u i) (bit-shift-left (u (+ i 1)) 8) | |
| 46 | + (bit-shift-left (u (+ i 2)) 16) (bit-shift-left (u (+ i 3)) 24))) | |
| 47 | + off (le 10) | |
| 48 | + w (le 18) | |
| 49 | + h (le 22) | |
| 50 | + stride (* 4 (quot (+ (* w 3) 3) 4)) | |
| 51 | + i (+ off (* (- h 1 y) stride) (* 3 x))] | |
| 52 | + {:b (u i) :g (u (+ i 1)) :r (u (+ i 2))})) | |
| 53 | + | |
| 54 | +(defn -main [& _] | |
| 55 | + (let [px (red-blue 64 64) | |
| 56 | + shot (str (System/getProperty "java.io.tmpdir") "/jvui-video-check.bmp") | |
| 57 | + n (ra/atom 0)] | |
| 58 | + (try | |
| 59 | + ;; Pushed from a TIMER, which is how frq does it — av.clj drives its | |
| 60 | + ;; whole media plane from (every! 16 pump!). Pushing from the | |
| 61 | + ;; component body instead is what the first version of this test did, | |
| 62 | + ;; and it silently proved nothing: a component with no state renders | |
| 63 | + ;; once, at mount, before the window exists — so the frame was | |
| 64 | + ;; dropped for want of a renderer and the tile was empty for a | |
| 65 | + ;; reason that had nothing to do with the picture. | |
| 66 | + (backend/every! 16 (fn [] (backend/frame-rgba! "peer" 64 64 px))) | |
| 67 | + (ui/run (fn [] [:image {:feed "peer" :size [120.0 120.0]}]) | |
| 68 | + {:title "video" :width 160 :height 160 :frames 6 :shot shot}) | |
| 69 | + (finally (ffi/free px))) | |
| 70 | + (let [left (bmp-pixel shot 40 80) | |
| 71 | + right (bmp-pixel shot 120 80) | |
| 72 | + ok-l (and (> (:r left) 150) (< (:b left) 100)) | |
| 73 | + ok-r (and (> (:b right) 150) (< (:r right) 100))] | |
| 74 | + (println " left pixel " (pr-str left)) | |
| 75 | + (println " right pixel" (pr-str right)) | |
| 76 | + (println (if ok-l "- the red half is red" "FAIL the red half is not red")) | |
| 77 | + (println (if ok-r "- the blue half is blue" "FAIL the blue half is not blue")) | |
| 78 | + (if (and ok-l ok-r) | |
| 79 | + (println "all 2 checks passed") | |
| 80 | + (do (println "channel order is wrong — see the ABGR note in jvui.sdl") | |
| 81 | + (System/exit 1)))))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,81 @@ | |||
| 1 | +(ns glimmer-jvui.video-check | ||
| 2 | + "An :image node with a live :feed, painted, and the pixels checked. | ||
| 3 | + | ||
| 4 | + :image and not a :video tag of its own, because that is how libvidya has | ||
| 5 | + it and how frq writes it: a feed and a file differ in where the pixels | ||
| 6 | + come from and in nothing downstream of that. | ||
| 7 | + | ||
| 8 | + The layout tests run headless and cannot see this: with no renderer every | ||
| 9 | + frame is dropped and a video tile is an empty rectangle that lays out | ||
| 10 | + correctly. What is actually at stake is the CHANNEL ORDER — a buffer whose | ||
| 11 | + bytes run R,G,B,A is the 32-bit word 0xAABBGGRR, so SDL wants ABGR8888 and | ||
| 12 | + the obvious-looking ARGB8888 rotates every channel and turns skin blue. A | ||
| 13 | + test that only asked whether a texture existed would pass either way. | ||
| 14 | + | ||
| 15 | + So: a window under SDL's dummy driver, a frame with a RED left half and a | ||
| 16 | + BLUE right half, a screenshot, and a look at what came out." | ||
| 17 | + (:require [glimmer.core :as ui] | ||
| 18 | + [glimmer.ratom :as ra] | ||
| 19 | + [glimmer-jvui.core :as backend] | ||
| 20 | + [jvui.sdl :as sdl] | ||
| 21 | + [jolt.ffi :as ffi])) | ||
| 22 | + | ||
| 23 | +(defn- red-blue | ||
| 24 | + "w by h RGBA: red left, blue right, opaque." | ||
| 25 | + [w h] | ||
| 26 | + (let [p (ffi/alloc (* 4 w h))] | ||
| 27 | + (dotimes [y h] | ||
| 28 | + (dotimes [x w] | ||
| 29 | + (let [o (* 4 (+ (* y w) x)) | ||
| 30 | + left? (< x (quot w 2))] | ||
| 31 | + (ffi/write (+ p o 0) :uint8 (if left? 230 10)) ; R | ||
| 32 | + (ffi/write (+ p o 1) :uint8 10) ; G | ||
| 33 | + (ffi/write (+ p o 2) :uint8 (if left? 10 230)) ; B | ||
| 34 | + (ffi/write (+ p o 3) :uint8 255)))) ; A | ||
| 35 | + p)) | ||
| 36 | + | ||
| 37 | +;; A BMP from SDL is bottom-up and 24bpp, with rows padded to a four-byte | ||
| 38 | +;; boundary and the pixel array offset in the header at byte 10. Every one of | ||
| 39 | +;; those was got wrong first time round — 32bpp and no padding — and the | ||
| 40 | +;; result was not an error but plausible-looking numbers from the wrong | ||
| 41 | +;; addresses, which is exactly how a pixel test lies to you. | ||
| 42 | +(defn- bmp-pixel [path x y] | ||
| 43 | + (let [b (java.nio.file.Files/readAllBytes (java.nio.file.Path/of path (into-array String []))) | ||
| 44 | + u (fn [i] (bit-and (int (aget b i)) 255)) | ||
| 45 | + le (fn [i] (+ (u i) (bit-shift-left (u (+ i 1)) 8) | ||
| 46 | + (bit-shift-left (u (+ i 2)) 16) (bit-shift-left (u (+ i 3)) 24))) | ||
| 47 | + off (le 10) | ||
| 48 | + w (le 18) | ||
| 49 | + h (le 22) | ||
| 50 | + stride (* 4 (quot (+ (* w 3) 3) 4)) | ||
| 51 | + i (+ off (* (- h 1 y) stride) (* 3 x))] | ||
| 52 | + {:b (u i) :g (u (+ i 1)) :r (u (+ i 2))})) | ||
| 53 | + | ||
| 54 | +(defn -main [& _] | ||
| 55 | + (let [px (red-blue 64 64) | ||
| 56 | + shot (str (System/getProperty "java.io.tmpdir") "/jvui-video-check.bmp") | ||
| 57 | + n (ra/atom 0)] | ||
| 58 | + (try | ||
| 59 | + ;; Pushed from a TIMER, which is how frq does it — av.clj drives its | ||
| 60 | + ;; whole media plane from (every! 16 pump!). Pushing from the | ||
| 61 | + ;; component body instead is what the first version of this test did, | ||
| 62 | + ;; and it silently proved nothing: a component with no state renders | ||
| 63 | + ;; once, at mount, before the window exists — so the frame was | ||
| 64 | + ;; dropped for want of a renderer and the tile was empty for a | ||
| 65 | + ;; reason that had nothing to do with the picture. | ||
| 66 | + (backend/every! 16 (fn [] (backend/frame-rgba! "peer" 64 64 px))) | ||
| 67 | + (ui/run (fn [] [:image {:feed "peer" :size [120.0 120.0]}]) | ||
| 68 | + {:title "video" :width 160 :height 160 :frames 6 :shot shot}) | ||
| 69 | + (finally (ffi/free px))) | ||
| 70 | + (let [left (bmp-pixel shot 40 80) | ||
| 71 | + right (bmp-pixel shot 120 80) | ||
| 72 | + ok-l (and (> (:r left) 150) (< (:b left) 100)) | ||
| 73 | + ok-r (and (> (:b right) 150) (< (:r right) 100))] | ||
| 74 | + (println " left pixel " (pr-str left)) | ||
| 75 | + (println " right pixel" (pr-str right)) | ||
| 76 | + (println (if ok-l "- the red half is red" "FAIL the red half is not red")) | ||
| 77 | + (println (if ok-r "- the blue half is blue" "FAIL the blue half is not blue")) | ||
| 78 | + (if (and ok-l ok-r) | ||
| 79 | + (println "all 2 checks passed") | ||
| 80 | + (do (println "channel order is wrong — see the ABGR note in jvui.sdl") | ||
| 81 | + (System/exit 1)))))) | ||
modified
jvui/deps.edn +6 -1 | @@ -12,7 +12,12 @@ | ||
| 12 | 12 | :jolt/native [{:name "SDL3" :optional true |
| 13 | 13 | :linux ["libSDL3.so.0"] :darwin ["libSDL3.0.dylib"]} |
| 14 | 14 | {:name "SDL3_ttf" :optional true |
| 15 | - :linux ["libSDL3_ttf.so.0"] :darwin ["libSDL3_ttf.0.dylib"]}] | |
| 15 | + :linux ["libSDL3_ttf.so.0"] :darwin ["libSDL3_ttf.0.dylib"]} | |
| 16 | + ;; Pictures from files — an avatar, an attachment, a paste. | |
| 17 | + ;; Optional like the other two: a caller that shows none, and | |
| 18 | + ;; the layout tests, run without it. | |
| 19 | + {:name "SDL3_image" :optional true | |
| 20 | + :linux ["libSDL3_image.so.0"] :darwin ["libSDL3_image.0.dylib"]}] | |
| 16 | 21 | |
| 17 | 22 | :aliases {:test {:extra-paths ["test"] :main-opts ["-m" "jvui.tests"]} |
| 18 | 23 | :counter {:extra-paths ["examples"] :main-opts ["-m" "jvui.counter"]} |
| @@ -12,7 +12,12 @@ | |||
| 12 | :jolt/native [{:name "SDL3" :optional true | 12 | :jolt/native [{:name "SDL3" :optional true |
| 13 | :linux ["libSDL3.so.0"] :darwin ["libSDL3.0.dylib"]} | 13 | :linux ["libSDL3.so.0"] :darwin ["libSDL3.0.dylib"]} |
| 14 | {:name "SDL3_ttf" :optional true | 14 | {:name "SDL3_ttf" :optional true |
| 15 | - :linux ["libSDL3_ttf.so.0"] :darwin ["libSDL3_ttf.0.dylib"]}] | 15 | + :linux ["libSDL3_ttf.so.0"] :darwin ["libSDL3_ttf.0.dylib"]} |
| 16 | + ;; Pictures from files — an avatar, an attachment, a paste. | ||
| 17 | + ;; Optional like the other two: a caller that shows none, and | ||
| 18 | + ;; the layout tests, run without it. | ||
| 19 | + {:name "SDL3_image" :optional true | ||
| 20 | + :linux ["libSDL3_image.so.0"] :darwin ["libSDL3_image.0.dylib"]}] | ||
| 16 | 21 | ||
| 17 | :aliases {:test {:extra-paths ["test"] :main-opts ["-m" "jvui.tests"]} | 22 | :aliases {:test {:extra-paths ["test"] :main-opts ["-m" "jvui.tests"]} |
| 18 | :counter {:extra-paths ["examples"] :main-opts ["-m" "jvui.counter"]} | 23 | :counter {:extra-paths ["examples"] :main-opts ["-m" "jvui.counter"]} |
modified
jvui/src/jvui/app.clj +14 -0 | @@ -7,6 +7,8 @@ | ||
| 7 | 7 | (:require [jvui.sdl :as sdl] |
| 8 | 8 | [jvui.font :as font] |
| 9 | 9 | [jvui.paint :as paint] |
| 10 | + [jvui.frames :as frames] | |
| 11 | + [jvui.host :as host] | |
| 10 | 12 | [jvui.theme :as theme] |
| 11 | 13 | [jvui.core :as c])) |
| 12 | 14 | |
| @@ -68,6 +70,11 @@ | ||
| 68 | 70 | :theme (or theme theme/dark)}) |
| 69 | 71 | painter (:painter @ctx) |
| 70 | 72 | deadline (when auto-quit-ms (+ (System/currentTimeMillis) auto-quit-ms))] |
| 73 | + ;; Feeds can only be uploaded once there is a renderer to upload | |
| 74 | + ;; into. Anything that arrives before this is dropped rather than | |
| 75 | + ;; queued — see jvui.frames. | |
| 76 | + (frames/install-renderer! renderer) | |
| 77 | + (host/install! window) | |
| 71 | 78 | (sdl/start-text-input! window) |
| 72 | 79 | (try |
| 73 | 80 | (loop [n 0] |
| @@ -92,10 +99,17 @@ | ||
| 92 | 99 | (sdl/present! renderer) |
| 93 | 100 | (sdl/delay-ms! 16) |
| 94 | 101 | (when-not (or (:quit? @ctx) |
| 102 | + ;; A client asking to close — see jvui.host/quit!. | |
| 103 | + ;; Checked here so teardown stays the loop's, which | |
| 104 | + ;; is the only thing that knows what is still being | |
| 105 | + ;; walked. | |
| 106 | + (host/quit-requested?) | |
| 95 | 107 | (and frames (>= (inc n) frames)) |
| 96 | 108 | (and deadline (> (System/currentTimeMillis) deadline))) |
| 97 | 109 | (recur (inc n))))) |
| 98 | 110 | (finally |
| 111 | + (frames/clear!) | |
| 112 | + (host/clear!) | |
| 99 | 113 | (paint/close! painter) |
| 100 | 114 | (font/close! fonts) |
| 101 | 115 | (sdl/close! win)))))) |
| @@ -7,6 +7,8 @@ | |||
| 7 | (:require [jvui.sdl :as sdl] | 7 | (:require [jvui.sdl :as sdl] |
| 8 | [jvui.font :as font] | 8 | [jvui.font :as font] |
| 9 | [jvui.paint :as paint] | 9 | [jvui.paint :as paint] |
| 10 | + [jvui.frames :as frames] | ||
| 11 | + [jvui.host :as host] | ||
| 10 | [jvui.theme :as theme] | 12 | [jvui.theme :as theme] |
| 11 | [jvui.core :as c])) | 13 | [jvui.core :as c])) |
| 12 | 14 | ||
| @@ -68,6 +70,11 @@ | |||
| 68 | :theme (or theme theme/dark)}) | 70 | :theme (or theme theme/dark)}) |
| 69 | painter (:painter @ctx) | 71 | painter (:painter @ctx) |
| 70 | deadline (when auto-quit-ms (+ (System/currentTimeMillis) auto-quit-ms))] | 72 | deadline (when auto-quit-ms (+ (System/currentTimeMillis) auto-quit-ms))] |
| 73 | + ;; Feeds can only be uploaded once there is a renderer to upload | ||
| 74 | + ;; into. Anything that arrives before this is dropped rather than | ||
| 75 | + ;; queued — see jvui.frames. | ||
| 76 | + (frames/install-renderer! renderer) | ||
| 77 | + (host/install! window) | ||
| 71 | (sdl/start-text-input! window) | 78 | (sdl/start-text-input! window) |
| 72 | (try | 79 | (try |
| 73 | (loop [n 0] | 80 | (loop [n 0] |
| @@ -92,10 +99,17 @@ | |||
| 92 | (sdl/present! renderer) | 99 | (sdl/present! renderer) |
| 93 | (sdl/delay-ms! 16) | 100 | (sdl/delay-ms! 16) |
| 94 | (when-not (or (:quit? @ctx) | 101 | (when-not (or (:quit? @ctx) |
| 102 | + ;; A client asking to close — see jvui.host/quit!. | ||
| 103 | + ;; Checked here so teardown stays the loop's, which | ||
| 104 | + ;; is the only thing that knows what is still being | ||
| 105 | + ;; walked. | ||
| 106 | + (host/quit-requested?) | ||
| 95 | (and frames (>= (inc n) frames)) | 107 | (and frames (>= (inc n) frames)) |
| 96 | (and deadline (> (System/currentTimeMillis) deadline))) | 108 | (and deadline (> (System/currentTimeMillis) deadline))) |
| 97 | (recur (inc n))))) | 109 | (recur (inc n))))) |
| 98 | (finally | 110 | (finally |
| 111 | + (frames/clear!) | ||
| 112 | + (host/clear!) | ||
| 99 | (paint/close! painter) | 113 | (paint/close! painter) |
| 100 | (font/close! fonts) | 114 | (font/close! fonts) |
| 101 | (sdl/close! win)))))) | 115 | (sdl/close! win)))))) |
modified
jvui/src/jvui/core.clj +156 -11 | @@ -37,6 +37,7 @@ | ||
| 37 | 37 | the drag until release even when the pointer leaves its rectangle, which is |
| 38 | 38 | what makes a slider survive a fast gesture." |
| 39 | 39 | (:require [jvui.paint :as paint] |
| 40 | + [jvui.frames :as frames] | |
| 40 | 41 | [jvui.theme :as theme] |
| 41 | 42 | [jvui.font :as font] |
| 42 | 43 | [jvui.sdl :as sdl])) |
| @@ -121,6 +122,51 @@ | ||
| 121 | 122 | [s x y size colour] |
| 122 | 123 | (when (drawing?) (paint/text! (:painter (ui)) s x y size colour))) |
| 123 | 124 | |
| 125 | +(defn draw-picture! | |
| 126 | + "Paint a texture into `rect`, letterboxed to keep its shape." | |
| 127 | + [tex tw th rect] | |
| 128 | + (when (and (drawing?) tex) | |
| 129 | + (paint/frame! (:painter (ui)) tex tw th rect))) | |
| 130 | + | |
| 131 | +(defn colour-emoji? | |
| 132 | + "Whether `s` would be drawn as a colour picture rather than as text. | |
| 133 | + | |
| 134 | + Answered off the painter and not off `drawing?`, so the layout passes and | |
| 135 | + the paint pass agree: a glyph that will be a square picture has to be | |
| 136 | + MEASURED as one, three walks before it is drawn. A headless walk has no | |
| 137 | + painter and no colour anything, and answers false." | |
| 138 | + [s] | |
| 139 | + (boolean (when-let [p (:painter (ui))] (paint/colour-emoji? p s)))) | |
| 140 | + | |
| 141 | +(defn draw-emoji! | |
| 142 | + "Paint `s` as a colour picture in `rect`, if there is one." | |
| 143 | + [s rect] | |
| 144 | + (when (drawing?) (paint/emoji! (:painter (ui)) s rect))) | |
| 145 | + | |
| 146 | +(defn draw-frame! | |
| 147 | + "Paint feed `key`'s latest picture into `rect`, if it has one." | |
| 148 | + [key rect] | |
| 149 | + (when-let [{:keys [tex w h]} (frames/lookup key)] | |
| 150 | + (draw-picture! tex w h rect))) | |
| 151 | + | |
| 152 | +(def ^:private name-palette | |
| 153 | + ;; Eight, and chosen to stay apart at avatar size against both themes. | |
| 154 | + ;; More would collide less and look less like a set. | |
| 155 | + [[214 96 96 255] [214 148 60 255] [186 186 64 255] [104 186 104 255] | |
| 156 | + [76 176 176 255] [92 140 214 255] [148 112 208 255] [204 104 168 255]]) | |
| 157 | + | |
| 158 | +(defn name-colour | |
| 159 | + "A stable colour for a name. | |
| 160 | + | |
| 161 | + Stable is the whole requirement: the same person must be the same colour | |
| 162 | + in the member list, in the message they sent and in the tile they are | |
| 163 | + speaking from, or the colour is decoration rather than identity. So it is | |
| 164 | + a hash of the name and not a counter." | |
| 165 | + [s] | |
| 166 | + (let [h (reduce (fn [a ch] (bit-and (+ (* a 31) (int ch)) 0x7fffffff)) | |
| 167 | + 7 (seq (str s)))] | |
| 168 | + (nth name-palette (mod h (count name-palette))))) | |
| 169 | + | |
| 124 | 170 | (defn draw-line! |
| 125 | 171 | [x0 y0 x1 y1 colour width] |
| 126 | 172 | (when (drawing?) (paint/line! (:painter (ui)) x0 y0 x1 y1 colour width))) |
| @@ -164,12 +210,26 @@ | ||
| 164 | 210 | (def ^:private CROSS 2) |
| 165 | 211 | (def ^:private N 3) |
| 166 | 212 | (def ^:private EXPANDERS 4) |
| 213 | +;; Wrapped rows only: where the current row starts, and how tall it is so | |
| 214 | +;; far. A box that does not wrap leaves both at zero and behaves exactly as | |
| 215 | +;; it did. | |
| 216 | +(def ^:private ROWY 5) | |
| 217 | +(def ^:private ROWH 6) | |
| 167 | 218 | |
| 168 | -(defn- counters [] (double-array 5)) | |
| 219 | +(defn- counters [] (double-array 7)) | |
| 169 | 220 | (defn- ctr [f i] (aget (:c f) i)) |
| 170 | 221 | (defn- ctr+ [f i v] (aset (:c f) i (+ (aget (:c f) i) v))) |
| 171 | 222 | |
| 172 | 223 | (defn- expands? |
| 224 | + "Does a child with this `expand` take more room along `axis`? | |
| 225 | + | |
| 226 | + `:cross` is the container's answer, and the one worth explaining: fill | |
| 227 | + the parent's OTHER axis and take no share of its own. A column inside a | |
| 228 | + column should be as wide as its parent and only as tall as its | |
| 229 | + contents; a row inside that column, the same. Saying `:horizontal` | |
| 230 | + instead works in a column and is wrong in a row — there it makes the | |
| 231 | + child fight its siblings for the slack, and a line of buttons stretches | |
| 232 | + to fill the window." | |
| 173 | 233 | [expand axis] |
| 174 | 234 | (case expand |
| 175 | 235 | :both true |
| @@ -186,8 +246,17 @@ | ||
| 186 | 246 | [advance min-along min-cross grew?] |
| 187 | 247 | (let [f (top)] |
| 188 | 248 | (ctr+ f CURSOR (+ advance (:spacing f))) |
| 189 | - (ctr+ f ALONG (+ min-along (if (pos? (ctr f N)) (:spacing f) 0.0))) | |
| 190 | - (aset (:c f) CROSS (max (ctr f CROSS) (double min-cross))) | |
| 249 | + (if (:wrap f) | |
| 250 | + ;; A wrapped row measures differently in both directions: its own | |
| 251 | + ;; axis is the WIDEST row rather than the run of every child, and | |
| 252 | + ;; its cross axis is the rows stacked rather than the tallest child. | |
| 253 | + (do | |
| 254 | + (aset (:c f) ALONG (max (ctr f ALONG) (- (ctr f CURSOR) (:spacing f)))) | |
| 255 | + (aset (:c f) ROWH (max (ctr f ROWH) (double min-cross))) | |
| 256 | + (aset (:c f) CROSS (+ (ctr f ROWY) (ctr f ROWH)))) | |
| 257 | + (do | |
| 258 | + (ctr+ f ALONG (+ min-along (if (pos? (ctr f N)) (:spacing f) 0.0))) | |
| 259 | + (aset (:c f) CROSS (max (ctr f CROSS) (double min-cross))))) | |
| 191 | 260 | (ctr+ f N 1.0) |
| 192 | 261 | (when grew? (ctr+ f EXPANDERS 1.0)))) |
| 193 | 262 | |
| @@ -204,8 +273,9 @@ | ||
| 204 | 273 | [rx ry rw rh] rect |
| 205 | 274 | [ox oy] (or offset [0.0 0.0]) |
| 206 | 275 | along-axis (if (= dir :vertical) :vertical :horizontal) |
| 207 | - grow-along? (expands? expand along-axis) | |
| 208 | - grow-cross? (expands? expand (if (= dir :vertical) :horizontal :vertical))] | |
| 276 | + cross-axis (if (= dir :vertical) :horizontal :vertical) | |
| 277 | + grow-along? (and (not= expand :cross) (expands? expand along-axis)) | |
| 278 | + grow-cross? (or (= expand :cross) (expands? expand cross-axis))] | |
| 209 | 279 | (if (= dir :vertical) |
| 210 | 280 | (let [h (+ mh (if grow-along? share 0.0)) |
| 211 | 281 | ;; NOT clamped to the box's width. A container's own size comes from |
| @@ -219,12 +289,61 @@ | ||
| 219 | 289 | (tally! h mh mw grow-along?) |
| 220 | 290 | [x y w h]) |
| 221 | 291 | (let [w (+ mw (if grow-along? share 0.0)) |
| 292 | + ;; Wrap BEFORE placing: a child that will not fit on this row | |
| 293 | + ;; starts the next one. Never on the first child of a row — | |
| 294 | + ;; something wider than the whole box has to go somewhere, and | |
| 295 | + ;; wrapping it forever is worse than letting it overflow. | |
| 296 | + _ (when (and (:wrap f) (pos? (ctr f N)) (> (+ cursor w) rw)) | |
| 297 | + (aset (:c f) CURSOR 0.0) | |
| 298 | + (ctr+ f ROWY (+ (ctr f ROWH) (:spacing f))) | |
| 299 | + (aset (:c f) ROWH 0.0)) | |
| 300 | + cursor (ctr f CURSOR) | |
| 222 | 301 | h (if grow-cross? rh mh) |
| 223 | 302 | x (+ rx ox cursor) |
| 224 | - y (+ ry oy (* gy (- rh h)))] | |
| 303 | + y (+ ry oy (ctr f ROWY) (if (:wrap f) 0.0 (* gy (- rh h))))] | |
| 225 | 304 | (tally! w mw mh grow-along?) |
| 226 | 305 | [x y w h])))) |
| 227 | 306 | |
| 307 | +(defn avail-width | |
| 308 | + "How much width the current box can still give a child. | |
| 309 | + | |
| 310 | + Along a row that is what is left after the children already placed; down | |
| 311 | + a column it is the whole width, because each child starts at the left. | |
| 312 | + | |
| 313 | + Answers 0 before a box has a width — a container's size comes from what | |
| 314 | + its children asked for LAST frame, so on the first one there is nothing | |
| 315 | + to divide. A caller must read 0 as \"no constraint known yet\" and not as | |
| 316 | + \"no room\": treating it as no room pins the child at zero and the loop | |
| 317 | + never recovers, which is the same trap `rect-for` documents above." | |
| 318 | + [] | |
| 319 | + (let [{:keys [dir rect] :as f} (top) | |
| 320 | + [rx _ rw _] rect | |
| 321 | + [sw _] (:size (ui)) | |
| 322 | + box (if (= dir :vertical) rw (- rw (ctr f CURSOR))) | |
| 323 | + ;; Bounded by the WINDOW, not only by the box. A box's width comes | |
| 324 | + ;; from what its children asked for last frame, so an unconstrained | |
| 325 | + ;; one is exactly as wide as its widest child — ask it how much room | |
| 326 | + ;; there is and it answers "as much as you took", and a label that | |
| 327 | + ;; wants to wrap never does. The window is the one width in the tree | |
| 328 | + ;; that no child can talk into growing. | |
| 329 | + room (- (double (or sw 0)) rx)] | |
| 330 | + (max 0.0 (if (pos? room) (min box room) box)))) | |
| 331 | + | |
| 332 | +(defn avail-height | |
| 333 | + "How much height the current box can still give a child. | |
| 334 | + | |
| 335 | + The mirror of `avail-width`, and bounded by the WINDOW for the same | |
| 336 | + reason: a column's height comes from what its children asked for last | |
| 337 | + frame, so asking the box how much room there is gets back what you | |
| 338 | + already took." | |
| 339 | + [] | |
| 340 | + (let [{:keys [dir rect] :as f} (top) | |
| 341 | + [_ ry _ rh] rect | |
| 342 | + [_ sh] (:size (ui)) | |
| 343 | + box (if (= dir :vertical) (- rh (ctr f CURSOR)) rh) | |
| 344 | + room (- (double (or sh 0)) ry)] | |
| 345 | + (max 0.0 (if (pos? room) (min box room) box)))) | |
| 346 | + | |
| 228 | 347 | (defn next-id |
| 229 | 348 | "Claim the next id under the current box." |
| 230 | 349 | ([] (next-id nil)) |
| @@ -240,14 +359,29 @@ | ||
| 240 | 359 | (upd! assoc :clip rect) |
| 241 | 360 | (when (drawing?) (paint/set-clip! (:painter (ui)) rect))) |
| 242 | 361 | |
| 362 | +(defn with-clip | |
| 363 | + "Run `f` with drawing confined to `rect`, then put the old clip back. | |
| 364 | + | |
| 365 | + Intersected with whatever clip is already in force, so a field inside a | |
| 366 | + scrolled viewport is bounded by both and not just by itself." | |
| 367 | + [rect f] | |
| 368 | + (let [prev (:clip (ui)) | |
| 369 | + r (if prev (intersect prev rect) rect)] | |
| 370 | + (set-clip! r) | |
| 371 | + (try (f) (finally (set-clip! prev))))) | |
| 372 | + | |
| 243 | 373 | (defn box* |
| 244 | 374 | "The container everything else is built from. |
| 245 | 375 | |
| 246 | 376 | opts: :dir :spacing :padding :margin :expand :gravity :key :fill :border |
| 247 | - :radius :min-size :clip? :offset" | |
| 377 | + :radius :min-size :clip? :offset :wrap | |
| 378 | + | |
| 379 | + :wrap only means anything on a row: children that will not fit start a | |
| 380 | + new line instead of running off the end. A column already puts every | |
| 381 | + child on a line of its own." | |
| 248 | 382 | [opts body] |
| 249 | 383 | (let [{:keys [dir spacing padding margin expand gravity key fill border |
| 250 | - radius min-size clip? offset fixed] | |
| 384 | + radius min-size clip? offset fixed wrap pad-right] | |
| 251 | 385 | :or {dir :vertical expand :none gravity [0.0 0.0] clip? false}} opts |
| 252 | 386 | spacing (double (or spacing 0.0)) |
| 253 | 387 | padding (double (or padding 0.0)) |
| @@ -266,8 +400,13 @@ | ||
| 266 | 400 | [ox oy ow oh] outer |
| 267 | 401 | box-rect [(+ ox margin) (+ oy margin) |
| 268 | 402 | (- ow (* 2 margin)) (- oh (* 2 margin))] |
| 403 | + ;; A strip along the right the children never get, on top of the | |
| 404 | + ;; padding: it is where a scrollbar goes. Only the CONTENT loses | |
| 405 | + ;; it — the box keeps its rectangle, so the bar is drawn inside the | |
| 406 | + ;; box's own edge rather than beyond it. | |
| 407 | + pad-right (double (or pad-right 0.0)) | |
| 269 | 408 | content [(+ (first box-rect) padding) (+ (second box-rect) padding) |
| 270 | - (max 0.0 (- (nth box-rect 2) (* 2 padding))) | |
| 409 | + (max 0.0 (- (nth box-rect 2) (* 2 padding) pad-right)) | |
| 271 | 410 | (max 0.0 (- (nth box-rect 3) (* 2 padding)))] |
| 272 | 411 | ;; Extra space to hand out along our own axis, computed from what we |
| 273 | 412 | ;; counted last frame: whatever the fixed children took is spoken for, |
| @@ -289,6 +428,9 @@ | ||
| 289 | 428 | (when clip? (set-clip! (intersect (or outer-clip content) content))) |
| 290 | 429 | (push-frame! {:id id :dir dir :rect content :spacing spacing |
| 291 | 430 | :share share :c (counters) |
| 431 | + ;; Only a row can wrap. A column already puts every child | |
| 432 | + ;; on a line of its own. | |
| 433 | + :wrap (boolean (and wrap (= dir :horizontal))) | |
| 292 | 434 | :offset (or offset [0.0 0.0])}) |
| 293 | 435 | (let [result (when body (body id box-rect)) |
| 294 | 436 | f (pop-frame!)] |
| @@ -297,9 +439,12 @@ | ||
| 297 | 439 | ;; the run of them; this swap is the only place that distinction is spelled |
| 298 | 440 | ;; out. |
| 299 | 441 | (let [pad2 (+ (* 2 padding) (* 2 margin)) |
| 442 | + ;; The gutter counts towards the WIDTH the box needs and nothing | |
| 443 | + ;; else: it is a strip the children were not given, so a box | |
| 444 | + ;; wide enough for them is not wide enough for them and it. | |
| 300 | 445 | computed (if (= dir :vertical) |
| 301 | - [(+ (ctr f CROSS) pad2) (+ (ctr f ALONG) pad2)] | |
| 302 | - [(+ (ctr f ALONG) pad2) (+ (ctr f CROSS) pad2)]) | |
| 446 | + [(+ (ctr f CROSS) pad2 pad-right) (+ (ctr f ALONG) pad2)] | |
| 447 | + [(+ (ctr f ALONG) pad2 pad-right) (+ (ctr f CROSS) pad2)]) | |
| 303 | 448 | computed (if min-size |
| 304 | 449 | [(max (first computed) (first min-size)) |
| 305 | 450 | (max (second computed) (second min-size))] |
| @@ -37,6 +37,7 @@ | |||
| 37 | the drag until release even when the pointer leaves its rectangle, which is | 37 | the drag until release even when the pointer leaves its rectangle, which is |
| 38 | what makes a slider survive a fast gesture." | 38 | what makes a slider survive a fast gesture." |
| 39 | (:require [jvui.paint :as paint] | 39 | (:require [jvui.paint :as paint] |
| 40 | + [jvui.frames :as frames] | ||
| 40 | [jvui.theme :as theme] | 41 | [jvui.theme :as theme] |
| 41 | [jvui.font :as font] | 42 | [jvui.font :as font] |
| 42 | [jvui.sdl :as sdl])) | 43 | [jvui.sdl :as sdl])) |
| @@ -121,6 +122,51 @@ | |||
| 121 | [s x y size colour] | 122 | [s x y size colour] |
| 122 | (when (drawing?) (paint/text! (:painter (ui)) s x y size colour))) | 123 | (when (drawing?) (paint/text! (:painter (ui)) s x y size colour))) |
| 123 | 124 | ||
| 125 | +(defn draw-picture! | ||
| 126 | + "Paint a texture into `rect`, letterboxed to keep its shape." | ||
| 127 | + [tex tw th rect] | ||
| 128 | + (when (and (drawing?) tex) | ||
| 129 | + (paint/frame! (:painter (ui)) tex tw th rect))) | ||
| 130 | + | ||
| 131 | +(defn colour-emoji? | ||
| 132 | + "Whether `s` would be drawn as a colour picture rather than as text. | ||
| 133 | + | ||
| 134 | + Answered off the painter and not off `drawing?`, so the layout passes and | ||
| 135 | + the paint pass agree: a glyph that will be a square picture has to be | ||
| 136 | + MEASURED as one, three walks before it is drawn. A headless walk has no | ||
| 137 | + painter and no colour anything, and answers false." | ||
| 138 | + [s] | ||
| 139 | + (boolean (when-let [p (:painter (ui))] (paint/colour-emoji? p s)))) | ||
| 140 | + | ||
| 141 | +(defn draw-emoji! | ||
| 142 | + "Paint `s` as a colour picture in `rect`, if there is one." | ||
| 143 | + [s rect] | ||
| 144 | + (when (drawing?) (paint/emoji! (:painter (ui)) s rect))) | ||
| 145 | + | ||
| 146 | +(defn draw-frame! | ||
| 147 | + "Paint feed `key`'s latest picture into `rect`, if it has one." | ||
| 148 | + [key rect] | ||
| 149 | + (when-let [{:keys [tex w h]} (frames/lookup key)] | ||
| 150 | + (draw-picture! tex w h rect))) | ||
| 151 | + | ||
| 152 | +(def ^:private name-palette | ||
| 153 | + ;; Eight, and chosen to stay apart at avatar size against both themes. | ||
| 154 | + ;; More would collide less and look less like a set. | ||
| 155 | + [[214 96 96 255] [214 148 60 255] [186 186 64 255] [104 186 104 255] | ||
| 156 | + [76 176 176 255] [92 140 214 255] [148 112 208 255] [204 104 168 255]]) | ||
| 157 | + | ||
| 158 | +(defn name-colour | ||
| 159 | + "A stable colour for a name. | ||
| 160 | + | ||
| 161 | + Stable is the whole requirement: the same person must be the same colour | ||
| 162 | + in the member list, in the message they sent and in the tile they are | ||
| 163 | + speaking from, or the colour is decoration rather than identity. So it is | ||
| 164 | + a hash of the name and not a counter." | ||
| 165 | + [s] | ||
| 166 | + (let [h (reduce (fn [a ch] (bit-and (+ (* a 31) (int ch)) 0x7fffffff)) | ||
| 167 | + 7 (seq (str s)))] | ||
| 168 | + (nth name-palette (mod h (count name-palette))))) | ||
| 169 | + | ||
| 124 | (defn draw-line! | 170 | (defn draw-line! |
| 125 | [x0 y0 x1 y1 colour width] | 171 | [x0 y0 x1 y1 colour width] |
| 126 | (when (drawing?) (paint/line! (:painter (ui)) x0 y0 x1 y1 colour width))) | 172 | (when (drawing?) (paint/line! (:painter (ui)) x0 y0 x1 y1 colour width))) |
| @@ -164,12 +210,26 @@ | |||
| 164 | (def ^:private CROSS 2) | 210 | (def ^:private CROSS 2) |
| 165 | (def ^:private N 3) | 211 | (def ^:private N 3) |
| 166 | (def ^:private EXPANDERS 4) | 212 | (def ^:private EXPANDERS 4) |
| 213 | +;; Wrapped rows only: where the current row starts, and how tall it is so | ||
| 214 | +;; far. A box that does not wrap leaves both at zero and behaves exactly as | ||
| 215 | +;; it did. | ||
| 216 | +(def ^:private ROWY 5) | ||
| 217 | +(def ^:private ROWH 6) | ||
| 167 | 218 | ||
| 168 | -(defn- counters [] (double-array 5)) | 219 | +(defn- counters [] (double-array 7)) |
| 169 | (defn- ctr [f i] (aget (:c f) i)) | 220 | (defn- ctr [f i] (aget (:c f) i)) |
| 170 | (defn- ctr+ [f i v] (aset (:c f) i (+ (aget (:c f) i) v))) | 221 | (defn- ctr+ [f i v] (aset (:c f) i (+ (aget (:c f) i) v))) |
| 171 | 222 | ||
| 172 | (defn- expands? | 223 | (defn- expands? |
| 224 | + "Does a child with this `expand` take more room along `axis`? | ||
| 225 | + | ||
| 226 | + `:cross` is the container's answer, and the one worth explaining: fill | ||
| 227 | + the parent's OTHER axis and take no share of its own. A column inside a | ||
| 228 | + column should be as wide as its parent and only as tall as its | ||
| 229 | + contents; a row inside that column, the same. Saying `:horizontal` | ||
| 230 | + instead works in a column and is wrong in a row — there it makes the | ||
| 231 | + child fight its siblings for the slack, and a line of buttons stretches | ||
| 232 | + to fill the window." | ||
| 173 | [expand axis] | 233 | [expand axis] |
| 174 | (case expand | 234 | (case expand |
| 175 | :both true | 235 | :both true |
| @@ -186,8 +246,17 @@ | |||
| 186 | [advance min-along min-cross grew?] | 246 | [advance min-along min-cross grew?] |
| 187 | (let [f (top)] | 247 | (let [f (top)] |
| 188 | (ctr+ f CURSOR (+ advance (:spacing f))) | 248 | (ctr+ f CURSOR (+ advance (:spacing f))) |
| 189 | - (ctr+ f ALONG (+ min-along (if (pos? (ctr f N)) (:spacing f) 0.0))) | 249 | + (if (:wrap f) |
| 190 | - (aset (:c f) CROSS (max (ctr f CROSS) (double min-cross))) | 250 | + ;; A wrapped row measures differently in both directions: its own |
| 251 | + ;; axis is the WIDEST row rather than the run of every child, and | ||
| 252 | + ;; its cross axis is the rows stacked rather than the tallest child. | ||
| 253 | + (do | ||
| 254 | + (aset (:c f) ALONG (max (ctr f ALONG) (- (ctr f CURSOR) (:spacing f)))) | ||
| 255 | + (aset (:c f) ROWH (max (ctr f ROWH) (double min-cross))) | ||
| 256 | + (aset (:c f) CROSS (+ (ctr f ROWY) (ctr f ROWH)))) | ||
| 257 | + (do | ||
| 258 | + (ctr+ f ALONG (+ min-along (if (pos? (ctr f N)) (:spacing f) 0.0))) | ||
| 259 | + (aset (:c f) CROSS (max (ctr f CROSS) (double min-cross))))) | ||
| 191 | (ctr+ f N 1.0) | 260 | (ctr+ f N 1.0) |
| 192 | (when grew? (ctr+ f EXPANDERS 1.0)))) | 261 | (when grew? (ctr+ f EXPANDERS 1.0)))) |
| 193 | 262 | ||
| @@ -204,8 +273,9 @@ | |||
| 204 | [rx ry rw rh] rect | 273 | [rx ry rw rh] rect |
| 205 | [ox oy] (or offset [0.0 0.0]) | 274 | [ox oy] (or offset [0.0 0.0]) |
| 206 | along-axis (if (= dir :vertical) :vertical :horizontal) | 275 | along-axis (if (= dir :vertical) :vertical :horizontal) |
| 207 | - grow-along? (expands? expand along-axis) | 276 | + cross-axis (if (= dir :vertical) :horizontal :vertical) |
| 208 | - grow-cross? (expands? expand (if (= dir :vertical) :horizontal :vertical))] | 277 | + grow-along? (and (not= expand :cross) (expands? expand along-axis)) |
| 278 | + grow-cross? (or (= expand :cross) (expands? expand cross-axis))] | ||
| 209 | (if (= dir :vertical) | 279 | (if (= dir :vertical) |
| 210 | (let [h (+ mh (if grow-along? share 0.0)) | 280 | (let [h (+ mh (if grow-along? share 0.0)) |
| 211 | ;; NOT clamped to the box's width. A container's own size comes from | 281 | ;; NOT clamped to the box's width. A container's own size comes from |
| @@ -219,12 +289,61 @@ | |||
| 219 | (tally! h mh mw grow-along?) | 289 | (tally! h mh mw grow-along?) |
| 220 | [x y w h]) | 290 | [x y w h]) |
| 221 | (let [w (+ mw (if grow-along? share 0.0)) | 291 | (let [w (+ mw (if grow-along? share 0.0)) |
| 292 | + ;; Wrap BEFORE placing: a child that will not fit on this row | ||
| 293 | + ;; starts the next one. Never on the first child of a row — | ||
| 294 | + ;; something wider than the whole box has to go somewhere, and | ||
| 295 | + ;; wrapping it forever is worse than letting it overflow. | ||
| 296 | + _ (when (and (:wrap f) (pos? (ctr f N)) (> (+ cursor w) rw)) | ||
| 297 | + (aset (:c f) CURSOR 0.0) | ||
| 298 | + (ctr+ f ROWY (+ (ctr f ROWH) (:spacing f))) | ||
| 299 | + (aset (:c f) ROWH 0.0)) | ||
| 300 | + cursor (ctr f CURSOR) | ||
| 222 | h (if grow-cross? rh mh) | 301 | h (if grow-cross? rh mh) |
| 223 | x (+ rx ox cursor) | 302 | x (+ rx ox cursor) |
| 224 | - y (+ ry oy (* gy (- rh h)))] | 303 | + y (+ ry oy (ctr f ROWY) (if (:wrap f) 0.0 (* gy (- rh h))))] |
| 225 | (tally! w mw mh grow-along?) | 304 | (tally! w mw mh grow-along?) |
| 226 | [x y w h])))) | 305 | [x y w h])))) |
| 227 | 306 | ||
| 307 | +(defn avail-width | ||
| 308 | + "How much width the current box can still give a child. | ||
| 309 | + | ||
| 310 | + Along a row that is what is left after the children already placed; down | ||
| 311 | + a column it is the whole width, because each child starts at the left. | ||
| 312 | + | ||
| 313 | + Answers 0 before a box has a width — a container's size comes from what | ||
| 314 | + its children asked for LAST frame, so on the first one there is nothing | ||
| 315 | + to divide. A caller must read 0 as \"no constraint known yet\" and not as | ||
| 316 | + \"no room\": treating it as no room pins the child at zero and the loop | ||
| 317 | + never recovers, which is the same trap `rect-for` documents above." | ||
| 318 | + [] | ||
| 319 | + (let [{:keys [dir rect] :as f} (top) | ||
| 320 | + [rx _ rw _] rect | ||
| 321 | + [sw _] (:size (ui)) | ||
| 322 | + box (if (= dir :vertical) rw (- rw (ctr f CURSOR))) | ||
| 323 | + ;; Bounded by the WINDOW, not only by the box. A box's width comes | ||
| 324 | + ;; from what its children asked for last frame, so an unconstrained | ||
| 325 | + ;; one is exactly as wide as its widest child — ask it how much room | ||
| 326 | + ;; there is and it answers "as much as you took", and a label that | ||
| 327 | + ;; wants to wrap never does. The window is the one width in the tree | ||
| 328 | + ;; that no child can talk into growing. | ||
| 329 | + room (- (double (or sw 0)) rx)] | ||
| 330 | + (max 0.0 (if (pos? room) (min box room) box)))) | ||
| 331 | + | ||
| 332 | +(defn avail-height | ||
| 333 | + "How much height the current box can still give a child. | ||
| 334 | + | ||
| 335 | + The mirror of `avail-width`, and bounded by the WINDOW for the same | ||
| 336 | + reason: a column's height comes from what its children asked for last | ||
| 337 | + frame, so asking the box how much room there is gets back what you | ||
| 338 | + already took." | ||
| 339 | + [] | ||
| 340 | + (let [{:keys [dir rect] :as f} (top) | ||
| 341 | + [_ ry _ rh] rect | ||
| 342 | + [_ sh] (:size (ui)) | ||
| 343 | + box (if (= dir :vertical) (- rh (ctr f CURSOR)) rh) | ||
| 344 | + room (- (double (or sh 0)) ry)] | ||
| 345 | + (max 0.0 (if (pos? room) (min box room) box)))) | ||
| 346 | + | ||
| 228 | (defn next-id | 347 | (defn next-id |
| 229 | "Claim the next id under the current box." | 348 | "Claim the next id under the current box." |
| 230 | ([] (next-id nil)) | 349 | ([] (next-id nil)) |
| @@ -240,14 +359,29 @@ | |||
| 240 | (upd! assoc :clip rect) | 359 | (upd! assoc :clip rect) |
| 241 | (when (drawing?) (paint/set-clip! (:painter (ui)) rect))) | 360 | (when (drawing?) (paint/set-clip! (:painter (ui)) rect))) |
| 242 | 361 | ||
| 362 | +(defn with-clip | ||
| 363 | + "Run `f` with drawing confined to `rect`, then put the old clip back. | ||
| 364 | + | ||
| 365 | + Intersected with whatever clip is already in force, so a field inside a | ||
| 366 | + scrolled viewport is bounded by both and not just by itself." | ||
| 367 | + [rect f] | ||
| 368 | + (let [prev (:clip (ui)) | ||
| 369 | + r (if prev (intersect prev rect) rect)] | ||
| 370 | + (set-clip! r) | ||
| 371 | + (try (f) (finally (set-clip! prev))))) | ||
| 372 | + | ||
| 243 | (defn box* | 373 | (defn box* |
| 244 | "The container everything else is built from. | 374 | "The container everything else is built from. |
| 245 | 375 | ||
| 246 | opts: :dir :spacing :padding :margin :expand :gravity :key :fill :border | 376 | opts: :dir :spacing :padding :margin :expand :gravity :key :fill :border |
| 247 | - :radius :min-size :clip? :offset" | 377 | + :radius :min-size :clip? :offset :wrap |
| 378 | + | ||
| 379 | + :wrap only means anything on a row: children that will not fit start a | ||
| 380 | + new line instead of running off the end. A column already puts every | ||
| 381 | + child on a line of its own." | ||
| 248 | [opts body] | 382 | [opts body] |
| 249 | (let [{:keys [dir spacing padding margin expand gravity key fill border | 383 | (let [{:keys [dir spacing padding margin expand gravity key fill border |
| 250 | - radius min-size clip? offset fixed] | 384 | + radius min-size clip? offset fixed wrap pad-right] |
| 251 | :or {dir :vertical expand :none gravity [0.0 0.0] clip? false}} opts | 385 | :or {dir :vertical expand :none gravity [0.0 0.0] clip? false}} opts |
| 252 | spacing (double (or spacing 0.0)) | 386 | spacing (double (or spacing 0.0)) |
| 253 | padding (double (or padding 0.0)) | 387 | padding (double (or padding 0.0)) |
| @@ -266,8 +400,13 @@ | |||
| 266 | [ox oy ow oh] outer | 400 | [ox oy ow oh] outer |
| 267 | box-rect [(+ ox margin) (+ oy margin) | 401 | box-rect [(+ ox margin) (+ oy margin) |
| 268 | (- ow (* 2 margin)) (- oh (* 2 margin))] | 402 | (- ow (* 2 margin)) (- oh (* 2 margin))] |
| 403 | + ;; A strip along the right the children never get, on top of the | ||
| 404 | + ;; padding: it is where a scrollbar goes. Only the CONTENT loses | ||
| 405 | + ;; it — the box keeps its rectangle, so the bar is drawn inside the | ||
| 406 | + ;; box's own edge rather than beyond it. | ||
| 407 | + pad-right (double (or pad-right 0.0)) | ||
| 269 | content [(+ (first box-rect) padding) (+ (second box-rect) padding) | 408 | content [(+ (first box-rect) padding) (+ (second box-rect) padding) |
| 270 | - (max 0.0 (- (nth box-rect 2) (* 2 padding))) | 409 | + (max 0.0 (- (nth box-rect 2) (* 2 padding) pad-right)) |
| 271 | (max 0.0 (- (nth box-rect 3) (* 2 padding)))] | 410 | (max 0.0 (- (nth box-rect 3) (* 2 padding)))] |
| 272 | ;; Extra space to hand out along our own axis, computed from what we | 411 | ;; Extra space to hand out along our own axis, computed from what we |
| 273 | ;; counted last frame: whatever the fixed children took is spoken for, | 412 | ;; counted last frame: whatever the fixed children took is spoken for, |
| @@ -289,6 +428,9 @@ | |||
| 289 | (when clip? (set-clip! (intersect (or outer-clip content) content))) | 428 | (when clip? (set-clip! (intersect (or outer-clip content) content))) |
| 290 | (push-frame! {:id id :dir dir :rect content :spacing spacing | 429 | (push-frame! {:id id :dir dir :rect content :spacing spacing |
| 291 | :share share :c (counters) | 430 | :share share :c (counters) |
| 431 | + ;; Only a row can wrap. A column already puts every child | ||
| 432 | + ;; on a line of its own. | ||
| 433 | + :wrap (boolean (and wrap (= dir :horizontal))) | ||
| 292 | :offset (or offset [0.0 0.0])}) | 434 | :offset (or offset [0.0 0.0])}) |
| 293 | (let [result (when body (body id box-rect)) | 435 | (let [result (when body (body id box-rect)) |
| 294 | f (pop-frame!)] | 436 | f (pop-frame!)] |
| @@ -297,9 +439,12 @@ | |||
| 297 | ;; the run of them; this swap is the only place that distinction is spelled | 439 | ;; the run of them; this swap is the only place that distinction is spelled |
| 298 | ;; out. | 440 | ;; out. |
| 299 | (let [pad2 (+ (* 2 padding) (* 2 margin)) | 441 | (let [pad2 (+ (* 2 padding) (* 2 margin)) |
| 442 | + ;; The gutter counts towards the WIDTH the box needs and nothing | ||
| 443 | + ;; else: it is a strip the children were not given, so a box | ||
| 444 | + ;; wide enough for them is not wide enough for them and it. | ||
| 300 | computed (if (= dir :vertical) | 445 | computed (if (= dir :vertical) |
| 301 | - [(+ (ctr f CROSS) pad2) (+ (ctr f ALONG) pad2)] | 446 | + [(+ (ctr f CROSS) pad2 pad-right) (+ (ctr f ALONG) pad2)] |
| 302 | - [(+ (ctr f ALONG) pad2) (+ (ctr f CROSS) pad2)]) | 447 | + [(+ (ctr f ALONG) pad2 pad-right) (+ (ctr f CROSS) pad2)]) |
| 303 | computed (if min-size | 448 | computed (if min-size |
| 304 | [(max (first computed) (first min-size)) | 449 | [(max (first computed) (first min-size)) |
| 305 | (max (second computed) (second min-size))] | 450 | (max (second computed) (second min-size))] |
modified
jvui/src/jvui/font.clj +155 -1 | @@ -13,7 +13,8 @@ | ||
| 13 | 13 | with a colour-mod at draw time, so one cached texture serves a label in |
| 14 | 14 | every colour it is ever drawn in, and a static page uploads nothing at all |
| 15 | 15 | after its first frame." |
| 16 | - (:require [jvui.sdl :as sdl])) | |
| 16 | + (:require [clojure.string] | |
| 17 | + [jvui.sdl :as sdl])) | |
| 17 | 18 | |
| 18 | 19 | (def ^:private candidates |
| 19 | 20 | ["/usr/share/fonts/noto/NotoSans-Regular.ttf" |
| @@ -35,11 +36,137 @@ | ||
| 35 | 36 | |
| 36 | 37 | (defn- ffi-null? [p] (or (nil? p) (and (number? p) (zero? p)))) |
| 37 | 38 | |
| 39 | +(def ^:private fallbacks | |
| 40 | + "Faces to try for what the UI font has no glyph for. | |
| 41 | + | |
| 42 | + A UI font covers Latin and stops. frq's chrome is icons and its | |
| 43 | + messages carry emoji, and every one of those was coming out as the | |
| 44 | + missing-glyph box — which is what the reader sees as \"the glyphs are | |
| 45 | + broken\". SDL_ttf keeps a list of fallback faces and asks each in turn, | |
| 46 | + so this costs nothing for text that the main face already has. | |
| 47 | + | |
| 48 | + Outline faces first, and colour emoji last, because a colour emoji | |
| 49 | + font is usually BITMAP — one fixed strike, commonly 109 or 128 pixels | |
| 50 | + — and a face that cannot be scaled to the size asked for is worse than | |
| 51 | + no face at all. `usable?` below is what actually decides; the order | |
| 52 | + only settles who wins when both would do. | |
| 53 | + | |
| 54 | + JVUI_FALLBACK_FONTS overrides, colon-separated, for a machine whose | |
| 55 | + fonts live somewhere else." | |
| 56 | + [;; Monochrome and scalable. Noto Emoji is the outline companion to | |
| 57 | + ;; NotoColorEmoji and is the one that works at a UI size. | |
| 58 | + "/usr/share/fonts/noto/NotoEmoji-Regular.ttf" | |
| 59 | + "/usr/share/fonts/truetype/noto/NotoEmoji-Regular.ttf" | |
| 60 | + "/usr/share/fonts/noto/NotoSansSymbols2-Regular.ttf" | |
| 61 | + "/usr/share/fonts/noto/NotoSansSymbols-Regular.ttf" | |
| 62 | + "/usr/share/fonts/truetype/noto/NotoSansSymbols2-Regular.ttf" | |
| 63 | + "/usr/share/fonts/truetype/noto/NotoSansSymbols-Regular.ttf" | |
| 64 | + ;; Last, and only where SDL_ttf can scale it. | |
| 65 | + "/usr/share/fonts/noto/NotoColorEmoji.ttf" | |
| 66 | + "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf"]) | |
| 67 | + | |
| 68 | +(defn- fallback-paths [] | |
| 69 | + (if-let [env (System/getenv "JVUI_FALLBACK_FONTS")] | |
| 70 | + (remove clojure.string/blank? (clojure.string/split env #":")) | |
| 71 | + (filter #(.exists (java.io.File. %)) fallbacks))) | |
| 72 | + | |
| 73 | +(def ^:private colour-faces | |
| 74 | + "Colour emoji faces, for the picture path below rather than as fallbacks. | |
| 75 | + | |
| 76 | + JVUI_COLOUR_EMOJI_FONT overrides, and an empty value turns colour off — | |
| 77 | + which is the way to get the monochrome fallback back on a machine that | |
| 78 | + has both." | |
| 79 | + ["/usr/share/fonts/noto/NotoColorEmoji.ttf" | |
| 80 | + "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf" | |
| 81 | + "/usr/share/fonts/noto/NotoColorEmoji-Regular.ttf" | |
| 82 | + "/System/Library/Fonts/Apple Color Emoji.ttc"]) | |
| 83 | + | |
| 84 | +(defn- colour-path [] | |
| 85 | + (if-let [env (System/getenv "JVUI_COLOUR_EMOJI_FONT")] | |
| 86 | + (when (seq env) env) | |
| 87 | + (first (filter #(.exists (java.io.File. %)) colour-faces)))) | |
| 88 | + | |
| 38 | 89 | (defn open |
| 39 | 90 | "A font cache over `path`. Sizes are opened lazily and kept." |
| 40 | 91 | [path] |
| 41 | 92 | (atom {:path path :faces {} :sizes {} :textures {}})) |
| 42 | 93 | |
| 94 | +;; --- colour emoji ------------------------------------------------------------ | |
| 95 | +;; | |
| 96 | +;; Not through the fallback list, and it cannot be: a colour emoji face is a | |
| 97 | +;; BITMAP with one strike — NotoColorEmoji has a single 128-pixel one — so | |
| 98 | +;; SDL_ttf answers 128 to every size it is asked for, measurement as much as | |
| 99 | +;; drawing. Added as a fallback it makes any line holding an emoji a hundred | |
| 100 | +;; and twenty-eight pixels tall, which is what `usable?` above refuses. | |
| 101 | +;; | |
| 102 | +;; So it is drawn as a PICTURE instead: rendered once at the size it insists | |
| 103 | +;; on, kept as a texture, and blitted into whatever box the layout gave the | |
| 104 | +;; glyph. The scale happens on the GPU at draw time, where the size the face | |
| 105 | +;; will not do costs nothing. The monochrome fallback stays as it is and | |
| 106 | +;; answers everything this has no glyph for. | |
| 107 | + | |
| 108 | +(defn- colour-face | |
| 109 | + "The colour emoji face, opened once, or nil where there is none. | |
| 110 | + | |
| 111 | + `:colour` is a three-state cell: absent means not looked for, false means | |
| 112 | + looked for and not found. Without the false a machine with no colour face | |
| 113 | + re-opens nothing but re-walks the candidate list on every glyph." | |
| 114 | + [cache] | |
| 115 | + (let [v (:colour @cache)] | |
| 116 | + (if (some? v) | |
| 117 | + (or v nil) | |
| 118 | + (let [path (colour-path) | |
| 119 | + f (when path (sdl/open-font path (float 32))) | |
| 120 | + f (when-not (ffi-null? f) f)] | |
| 121 | + (swap! cache assoc :colour (or f false)) | |
| 122 | + f)))) | |
| 123 | + | |
| 124 | +(defn- ignorable? | |
| 125 | + "Codepoints an emoji carries that no face is expected to have a glyph for: | |
| 126 | + the variation selectors that ask for emoji presentation, the zero-width | |
| 127 | + joiner that binds a sequence, and the tag characters flags are spelled | |
| 128 | + with." | |
| 129 | + [cp] | |
| 130 | + (or (= cp 0x200D) (<= 0xFE00 cp 0xFE0F) (<= 0xE0020 cp 0xE007F))) | |
| 131 | + | |
| 132 | +(defn colour-glyph? | |
| 133 | + "Whether the colour face can draw `s` — every codepoint in it that is a | |
| 134 | + glyph at all. | |
| 135 | + | |
| 136 | + Every one, not the first: taking the first would claim a family the face | |
| 137 | + has only the man of, and draw it as a man." | |
| 138 | + [cache s] | |
| 139 | + (boolean | |
| 140 | + (when-let [f (and (seq s) (colour-face cache))] | |
| 141 | + ;; `seq` over the codepoints, not over UTF-16: a jolt string is | |
| 142 | + ;; Scheme's, whose characters are whole scalars, so there are no | |
| 143 | + ;; surrogate halves here to pair up. frq.glyphs walks emoji the same | |
| 144 | + ;; way. | |
| 145 | + (let [cps (remove ignorable? (map int (seq s)))] | |
| 146 | + (and (seq cps) (every? #(sdl/font-has-glyph? f (int %)) cps)))))) | |
| 147 | + | |
| 148 | +(defn colour-texture | |
| 149 | + "An SDL texture of `s` in colour, and its [w h], cached. nil where the | |
| 150 | + colour face cannot draw it. | |
| 151 | + | |
| 152 | + One texture per string rather than per string-and-size, because the face | |
| 153 | + has one size: the caller scales it into the rectangle the layout gave." | |
| 154 | + [cache renderer s] | |
| 155 | + (when (colour-glyph? cache s) | |
| 156 | + (or (get-in @cache [:colour-textures s]) | |
| 157 | + (let [surf (sdl/render-blended (colour-face cache) s)] | |
| 158 | + (when-not (ffi-null? surf) | |
| 159 | + (let [tex (sdl/texture-from-surface renderer surf) | |
| 160 | + wh (sdl/surface-size surf)] | |
| 161 | + (sdl/destroy-surface! surf) | |
| 162 | + (sdl/texture-blend-mode! tex sdl/BLEND) | |
| 163 | + ;; Linear, because this texture is only ever drawn scaled DOWN | |
| 164 | + ;; — 128 pixels into a sixteen-pixel line — and nearest at that | |
| 165 | + ;; ratio is a handful of the original pixels and looks it. | |
| 166 | + (sdl/texture-scale-mode! tex sdl/SCALE-LINEAR) | |
| 167 | + (swap! cache assoc-in [:colour-textures s] [tex wh]) | |
| 168 | + [tex wh])))))) | |
| 169 | + | |
| 43 | 170 | (defn- face |
| 44 | 171 | [cache size] |
| 45 | 172 | (let [k (int size)] |
| @@ -47,6 +174,31 @@ | ||
| 47 | 174 | (let [f (sdl/open-font (:path @cache) (float size))] |
| 48 | 175 | (when (or (nil? f) (and (number? f) (zero? f))) |
| 49 | 176 | (throw (ex-info (str "TTF_OpenFont " (:path @cache) ": " (sdl/error)) {}))) |
| 177 | + ;; Fallbacks are per FACE, not per family, so each size opens its | |
| 178 | + ;; own and they are kept for as long as the face is — SDL_ttf | |
| 179 | + ;; holds the pointer and does not copy the font. | |
| 180 | + (let [want (sdl/font-height f) | |
| 181 | + fbs (into [] | |
| 182 | + (keep (fn [path] | |
| 183 | + (let [g (sdl/open-font path (float size))] | |
| 184 | + (when-not (ffi-null? g) | |
| 185 | + ;; A face that answers a size it was | |
| 186 | + ;; not asked for cannot be used. A | |
| 187 | + ;; colour emoji font is a bitmap with | |
| 188 | + ;; one strike — ask for 14 and it | |
| 189 | + ;; still gives 128 — and SDL_ttf draws | |
| 190 | + ;; it at that, so one emoji in a | |
| 191 | + ;; message makes a row a hundred and | |
| 192 | + ;; twenty-eight pixels tall and pushes | |
| 193 | + ;; the rest of the window out of the | |
| 194 | + ;; way. Twice the face's own height is | |
| 195 | + ;; the line: a symbol face runs a | |
| 196 | + ;; little tall and is fine. | |
| 197 | + (if (> (sdl/font-height g) (* 2 want)) | |
| 198 | + (do (sdl/close-font! g) nil) | |
| 199 | + (do (sdl/add-fallback-font! f g) g)))))) | |
| 200 | + (fallback-paths))] | |
| 201 | + (swap! cache update :fallbacks (fnil into []) fbs)) | |
| 50 | 202 | (swap! cache assoc-in [:faces k] f) |
| 51 | 203 | f)))) |
| 52 | 204 | |
| @@ -80,5 +232,7 @@ | ||
| 80 | 232 | (defn close! |
| 81 | 233 | [cache] |
| 82 | 234 | (doseq [[_ [tex _]] (:textures @cache)] (sdl/destroy-texture! tex)) |
| 235 | + (doseq [[_ [tex _]] (:colour-textures @cache)] (sdl/destroy-texture! tex)) | |
| 83 | 236 | (doseq [[_ f] (:faces @cache)] (sdl/close-font! f)) |
| 237 | + (when-let [f (:colour @cache)] (sdl/close-font! f)) | |
| 84 | 238 | (reset! cache {:path (:path @cache) :faces {} :sizes {} :textures {}})) |
| @@ -13,7 +13,8 @@ | |||
| 13 | with a colour-mod at draw time, so one cached texture serves a label in | 13 | with a colour-mod at draw time, so one cached texture serves a label in |
| 14 | every colour it is ever drawn in, and a static page uploads nothing at all | 14 | every colour it is ever drawn in, and a static page uploads nothing at all |
| 15 | after its first frame." | 15 | after its first frame." |
| 16 | - (:require [jvui.sdl :as sdl])) | 16 | + (:require [clojure.string] |
| 17 | + [jvui.sdl :as sdl])) | ||
| 17 | 18 | ||
| 18 | (def ^:private candidates | 19 | (def ^:private candidates |
| 19 | ["/usr/share/fonts/noto/NotoSans-Regular.ttf" | 20 | ["/usr/share/fonts/noto/NotoSans-Regular.ttf" |
| @@ -35,11 +36,137 @@ | |||
| 35 | 36 | ||
| 36 | (defn- ffi-null? [p] (or (nil? p) (and (number? p) (zero? p)))) | 37 | (defn- ffi-null? [p] (or (nil? p) (and (number? p) (zero? p)))) |
| 37 | 38 | ||
| 39 | +(def ^:private fallbacks | ||
| 40 | + "Faces to try for what the UI font has no glyph for. | ||
| 41 | + | ||
| 42 | + A UI font covers Latin and stops. frq's chrome is icons and its | ||
| 43 | + messages carry emoji, and every one of those was coming out as the | ||
| 44 | + missing-glyph box — which is what the reader sees as \"the glyphs are | ||
| 45 | + broken\". SDL_ttf keeps a list of fallback faces and asks each in turn, | ||
| 46 | + so this costs nothing for text that the main face already has. | ||
| 47 | + | ||
| 48 | + Outline faces first, and colour emoji last, because a colour emoji | ||
| 49 | + font is usually BITMAP — one fixed strike, commonly 109 or 128 pixels | ||
| 50 | + — and a face that cannot be scaled to the size asked for is worse than | ||
| 51 | + no face at all. `usable?` below is what actually decides; the order | ||
| 52 | + only settles who wins when both would do. | ||
| 53 | + | ||
| 54 | + JVUI_FALLBACK_FONTS overrides, colon-separated, for a machine whose | ||
| 55 | + fonts live somewhere else." | ||
| 56 | + [;; Monochrome and scalable. Noto Emoji is the outline companion to | ||
| 57 | + ;; NotoColorEmoji and is the one that works at a UI size. | ||
| 58 | + "/usr/share/fonts/noto/NotoEmoji-Regular.ttf" | ||
| 59 | + "/usr/share/fonts/truetype/noto/NotoEmoji-Regular.ttf" | ||
| 60 | + "/usr/share/fonts/noto/NotoSansSymbols2-Regular.ttf" | ||
| 61 | + "/usr/share/fonts/noto/NotoSansSymbols-Regular.ttf" | ||
| 62 | + "/usr/share/fonts/truetype/noto/NotoSansSymbols2-Regular.ttf" | ||
| 63 | + "/usr/share/fonts/truetype/noto/NotoSansSymbols-Regular.ttf" | ||
| 64 | + ;; Last, and only where SDL_ttf can scale it. | ||
| 65 | + "/usr/share/fonts/noto/NotoColorEmoji.ttf" | ||
| 66 | + "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf"]) | ||
| 67 | + | ||
| 68 | +(defn- fallback-paths [] | ||
| 69 | + (if-let [env (System/getenv "JVUI_FALLBACK_FONTS")] | ||
| 70 | + (remove clojure.string/blank? (clojure.string/split env #":")) | ||
| 71 | + (filter #(.exists (java.io.File. %)) fallbacks))) | ||
| 72 | + | ||
| 73 | +(def ^:private colour-faces | ||
| 74 | + "Colour emoji faces, for the picture path below rather than as fallbacks. | ||
| 75 | + | ||
| 76 | + JVUI_COLOUR_EMOJI_FONT overrides, and an empty value turns colour off — | ||
| 77 | + which is the way to get the monochrome fallback back on a machine that | ||
| 78 | + has both." | ||
| 79 | + ["/usr/share/fonts/noto/NotoColorEmoji.ttf" | ||
| 80 | + "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf" | ||
| 81 | + "/usr/share/fonts/noto/NotoColorEmoji-Regular.ttf" | ||
| 82 | + "/System/Library/Fonts/Apple Color Emoji.ttc"]) | ||
| 83 | + | ||
| 84 | +(defn- colour-path [] | ||
| 85 | + (if-let [env (System/getenv "JVUI_COLOUR_EMOJI_FONT")] | ||
| 86 | + (when (seq env) env) | ||
| 87 | + (first (filter #(.exists (java.io.File. %)) colour-faces)))) | ||
| 88 | + | ||
| 38 | (defn open | 89 | (defn open |
| 39 | "A font cache over `path`. Sizes are opened lazily and kept." | 90 | "A font cache over `path`. Sizes are opened lazily and kept." |
| 40 | [path] | 91 | [path] |
| 41 | (atom {:path path :faces {} :sizes {} :textures {}})) | 92 | (atom {:path path :faces {} :sizes {} :textures {}})) |
| 42 | 93 | ||
| 94 | +;; --- colour emoji ------------------------------------------------------------ | ||
| 95 | +;; | ||
| 96 | +;; Not through the fallback list, and it cannot be: a colour emoji face is a | ||
| 97 | +;; BITMAP with one strike — NotoColorEmoji has a single 128-pixel one — so | ||
| 98 | +;; SDL_ttf answers 128 to every size it is asked for, measurement as much as | ||
| 99 | +;; drawing. Added as a fallback it makes any line holding an emoji a hundred | ||
| 100 | +;; and twenty-eight pixels tall, which is what `usable?` above refuses. | ||
| 101 | +;; | ||
| 102 | +;; So it is drawn as a PICTURE instead: rendered once at the size it insists | ||
| 103 | +;; on, kept as a texture, and blitted into whatever box the layout gave the | ||
| 104 | +;; glyph. The scale happens on the GPU at draw time, where the size the face | ||
| 105 | +;; will not do costs nothing. The monochrome fallback stays as it is and | ||
| 106 | +;; answers everything this has no glyph for. | ||
| 107 | + | ||
| 108 | +(defn- colour-face | ||
| 109 | + "The colour emoji face, opened once, or nil where there is none. | ||
| 110 | + | ||
| 111 | + `:colour` is a three-state cell: absent means not looked for, false means | ||
| 112 | + looked for and not found. Without the false a machine with no colour face | ||
| 113 | + re-opens nothing but re-walks the candidate list on every glyph." | ||
| 114 | + [cache] | ||
| 115 | + (let [v (:colour @cache)] | ||
| 116 | + (if (some? v) | ||
| 117 | + (or v nil) | ||
| 118 | + (let [path (colour-path) | ||
| 119 | + f (when path (sdl/open-font path (float 32))) | ||
| 120 | + f (when-not (ffi-null? f) f)] | ||
| 121 | + (swap! cache assoc :colour (or f false)) | ||
| 122 | + f)))) | ||
| 123 | + | ||
| 124 | +(defn- ignorable? | ||
| 125 | + "Codepoints an emoji carries that no face is expected to have a glyph for: | ||
| 126 | + the variation selectors that ask for emoji presentation, the zero-width | ||
| 127 | + joiner that binds a sequence, and the tag characters flags are spelled | ||
| 128 | + with." | ||
| 129 | + [cp] | ||
| 130 | + (or (= cp 0x200D) (<= 0xFE00 cp 0xFE0F) (<= 0xE0020 cp 0xE007F))) | ||
| 131 | + | ||
| 132 | +(defn colour-glyph? | ||
| 133 | + "Whether the colour face can draw `s` — every codepoint in it that is a | ||
| 134 | + glyph at all. | ||
| 135 | + | ||
| 136 | + Every one, not the first: taking the first would claim a family the face | ||
| 137 | + has only the man of, and draw it as a man." | ||
| 138 | + [cache s] | ||
| 139 | + (boolean | ||
| 140 | + (when-let [f (and (seq s) (colour-face cache))] | ||
| 141 | + ;; `seq` over the codepoints, not over UTF-16: a jolt string is | ||
| 142 | + ;; Scheme's, whose characters are whole scalars, so there are no | ||
| 143 | + ;; surrogate halves here to pair up. frq.glyphs walks emoji the same | ||
| 144 | + ;; way. | ||
| 145 | + (let [cps (remove ignorable? (map int (seq s)))] | ||
| 146 | + (and (seq cps) (every? #(sdl/font-has-glyph? f (int %)) cps)))))) | ||
| 147 | + | ||
| 148 | +(defn colour-texture | ||
| 149 | + "An SDL texture of `s` in colour, and its [w h], cached. nil where the | ||
| 150 | + colour face cannot draw it. | ||
| 151 | + | ||
| 152 | + One texture per string rather than per string-and-size, because the face | ||
| 153 | + has one size: the caller scales it into the rectangle the layout gave." | ||
| 154 | + [cache renderer s] | ||
| 155 | + (when (colour-glyph? cache s) | ||
| 156 | + (or (get-in @cache [:colour-textures s]) | ||
| 157 | + (let [surf (sdl/render-blended (colour-face cache) s)] | ||
| 158 | + (when-not (ffi-null? surf) | ||
| 159 | + (let [tex (sdl/texture-from-surface renderer surf) | ||
| 160 | + wh (sdl/surface-size surf)] | ||
| 161 | + (sdl/destroy-surface! surf) | ||
| 162 | + (sdl/texture-blend-mode! tex sdl/BLEND) | ||
| 163 | + ;; Linear, because this texture is only ever drawn scaled DOWN | ||
| 164 | + ;; — 128 pixels into a sixteen-pixel line — and nearest at that | ||
| 165 | + ;; ratio is a handful of the original pixels and looks it. | ||
| 166 | + (sdl/texture-scale-mode! tex sdl/SCALE-LINEAR) | ||
| 167 | + (swap! cache assoc-in [:colour-textures s] [tex wh]) | ||
| 168 | + [tex wh])))))) | ||
| 169 | + | ||
| 43 | (defn- face | 170 | (defn- face |
| 44 | [cache size] | 171 | [cache size] |
| 45 | (let [k (int size)] | 172 | (let [k (int size)] |
| @@ -47,6 +174,31 @@ | |||
| 47 | (let [f (sdl/open-font (:path @cache) (float size))] | 174 | (let [f (sdl/open-font (:path @cache) (float size))] |
| 48 | (when (or (nil? f) (and (number? f) (zero? f))) | 175 | (when (or (nil? f) (and (number? f) (zero? f))) |
| 49 | (throw (ex-info (str "TTF_OpenFont " (:path @cache) ": " (sdl/error)) {}))) | 176 | (throw (ex-info (str "TTF_OpenFont " (:path @cache) ": " (sdl/error)) {}))) |
| 177 | + ;; Fallbacks are per FACE, not per family, so each size opens its | ||
| 178 | + ;; own and they are kept for as long as the face is — SDL_ttf | ||
| 179 | + ;; holds the pointer and does not copy the font. | ||
| 180 | + (let [want (sdl/font-height f) | ||
| 181 | + fbs (into [] | ||
| 182 | + (keep (fn [path] | ||
| 183 | + (let [g (sdl/open-font path (float size))] | ||
| 184 | + (when-not (ffi-null? g) | ||
| 185 | + ;; A face that answers a size it was | ||
| 186 | + ;; not asked for cannot be used. A | ||
| 187 | + ;; colour emoji font is a bitmap with | ||
| 188 | + ;; one strike — ask for 14 and it | ||
| 189 | + ;; still gives 128 — and SDL_ttf draws | ||
| 190 | + ;; it at that, so one emoji in a | ||
| 191 | + ;; message makes a row a hundred and | ||
| 192 | + ;; twenty-eight pixels tall and pushes | ||
| 193 | + ;; the rest of the window out of the | ||
| 194 | + ;; way. Twice the face's own height is | ||
| 195 | + ;; the line: a symbol face runs a | ||
| 196 | + ;; little tall and is fine. | ||
| 197 | + (if (> (sdl/font-height g) (* 2 want)) | ||
| 198 | + (do (sdl/close-font! g) nil) | ||
| 199 | + (do (sdl/add-fallback-font! f g) g)))))) | ||
| 200 | + (fallback-paths))] | ||
| 201 | + (swap! cache update :fallbacks (fnil into []) fbs)) | ||
| 50 | (swap! cache assoc-in [:faces k] f) | 202 | (swap! cache assoc-in [:faces k] f) |
| 51 | f)))) | 203 | f)))) |
| 52 | 204 | ||
| @@ -80,5 +232,7 @@ | |||
| 80 | (defn close! | 232 | (defn close! |
| 81 | [cache] | 233 | [cache] |
| 82 | (doseq [[_ [tex _]] (:textures @cache)] (sdl/destroy-texture! tex)) | 234 | (doseq [[_ [tex _]] (:textures @cache)] (sdl/destroy-texture! tex)) |
| 235 | + (doseq [[_ [tex _]] (:colour-textures @cache)] (sdl/destroy-texture! tex)) | ||
| 83 | (doseq [[_ f] (:faces @cache)] (sdl/close-font! f)) | 236 | (doseq [[_ f] (:faces @cache)] (sdl/close-font! f)) |
| 237 | + (when-let [f (:colour @cache)] (sdl/close-font! f)) | ||
| 84 | (reset! cache {:path (:path @cache) :faces {} :sizes {} :textures {}})) | 238 | (reset! cache {:path (:path @cache) :faces {} :sizes {} :textures {}})) |
added
jvui/src/jvui/frames.clj +118 -0 | new file mode 100644 | ||
| @@ -0,0 +1,118 @@ | ||
| 1 | +(ns jvui.frames | |
| 2 | + "Pictures that arrive from somewhere else — a camera, a decoder, a call. | |
| 3 | + | |
| 4 | + Everything else jvui paints, it paints: a rectangle, a glyph, a rounded | |
| 5 | + corner. A video frame is different in two ways that make it worth its own | |
| 6 | + namespace. | |
| 7 | + | |
| 8 | + IT ARRIVES BETWEEN FRAMES. A decoder hands over a picture when the network | |
| 9 | + gives it one, not when the UI is walking, so there is no painter in scope | |
| 10 | + and no rect to put it in. The texture is uploaded THEN — immediately, on | |
| 11 | + the thread that brought it — and the walk finds it already on the GPU. | |
| 12 | + | |
| 13 | + AND THE PIXELS ARE BORROWED. The pointer belongs to whatever decoded it and | |
| 14 | + is good until that decoder produces its next picture, so it cannot be kept | |
| 15 | + and cannot be copied into a jolt value on the way past. `put!` uploads | |
| 16 | + straight from the pointer and returns; after that the caller may do what it | |
| 17 | + likes with the memory. | |
| 18 | + | |
| 19 | + Which is why the renderer is held here as state rather than passed in. It | |
| 20 | + is not lovely, and the alternative is worse: threading a painter through a | |
| 21 | + media pipeline so a codec can know about a toolkit." | |
| 22 | + (:require [jvui.sdl :as sdl] | |
| 23 | + [jolt.ffi :as ffi])) | |
| 24 | + | |
| 25 | +(defonce ^:private renderer (atom nil)) | |
| 26 | +(defonce ^:private textures (atom {})) | |
| 27 | + | |
| 28 | +(defn install-renderer! | |
| 29 | + "Called by the frame loop when a window opens. | |
| 30 | + | |
| 31 | + Frames that arrive before this are dropped rather than queued: a picture | |
| 32 | + with nowhere to go is a picture nobody saw, and holding it would mean | |
| 33 | + holding a pointer whose owner has moved on." | |
| 34 | + [r] | |
| 35 | + (reset! renderer r)) | |
| 36 | + | |
| 37 | +(defn- free-texture! [{:keys [tex]}] | |
| 38 | + (when tex (sdl/destroy-texture! tex))) | |
| 39 | + | |
| 40 | +(defn drop! | |
| 41 | + "Forget a feed and release its texture." | |
| 42 | + [key] | |
| 43 | + (when-let [t (get @textures key)] | |
| 44 | + (swap! textures dissoc key) | |
| 45 | + (free-texture! t)) | |
| 46 | + nil) | |
| 47 | + | |
| 48 | +(defn put! | |
| 49 | + "Upload one RGBA frame, `w` by `h`, from FOREIGN memory at `ptr`. | |
| 50 | + | |
| 51 | + The texture is recreated when the size changes — a peer switching camera | |
| 52 | + or resolution mid-call is a real thing, and SDL will not resize one in | |
| 53 | + place. Otherwise the same texture is written over, which is what | |
| 54 | + STREAMING access is for." | |
| 55 | + [key w h ptr] | |
| 56 | + (when-let [r @renderer] | |
| 57 | + (when (and (pos? w) (pos? h) ptr) | |
| 58 | + (let [have (get @textures key) | |
| 59 | + t (if (and have (= w (:w have)) (= h (:h have))) | |
| 60 | + have | |
| 61 | + (do (when have (free-texture! have)) | |
| 62 | + (let [tex (sdl/create-texture r sdl/PIXELFORMAT-ABGR8888 | |
| 63 | + sdl/TEXTUREACCESS-STREAMING | |
| 64 | + w h)] | |
| 65 | + (when tex | |
| 66 | + (sdl/texture-blend-mode! tex sdl/BLEND) | |
| 67 | + ;; Linear, because a call tile is almost never shown | |
| 68 | + ;; at the size it was encoded. | |
| 69 | + (sdl/texture-scale-mode! tex sdl/SCALE-LINEAR)) | |
| 70 | + {:tex tex :w w :h h})))] | |
| 71 | + (when (:tex t) | |
| 72 | + (sdl/update-texture-raw! (:tex t) ptr (* 4 w)) | |
| 73 | + (swap! textures assoc key t)))) | |
| 74 | + nil)) | |
| 75 | + | |
| 76 | +(defn from-file | |
| 77 | + "The texture for a picture on disk, decoded once and kept. | |
| 78 | + | |
| 79 | + Cached by PATH, and never re-read: a file that changed under us is not a | |
| 80 | + case this has — frq writes an attachment once, under a name it chose, and | |
| 81 | + a picture that needs to change gets a new path. | |
| 82 | + | |
| 83 | + nil when there is no renderer yet, when SDL3_image is not installed, or | |
| 84 | + when the file will not decode. All three are the same answer to a caller: | |
| 85 | + there is no picture, draw what you would have drawn without one. A | |
| 86 | + broken-image glyph would be worse — the text beside it already says what | |
| 87 | + it was meant to be." | |
| 88 | + [path] | |
| 89 | + (when-let [r @renderer] | |
| 90 | + (let [k [::file path]] | |
| 91 | + (or (get @textures k) | |
| 92 | + (let [tex (try (sdl/img-load-texture r (str path)) | |
| 93 | + (catch Exception _ nil))] | |
| 94 | + (when (and tex (not (ffi/null? tex))) | |
| 95 | + (let [[w h] (or (sdl/texture-size tex) [0 0]) | |
| 96 | + t {:tex tex :w w :h h}] | |
| 97 | + (sdl/texture-scale-mode! tex sdl/SCALE-LINEAR) | |
| 98 | + (swap! textures assoc k t) | |
| 99 | + t))))))) | |
| 100 | + | |
| 101 | +(defn lookup | |
| 102 | + "{:tex :w :h} for a feed, or nil." | |
| 103 | + [key] | |
| 104 | + (get @textures key)) | |
| 105 | + | |
| 106 | +(defn keys* | |
| 107 | + "Every live feed with a picture. Decoded files are not feeds and are not | |
| 108 | + listed — a caller asking this wants to know who is on camera." | |
| 109 | + [] | |
| 110 | + (set (remove vector? (keys @textures)))) | |
| 111 | + | |
| 112 | +(defn clear! | |
| 113 | + "Release every texture — the window is going away." | |
| 114 | + [] | |
| 115 | + (doseq [[_ t] @textures] (free-texture! t)) | |
| 116 | + (reset! textures {}) | |
| 117 | + (reset! renderer nil) | |
| 118 | + nil) | |
| new file mode 100644 | |||
| @@ -0,0 +1,118 @@ | |||
| 1 | +(ns jvui.frames | ||
| 2 | + "Pictures that arrive from somewhere else — a camera, a decoder, a call. | ||
| 3 | + | ||
| 4 | + Everything else jvui paints, it paints: a rectangle, a glyph, a rounded | ||
| 5 | + corner. A video frame is different in two ways that make it worth its own | ||
| 6 | + namespace. | ||
| 7 | + | ||
| 8 | + IT ARRIVES BETWEEN FRAMES. A decoder hands over a picture when the network | ||
| 9 | + gives it one, not when the UI is walking, so there is no painter in scope | ||
| 10 | + and no rect to put it in. The texture is uploaded THEN — immediately, on | ||
| 11 | + the thread that brought it — and the walk finds it already on the GPU. | ||
| 12 | + | ||
| 13 | + AND THE PIXELS ARE BORROWED. The pointer belongs to whatever decoded it and | ||
| 14 | + is good until that decoder produces its next picture, so it cannot be kept | ||
| 15 | + and cannot be copied into a jolt value on the way past. `put!` uploads | ||
| 16 | + straight from the pointer and returns; after that the caller may do what it | ||
| 17 | + likes with the memory. | ||
| 18 | + | ||
| 19 | + Which is why the renderer is held here as state rather than passed in. It | ||
| 20 | + is not lovely, and the alternative is worse: threading a painter through a | ||
| 21 | + media pipeline so a codec can know about a toolkit." | ||
| 22 | + (:require [jvui.sdl :as sdl] | ||
| 23 | + [jolt.ffi :as ffi])) | ||
| 24 | + | ||
| 25 | +(defonce ^:private renderer (atom nil)) | ||
| 26 | +(defonce ^:private textures (atom {})) | ||
| 27 | + | ||
| 28 | +(defn install-renderer! | ||
| 29 | + "Called by the frame loop when a window opens. | ||
| 30 | + | ||
| 31 | + Frames that arrive before this are dropped rather than queued: a picture | ||
| 32 | + with nowhere to go is a picture nobody saw, and holding it would mean | ||
| 33 | + holding a pointer whose owner has moved on." | ||
| 34 | + [r] | ||
| 35 | + (reset! renderer r)) | ||
| 36 | + | ||
| 37 | +(defn- free-texture! [{:keys [tex]}] | ||
| 38 | + (when tex (sdl/destroy-texture! tex))) | ||
| 39 | + | ||
| 40 | +(defn drop! | ||
| 41 | + "Forget a feed and release its texture." | ||
| 42 | + [key] | ||
| 43 | + (when-let [t (get @textures key)] | ||
| 44 | + (swap! textures dissoc key) | ||
| 45 | + (free-texture! t)) | ||
| 46 | + nil) | ||
| 47 | + | ||
| 48 | +(defn put! | ||
| 49 | + "Upload one RGBA frame, `w` by `h`, from FOREIGN memory at `ptr`. | ||
| 50 | + | ||
| 51 | + The texture is recreated when the size changes — a peer switching camera | ||
| 52 | + or resolution mid-call is a real thing, and SDL will not resize one in | ||
| 53 | + place. Otherwise the same texture is written over, which is what | ||
| 54 | + STREAMING access is for." | ||
| 55 | + [key w h ptr] | ||
| 56 | + (when-let [r @renderer] | ||
| 57 | + (when (and (pos? w) (pos? h) ptr) | ||
| 58 | + (let [have (get @textures key) | ||
| 59 | + t (if (and have (= w (:w have)) (= h (:h have))) | ||
| 60 | + have | ||
| 61 | + (do (when have (free-texture! have)) | ||
| 62 | + (let [tex (sdl/create-texture r sdl/PIXELFORMAT-ABGR8888 | ||
| 63 | + sdl/TEXTUREACCESS-STREAMING | ||
| 64 | + w h)] | ||
| 65 | + (when tex | ||
| 66 | + (sdl/texture-blend-mode! tex sdl/BLEND) | ||
| 67 | + ;; Linear, because a call tile is almost never shown | ||
| 68 | + ;; at the size it was encoded. | ||
| 69 | + (sdl/texture-scale-mode! tex sdl/SCALE-LINEAR)) | ||
| 70 | + {:tex tex :w w :h h})))] | ||
| 71 | + (when (:tex t) | ||
| 72 | + (sdl/update-texture-raw! (:tex t) ptr (* 4 w)) | ||
| 73 | + (swap! textures assoc key t)))) | ||
| 74 | + nil)) | ||
| 75 | + | ||
| 76 | +(defn from-file | ||
| 77 | + "The texture for a picture on disk, decoded once and kept. | ||
| 78 | + | ||
| 79 | + Cached by PATH, and never re-read: a file that changed under us is not a | ||
| 80 | + case this has — frq writes an attachment once, under a name it chose, and | ||
| 81 | + a picture that needs to change gets a new path. | ||
| 82 | + | ||
| 83 | + nil when there is no renderer yet, when SDL3_image is not installed, or | ||
| 84 | + when the file will not decode. All three are the same answer to a caller: | ||
| 85 | + there is no picture, draw what you would have drawn without one. A | ||
| 86 | + broken-image glyph would be worse — the text beside it already says what | ||
| 87 | + it was meant to be." | ||
| 88 | + [path] | ||
| 89 | + (when-let [r @renderer] | ||
| 90 | + (let [k [::file path]] | ||
| 91 | + (or (get @textures k) | ||
| 92 | + (let [tex (try (sdl/img-load-texture r (str path)) | ||
| 93 | + (catch Exception _ nil))] | ||
| 94 | + (when (and tex (not (ffi/null? tex))) | ||
| 95 | + (let [[w h] (or (sdl/texture-size tex) [0 0]) | ||
| 96 | + t {:tex tex :w w :h h}] | ||
| 97 | + (sdl/texture-scale-mode! tex sdl/SCALE-LINEAR) | ||
| 98 | + (swap! textures assoc k t) | ||
| 99 | + t))))))) | ||
| 100 | + | ||
| 101 | +(defn lookup | ||
| 102 | + "{:tex :w :h} for a feed, or nil." | ||
| 103 | + [key] | ||
| 104 | + (get @textures key)) | ||
| 105 | + | ||
| 106 | +(defn keys* | ||
| 107 | + "Every live feed with a picture. Decoded files are not feeds and are not | ||
| 108 | + listed — a caller asking this wants to know who is on camera." | ||
| 109 | + [] | ||
| 110 | + (set (remove vector? (keys @textures)))) | ||
| 111 | + | ||
| 112 | +(defn clear! | ||
| 113 | + "Release every texture — the window is going away." | ||
| 114 | + [] | ||
| 115 | + (doseq [[_ t] @textures] (free-texture! t)) | ||
| 116 | + (reset! textures {}) | ||
| 117 | + (reset! renderer nil) | ||
| 118 | + nil) | ||
added
jvui/src/jvui/host.clj +122 -0 | new file mode 100644 | ||
| @@ -0,0 +1,122 @@ | ||
| 1 | +(ns jvui.host | |
| 2 | + "The window as a thing in an operating system, rather than a thing to draw in. | |
| 3 | + | |
| 4 | + Its title, the display it is on, the browser, the clipboard, and asking it | |
| 5 | + to close. None of this is painting and none of it belongs in a widget, but | |
| 6 | + a client cannot do any of it either — the window handle lives here, so the | |
| 7 | + questions come here too. | |
| 8 | + | |
| 9 | + Everything answers a harmless nothing before a window exists: no title to | |
| 10 | + set, no display to measure, no clipboard to read. A client that runs | |
| 11 | + headless — jvui's own tests do — should not have to guard every call." | |
| 12 | + (:require [jvui.sdl :as sdl])) | |
| 13 | + | |
| 14 | +(defonce ^:private win (atom nil)) | |
| 15 | +(defonce ^:private quit? (atom false)) | |
| 16 | + | |
| 17 | +(defn install! | |
| 18 | + "Called by the frame loop when a window opens." | |
| 19 | + [window] | |
| 20 | + (reset! win window) | |
| 21 | + (reset! quit? false)) | |
| 22 | + | |
| 23 | +(defn clear! [] (reset! win nil) nil) | |
| 24 | + | |
| 25 | +(defn window | |
| 26 | + "The open window, or nil." | |
| 27 | + [] | |
| 28 | + @win) | |
| 29 | + | |
| 30 | +;; --- closing ----------------------------------------------------------------- | |
| 31 | + | |
| 32 | +(defn quit! | |
| 33 | + "Ask the frame loop to stop after the current frame. | |
| 34 | + | |
| 35 | + A request rather than an exit: the loop owns teardown — textures, painter, | |
| 36 | + fonts, the window itself — and tearing any of it down from under a walk | |
| 37 | + that is still running is how a close turns into a crash." | |
| 38 | + [] | |
| 39 | + (reset! quit? true) | |
| 40 | + nil) | |
| 41 | + | |
| 42 | +(defn quit-requested? [] @quit?) | |
| 43 | + | |
| 44 | +;; --- the window -------------------------------------------------------------- | |
| 45 | + | |
| 46 | +(defn set-title! [s] | |
| 47 | + (when-let [w @win] (sdl/set-window-title! w (str s))) | |
| 48 | + nil) | |
| 49 | + | |
| 50 | +(defn window-size | |
| 51 | + "[w h] in window units, or nil with no window." | |
| 52 | + [] | |
| 53 | + (when-let [w @win] (sdl/window-size w))) | |
| 54 | + | |
| 55 | +(defn window-width | |
| 56 | + "Width in window units, or 0 with no window — a caller dividing by it wants | |
| 57 | + a number, and zero is the honest one." | |
| 58 | + [] | |
| 59 | + (or (first (window-size)) 0)) | |
| 60 | + | |
| 61 | +(defn screen-size | |
| 62 | + "[w h] of the display this window is on. [0 0] before there is one. | |
| 63 | + | |
| 64 | + NUMBERS, not nil, and that is the whole point of the zero. A client asks | |
| 65 | + this while it is starting — frq computes its layout from | |
| 66 | + `(long (second (screen-size)))` on the first tick, which is before the | |
| 67 | + window opens — and nil there is a NullPointerException in the caller | |
| 68 | + rather than a small first frame. `window-width` already answers 0 for the | |
| 69 | + same reason; this matches it. | |
| 70 | + | |
| 71 | + The value is right on the next tick, and a layout computed against zero | |
| 72 | + for one frame is a layout nobody saw." | |
| 73 | + [] | |
| 74 | + (or (when-let [w @win] (sdl/display-bounds w)) [0 0])) | |
| 75 | + | |
| 76 | +;; --- the world outside ------------------------------------------------------- | |
| 77 | + | |
| 78 | +(defn open-url! | |
| 79 | + "Hand a URL to whatever the desktop opens URLs with." | |
| 80 | + [url] | |
| 81 | + (boolean (and (string? url) (seq url) (sdl/open-url! url)))) | |
| 82 | + | |
| 83 | +(defn clipboard-image-png! | |
| 84 | + "Write the clipboard's picture to `path` as PNG; true when there was one. | |
| 85 | + | |
| 86 | + Only image/png is asked for. A clipboard may also hold a bitmap or a TIFF | |
| 87 | + and converting those would mean an encoder in here, which is a lot of | |
| 88 | + toolkit for a paste — and every desktop that puts a picture on a clipboard | |
| 89 | + this decade puts a PNG there too." | |
| 90 | + [path] | |
| 91 | + (boolean | |
| 92 | + (when (sdl/has-clipboard-data "image/png") | |
| 93 | + (when-let [[p n] (sdl/clipboard-data "image/png")] | |
| 94 | + (try | |
| 95 | + (let [bs (byte-array n)] | |
| 96 | + (jolt.ffi/read-array p :byte bs) | |
| 97 | + (java.nio.file.Files/write | |
| 98 | + (java.nio.file.Path/of (str path) (into-array String [])) | |
| 99 | + bs | |
| 100 | + (into-array java.nio.file.OpenOption [])) | |
| 101 | + true) | |
| 102 | + (finally (sdl/sdl-free! p))))))) | |
| 103 | + | |
| 104 | +;; --- the picture chooser ------------------------------------------------------ | |
| 105 | +;; There is not one, and that is the same answer glimmer-vidya gives on a | |
| 106 | +;; desktop. The chooser exists so a phone can hand back a grant for one | |
| 107 | +;; picture without the app holding a permission over all of them; a desktop | |
| 108 | +;; has no such thing and browsing the filesystem is the answer there. | |
| 109 | +;; | |
| 110 | +;; So these are not stubs waiting to be filled in on this backend — they are | |
| 111 | +;; the correct answer for it, and a caller reads the false and offers a file | |
| 112 | +;; browser instead. An Android jvui would replace them. | |
| 113 | + | |
| 114 | +(defn pick-image! | |
| 115 | + "False: this platform has no picture chooser." | |
| 116 | + [] | |
| 117 | + false) | |
| 118 | + | |
| 119 | +(defn picked-image! | |
| 120 | + "nil: nothing was ever chosen, because nothing could be." | |
| 121 | + [_path] | |
| 122 | + nil) | |
| new file mode 100644 | |||
| @@ -0,0 +1,122 @@ | |||
| 1 | +(ns jvui.host | ||
| 2 | + "The window as a thing in an operating system, rather than a thing to draw in. | ||
| 3 | + | ||
| 4 | + Its title, the display it is on, the browser, the clipboard, and asking it | ||
| 5 | + to close. None of this is painting and none of it belongs in a widget, but | ||
| 6 | + a client cannot do any of it either — the window handle lives here, so the | ||
| 7 | + questions come here too. | ||
| 8 | + | ||
| 9 | + Everything answers a harmless nothing before a window exists: no title to | ||
| 10 | + set, no display to measure, no clipboard to read. A client that runs | ||
| 11 | + headless — jvui's own tests do — should not have to guard every call." | ||
| 12 | + (:require [jvui.sdl :as sdl])) | ||
| 13 | + | ||
| 14 | +(defonce ^:private win (atom nil)) | ||
| 15 | +(defonce ^:private quit? (atom false)) | ||
| 16 | + | ||
| 17 | +(defn install! | ||
| 18 | + "Called by the frame loop when a window opens." | ||
| 19 | + [window] | ||
| 20 | + (reset! win window) | ||
| 21 | + (reset! quit? false)) | ||
| 22 | + | ||
| 23 | +(defn clear! [] (reset! win nil) nil) | ||
| 24 | + | ||
| 25 | +(defn window | ||
| 26 | + "The open window, or nil." | ||
| 27 | + [] | ||
| 28 | + @win) | ||
| 29 | + | ||
| 30 | +;; --- closing ----------------------------------------------------------------- | ||
| 31 | + | ||
| 32 | +(defn quit! | ||
| 33 | + "Ask the frame loop to stop after the current frame. | ||
| 34 | + | ||
| 35 | + A request rather than an exit: the loop owns teardown — textures, painter, | ||
| 36 | + fonts, the window itself — and tearing any of it down from under a walk | ||
| 37 | + that is still running is how a close turns into a crash." | ||
| 38 | + [] | ||
| 39 | + (reset! quit? true) | ||
| 40 | + nil) | ||
| 41 | + | ||
| 42 | +(defn quit-requested? [] @quit?) | ||
| 43 | + | ||
| 44 | +;; --- the window -------------------------------------------------------------- | ||
| 45 | + | ||
| 46 | +(defn set-title! [s] | ||
| 47 | + (when-let [w @win] (sdl/set-window-title! w (str s))) | ||
| 48 | + nil) | ||
| 49 | + | ||
| 50 | +(defn window-size | ||
| 51 | + "[w h] in window units, or nil with no window." | ||
| 52 | + [] | ||
| 53 | + (when-let [w @win] (sdl/window-size w))) | ||
| 54 | + | ||
| 55 | +(defn window-width | ||
| 56 | + "Width in window units, or 0 with no window — a caller dividing by it wants | ||
| 57 | + a number, and zero is the honest one." | ||
| 58 | + [] | ||
| 59 | + (or (first (window-size)) 0)) | ||
| 60 | + | ||
| 61 | +(defn screen-size | ||
| 62 | + "[w h] of the display this window is on. [0 0] before there is one. | ||
| 63 | + | ||
| 64 | + NUMBERS, not nil, and that is the whole point of the zero. A client asks | ||
| 65 | + this while it is starting — frq computes its layout from | ||
| 66 | + `(long (second (screen-size)))` on the first tick, which is before the | ||
| 67 | + window opens — and nil there is a NullPointerException in the caller | ||
| 68 | + rather than a small first frame. `window-width` already answers 0 for the | ||
| 69 | + same reason; this matches it. | ||
| 70 | + | ||
| 71 | + The value is right on the next tick, and a layout computed against zero | ||
| 72 | + for one frame is a layout nobody saw." | ||
| 73 | + [] | ||
| 74 | + (or (when-let [w @win] (sdl/display-bounds w)) [0 0])) | ||
| 75 | + | ||
| 76 | +;; --- the world outside ------------------------------------------------------- | ||
| 77 | + | ||
| 78 | +(defn open-url! | ||
| 79 | + "Hand a URL to whatever the desktop opens URLs with." | ||
| 80 | + [url] | ||
| 81 | + (boolean (and (string? url) (seq url) (sdl/open-url! url)))) | ||
| 82 | + | ||
| 83 | +(defn clipboard-image-png! | ||
| 84 | + "Write the clipboard's picture to `path` as PNG; true when there was one. | ||
| 85 | + | ||
| 86 | + Only image/png is asked for. A clipboard may also hold a bitmap or a TIFF | ||
| 87 | + and converting those would mean an encoder in here, which is a lot of | ||
| 88 | + toolkit for a paste — and every desktop that puts a picture on a clipboard | ||
| 89 | + this decade puts a PNG there too." | ||
| 90 | + [path] | ||
| 91 | + (boolean | ||
| 92 | + (when (sdl/has-clipboard-data "image/png") | ||
| 93 | + (when-let [[p n] (sdl/clipboard-data "image/png")] | ||
| 94 | + (try | ||
| 95 | + (let [bs (byte-array n)] | ||
| 96 | + (jolt.ffi/read-array p :byte bs) | ||
| 97 | + (java.nio.file.Files/write | ||
| 98 | + (java.nio.file.Path/of (str path) (into-array String [])) | ||
| 99 | + bs | ||
| 100 | + (into-array java.nio.file.OpenOption [])) | ||
| 101 | + true) | ||
| 102 | + (finally (sdl/sdl-free! p))))))) | ||
| 103 | + | ||
| 104 | +;; --- the picture chooser ------------------------------------------------------ | ||
| 105 | +;; There is not one, and that is the same answer glimmer-vidya gives on a | ||
| 106 | +;; desktop. The chooser exists so a phone can hand back a grant for one | ||
| 107 | +;; picture without the app holding a permission over all of them; a desktop | ||
| 108 | +;; has no such thing and browsing the filesystem is the answer there. | ||
| 109 | +;; | ||
| 110 | +;; So these are not stubs waiting to be filled in on this backend — they are | ||
| 111 | +;; the correct answer for it, and a caller reads the false and offers a file | ||
| 112 | +;; browser instead. An Android jvui would replace them. | ||
| 113 | + | ||
| 114 | +(defn pick-image! | ||
| 115 | + "False: this platform has no picture chooser." | ||
| 116 | + [] | ||
| 117 | + false) | ||
| 118 | + | ||
| 119 | +(defn picked-image! | ||
| 120 | + "nil: nothing was ever chosen, because nothing could be." | ||
| 121 | + [_path] | ||
| 122 | + nil) | ||
modified
jvui/src/jvui/paint.clj +30 -0 | @@ -134,6 +134,36 @@ | ||
| 134 | 134 | colour (max 0.0 (- radius bw))))) |
| 135 | 135 | (when colour (round-rect! p [x y w h] colour radius)))))) |
| 136 | 136 | |
| 137 | +(defn frame! | |
| 138 | + "Blit a whole texture into `rect`, letterboxed to keep its shape. | |
| 139 | + | |
| 140 | + Stretching to fill would be one line shorter and would make every face in | |
| 141 | + a call slightly wrong — a 16:9 camera in a square tile is the ordinary | |
| 142 | + case, not the exceptional one." | |
| 143 | + [p tex tw th [x y w h]] | |
| 144 | + (let [{:keys [r]} @p | |
| 145 | + sx (/ (double w) tw) | |
| 146 | + sy (/ (double h) th) | |
| 147 | + k (min sx sy) | |
| 148 | + dw (* tw k) | |
| 149 | + dh (* th k)] | |
| 150 | + (flush! p) | |
| 151 | + (sdl/blit! r tex nil [(+ x (/ (- w dw) 2.0)) (+ y (/ (- h dh) 2.0)) dw dh]))) | |
| 152 | + | |
| 153 | +(defn colour-emoji? | |
| 154 | + "Whether `s` has a colour picture to be drawn as." | |
| 155 | + [p s] | |
| 156 | + (font/colour-glyph? (:fonts @p) s)) | |
| 157 | + | |
| 158 | +(defn emoji! | |
| 159 | + "Draw `s` as a colour picture in `rect`. Answers whether it did — a caller | |
| 160 | + that gets false draws it as text instead." | |
| 161 | + [p s rect] | |
| 162 | + (let [{:keys [r fonts]} @p] | |
| 163 | + (if-let [[tex [tw th]] (font/colour-texture fonts r s)] | |
| 164 | + (do (frame! p tex tw th rect) true) | |
| 165 | + false))) | |
| 166 | + | |
| 137 | 167 | (defn line! |
| 138 | 168 | "A `width`-thick line. Axis-aligned lines are a rectangle; the diagonal case |
| 139 | 169 | — which in this toolkit is a checkbox tick — is a few offset hairlines, |
| @@ -134,6 +134,36 @@ | |||
| 134 | colour (max 0.0 (- radius bw))))) | 134 | colour (max 0.0 (- radius bw))))) |
| 135 | (when colour (round-rect! p [x y w h] colour radius)))))) | 135 | (when colour (round-rect! p [x y w h] colour radius)))))) |
| 136 | 136 | ||
| 137 | +(defn frame! | ||
| 138 | + "Blit a whole texture into `rect`, letterboxed to keep its shape. | ||
| 139 | + | ||
| 140 | + Stretching to fill would be one line shorter and would make every face in | ||
| 141 | + a call slightly wrong — a 16:9 camera in a square tile is the ordinary | ||
| 142 | + case, not the exceptional one." | ||
| 143 | + [p tex tw th [x y w h]] | ||
| 144 | + (let [{:keys [r]} @p | ||
| 145 | + sx (/ (double w) tw) | ||
| 146 | + sy (/ (double h) th) | ||
| 147 | + k (min sx sy) | ||
| 148 | + dw (* tw k) | ||
| 149 | + dh (* th k)] | ||
| 150 | + (flush! p) | ||
| 151 | + (sdl/blit! r tex nil [(+ x (/ (- w dw) 2.0)) (+ y (/ (- h dh) 2.0)) dw dh]))) | ||
| 152 | + | ||
| 153 | +(defn colour-emoji? | ||
| 154 | + "Whether `s` has a colour picture to be drawn as." | ||
| 155 | + [p s] | ||
| 156 | + (font/colour-glyph? (:fonts @p) s)) | ||
| 157 | + | ||
| 158 | +(defn emoji! | ||
| 159 | + "Draw `s` as a colour picture in `rect`. Answers whether it did — a caller | ||
| 160 | + that gets false draws it as text instead." | ||
| 161 | + [p s rect] | ||
| 162 | + (let [{:keys [r fonts]} @p] | ||
| 163 | + (if-let [[tex [tw th]] (font/colour-texture fonts r s)] | ||
| 164 | + (do (frame! p tex tw th rect) true) | ||
| 165 | + false))) | ||
| 166 | + | ||
| 137 | (defn line! | 167 | (defn line! |
| 138 | "A `width`-thick line. Axis-aligned lines are a rectangle; the diagonal case | 168 | "A `width`-thick line. Axis-aligned lines are a rectangle; the diagonal case |
| 139 | — which in this toolkit is a checkbox tick — is a few offset hairlines, | 169 | — which in this toolkit is a checkbox tick — is a few offset hairlines, |
modified
jvui/src/jvui/sdl.clj +75 -0 | @@ -67,6 +67,10 @@ | ||
| 67 | 67 | (ffi/defcfn open-font "TTF_OpenFont" [:string :float] :pointer) |
| 68 | 68 | (ffi/defcfn close-font! "TTF_CloseFont" [:pointer] :void) |
| 69 | 69 | (ffi/defcfn font-height "TTF_GetFontHeight" [:pointer] :int) |
| 70 | +;; A face to try for characters the main one has no glyph for. SDL_ttf keeps | |
| 71 | +;; a list of them and asks each in turn. | |
| 72 | +(ffi/defcfn add-fallback-font! "TTF_AddFallbackFont" [:pointer :pointer] :bool) | |
| 73 | +(ffi/defcfn font-has-glyph? "TTF_FontHasGlyph" [:pointer :uint] :bool) | |
| 70 | 74 | (ffi/defcfn ^:private raw-string-size "TTF_GetStringSize" |
| 71 | 75 | [:pointer :string :ulong :pointer :pointer] :bool) |
| 72 | 76 | (ffi/defcfn ^:private raw-render-blended "TTF_RenderText_Blended" |
| @@ -259,7 +263,20 @@ | ||
| 259 | 263 | [:pointer :float :float :float :float] :bool) |
| 260 | 264 | |
| 261 | 265 | (def PIXELFORMAT-ARGB8888 372645892) |
| 266 | + | |
| 267 | +;; SDL_PIXELFORMAT_ABGR8888, which is what SDL_PIXELFORMAT_RGBA32 aliases to | |
| 268 | +;; on a little-endian machine. Named by its packed layout rather than its byte | |
| 269 | +;; order, which is the trap: a buffer whose BYTES run R,G,B,A reads as the | |
| 270 | +;; 32-bit word 0xAABBGGRR, so ABGR8888 is the one that matches it and | |
| 271 | +;; ARGB8888 — the obvious-looking choice — puts the channels through a | |
| 272 | +;; rotation and turns skin blue. | |
| 273 | +(def PIXELFORMAT-ABGR8888 376840196) | |
| 274 | + | |
| 262 | 275 | (def TEXTUREACCESS-STATIC 0) |
| 276 | +;; STREAMING for anything uploaded every frame. STATIC textures live in | |
| 277 | +;; memory the driver expects to write rarely; a video feed at thirty a second | |
| 278 | +;; is the case the distinction exists for. | |
| 279 | +(def TEXTUREACCESS-STREAMING 1) | |
| 263 | 280 | |
| 264 | 281 | (defn update-texture! |
| 265 | 282 | "Upload an int-array of ARGB8888 pixels, `w` wide, into the whole of `tex`." |
| @@ -269,6 +286,64 @@ | ||
| 269 | 286 | (ffi/write-array p :int pixels) |
| 270 | 287 | (raw-update-texture tex ffi/null p (* 4 w))))) |
| 271 | 288 | |
| 289 | +;; --- the platform around the window ----------------------------------------- | |
| 290 | +;; Title, display, browser, clipboard. None of it is drawing, and all of it is | |
| 291 | +;; what a client asks the toolkit for because the toolkit is the only thing | |
| 292 | +;; holding a window handle. | |
| 293 | + | |
| 294 | +(ffi/defcfn set-window-title! "SDL_SetWindowTitle" [:pointer :string] :bool) | |
| 295 | +(ffi/defcfn open-url! "SDL_OpenURL" [:string] :bool) | |
| 296 | +(ffi/defcfn display-for-window "SDL_GetDisplayForWindow" [:pointer] :uint) | |
| 297 | +(ffi/defcfn ^:private raw-display-bounds "SDL_GetDisplayUsableBounds" | |
| 298 | + [:uint :pointer] :bool) | |
| 299 | +(ffi/defcfn has-clipboard-data "SDL_HasClipboardData" [:string] :bool) | |
| 300 | +(ffi/defcfn ^:private raw-clipboard-data "SDL_GetClipboardData" | |
| 301 | + [:string :pointer] :pointer) | |
| 302 | +(ffi/defcfn sdl-free! "SDL_free" [:pointer] :void) | |
| 303 | + | |
| 304 | +(defn display-bounds | |
| 305 | + "[w h] of the display `window` is on, minus whatever the desktop reserves | |
| 306 | + for panels — usable bounds rather than raw, because a window sized to the | |
| 307 | + whole display is one with its bottom edge under a taskbar." | |
| 308 | + [window] | |
| 309 | + (ffi/with-alloc [r 16] | |
| 310 | + (when (raw-display-bounds (display-for-window window) r) | |
| 311 | + [(ffi/read (+ r 8) :int) (ffi/read (+ r 12) :int)]))) | |
| 312 | + | |
| 313 | +(defn clipboard-data | |
| 314 | + "The clipboard's contents for `mime`, as [pointer length], or nil. | |
| 315 | + | |
| 316 | + The pointer is SDL's and the caller must hand it back to `sdl-free!`." | |
| 317 | + [mime] | |
| 318 | + (ffi/with-alloc [sz 8] | |
| 319 | + (let [p (raw-clipboard-data mime sz) | |
| 320 | + n (ffi/read sz :uint64)] | |
| 321 | + (when (and p (not (ffi/null? p)) (pos? n)) | |
| 322 | + [p n])))) | |
| 323 | + | |
| 324 | +(ffi/defcfn img-load-texture "IMG_LoadTexture" [:pointer :string] :pointer) | |
| 325 | +(ffi/defcfn ^:private raw-texture-size "SDL_GetTextureSize" | |
| 326 | + [:pointer :pointer :pointer] :bool) | |
| 327 | + | |
| 328 | +(defn texture-size | |
| 329 | + "[w h] of a texture, or nil." | |
| 330 | + [tex] | |
| 331 | + (ffi/with-alloc [w 4] | |
| 332 | + (ffi/with-alloc [h 4] | |
| 333 | + (when (raw-texture-size tex w h) | |
| 334 | + [(long (ffi/read w :float)) (long (ffi/read h :float))])))) | |
| 335 | + | |
| 336 | +(defn update-texture-raw! | |
| 337 | + "Upload `h` rows of `pitch` bytes from FOREIGN memory into the whole of `tex`. | |
| 338 | + | |
| 339 | + The pointer is the caller's and is read during the call and not kept. That | |
| 340 | + is the difference from `update-texture!` above, and the reason this exists: | |
| 341 | + that one takes an int-array, which means a decoded frame becomes a jolt | |
| 342 | + value on its way to the screen. At thirty frames a second and two megabytes | |
| 343 | + a frame, the copy costs more than the decode." | |
| 344 | + [tex ptr pitch] | |
| 345 | + (raw-update-texture tex ffi/null ptr pitch)) | |
| 346 | + | |
| 272 | 347 | (defonce ^:private rect-a (delay (ffi/alloc 16))) |
| 273 | 348 | (defonce ^:private rect-b (delay (ffi/alloc 16))) |
| 274 | 349 | |
| @@ -67,6 +67,10 @@ | |||
| 67 | (ffi/defcfn open-font "TTF_OpenFont" [:string :float] :pointer) | 67 | (ffi/defcfn open-font "TTF_OpenFont" [:string :float] :pointer) |
| 68 | (ffi/defcfn close-font! "TTF_CloseFont" [:pointer] :void) | 68 | (ffi/defcfn close-font! "TTF_CloseFont" [:pointer] :void) |
| 69 | (ffi/defcfn font-height "TTF_GetFontHeight" [:pointer] :int) | 69 | (ffi/defcfn font-height "TTF_GetFontHeight" [:pointer] :int) |
| 70 | +;; A face to try for characters the main one has no glyph for. SDL_ttf keeps | ||
| 71 | +;; a list of them and asks each in turn. | ||
| 72 | +(ffi/defcfn add-fallback-font! "TTF_AddFallbackFont" [:pointer :pointer] :bool) | ||
| 73 | +(ffi/defcfn font-has-glyph? "TTF_FontHasGlyph" [:pointer :uint] :bool) | ||
| 70 | (ffi/defcfn ^:private raw-string-size "TTF_GetStringSize" | 74 | (ffi/defcfn ^:private raw-string-size "TTF_GetStringSize" |
| 71 | [:pointer :string :ulong :pointer :pointer] :bool) | 75 | [:pointer :string :ulong :pointer :pointer] :bool) |
| 72 | (ffi/defcfn ^:private raw-render-blended "TTF_RenderText_Blended" | 76 | (ffi/defcfn ^:private raw-render-blended "TTF_RenderText_Blended" |
| @@ -259,7 +263,20 @@ | |||
| 259 | [:pointer :float :float :float :float] :bool) | 263 | [:pointer :float :float :float :float] :bool) |
| 260 | 264 | ||
| 261 | (def PIXELFORMAT-ARGB8888 372645892) | 265 | (def PIXELFORMAT-ARGB8888 372645892) |
| 266 | + | ||
| 267 | +;; SDL_PIXELFORMAT_ABGR8888, which is what SDL_PIXELFORMAT_RGBA32 aliases to | ||
| 268 | +;; on a little-endian machine. Named by its packed layout rather than its byte | ||
| 269 | +;; order, which is the trap: a buffer whose BYTES run R,G,B,A reads as the | ||
| 270 | +;; 32-bit word 0xAABBGGRR, so ABGR8888 is the one that matches it and | ||
| 271 | +;; ARGB8888 — the obvious-looking choice — puts the channels through a | ||
| 272 | +;; rotation and turns skin blue. | ||
| 273 | +(def PIXELFORMAT-ABGR8888 376840196) | ||
| 274 | + | ||
| 262 | (def TEXTUREACCESS-STATIC 0) | 275 | (def TEXTUREACCESS-STATIC 0) |
| 276 | +;; STREAMING for anything uploaded every frame. STATIC textures live in | ||
| 277 | +;; memory the driver expects to write rarely; a video feed at thirty a second | ||
| 278 | +;; is the case the distinction exists for. | ||
| 279 | +(def TEXTUREACCESS-STREAMING 1) | ||
| 263 | 280 | ||
| 264 | (defn update-texture! | 281 | (defn update-texture! |
| 265 | "Upload an int-array of ARGB8888 pixels, `w` wide, into the whole of `tex`." | 282 | "Upload an int-array of ARGB8888 pixels, `w` wide, into the whole of `tex`." |
| @@ -269,6 +286,64 @@ | |||
| 269 | (ffi/write-array p :int pixels) | 286 | (ffi/write-array p :int pixels) |
| 270 | (raw-update-texture tex ffi/null p (* 4 w))))) | 287 | (raw-update-texture tex ffi/null p (* 4 w))))) |
| 271 | 288 | ||
| 289 | +;; --- the platform around the window ----------------------------------------- | ||
| 290 | +;; Title, display, browser, clipboard. None of it is drawing, and all of it is | ||
| 291 | +;; what a client asks the toolkit for because the toolkit is the only thing | ||
| 292 | +;; holding a window handle. | ||
| 293 | + | ||
| 294 | +(ffi/defcfn set-window-title! "SDL_SetWindowTitle" [:pointer :string] :bool) | ||
| 295 | +(ffi/defcfn open-url! "SDL_OpenURL" [:string] :bool) | ||
| 296 | +(ffi/defcfn display-for-window "SDL_GetDisplayForWindow" [:pointer] :uint) | ||
| 297 | +(ffi/defcfn ^:private raw-display-bounds "SDL_GetDisplayUsableBounds" | ||
| 298 | + [:uint :pointer] :bool) | ||
| 299 | +(ffi/defcfn has-clipboard-data "SDL_HasClipboardData" [:string] :bool) | ||
| 300 | +(ffi/defcfn ^:private raw-clipboard-data "SDL_GetClipboardData" | ||
| 301 | + [:string :pointer] :pointer) | ||
| 302 | +(ffi/defcfn sdl-free! "SDL_free" [:pointer] :void) | ||
| 303 | + | ||
| 304 | +(defn display-bounds | ||
| 305 | + "[w h] of the display `window` is on, minus whatever the desktop reserves | ||
| 306 | + for panels — usable bounds rather than raw, because a window sized to the | ||
| 307 | + whole display is one with its bottom edge under a taskbar." | ||
| 308 | + [window] | ||
| 309 | + (ffi/with-alloc [r 16] | ||
| 310 | + (when (raw-display-bounds (display-for-window window) r) | ||
| 311 | + [(ffi/read (+ r 8) :int) (ffi/read (+ r 12) :int)]))) | ||
| 312 | + | ||
| 313 | +(defn clipboard-data | ||
| 314 | + "The clipboard's contents for `mime`, as [pointer length], or nil. | ||
| 315 | + | ||
| 316 | + The pointer is SDL's and the caller must hand it back to `sdl-free!`." | ||
| 317 | + [mime] | ||
| 318 | + (ffi/with-alloc [sz 8] | ||
| 319 | + (let [p (raw-clipboard-data mime sz) | ||
| 320 | + n (ffi/read sz :uint64)] | ||
| 321 | + (when (and p (not (ffi/null? p)) (pos? n)) | ||
| 322 | + [p n])))) | ||
| 323 | + | ||
| 324 | +(ffi/defcfn img-load-texture "IMG_LoadTexture" [:pointer :string] :pointer) | ||
| 325 | +(ffi/defcfn ^:private raw-texture-size "SDL_GetTextureSize" | ||
| 326 | + [:pointer :pointer :pointer] :bool) | ||
| 327 | + | ||
| 328 | +(defn texture-size | ||
| 329 | + "[w h] of a texture, or nil." | ||
| 330 | + [tex] | ||
| 331 | + (ffi/with-alloc [w 4] | ||
| 332 | + (ffi/with-alloc [h 4] | ||
| 333 | + (when (raw-texture-size tex w h) | ||
| 334 | + [(long (ffi/read w :float)) (long (ffi/read h :float))])))) | ||
| 335 | + | ||
| 336 | +(defn update-texture-raw! | ||
| 337 | + "Upload `h` rows of `pitch` bytes from FOREIGN memory into the whole of `tex`. | ||
| 338 | + | ||
| 339 | + The pointer is the caller's and is read during the call and not kept. That | ||
| 340 | + is the difference from `update-texture!` above, and the reason this exists: | ||
| 341 | + that one takes an int-array, which means a decoded frame becomes a jolt | ||
| 342 | + value on its way to the screen. At thirty frames a second and two megabytes | ||
| 343 | + a frame, the copy costs more than the decode." | ||
| 344 | + [tex ptr pitch] | ||
| 345 | + (raw-update-texture tex ffi/null ptr pitch)) | ||
| 346 | + | ||
| 272 | (defonce ^:private rect-a (delay (ffi/alloc 16))) | 347 | (defonce ^:private rect-a (delay (ffi/alloc 16))) |
| 273 | (defonce ^:private rect-b (delay (ffi/alloc 16))) | 348 | (defonce ^:private rect-b (delay (ffi/alloc 16))) |
| 274 | 349 | ||
modified
jvui/src/jvui/widgets.clj +426 -34 | @@ -13,7 +13,9 @@ | ||
| 13 | 13 | closure. They are thin: each is `core/box*` with a different set of defaults |
| 14 | 14 | from the theme." |
| 15 | 15 | (:require [jvui.core :as c] |
| 16 | - [jvui.theme :as theme])) | |
| 16 | + [jvui.theme :as theme] | |
| 17 | + [jvui.frames :as frames] | |
| 18 | + [clojure.string :as str])) | |
| 17 | 19 | |
| 18 | 20 | ;; -------------------------------------------------------------- containers |
| 19 | 21 | |
| @@ -64,22 +66,89 @@ | ||
| 64 | 66 | |
| 65 | 67 | ;; -------------------------------------------------------------------- text |
| 66 | 68 | |
| 69 | +(defn- break-word | |
| 70 | + "Cut a word too long for `width` into pieces that fit. | |
| 71 | + | |
| 72 | + Character-wise and greedy. Only ever reached for a word that would not | |
| 73 | + fit on a line of its own — an ordinary sentence never comes here." | |
| 74 | + [w size width] | |
| 75 | + (loop [rest* w out []] | |
| 76 | + (if (or (empty? rest*) (<= (first (c/measure rest* size)) width)) | |
| 77 | + (if (seq rest*) (conj out rest*) out) | |
| 78 | + (let [n (loop [i 1] | |
| 79 | + (cond (>= i (count rest*)) (count rest*) | |
| 80 | + (> (first (c/measure (subs rest* 0 i) size)) width) (max 1 (dec i)) | |
| 81 | + :else (recur (inc i))))] | |
| 82 | + (recur (subs rest* n) (conj out (subs rest* 0 n))))))) | |
| 83 | + | |
| 84 | +(defn- wrap-lines | |
| 85 | + "Break `s` into lines that each fit `width`, on spaces. | |
| 86 | + | |
| 87 | + Greedy and word-wise, and a word that cannot fit on a line of its own | |
| 88 | + is broken at a character. That second half was left out first time | |
| 89 | + round on the grounds that a URL cut in half reads worse than one that | |
| 90 | + overflows — which was wrong, and the sign-in screen showed why: an | |
| 91 | + OAuth login URL is one unbreakable word, so it made its container | |
| 92 | + wider than the window and pushed the whole page off both edges. A | |
| 93 | + broken URL is worse than an unbroken one; a window you cannot read is | |
| 94 | + worse than both. | |
| 95 | + | |
| 96 | + Measured through the same `c/measure` the drawing uses, so a line that | |
| 97 | + is said to fit does fit — a wrap computed against a different metric | |
| 98 | + than the renderer's is off by a word at the worst moments." | |
| 99 | + [s size width] | |
| 100 | + (let [words (mapcat (fn [w] | |
| 101 | + (if (> (first (c/measure w size)) width) | |
| 102 | + (break-word w size width) | |
| 103 | + [w])) | |
| 104 | + (str/split (str s) #" "))] | |
| 105 | + (loop [[w & more] words line nil out []] | |
| 106 | + (cond | |
| 107 | + (nil? w) (if line (conj out line) out) | |
| 108 | + (nil? line) (recur more w out) | |
| 109 | + :else | |
| 110 | + (let [try* (str line " " w)] | |
| 111 | + (if (<= (first (c/measure try* size)) width) | |
| 112 | + (recur more try* out) | |
| 113 | + (recur more w (conj out line)))))))) | |
| 114 | + | |
| 67 | 115 | (defn label |
| 68 | - "A line of text. Answers its rectangle." | |
| 116 | + "A line of text, wrapped to the space it was given. Answers its rectangle. | |
| 117 | + | |
| 118 | + Wrapping is on when the text does not fit and the box has told us how | |
| 119 | + much room there is. That is not a style choice: without it one long | |
| 120 | + sentence makes its container wider than the window, and a page that | |
| 121 | + centres its content then hangs off BOTH edges — which is exactly what | |
| 122 | + frq's sign-in screen did the first time it was painted here. | |
| 123 | + | |
| 124 | + `:wrap false` turns it off for a caller that would rather overflow, and | |
| 125 | + a width of zero — the first frame, before any box knows its size — is | |
| 126 | + read as \"not yet\" rather than \"no room\"." | |
| 69 | 127 | ([s] (label s {})) |
| 70 | - ([s {:keys [size colour expand gravity align] | |
| 71 | - :or {expand :horizontal gravity [0.0 0.5] align :left}}] | |
| 128 | + ([s {:keys [size colour expand gravity align wrap] | |
| 129 | + :or {expand :horizontal gravity [0.0 0.5] align :left wrap true}}] | |
| 72 | 130 | (let [size (or size (c/th :font-size)) |
| 73 | 131 | colour (or colour (c/th :text)) |
| 74 | - [tw th*] (c/measure s size) | |
| 75 | - rect (c/leaf [(double tw) (double (max th* (c/line-height size)))] | |
| 132 | + [tw0 th0] (c/measure s size) | |
| 133 | + avail (c/avail-width) | |
| 134 | + lines (if (and wrap (pos? avail) (> tw0 avail)) | |
| 135 | + (wrap-lines s size avail) | |
| 136 | + [(str s)]) | |
| 137 | + many? (> (count lines) 1) | |
| 138 | + widths (mapv #(first (c/measure % size)) lines) | |
| 139 | + tw (if many? (reduce max 0.0 widths) tw0) | |
| 140 | + lh (max th0 (c/line-height size)) | |
| 141 | + rect (c/leaf [(double tw) (double (* lh (count lines)))] | |
| 76 | 142 | expand gravity) |
| 77 | - [x y w h] rect | |
| 78 | - tx (case align | |
| 79 | - :center (+ x (/ (- w tw) 2.0)) | |
| 80 | - :right (+ x (- w tw)) | |
| 81 | - x)] | |
| 82 | - (c/draw-text! s tx (+ y (/ (- h th*) 2.0)) size colour) | |
| 143 | + [x y w h] rect] | |
| 144 | + (dotimes [i (count lines)] | |
| 145 | + (let [ln (nth lines i) | |
| 146 | + lw (nth widths i) | |
| 147 | + tx (case align | |
| 148 | + :center (+ x (/ (- w lw) 2.0)) | |
| 149 | + :right (+ x (- w lw)) | |
| 150 | + x)] | |
| 151 | + (c/draw-text! ln tx (+ y (* i lh) (/ (- lh th0) 2.0)) size colour))) | |
| 83 | 152 | rect))) |
| 84 | 153 | |
| 85 | 154 | (defn title [s & [opts]] |
| @@ -231,18 +300,28 @@ | ||
| 231 | 300 | |
| 232 | 301 | (defn- clamp [v lo hi] (max lo (min hi v))) |
| 233 | 302 | |
| 303 | +(defn entry-activated? | |
| 304 | + "Did the field under `id` see Enter on the frame just walked? | |
| 305 | + | |
| 306 | + Asked after `text-entry`, which records it. Enter is the one key a text | |
| 307 | + field must NOT treat as input — the client sends on it — and a widget | |
| 308 | + that answered only its text gave a caller no way to know." | |
| 309 | + [id] | |
| 310 | + (boolean (c/state id :activated false))) | |
| 311 | + | |
| 234 | 312 | (defn text-entry |
| 235 | 313 | "A single-line editable string. Answers the text after this frame. |
| 236 | 314 | |
| 237 | 315 | The caret is an index into the string kept under the widget's id, which is |
| 238 | 316 | the one piece of state a text field cannot recompute from its value." |
| 239 | 317 | ([value] (text-entry value {})) |
| 240 | - ([value {:keys [key expand placeholder] :or {expand :horizontal}}] | |
| 318 | + ([value {:keys [key expand placeholder min-width] | |
| 319 | + :or {expand :horizontal}}] | |
| 241 | 320 | (let [size (c/th :font-size) |
| 242 | 321 | pad (c/th :padding) |
| 243 | 322 | h (double (c/th :control-height)) |
| 244 | 323 | id (c/next-id key) |
| 245 | - rect (c/leaf [160.0 h] expand [0.0 0.5]) | |
| 324 | + rect (c/leaf [(double (or min-width 160.0)) h] expand [0.0 0.5]) | |
| 246 | 325 | {:keys [hover? focused?]} (c/interact! id rect) |
| 247 | 326 | [rx ry rw rh] rect |
| 248 | 327 | s (str value) |
| @@ -273,50 +352,178 @@ | ||
| 273 | 352 | [s caret0] (or evs [])) |
| 274 | 353 | caret' (clamp caret' 0 (count s'))] |
| 275 | 354 | (c/state! id :caret caret') |
| 355 | + ;; Enter is not an edit and must not be swallowed as one: a client | |
| 356 | + ;; sends its message on it. Recorded as state rather than returned, | |
| 357 | + ;; because `text-entry` already answers the text and a second return | |
| 358 | + ;; value would change every existing call. | |
| 359 | + (c/state! id :activated | |
| 360 | + (boolean (some #(and (= :key-down (:kind %)) (= :return (:key %))) | |
| 361 | + (or evs [])))) | |
| 276 | 362 | (c/fill! rect (c/th :surface) (c/th :radius) |
| 277 | 363 | (if focused? (c/th :focus) (c/th :border)) |
| 278 | 364 | (if focused? 2.0 (c/th :border-width))) |
| 279 | 365 | (let [[_ th*] (c/measure (if (= s' "") "M" s') size) |
| 280 | - ty (+ ry (/ (- rh th*) 2.0))] | |
| 281 | - (if (and (= s' "") placeholder (not focused?)) | |
| 282 | - (c/draw-text! placeholder (+ rx pad) ty size (c/th :text-dim)) | |
| 283 | - (c/draw-text! s' (+ rx pad) ty size (c/th :text))) | |
| 366 | + ty (+ ry (/ (- rh th*) 2.0)) | |
| 367 | + inner (- rw (* 2 pad)) | |
| 368 | + ;; How far the text is slid left so the caret stays in view. A | |
| 369 | + ;; field narrower than its contents is the ordinary case — a | |
| 370 | + ;; handle, a URL, a password — and without this the text simply | |
| 371 | + ;; runs out past the border and over whatever is beside it. | |
| 372 | + caret-x (first (c/measure (subs s' 0 caret') size)) | |
| 373 | + shift (max 0.0 (- caret-x inner))] | |
| 374 | + (c/with-clip [(+ rx pad) ry inner rh] | |
| 375 | + (fn [] | |
| 376 | + (if (and (= s' "") placeholder (not focused?)) | |
| 377 | + (c/draw-text! placeholder (+ rx pad) ty size (c/th :text-dim)) | |
| 378 | + (c/draw-text! s' (- (+ rx pad) shift) ty size (c/th :text))))) | |
| 379 | + ;; The caret rides the same shift, and inside the same clip: a caret | |
| 380 | + ;; drawn at the untranslated offset sits past the border on a full | |
| 381 | + ;; field, pointing at where the text would have been. | |
| 284 | 382 | (when focused? |
| 285 | - (let [[cw _] (c/measure (subs s' 0 caret') size)] | |
| 286 | - (c/fill! [(+ rx pad cw) (+ ty 1.0) 1.5 (- th* 2.0)] (c/th :text))))) | |
| 383 | + (c/with-clip [(+ rx pad) ry inner rh] | |
| 384 | + (fn [] | |
| 385 | + (c/fill! [(- (+ rx pad caret-x) shift) (+ ty 1.0) 1.5 (- th* 2.0)] | |
| 386 | + (c/th :text)))))) | |
| 287 | 387 | s'))) |
| 288 | 388 | |
| 289 | 389 | ;; ------------------------------------------------------------------- scroll |
| 290 | 390 | |
| 391 | +(defonce ^:private scroll-areas | |
| 392 | + ;; Offsets kept by NAME, outliving the node that showed them. | |
| 393 | + ;; | |
| 394 | + ;; A widget id is a position in this frame's tree, so two lists that are | |
| 395 | + ;; never on screen together — one channel's messages and another's — | |
| 396 | + ;; share one id and therefore one offset, and switching between them | |
| 397 | + ;; carries the wrong scroll across. `:scroll-key` names the area instead, | |
| 398 | + ;; and the name outlives the node. | |
| 399 | + (atom {})) | |
| 400 | + | |
| 401 | +(defn scroll-offset | |
| 402 | + "How far the area named `k` is scrolled, or nil if it has never shown. | |
| 403 | + | |
| 404 | + Public because it is the only way to ask a viewport what it did — the | |
| 405 | + widget answers its rectangle, not its state — and a caller restoring a | |
| 406 | + position, or a test asking whether a list actually followed its | |
| 407 | + content, has nowhere else to look." | |
| 408 | + [k] | |
| 409 | + (:offset (get @scroll-areas k))) | |
| 410 | + | |
| 291 | 411 | (defn scroll* |
| 292 | 412 | "A clipped viewport that scrolls its contents vertically. |
| 293 | 413 | |
| 294 | - The viewport reports the height it was given, not the height of what is in | |
| 295 | - it; the difference between the two is what there is to scroll, which is why | |
| 296 | - `core/box*` keeps both numbers." | |
| 414 | + The viewport reports the height it was given, not the height of what is | |
| 415 | + in it; the difference between the two is what there is to scroll, which | |
| 416 | + is why `core/box*` keeps both numbers. | |
| 417 | + | |
| 418 | + opts beyond `core/box*`'s: | |
| 419 | + | |
| 420 | + :height the viewport's own height | |
| 421 | + :scroll-key a name for the offset, so it survives the node | |
| 422 | + :reserve leave this much of the available height behind | |
| 423 | + :stick-to-bottom follow new content while the reader is at the end | |
| 424 | + :scroll-to-bottom a number the caller BUMPS to ask for a jump | |
| 425 | + :on-at-end called with true/false as the reader arrives at or | |
| 426 | + leaves the end" | |
| 297 | 427 | [opts body] |
| 298 | - (let [h (double (or (:height opts) 200.0)) | |
| 428 | + (let [{:keys [scroll-key reserve stick-to-bottom scroll-to-bottom on-at-end]} opts | |
| 299 | 429 | id (c/next-id (:key opts)) |
| 430 | + area (or scroll-key id) | |
| 300 | 431 | prev (c/data id) |
| 432 | + ;; A viewport with no height of its own FILLS what is left rather | |
| 433 | + ;; than ASKING for it. Asking is what it did first — a minimum of | |
| 434 | + ;; the whole column — and then the column had nothing left for | |
| 435 | + ;; the separator and compose bar under it, which went off the | |
| 436 | + ;; bottom of the window. | |
| 437 | + ;; | |
| 438 | + ;; So the height is the one it was GIVEN last frame, and it | |
| 439 | + ;; reports a minimum of nothing. Two hundred is the answer on the | |
| 440 | + ;; first frame only, before it has been given anything. | |
| 441 | + given (nth (:rect prev) 3 nil) | |
| 442 | + h (double (cond | |
| 443 | + (:height opts) (:height opts) | |
| 444 | + (and given (pos? (double given))) | |
| 445 | + (max 0.0 (- (double given) (double (or reserve 0.0)))) | |
| 446 | + :else 200.0)) | |
| 301 | 447 | content-h (second (or (:content-min prev) [0.0 0.0])) |
| 302 | 448 | view (or (:rect prev) [0.0 0.0 0.0 0.0]) |
| 303 | 449 | maxoff (max 0.0 (- content-h h)) |
| 304 | - off0 (min maxoff (max 0.0 (c/state id :scroll 0.0))) | |
| 305 | - off (reduce (fn [o e] (min maxoff (max 0.0 (- o (* 40.0 (:dy e)))))) | |
| 306 | - off0 (or (c/wheel-events view) [])) | |
| 450 | + st (get @scroll-areas area {}) | |
| 451 | + off0 (min maxoff (max 0.0 (or (:offset st) 0.0))) | |
| 452 | + wheel (or (c/wheel-events view) []) | |
| 453 | + ;; A jump is asked for by CHANGING the number, not by setting it: | |
| 454 | + ;; a caller that wanted to jump twice to the same place would | |
| 455 | + ;; otherwise have nothing to say the second time. | |
| 456 | + jump? (and scroll-to-bottom | |
| 457 | + (pos? (double scroll-to-bottom)) | |
| 458 | + (not= scroll-to-bottom (:jumped st))) | |
| 459 | + ;; The wheel is applied FIRST, and a wheel that moved the list | |
| 460 | + ;; breaks the stick for that frame. Deciding to stick before | |
| 461 | + ;; reading it pins the offset at the end every frame and the | |
| 462 | + ;; reader can never scroll up at all — they drag and it snaps | |
| 463 | + ;; back, which reads as the window being broken. | |
| 464 | + wheeled (reduce (fn [o e] (min maxoff (max 0.0 (- o (* 40.0 (:dy e)))))) | |
| 465 | + off0 wheel) | |
| 466 | + moved? (not= wheeled off0) | |
| 467 | + ;; Sticking and jumping are the same control pulling opposite | |
| 468 | + ;; ways. Sticking wins over a jump, so a jump to an old message | |
| 469 | + ;; is not snatched back by the next arrival — but a hand on the | |
| 470 | + ;; wheel wins over both. | |
| 471 | + stick? (and stick-to-bottom (not jump?) (not moved?) (:at-end st true)) | |
| 472 | + off (cond | |
| 473 | + jump? maxoff | |
| 474 | + stick? maxoff | |
| 475 | + :else wheeled) | |
| 476 | + ;; The gutter the bar is drawn in, taken off the content whether or | |
| 477 | + ;; not there is anything to scroll yet. Always, because the | |
| 478 | + ;; alternative is that a list reflows the moment it grows past the | |
| 479 | + ;; window — every line rewrapping under the reader as the message | |
| 480 | + ;; that overflowed arrives. And taken at all because the bar is | |
| 481 | + ;; painted over the viewport's right edge: without it the last | |
| 482 | + ;; characters of every wrapped line sit under the bar, which is | |
| 483 | + ;; where "the sidebar truncates" comes from. | |
| 484 | + ;; The bar's own width and a gap beside it. Reserving the bar alone | |
| 485 | + ;; leaves the wrap boundary exactly at its left edge, so a line ends | |
| 486 | + ;; flush against it — legible, but it reads as text running into the | |
| 487 | + ;; bar, and a glyph whose drawn width runs a hair past its measured | |
| 488 | + ;; one touches it. A gap is what puts the last word beside the bar | |
| 489 | + ;; rather than on it. | |
| 490 | + gutter (+ (double (c/th :scrollbar)) (double (c/th :spacing))) | |
| 307 | 491 | r (c/box* (merge {:dir :vertical |
| 308 | - :expand :horizontal | |
| 309 | - :min-size [0.0 h] | |
| 492 | + :expand :both | |
| 493 | + :pad-right gutter | |
| 494 | + ;; An explicit :height IS a demand and is | |
| 495 | + ;; reported as one. Without it the viewport | |
| 496 | + ;; fills what is left and asks for nothing — | |
| 497 | + ;; asking is what left the compose bar off the | |
| 498 | + ;; bottom of the window. | |
| 499 | + :min-size [0.0 (if (:height opts) h 0.0)] | |
| 310 | 500 | :fixed true |
| 311 | 501 | :clip? true |
| 312 | 502 | :spacing (c/th :spacing) |
| 313 | 503 | :key (:key opts) |
| 314 | 504 | :offset [0.0 (- off)]} |
| 315 | - (dissoc opts :height)) | |
| 316 | - body)] | |
| 317 | - (c/state! id :scroll off) | |
| 318 | - ;; The bar is drawn after the body so it sits over it, and only when there | |
| 319 | - ;; is something to scroll. | |
| 505 | + (dissoc opts :height :scroll-key :reserve :stick-to-bottom | |
| 506 | + :scroll-to-bottom :on-at-end)) | |
| 507 | + body) | |
| 508 | + ;; Within a couple of lines of the end counts as at it: a reader | |
| 509 | + ;; who has not moved should not stop being followed because the | |
| 510 | + ;; last message was a pixel taller than the one before. | |
| 511 | + at-end? (>= off (- maxoff 24.0)) | |
| 512 | + ;; Arriving at the end is reported at once; leaving it has to hold | |
| 513 | + ;; for a few frames. A burst of messages grows the content faster | |
| 514 | + ;; than the offset follows, and reporting that honestly would | |
| 515 | + ;; blink "scrolled away" whenever a channel is busy. | |
| 516 | + away (if at-end? 0 (inc (or (:away st) 0))) | |
| 517 | + settled (cond at-end? true (>= away 3) false :else nil) | |
| 518 | + was (:reported st)] | |
| 519 | + (swap! scroll-areas assoc area | |
| 520 | + (cond-> (assoc st :offset off :at-end at-end? :away away) | |
| 521 | + jump? (assoc :jumped scroll-to-bottom) | |
| 522 | + (some? settled) (assoc :reported settled))) | |
| 523 | + ;; Not on the first report: the opening one arrives before the content | |
| 524 | + ;; has a height, and every list would announce itself as at its end. | |
| 525 | + (when (and on-at-end (some? settled) (some? was) (not= was settled)) | |
| 526 | + (on-at-end settled)) | |
| 320 | 527 | (when (pos? maxoff) |
| 321 | 528 | (let [[vx vy vw vh] (or (:rect (c/data id)) view) |
| 322 | 529 | bw (double (c/th :scrollbar)) |
| @@ -327,3 +534,188 @@ | ||
| 327 | 534 | r)) |
| 328 | 535 | |
| 329 | 536 | (defmacro scroll [opts & body] `(scroll* ~opts (fn [~'_id ~'_rect] ~@body))) |
| 537 | + | |
| 538 | +(defn- picture | |
| 539 | + "The texture for either kind of source: a live `feed` or a file `src`." | |
| 540 | + [{:keys [feed src]}] | |
| 541 | + (cond feed (frames/lookup feed) | |
| 542 | + src (frames/from-file src))) | |
| 543 | + | |
| 544 | +(defn image | |
| 545 | + "A picture: live pixels under `:feed`, or a file at `:src`. | |
| 546 | + | |
| 547 | + One widget and not two, because everything downstream — the fit, the | |
| 548 | + bounds, the click — is the same for both. That is libvidya's arrangement | |
| 549 | + too, and frq writes [:image {:feed k}] for a call tile and | |
| 550 | + [:image {:src p}] for an attachment. | |
| 551 | + | |
| 552 | + `:fit` gives it every point of the space it has been handed; otherwise it | |
| 553 | + asks for its own size, bounded by `:max-width` and `:max-height`. Either | |
| 554 | + way the picture keeps its shape — a 16:9 camera in a square tile is the | |
| 555 | + ordinary case, and stretching it is the one thing nobody wants. | |
| 556 | + | |
| 557 | + A source with no picture yet — a feed before its first frame, a file that | |
| 558 | + will not decode — draws the empty tile and nothing else. Not a | |
| 559 | + broken-image glyph: the text beside it already says what it was meant to | |
| 560 | + be, and a tile that appears only once a frame lands rearranges the wall | |
| 561 | + under the person every time somebody joins." | |
| 562 | + ([opts] (image opts {})) | |
| 563 | + ([{:keys [feed src] :as source} | |
| 564 | + {:keys [fit max-width max-height size expand gravity placeholder?] | |
| 565 | + :or {gravity [0.5 0.5] placeholder? true}}] | |
| 566 | + (let [t (picture source) | |
| 567 | + [tw th*] (if t [(:w t) (:h t)] [0 0]) | |
| 568 | + want (cond | |
| 569 | + size size | |
| 570 | + (and fit) [320.0 180.0] | |
| 571 | + (pos? tw) | |
| 572 | + (let [k (min (if max-width (/ (double max-width) tw) 1.0) | |
| 573 | + (if max-height (/ (double max-height) th*) 1.0))] | |
| 574 | + [(* tw k) (* th* k)]) | |
| 575 | + :else [(double (or max-width 160)) (double (or max-height 90))]) | |
| 576 | + rect (c/leaf (mapv double want) | |
| 577 | + (or expand (if fit :both :none)) | |
| 578 | + gravity)] | |
| 579 | + (when placeholder? (c/fill! rect (c/th :surface-alt) 6.0)) | |
| 580 | + (when (and t (:tex t)) (c/draw-picture! (:tex t) (:w t) (:h t) rect)) | |
| 581 | + rect))) | |
| 582 | + | |
| 583 | +(defn title-2 | |
| 584 | + "A second-level heading — smaller than `title`, still bold." | |
| 585 | + [s] | |
| 586 | + (label s {:size (* 1.15 (c/th :font-size)) :colour (c/th :text)})) | |
| 587 | + | |
| 588 | +(defn status | |
| 589 | + "A line of text with a dot in front of it saying whether the thing is live." | |
| 590 | + ([s] (status s false)) | |
| 591 | + ([s live?] | |
| 592 | + (c/box* {:dir :horizontal :spacing 6 :gravity [0.0 0.5]} | |
| 593 | + (fn [_ _] | |
| 594 | + (let [line (c/th :font-size) | |
| 595 | + d (max 7.0 (min 12.0 (* line 0.55))) | |
| 596 | + r (c/leaf [(+ d 4.0) line] :none [0.0 0.5]) | |
| 597 | + [x y w h] r] | |
| 598 | + (c/fill! [(+ x 2.0) (+ y (/ (- h d) 2.0)) d d] | |
| 599 | + (if live? (c/th :accent) (c/th :text-dim)) | |
| 600 | + (/ d 2.0))) | |
| 601 | + (label s))))) | |
| 602 | + | |
| 603 | +(defn spinner | |
| 604 | + "A turning mark, and a word beside it if there is one. | |
| 605 | + | |
| 606 | + Turned from the wall clock rather than a frame counter: a loop that | |
| 607 | + dropped frames would otherwise show a spinner that stutters in a way that | |
| 608 | + reads as the work having stalled, which is the one thing it is there to | |
| 609 | + deny." | |
| 610 | + ([] (spinner "")) | |
| 611 | + ([s] | |
| 612 | + (c/box* {:dir :horizontal :spacing 6 :gravity [0.0 0.5]} | |
| 613 | + (fn [_ _] | |
| 614 | + (let [line (c/th :font-size) | |
| 615 | + r (c/leaf [line line] :none [0.0 0.5]) | |
| 616 | + [x y w h] r | |
| 617 | + cx (+ x (/ w 2.0)) cy (+ y (/ h 2.0)) | |
| 618 | + rad (* 0.4 (min w h)) | |
| 619 | + t (/ (double (mod (System/currentTimeMillis) 1000)) 1000.0) | |
| 620 | + a (* t 2.0 Math/PI)] | |
| 621 | + ;; Three ticks around a circle: enough to read as turning, and no | |
| 622 | + ;; arc primitive needed. | |
| 623 | + (dotimes [i 3] | |
| 624 | + (let [th* (+ a (* i (/ (* 2.0 Math/PI) 3.0)))] | |
| 625 | + (c/fill! [(+ cx (* rad (Math/cos th*)) -1.5) | |
| 626 | + (+ cy (* rad (Math/sin th*)) -1.5) 3.0 3.0] | |
| 627 | + (c/th :accent) 1.5)))) | |
| 628 | + (when (seq s) (label s)))))) | |
| 629 | + | |
| 630 | +(defn link | |
| 631 | + "Text that is a place to go. Answers true on the frame it was clicked." | |
| 632 | + ([s] (link s {})) | |
| 633 | + ([s {:keys [key]}] | |
| 634 | + (let [id (c/next-id key) | |
| 635 | + rect (label s {:colour (c/th :accent)})] | |
| 636 | + (:clicked? (c/interact! id rect))))) | |
| 637 | + | |
| 638 | +(defn emoji | |
| 639 | + "One emoji, drawn as a character and sized to sit level with the words | |
| 640 | + either side. | |
| 641 | + | |
| 642 | + In colour where there is a colour face to draw it from, and as text | |
| 643 | + where there is not. | |
| 644 | + | |
| 645 | + The two paths differ in more than the picture: a colour emoji face is a | |
| 646 | + bitmap with one 128-pixel strike, so it cannot be a fallback font and is | |
| 647 | + drawn as an image scaled into the line — which means the glyph occupies a | |
| 648 | + SQUARE of the size asked for, decided here rather than measured. The text | |
| 649 | + path stays exactly what it was, a label in the UI font, and is what a | |
| 650 | + machine with no colour face gets." | |
| 651 | + ([s] (emoji s nil)) | |
| 652 | + ([s size] | |
| 653 | + (let [sz (double (or size (c/th :font-size)))] | |
| 654 | + (if (c/colour-emoji? s) | |
| 655 | + (let [rect (c/leaf [sz sz] :none [0.5 0.5])] | |
| 656 | + (c/draw-emoji! s rect) | |
| 657 | + rect) | |
| 658 | + (label s {:size sz}))))) | |
| 659 | + | |
| 660 | +(defn avatar | |
| 661 | + "A round picture for somebody, or their initial on a colour if there is | |
| 662 | + none. | |
| 663 | + | |
| 664 | + The fallback is not a placeholder to be replaced later — most people in | |
| 665 | + most rooms have no picture, so the initial IS the avatar, and its colour | |
| 666 | + comes from the name so the same person is the same colour everywhere." | |
| 667 | + ([nick] (avatar nick {})) | |
| 668 | + ([nick {:keys [src size] :or {size 24.0}}] | |
| 669 | + (let [rect (c/leaf [size size] :none [0.5 0.5]) | |
| 670 | + t (when src (frames/from-file src))] | |
| 671 | + (if (and t (:tex t)) | |
| 672 | + (c/draw-picture! (:tex t) (:w t) (:h t) rect) | |
| 673 | + (let [name (str nick) | |
| 674 | + bare (str/replace name #"^[#&@+%~]+" "") | |
| 675 | + initial (if (seq bare) (str/upper-case (subs bare 0 1)) "?") | |
| 676 | + [x y w h] rect] | |
| 677 | + (c/fill! rect (c/name-colour name) (/ size 2.0)) | |
| 678 | + (let [sz (* size 0.5) | |
| 679 | + [tw th*] (c/measure initial sz)] | |
| 680 | + (c/draw-text! initial (+ x (/ (- w tw) 2.0)) (+ y (/ (- h th*) 2.0)) | |
| 681 | + sz (c/th :accent-text))))) | |
| 682 | + rect))) | |
| 683 | + | |
| 684 | +(defn reaction | |
| 685 | + "A tally wearing a pill: an emoji, how many people, and whether you are one | |
| 686 | + of them. | |
| 687 | + | |
| 688 | + The same glyph `emoji` draws, and the same two ways of drawing it: a | |
| 689 | + colour face gives a square picture, and everything else is text. So the | |
| 690 | + glyph and the tally are measured and drawn apart rather than as one | |
| 691 | + string — a picture cannot be concatenated onto a number. | |
| 692 | + | |
| 693 | + A reaction is not a character otherwise either: it answers the pointer | |
| 694 | + and it is a count. `mine?` is bordered rather than filled differently, | |
| 695 | + because the pill has to stay readable at the size a line of them ends | |
| 696 | + up." | |
| 697 | + ([glyph] (reaction glyph {})) | |
| 698 | + ([glyph {:keys [count mine? size key] | |
| 699 | + :or {count 0 mine? false}}] | |
| 700 | + (let [sz (double (or size (c/th :font-size))) | |
| 701 | + colour? (c/colour-emoji? (str glyph)) | |
| 702 | + tally (when (pos? count) (str " " count)) | |
| 703 | + [gw gh] (if colour? [sz sz] (c/measure (str glyph) sz)) | |
| 704 | + [cw ch] (if tally (c/measure tally sz) [0.0 0.0]) | |
| 705 | + tw (+ (double gw) (double cw)) | |
| 706 | + th* (max (double gh) (double ch)) | |
| 707 | + pad 6.0 | |
| 708 | + rect (c/leaf [(+ tw (* 2 pad)) (+ th* 4.0)] :none [0.0 0.5]) | |
| 709 | + id (c/next-id key) | |
| 710 | + [x y w h] rect] | |
| 711 | + (c/fill! rect (if mine? (c/th :press) (c/th :surface-alt)) | |
| 712 | + (/ h 2.0) | |
| 713 | + (when mine? (c/th :accent)) (if mine? 1.0 0.0)) | |
| 714 | + (if colour? | |
| 715 | + (c/draw-emoji! (str glyph) [(+ x pad) (+ y (/ (- h sz) 2.0)) sz sz]) | |
| 716 | + (c/draw-text! (str glyph) (+ x pad) (+ y (/ (- h (double gh)) 2.0)) | |
| 717 | + sz (c/th :text))) | |
| 718 | + (when tally | |
| 719 | + (c/draw-text! tally (+ x pad (double gw)) (+ y (/ (- h (double ch)) 2.0)) | |
| 720 | + sz (c/th :text))) | |
| 721 | + (:clicked? (c/interact! id rect))))) | |
| @@ -13,7 +13,9 @@ | |||
| 13 | closure. They are thin: each is `core/box*` with a different set of defaults | 13 | closure. They are thin: each is `core/box*` with a different set of defaults |
| 14 | from the theme." | 14 | from the theme." |
| 15 | (:require [jvui.core :as c] | 15 | (:require [jvui.core :as c] |
| 16 | - [jvui.theme :as theme])) | 16 | + [jvui.theme :as theme] |
| 17 | + [jvui.frames :as frames] | ||
| 18 | + [clojure.string :as str])) | ||
| 17 | 19 | ||
| 18 | ;; -------------------------------------------------------------- containers | 20 | ;; -------------------------------------------------------------- containers |
| 19 | 21 | ||
| @@ -64,22 +66,89 @@ | |||
| 64 | 66 | ||
| 65 | ;; -------------------------------------------------------------------- text | 67 | ;; -------------------------------------------------------------------- text |
| 66 | 68 | ||
| 69 | +(defn- break-word | ||
| 70 | + "Cut a word too long for `width` into pieces that fit. | ||
| 71 | + | ||
| 72 | + Character-wise and greedy. Only ever reached for a word that would not | ||
| 73 | + fit on a line of its own — an ordinary sentence never comes here." | ||
| 74 | + [w size width] | ||
| 75 | + (loop [rest* w out []] | ||
| 76 | + (if (or (empty? rest*) (<= (first (c/measure rest* size)) width)) | ||
| 77 | + (if (seq rest*) (conj out rest*) out) | ||
| 78 | + (let [n (loop [i 1] | ||
| 79 | + (cond (>= i (count rest*)) (count rest*) | ||
| 80 | + (> (first (c/measure (subs rest* 0 i) size)) width) (max 1 (dec i)) | ||
| 81 | + :else (recur (inc i))))] | ||
| 82 | + (recur (subs rest* n) (conj out (subs rest* 0 n))))))) | ||
| 83 | + | ||
| 84 | +(defn- wrap-lines | ||
| 85 | + "Break `s` into lines that each fit `width`, on spaces. | ||
| 86 | + | ||
| 87 | + Greedy and word-wise, and a word that cannot fit on a line of its own | ||
| 88 | + is broken at a character. That second half was left out first time | ||
| 89 | + round on the grounds that a URL cut in half reads worse than one that | ||
| 90 | + overflows — which was wrong, and the sign-in screen showed why: an | ||
| 91 | + OAuth login URL is one unbreakable word, so it made its container | ||
| 92 | + wider than the window and pushed the whole page off both edges. A | ||
| 93 | + broken URL is worse than an unbroken one; a window you cannot read is | ||
| 94 | + worse than both. | ||
| 95 | + | ||
| 96 | + Measured through the same `c/measure` the drawing uses, so a line that | ||
| 97 | + is said to fit does fit — a wrap computed against a different metric | ||
| 98 | + than the renderer's is off by a word at the worst moments." | ||
| 99 | + [s size width] | ||
| 100 | + (let [words (mapcat (fn [w] | ||
| 101 | + (if (> (first (c/measure w size)) width) | ||
| 102 | + (break-word w size width) | ||
| 103 | + [w])) | ||
| 104 | + (str/split (str s) #" "))] | ||
| 105 | + (loop [[w & more] words line nil out []] | ||
| 106 | + (cond | ||
| 107 | + (nil? w) (if line (conj out line) out) | ||
| 108 | + (nil? line) (recur more w out) | ||
| 109 | + :else | ||
| 110 | + (let [try* (str line " " w)] | ||
| 111 | + (if (<= (first (c/measure try* size)) width) | ||
| 112 | + (recur more try* out) | ||
| 113 | + (recur more w (conj out line)))))))) | ||
| 114 | + | ||
| 67 | (defn label | 115 | (defn label |
| 68 | - "A line of text. Answers its rectangle." | 116 | + "A line of text, wrapped to the space it was given. Answers its rectangle. |
| 117 | + | ||
| 118 | + Wrapping is on when the text does not fit and the box has told us how | ||
| 119 | + much room there is. That is not a style choice: without it one long | ||
| 120 | + sentence makes its container wider than the window, and a page that | ||
| 121 | + centres its content then hangs off BOTH edges — which is exactly what | ||
| 122 | + frq's sign-in screen did the first time it was painted here. | ||
| 123 | + | ||
| 124 | + `:wrap false` turns it off for a caller that would rather overflow, and | ||
| 125 | + a width of zero — the first frame, before any box knows its size — is | ||
| 126 | + read as \"not yet\" rather than \"no room\"." | ||
| 69 | ([s] (label s {})) | 127 | ([s] (label s {})) |
| 70 | - ([s {:keys [size colour expand gravity align] | 128 | + ([s {:keys [size colour expand gravity align wrap] |
| 71 | - :or {expand :horizontal gravity [0.0 0.5] align :left}}] | 129 | + :or {expand :horizontal gravity [0.0 0.5] align :left wrap true}}] |
| 72 | (let [size (or size (c/th :font-size)) | 130 | (let [size (or size (c/th :font-size)) |
| 73 | colour (or colour (c/th :text)) | 131 | colour (or colour (c/th :text)) |
| 74 | - [tw th*] (c/measure s size) | 132 | + [tw0 th0] (c/measure s size) |
| 75 | - rect (c/leaf [(double tw) (double (max th* (c/line-height size)))] | 133 | + avail (c/avail-width) |
| 134 | + lines (if (and wrap (pos? avail) (> tw0 avail)) | ||
| 135 | + (wrap-lines s size avail) | ||
| 136 | + [(str s)]) | ||
| 137 | + many? (> (count lines) 1) | ||
| 138 | + widths (mapv #(first (c/measure % size)) lines) | ||
| 139 | + tw (if many? (reduce max 0.0 widths) tw0) | ||
| 140 | + lh (max th0 (c/line-height size)) | ||
| 141 | + rect (c/leaf [(double tw) (double (* lh (count lines)))] | ||
| 76 | expand gravity) | 142 | expand gravity) |
| 77 | - [x y w h] rect | 143 | + [x y w h] rect] |
| 78 | - tx (case align | 144 | + (dotimes [i (count lines)] |
| 79 | - :center (+ x (/ (- w tw) 2.0)) | 145 | + (let [ln (nth lines i) |
| 80 | - :right (+ x (- w tw)) | 146 | + lw (nth widths i) |
| 81 | - x)] | 147 | + tx (case align |
| 82 | - (c/draw-text! s tx (+ y (/ (- h th*) 2.0)) size colour) | 148 | + :center (+ x (/ (- w lw) 2.0)) |
| 149 | + :right (+ x (- w lw)) | ||
| 150 | + x)] | ||
| 151 | + (c/draw-text! ln tx (+ y (* i lh) (/ (- lh th0) 2.0)) size colour))) | ||
| 83 | rect))) | 152 | rect))) |
| 84 | 153 | ||
| 85 | (defn title [s & [opts]] | 154 | (defn title [s & [opts]] |
| @@ -231,18 +300,28 @@ | |||
| 231 | 300 | ||
| 232 | (defn- clamp [v lo hi] (max lo (min hi v))) | 301 | (defn- clamp [v lo hi] (max lo (min hi v))) |
| 233 | 302 | ||
| 303 | +(defn entry-activated? | ||
| 304 | + "Did the field under `id` see Enter on the frame just walked? | ||
| 305 | + | ||
| 306 | + Asked after `text-entry`, which records it. Enter is the one key a text | ||
| 307 | + field must NOT treat as input — the client sends on it — and a widget | ||
| 308 | + that answered only its text gave a caller no way to know." | ||
| 309 | + [id] | ||
| 310 | + (boolean (c/state id :activated false))) | ||
| 311 | + | ||
| 234 | (defn text-entry | 312 | (defn text-entry |
| 235 | "A single-line editable string. Answers the text after this frame. | 313 | "A single-line editable string. Answers the text after this frame. |
| 236 | 314 | ||
| 237 | The caret is an index into the string kept under the widget's id, which is | 315 | The caret is an index into the string kept under the widget's id, which is |
| 238 | the one piece of state a text field cannot recompute from its value." | 316 | the one piece of state a text field cannot recompute from its value." |
| 239 | ([value] (text-entry value {})) | 317 | ([value] (text-entry value {})) |
| 240 | - ([value {:keys [key expand placeholder] :or {expand :horizontal}}] | 318 | + ([value {:keys [key expand placeholder min-width] |
| 319 | + :or {expand :horizontal}}] | ||
| 241 | (let [size (c/th :font-size) | 320 | (let [size (c/th :font-size) |
| 242 | pad (c/th :padding) | 321 | pad (c/th :padding) |
| 243 | h (double (c/th :control-height)) | 322 | h (double (c/th :control-height)) |
| 244 | id (c/next-id key) | 323 | id (c/next-id key) |
| 245 | - rect (c/leaf [160.0 h] expand [0.0 0.5]) | 324 | + rect (c/leaf [(double (or min-width 160.0)) h] expand [0.0 0.5]) |
| 246 | {:keys [hover? focused?]} (c/interact! id rect) | 325 | {:keys [hover? focused?]} (c/interact! id rect) |
| 247 | [rx ry rw rh] rect | 326 | [rx ry rw rh] rect |
| 248 | s (str value) | 327 | s (str value) |
| @@ -273,50 +352,178 @@ | |||
| 273 | [s caret0] (or evs [])) | 352 | [s caret0] (or evs [])) |
| 274 | caret' (clamp caret' 0 (count s'))] | 353 | caret' (clamp caret' 0 (count s'))] |
| 275 | (c/state! id :caret caret') | 354 | (c/state! id :caret caret') |
| 355 | + ;; Enter is not an edit and must not be swallowed as one: a client | ||
| 356 | + ;; sends its message on it. Recorded as state rather than returned, | ||
| 357 | + ;; because `text-entry` already answers the text and a second return | ||
| 358 | + ;; value would change every existing call. | ||
| 359 | + (c/state! id :activated | ||
| 360 | + (boolean (some #(and (= :key-down (:kind %)) (= :return (:key %))) | ||
| 361 | + (or evs [])))) | ||
| 276 | (c/fill! rect (c/th :surface) (c/th :radius) | 362 | (c/fill! rect (c/th :surface) (c/th :radius) |
| 277 | (if focused? (c/th :focus) (c/th :border)) | 363 | (if focused? (c/th :focus) (c/th :border)) |
| 278 | (if focused? 2.0 (c/th :border-width))) | 364 | (if focused? 2.0 (c/th :border-width))) |
| 279 | (let [[_ th*] (c/measure (if (= s' "") "M" s') size) | 365 | (let [[_ th*] (c/measure (if (= s' "") "M" s') size) |
| 280 | - ty (+ ry (/ (- rh th*) 2.0))] | 366 | + ty (+ ry (/ (- rh th*) 2.0)) |
| 281 | - (if (and (= s' "") placeholder (not focused?)) | 367 | + inner (- rw (* 2 pad)) |
| 282 | - (c/draw-text! placeholder (+ rx pad) ty size (c/th :text-dim)) | 368 | + ;; How far the text is slid left so the caret stays in view. A |
| 283 | - (c/draw-text! s' (+ rx pad) ty size (c/th :text))) | 369 | + ;; field narrower than its contents is the ordinary case — a |
| 370 | + ;; handle, a URL, a password — and without this the text simply | ||
| 371 | + ;; runs out past the border and over whatever is beside it. | ||
| 372 | + caret-x (first (c/measure (subs s' 0 caret') size)) | ||
| 373 | + shift (max 0.0 (- caret-x inner))] | ||
| 374 | + (c/with-clip [(+ rx pad) ry inner rh] | ||
| 375 | + (fn [] | ||
| 376 | + (if (and (= s' "") placeholder (not focused?)) | ||
| 377 | + (c/draw-text! placeholder (+ rx pad) ty size (c/th :text-dim)) | ||
| 378 | + (c/draw-text! s' (- (+ rx pad) shift) ty size (c/th :text))))) | ||
| 379 | + ;; The caret rides the same shift, and inside the same clip: a caret | ||
| 380 | + ;; drawn at the untranslated offset sits past the border on a full | ||
| 381 | + ;; field, pointing at where the text would have been. | ||
| 284 | (when focused? | 382 | (when focused? |
| 285 | - (let [[cw _] (c/measure (subs s' 0 caret') size)] | 383 | + (c/with-clip [(+ rx pad) ry inner rh] |
| 286 | - (c/fill! [(+ rx pad cw) (+ ty 1.0) 1.5 (- th* 2.0)] (c/th :text))))) | 384 | + (fn [] |
| 385 | + (c/fill! [(- (+ rx pad caret-x) shift) (+ ty 1.0) 1.5 (- th* 2.0)] | ||
| 386 | + (c/th :text)))))) | ||
| 287 | s'))) | 387 | s'))) |
| 288 | 388 | ||
| 289 | ;; ------------------------------------------------------------------- scroll | 389 | ;; ------------------------------------------------------------------- scroll |
| 290 | 390 | ||
| 391 | +(defonce ^:private scroll-areas | ||
| 392 | + ;; Offsets kept by NAME, outliving the node that showed them. | ||
| 393 | + ;; | ||
| 394 | + ;; A widget id is a position in this frame's tree, so two lists that are | ||
| 395 | + ;; never on screen together — one channel's messages and another's — | ||
| 396 | + ;; share one id and therefore one offset, and switching between them | ||
| 397 | + ;; carries the wrong scroll across. `:scroll-key` names the area instead, | ||
| 398 | + ;; and the name outlives the node. | ||
| 399 | + (atom {})) | ||
| 400 | + | ||
| 401 | +(defn scroll-offset | ||
| 402 | + "How far the area named `k` is scrolled, or nil if it has never shown. | ||
| 403 | + | ||
| 404 | + Public because it is the only way to ask a viewport what it did — the | ||
| 405 | + widget answers its rectangle, not its state — and a caller restoring a | ||
| 406 | + position, or a test asking whether a list actually followed its | ||
| 407 | + content, has nowhere else to look." | ||
| 408 | + [k] | ||
| 409 | + (:offset (get @scroll-areas k))) | ||
| 410 | + | ||
| 291 | (defn scroll* | 411 | (defn scroll* |
| 292 | "A clipped viewport that scrolls its contents vertically. | 412 | "A clipped viewport that scrolls its contents vertically. |
| 293 | 413 | ||
| 294 | - The viewport reports the height it was given, not the height of what is in | 414 | + The viewport reports the height it was given, not the height of what is |
| 295 | - it; the difference between the two is what there is to scroll, which is why | 415 | + in it; the difference between the two is what there is to scroll, which |
| 296 | - `core/box*` keeps both numbers." | 416 | + is why `core/box*` keeps both numbers. |
| 417 | + | ||
| 418 | + opts beyond `core/box*`'s: | ||
| 419 | + | ||
| 420 | + :height the viewport's own height | ||
| 421 | + :scroll-key a name for the offset, so it survives the node | ||
| 422 | + :reserve leave this much of the available height behind | ||
| 423 | + :stick-to-bottom follow new content while the reader is at the end | ||
| 424 | + :scroll-to-bottom a number the caller BUMPS to ask for a jump | ||
| 425 | + :on-at-end called with true/false as the reader arrives at or | ||
| 426 | + leaves the end" | ||
| 297 | [opts body] | 427 | [opts body] |
| 298 | - (let [h (double (or (:height opts) 200.0)) | 428 | + (let [{:keys [scroll-key reserve stick-to-bottom scroll-to-bottom on-at-end]} opts |
| 299 | id (c/next-id (:key opts)) | 429 | id (c/next-id (:key opts)) |
| 430 | + area (or scroll-key id) | ||
| 300 | prev (c/data id) | 431 | prev (c/data id) |
| 432 | + ;; A viewport with no height of its own FILLS what is left rather | ||
| 433 | + ;; than ASKING for it. Asking is what it did first — a minimum of | ||
| 434 | + ;; the whole column — and then the column had nothing left for | ||
| 435 | + ;; the separator and compose bar under it, which went off the | ||
| 436 | + ;; bottom of the window. | ||
| 437 | + ;; | ||
| 438 | + ;; So the height is the one it was GIVEN last frame, and it | ||
| 439 | + ;; reports a minimum of nothing. Two hundred is the answer on the | ||
| 440 | + ;; first frame only, before it has been given anything. | ||
| 441 | + given (nth (:rect prev) 3 nil) | ||
| 442 | + h (double (cond | ||
| 443 | + (:height opts) (:height opts) | ||
| 444 | + (and given (pos? (double given))) | ||
| 445 | + (max 0.0 (- (double given) (double (or reserve 0.0)))) | ||
| 446 | + :else 200.0)) | ||
| 301 | content-h (second (or (:content-min prev) [0.0 0.0])) | 447 | content-h (second (or (:content-min prev) [0.0 0.0])) |
| 302 | view (or (:rect prev) [0.0 0.0 0.0 0.0]) | 448 | view (or (:rect prev) [0.0 0.0 0.0 0.0]) |
| 303 | maxoff (max 0.0 (- content-h h)) | 449 | maxoff (max 0.0 (- content-h h)) |
| 304 | - off0 (min maxoff (max 0.0 (c/state id :scroll 0.0))) | 450 | + st (get @scroll-areas area {}) |
| 305 | - off (reduce (fn [o e] (min maxoff (max 0.0 (- o (* 40.0 (:dy e)))))) | 451 | + off0 (min maxoff (max 0.0 (or (:offset st) 0.0))) |
| 306 | - off0 (or (c/wheel-events view) [])) | 452 | + wheel (or (c/wheel-events view) []) |
| 453 | + ;; A jump is asked for by CHANGING the number, not by setting it: | ||
| 454 | + ;; a caller that wanted to jump twice to the same place would | ||
| 455 | + ;; otherwise have nothing to say the second time. | ||
| 456 | + jump? (and scroll-to-bottom | ||
| 457 | + (pos? (double scroll-to-bottom)) | ||
| 458 | + (not= scroll-to-bottom (:jumped st))) | ||
| 459 | + ;; The wheel is applied FIRST, and a wheel that moved the list | ||
| 460 | + ;; breaks the stick for that frame. Deciding to stick before | ||
| 461 | + ;; reading it pins the offset at the end every frame and the | ||
| 462 | + ;; reader can never scroll up at all — they drag and it snaps | ||
| 463 | + ;; back, which reads as the window being broken. | ||
| 464 | + wheeled (reduce (fn [o e] (min maxoff (max 0.0 (- o (* 40.0 (:dy e)))))) | ||
| 465 | + off0 wheel) | ||
| 466 | + moved? (not= wheeled off0) | ||
| 467 | + ;; Sticking and jumping are the same control pulling opposite | ||
| 468 | + ;; ways. Sticking wins over a jump, so a jump to an old message | ||
| 469 | + ;; is not snatched back by the next arrival — but a hand on the | ||
| 470 | + ;; wheel wins over both. | ||
| 471 | + stick? (and stick-to-bottom (not jump?) (not moved?) (:at-end st true)) | ||
| 472 | + off (cond | ||
| 473 | + jump? maxoff | ||
| 474 | + stick? maxoff | ||
| 475 | + :else wheeled) | ||
| 476 | + ;; The gutter the bar is drawn in, taken off the content whether or | ||
| 477 | + ;; not there is anything to scroll yet. Always, because the | ||
| 478 | + ;; alternative is that a list reflows the moment it grows past the | ||
| 479 | + ;; window — every line rewrapping under the reader as the message | ||
| 480 | + ;; that overflowed arrives. And taken at all because the bar is | ||
| 481 | + ;; painted over the viewport's right edge: without it the last | ||
| 482 | + ;; characters of every wrapped line sit under the bar, which is | ||
| 483 | + ;; where "the sidebar truncates" comes from. | ||
| 484 | + ;; The bar's own width and a gap beside it. Reserving the bar alone | ||
| 485 | + ;; leaves the wrap boundary exactly at its left edge, so a line ends | ||
| 486 | + ;; flush against it — legible, but it reads as text running into the | ||
| 487 | + ;; bar, and a glyph whose drawn width runs a hair past its measured | ||
| 488 | + ;; one touches it. A gap is what puts the last word beside the bar | ||
| 489 | + ;; rather than on it. | ||
| 490 | + gutter (+ (double (c/th :scrollbar)) (double (c/th :spacing))) | ||
| 307 | r (c/box* (merge {:dir :vertical | 491 | r (c/box* (merge {:dir :vertical |
| 308 | - :expand :horizontal | 492 | + :expand :both |
| 309 | - :min-size [0.0 h] | 493 | + :pad-right gutter |
| 494 | + ;; An explicit :height IS a demand and is | ||
| 495 | + ;; reported as one. Without it the viewport | ||
| 496 | + ;; fills what is left and asks for nothing — | ||
| 497 | + ;; asking is what left the compose bar off the | ||
| 498 | + ;; bottom of the window. | ||
| 499 | + :min-size [0.0 (if (:height opts) h 0.0)] | ||
| 310 | :fixed true | 500 | :fixed true |
| 311 | :clip? true | 501 | :clip? true |
| 312 | :spacing (c/th :spacing) | 502 | :spacing (c/th :spacing) |
| 313 | :key (:key opts) | 503 | :key (:key opts) |
| 314 | :offset [0.0 (- off)]} | 504 | :offset [0.0 (- off)]} |
| 315 | - (dissoc opts :height)) | 505 | + (dissoc opts :height :scroll-key :reserve :stick-to-bottom |
| 316 | - body)] | 506 | + :scroll-to-bottom :on-at-end)) |
| 317 | - (c/state! id :scroll off) | 507 | + body) |
| 318 | - ;; The bar is drawn after the body so it sits over it, and only when there | 508 | + ;; Within a couple of lines of the end counts as at it: a reader |
| 319 | - ;; is something to scroll. | 509 | + ;; who has not moved should not stop being followed because the |
| 510 | + ;; last message was a pixel taller than the one before. | ||
| 511 | + at-end? (>= off (- maxoff 24.0)) | ||
| 512 | + ;; Arriving at the end is reported at once; leaving it has to hold | ||
| 513 | + ;; for a few frames. A burst of messages grows the content faster | ||
| 514 | + ;; than the offset follows, and reporting that honestly would | ||
| 515 | + ;; blink "scrolled away" whenever a channel is busy. | ||
| 516 | + away (if at-end? 0 (inc (or (:away st) 0))) | ||
| 517 | + settled (cond at-end? true (>= away 3) false :else nil) | ||
| 518 | + was (:reported st)] | ||
| 519 | + (swap! scroll-areas assoc area | ||
| 520 | + (cond-> (assoc st :offset off :at-end at-end? :away away) | ||
| 521 | + jump? (assoc :jumped scroll-to-bottom) | ||
| 522 | + (some? settled) (assoc :reported settled))) | ||
| 523 | + ;; Not on the first report: the opening one arrives before the content | ||
| 524 | + ;; has a height, and every list would announce itself as at its end. | ||
| 525 | + (when (and on-at-end (some? settled) (some? was) (not= was settled)) | ||
| 526 | + (on-at-end settled)) | ||
| 320 | (when (pos? maxoff) | 527 | (when (pos? maxoff) |
| 321 | (let [[vx vy vw vh] (or (:rect (c/data id)) view) | 528 | (let [[vx vy vw vh] (or (:rect (c/data id)) view) |
| 322 | bw (double (c/th :scrollbar)) | 529 | bw (double (c/th :scrollbar)) |
| @@ -327,3 +534,188 @@ | |||
| 327 | r)) | 534 | r)) |
| 328 | 535 | ||
| 329 | (defmacro scroll [opts & body] `(scroll* ~opts (fn [~'_id ~'_rect] ~@body))) | 536 | (defmacro scroll [opts & body] `(scroll* ~opts (fn [~'_id ~'_rect] ~@body))) |
| 537 | + | ||
| 538 | +(defn- picture | ||
| 539 | + "The texture for either kind of source: a live `feed` or a file `src`." | ||
| 540 | + [{:keys [feed src]}] | ||
| 541 | + (cond feed (frames/lookup feed) | ||
| 542 | + src (frames/from-file src))) | ||
| 543 | + | ||
| 544 | +(defn image | ||
| 545 | + "A picture: live pixels under `:feed`, or a file at `:src`. | ||
| 546 | + | ||
| 547 | + One widget and not two, because everything downstream — the fit, the | ||
| 548 | + bounds, the click — is the same for both. That is libvidya's arrangement | ||
| 549 | + too, and frq writes [:image {:feed k}] for a call tile and | ||
| 550 | + [:image {:src p}] for an attachment. | ||
| 551 | + | ||
| 552 | + `:fit` gives it every point of the space it has been handed; otherwise it | ||
| 553 | + asks for its own size, bounded by `:max-width` and `:max-height`. Either | ||
| 554 | + way the picture keeps its shape — a 16:9 camera in a square tile is the | ||
| 555 | + ordinary case, and stretching it is the one thing nobody wants. | ||
| 556 | + | ||
| 557 | + A source with no picture yet — a feed before its first frame, a file that | ||
| 558 | + will not decode — draws the empty tile and nothing else. Not a | ||
| 559 | + broken-image glyph: the text beside it already says what it was meant to | ||
| 560 | + be, and a tile that appears only once a frame lands rearranges the wall | ||
| 561 | + under the person every time somebody joins." | ||
| 562 | + ([opts] (image opts {})) | ||
| 563 | + ([{:keys [feed src] :as source} | ||
| 564 | + {:keys [fit max-width max-height size expand gravity placeholder?] | ||
| 565 | + :or {gravity [0.5 0.5] placeholder? true}}] | ||
| 566 | + (let [t (picture source) | ||
| 567 | + [tw th*] (if t [(:w t) (:h t)] [0 0]) | ||
| 568 | + want (cond | ||
| 569 | + size size | ||
| 570 | + (and fit) [320.0 180.0] | ||
| 571 | + (pos? tw) | ||
| 572 | + (let [k (min (if max-width (/ (double max-width) tw) 1.0) | ||
| 573 | + (if max-height (/ (double max-height) th*) 1.0))] | ||
| 574 | + [(* tw k) (* th* k)]) | ||
| 575 | + :else [(double (or max-width 160)) (double (or max-height 90))]) | ||
| 576 | + rect (c/leaf (mapv double want) | ||
| 577 | + (or expand (if fit :both :none)) | ||
| 578 | + gravity)] | ||
| 579 | + (when placeholder? (c/fill! rect (c/th :surface-alt) 6.0)) | ||
| 580 | + (when (and t (:tex t)) (c/draw-picture! (:tex t) (:w t) (:h t) rect)) | ||
| 581 | + rect))) | ||
| 582 | + | ||
| 583 | +(defn title-2 | ||
| 584 | + "A second-level heading — smaller than `title`, still bold." | ||
| 585 | + [s] | ||
| 586 | + (label s {:size (* 1.15 (c/th :font-size)) :colour (c/th :text)})) | ||
| 587 | + | ||
| 588 | +(defn status | ||
| 589 | + "A line of text with a dot in front of it saying whether the thing is live." | ||
| 590 | + ([s] (status s false)) | ||
| 591 | + ([s live?] | ||
| 592 | + (c/box* {:dir :horizontal :spacing 6 :gravity [0.0 0.5]} | ||
| 593 | + (fn [_ _] | ||
| 594 | + (let [line (c/th :font-size) | ||
| 595 | + d (max 7.0 (min 12.0 (* line 0.55))) | ||
| 596 | + r (c/leaf [(+ d 4.0) line] :none [0.0 0.5]) | ||
| 597 | + [x y w h] r] | ||
| 598 | + (c/fill! [(+ x 2.0) (+ y (/ (- h d) 2.0)) d d] | ||
| 599 | + (if live? (c/th :accent) (c/th :text-dim)) | ||
| 600 | + (/ d 2.0))) | ||
| 601 | + (label s))))) | ||
| 602 | + | ||
| 603 | +(defn spinner | ||
| 604 | + "A turning mark, and a word beside it if there is one. | ||
| 605 | + | ||
| 606 | + Turned from the wall clock rather than a frame counter: a loop that | ||
| 607 | + dropped frames would otherwise show a spinner that stutters in a way that | ||
| 608 | + reads as the work having stalled, which is the one thing it is there to | ||
| 609 | + deny." | ||
| 610 | + ([] (spinner "")) | ||
| 611 | + ([s] | ||
| 612 | + (c/box* {:dir :horizontal :spacing 6 :gravity [0.0 0.5]} | ||
| 613 | + (fn [_ _] | ||
| 614 | + (let [line (c/th :font-size) | ||
| 615 | + r (c/leaf [line line] :none [0.0 0.5]) | ||
| 616 | + [x y w h] r | ||
| 617 | + cx (+ x (/ w 2.0)) cy (+ y (/ h 2.0)) | ||
| 618 | + rad (* 0.4 (min w h)) | ||
| 619 | + t (/ (double (mod (System/currentTimeMillis) 1000)) 1000.0) | ||
| 620 | + a (* t 2.0 Math/PI)] | ||
| 621 | + ;; Three ticks around a circle: enough to read as turning, and no | ||
| 622 | + ;; arc primitive needed. | ||
| 623 | + (dotimes [i 3] | ||
| 624 | + (let [th* (+ a (* i (/ (* 2.0 Math/PI) 3.0)))] | ||
| 625 | + (c/fill! [(+ cx (* rad (Math/cos th*)) -1.5) | ||
| 626 | + (+ cy (* rad (Math/sin th*)) -1.5) 3.0 3.0] | ||
| 627 | + (c/th :accent) 1.5)))) | ||
| 628 | + (when (seq s) (label s)))))) | ||
| 629 | + | ||
| 630 | +(defn link | ||
| 631 | + "Text that is a place to go. Answers true on the frame it was clicked." | ||
| 632 | + ([s] (link s {})) | ||
| 633 | + ([s {:keys [key]}] | ||
| 634 | + (let [id (c/next-id key) | ||
| 635 | + rect (label s {:colour (c/th :accent)})] | ||
| 636 | + (:clicked? (c/interact! id rect))))) | ||
| 637 | + | ||
| 638 | +(defn emoji | ||
| 639 | + "One emoji, drawn as a character and sized to sit level with the words | ||
| 640 | + either side. | ||
| 641 | + | ||
| 642 | + In colour where there is a colour face to draw it from, and as text | ||
| 643 | + where there is not. | ||
| 644 | + | ||
| 645 | + The two paths differ in more than the picture: a colour emoji face is a | ||
| 646 | + bitmap with one 128-pixel strike, so it cannot be a fallback font and is | ||
| 647 | + drawn as an image scaled into the line — which means the glyph occupies a | ||
| 648 | + SQUARE of the size asked for, decided here rather than measured. The text | ||
| 649 | + path stays exactly what it was, a label in the UI font, and is what a | ||
| 650 | + machine with no colour face gets." | ||
| 651 | + ([s] (emoji s nil)) | ||
| 652 | + ([s size] | ||
| 653 | + (let [sz (double (or size (c/th :font-size)))] | ||
| 654 | + (if (c/colour-emoji? s) | ||
| 655 | + (let [rect (c/leaf [sz sz] :none [0.5 0.5])] | ||
| 656 | + (c/draw-emoji! s rect) | ||
| 657 | + rect) | ||
| 658 | + (label s {:size sz}))))) | ||
| 659 | + | ||
| 660 | +(defn avatar | ||
| 661 | + "A round picture for somebody, or their initial on a colour if there is | ||
| 662 | + none. | ||
| 663 | + | ||
| 664 | + The fallback is not a placeholder to be replaced later — most people in | ||
| 665 | + most rooms have no picture, so the initial IS the avatar, and its colour | ||
| 666 | + comes from the name so the same person is the same colour everywhere." | ||
| 667 | + ([nick] (avatar nick {})) | ||
| 668 | + ([nick {:keys [src size] :or {size 24.0}}] | ||
| 669 | + (let [rect (c/leaf [size size] :none [0.5 0.5]) | ||
| 670 | + t (when src (frames/from-file src))] | ||
| 671 | + (if (and t (:tex t)) | ||
| 672 | + (c/draw-picture! (:tex t) (:w t) (:h t) rect) | ||
| 673 | + (let [name (str nick) | ||
| 674 | + bare (str/replace name #"^[#&@+%~]+" "") | ||
| 675 | + initial (if (seq bare) (str/upper-case (subs bare 0 1)) "?") | ||
| 676 | + [x y w h] rect] | ||
| 677 | + (c/fill! rect (c/name-colour name) (/ size 2.0)) | ||
| 678 | + (let [sz (* size 0.5) | ||
| 679 | + [tw th*] (c/measure initial sz)] | ||
| 680 | + (c/draw-text! initial (+ x (/ (- w tw) 2.0)) (+ y (/ (- h th*) 2.0)) | ||
| 681 | + sz (c/th :accent-text))))) | ||
| 682 | + rect))) | ||
| 683 | + | ||
| 684 | +(defn reaction | ||
| 685 | + "A tally wearing a pill: an emoji, how many people, and whether you are one | ||
| 686 | + of them. | ||
| 687 | + | ||
| 688 | + The same glyph `emoji` draws, and the same two ways of drawing it: a | ||
| 689 | + colour face gives a square picture, and everything else is text. So the | ||
| 690 | + glyph and the tally are measured and drawn apart rather than as one | ||
| 691 | + string — a picture cannot be concatenated onto a number. | ||
| 692 | + | ||
| 693 | + A reaction is not a character otherwise either: it answers the pointer | ||
| 694 | + and it is a count. `mine?` is bordered rather than filled differently, | ||
| 695 | + because the pill has to stay readable at the size a line of them ends | ||
| 696 | + up." | ||
| 697 | + ([glyph] (reaction glyph {})) | ||
| 698 | + ([glyph {:keys [count mine? size key] | ||
| 699 | + :or {count 0 mine? false}}] | ||
| 700 | + (let [sz (double (or size (c/th :font-size))) | ||
| 701 | + colour? (c/colour-emoji? (str glyph)) | ||
| 702 | + tally (when (pos? count) (str " " count)) | ||
| 703 | + [gw gh] (if colour? [sz sz] (c/measure (str glyph) sz)) | ||
| 704 | + [cw ch] (if tally (c/measure tally sz) [0.0 0.0]) | ||
| 705 | + tw (+ (double gw) (double cw)) | ||
| 706 | + th* (max (double gh) (double ch)) | ||
| 707 | + pad 6.0 | ||
| 708 | + rect (c/leaf [(+ tw (* 2 pad)) (+ th* 4.0)] :none [0.0 0.5]) | ||
| 709 | + id (c/next-id key) | ||
| 710 | + [x y w h] rect] | ||
| 711 | + (c/fill! rect (if mine? (c/th :press) (c/th :surface-alt)) | ||
| 712 | + (/ h 2.0) | ||
| 713 | + (when mine? (c/th :accent)) (if mine? 1.0 0.0)) | ||
| 714 | + (if colour? | ||
| 715 | + (c/draw-emoji! (str glyph) [(+ x pad) (+ y (/ (- h sz) 2.0)) sz sz]) | ||
| 716 | + (c/draw-text! (str glyph) (+ x pad) (+ y (/ (- h (double gh)) 2.0)) | ||
| 717 | + sz (c/th :text))) | ||
| 718 | + (when tally | ||
| 719 | + (c/draw-text! tally (+ x pad (double gw)) (+ y (/ (- h (double ch)) 2.0)) | ||
| 720 | + sz (c/th :text))) | ||
| 721 | + (:clicked? (c/interact! id rect))))) | ||
added
jvui/test/jvui/font_check.clj +32 -0 | new file mode 100644 | ||
| @@ -0,0 +1,32 @@ | ||
| 1 | +(ns jvui.font-check | |
| 2 | + "A fallback face must not change the line. | |
| 3 | + | |
| 4 | + The headless tests measure with a stub — eight pixels a character — so | |
| 5 | + they cannot see this at all. What it guards is the thing that made the | |
| 6 | + compose bar a hundred and forty pixels tall: a colour emoji font is a | |
| 7 | + bitmap with one fixed strike, and asked for fourteen it answers a | |
| 8 | + hundred and twenty-eight. SDL_ttf then draws it at that, so one emoji | |
| 9 | + in a message reshapes the window around it. | |
| 10 | + | |
| 11 | + Needs the real fonts, which is why it is not in the headless set." | |
| 12 | + (:require [jvui.sdl :as sdl] [jvui.font :as font])) | |
| 13 | + | |
| 14 | +(defn -main [& _] | |
| 15 | + (sdl/ensure-loaded!) | |
| 16 | + (sdl/ttf-init!) | |
| 17 | + (let [c (font/open (font/find-font)) | |
| 18 | + h (font/line-height c 14) | |
| 19 | + out (atom []) | |
| 20 | + ck! (fn [n ok?] (swap! out conj [n (boolean ok?)]))] | |
| 21 | + (ck! "the face has a sane line height" (< 8 h 40)) | |
| 22 | + ;; Each of these goes to a fallback on most systems. Whatever answers, | |
| 23 | + ;; it must answer at the line the text sits on. | |
| 24 | + (doseq [[what s] [["an arrow" "←"] ["a picture glyph" "🖼"] ["plain text" "Chats"]]] | |
| 25 | + (let [[w gh] (font/size-of c s 14)] | |
| 26 | + (ck! (str what " measures on the line") | |
| 27 | + (and (pos? w) (<= gh (* 2 h)))))) | |
| 28 | + (doseq [[n ok?] @out] (println (if ok? "- " "FAIL ") n)) | |
| 29 | + (let [bad (remove second @out)] | |
| 30 | + (println (if (seq bad) (str (count bad) " of " (count @out) " checks FAILED") | |
| 31 | + (str "all " (count @out) " checks passed"))) | |
| 32 | + (when (seq bad) (System/exit 1))))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,32 @@ | |||
| 1 | +(ns jvui.font-check | ||
| 2 | + "A fallback face must not change the line. | ||
| 3 | + | ||
| 4 | + The headless tests measure with a stub — eight pixels a character — so | ||
| 5 | + they cannot see this at all. What it guards is the thing that made the | ||
| 6 | + compose bar a hundred and forty pixels tall: a colour emoji font is a | ||
| 7 | + bitmap with one fixed strike, and asked for fourteen it answers a | ||
| 8 | + hundred and twenty-eight. SDL_ttf then draws it at that, so one emoji | ||
| 9 | + in a message reshapes the window around it. | ||
| 10 | + | ||
| 11 | + Needs the real fonts, which is why it is not in the headless set." | ||
| 12 | + (:require [jvui.sdl :as sdl] [jvui.font :as font])) | ||
| 13 | + | ||
| 14 | +(defn -main [& _] | ||
| 15 | + (sdl/ensure-loaded!) | ||
| 16 | + (sdl/ttf-init!) | ||
| 17 | + (let [c (font/open (font/find-font)) | ||
| 18 | + h (font/line-height c 14) | ||
| 19 | + out (atom []) | ||
| 20 | + ck! (fn [n ok?] (swap! out conj [n (boolean ok?)]))] | ||
| 21 | + (ck! "the face has a sane line height" (< 8 h 40)) | ||
| 22 | + ;; Each of these goes to a fallback on most systems. Whatever answers, | ||
| 23 | + ;; it must answer at the line the text sits on. | ||
| 24 | + (doseq [[what s] [["an arrow" "←"] ["a picture glyph" "🖼"] ["plain text" "Chats"]]] | ||
| 25 | + (let [[w gh] (font/size-of c s 14)] | ||
| 26 | + (ck! (str what " measures on the line") | ||
| 27 | + (and (pos? w) (<= gh (* 2 h)))))) | ||
| 28 | + (doseq [[n ok?] @out] (println (if ok? "- " "FAIL ") n)) | ||
| 29 | + (let [bad (remove second @out)] | ||
| 30 | + (println (if (seq bad) (str (count bad) " of " (count @out) " checks FAILED") | ||
| 31 | + (str "all " (count @out) " checks passed"))) | ||
| 32 | + (when (seq bad) (System/exit 1))))) | ||
added
jvui/test/jvui/frames_check.clj +69 -0 | new file mode 100644 | ||
| @@ -0,0 +1,69 @@ | ||
| 1 | +(ns jvui.frames-check | |
| 2 | + "Does a borrowed pointer actually reach a texture? | |
| 3 | + | |
| 4 | + The other tests walk the UI with no window, which is what makes them fast | |
| 5 | + and what makes them blind to this: with no renderer there is nothing to | |
| 6 | + upload into, so every frame is dropped and every check passes for the | |
| 7 | + wrong reason. So this one opens a REAL window — under SDL's dummy video | |
| 8 | + driver, which gives a renderer and no display — and asks whether the | |
| 9 | + texture is there afterwards." | |
| 10 | + (:require [jvui.app :as app] | |
| 11 | + [jvui.core :as c] | |
| 12 | + [jvui.frames :as frames] | |
| 13 | + [jvui.widgets :as w] | |
| 14 | + [jolt.ffi :as ffi])) | |
| 15 | + | |
| 16 | +(defn- checker | |
| 17 | + "A w by h RGBA frame in FOREIGN memory: red on the left, blue on the right, | |
| 18 | + fully opaque. Two colours so a channel rotation shows up as the wrong one | |
| 19 | + rather than as a plausible picture." | |
| 20 | + [w h] | |
| 21 | + (let [p (ffi/alloc (* 4 w h))] | |
| 22 | + (dotimes [y h] | |
| 23 | + (dotimes [x w] | |
| 24 | + (let [o (* 4 (+ (* y w) x)) | |
| 25 | + left? (< x (quot w 2))] | |
| 26 | + (ffi/write (+ p o 0) :uint8 (if left? 220 20)) | |
| 27 | + (ffi/write (+ p o 1) :uint8 20) | |
| 28 | + (ffi/write (+ p o 2) :uint8 (if left? 20 220)) | |
| 29 | + (ffi/write (+ p o 3) :uint8 255)))) | |
| 30 | + p)) | |
| 31 | + | |
| 32 | +(defn -main [& _] | |
| 33 | + (let [px (checker 64 32) | |
| 34 | + results (atom []) | |
| 35 | + check! (fn [name ok?] (swap! results conj [name (boolean ok?)])) | |
| 36 | + frames-seen (atom 0)] | |
| 37 | + (try | |
| 38 | + (app/run! | |
| 39 | + (fn [] | |
| 40 | + (swap! frames-seen inc) | |
| 41 | + ;; Push a picture the way a decoder would: from foreign memory, | |
| 42 | + ;; between walks, with no painter in scope. | |
| 43 | + (when (= 1 @frames-seen) | |
| 44 | + (frames/put! "peer" 64 32 px)) | |
| 45 | + (when (= 2 @frames-seen) | |
| 46 | + (let [t (frames/lookup "peer")] | |
| 47 | + (check! "a frame becomes a texture" (some? (:tex t))) | |
| 48 | + (check! "at the size it was given" (and (= 64 (:w t)) (= 32 (:h t)))) | |
| 49 | + (check! "and is listed as a feed" (contains? (frames/keys*) "peer"))) | |
| 50 | + ;; A resize must replace the texture rather than write past it. | |
| 51 | + (frames/put! "peer" 32 64 px)) | |
| 52 | + (when (= 3 @frames-seen) | |
| 53 | + (let [t (frames/lookup "peer")] | |
| 54 | + (check! "a resize replaces it" (and (= 32 (:w t)) (= 64 (:h t))))) | |
| 55 | + (frames/drop! "peer") | |
| 56 | + (check! "dropping releases it" (nil? (frames/lookup "peer")))) | |
| 57 | + [:page {} | |
| 58 | + [:card {} [:title {:label "frames"}]]]) | |
| 59 | + {:title "frames" :width 200 :height 120 :frames 4}) | |
| 60 | + (finally (ffi/free px))) | |
| 61 | + (doseq [[name ok?] @results] | |
| 62 | + (println (if ok? "- " "FAIL ") name)) | |
| 63 | + (let [bad (remove second @results)] | |
| 64 | + (println (if (seq bad) | |
| 65 | + (str (count bad) " of " (count @results) " checks FAILED") | |
| 66 | + (str "all " (count @results) " checks passed"))) | |
| 67 | + (when (or (seq bad) (< (count @results) 5)) | |
| 68 | + (println "expected 5 checks, ran" (count @results)) | |
| 69 | + (System/exit 1))))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,69 @@ | |||
| 1 | +(ns jvui.frames-check | ||
| 2 | + "Does a borrowed pointer actually reach a texture? | ||
| 3 | + | ||
| 4 | + The other tests walk the UI with no window, which is what makes them fast | ||
| 5 | + and what makes them blind to this: with no renderer there is nothing to | ||
| 6 | + upload into, so every frame is dropped and every check passes for the | ||
| 7 | + wrong reason. So this one opens a REAL window — under SDL's dummy video | ||
| 8 | + driver, which gives a renderer and no display — and asks whether the | ||
| 9 | + texture is there afterwards." | ||
| 10 | + (:require [jvui.app :as app] | ||
| 11 | + [jvui.core :as c] | ||
| 12 | + [jvui.frames :as frames] | ||
| 13 | + [jvui.widgets :as w] | ||
| 14 | + [jolt.ffi :as ffi])) | ||
| 15 | + | ||
| 16 | +(defn- checker | ||
| 17 | + "A w by h RGBA frame in FOREIGN memory: red on the left, blue on the right, | ||
| 18 | + fully opaque. Two colours so a channel rotation shows up as the wrong one | ||
| 19 | + rather than as a plausible picture." | ||
| 20 | + [w h] | ||
| 21 | + (let [p (ffi/alloc (* 4 w h))] | ||
| 22 | + (dotimes [y h] | ||
| 23 | + (dotimes [x w] | ||
| 24 | + (let [o (* 4 (+ (* y w) x)) | ||
| 25 | + left? (< x (quot w 2))] | ||
| 26 | + (ffi/write (+ p o 0) :uint8 (if left? 220 20)) | ||
| 27 | + (ffi/write (+ p o 1) :uint8 20) | ||
| 28 | + (ffi/write (+ p o 2) :uint8 (if left? 20 220)) | ||
| 29 | + (ffi/write (+ p o 3) :uint8 255)))) | ||
| 30 | + p)) | ||
| 31 | + | ||
| 32 | +(defn -main [& _] | ||
| 33 | + (let [px (checker 64 32) | ||
| 34 | + results (atom []) | ||
| 35 | + check! (fn [name ok?] (swap! results conj [name (boolean ok?)])) | ||
| 36 | + frames-seen (atom 0)] | ||
| 37 | + (try | ||
| 38 | + (app/run! | ||
| 39 | + (fn [] | ||
| 40 | + (swap! frames-seen inc) | ||
| 41 | + ;; Push a picture the way a decoder would: from foreign memory, | ||
| 42 | + ;; between walks, with no painter in scope. | ||
| 43 | + (when (= 1 @frames-seen) | ||
| 44 | + (frames/put! "peer" 64 32 px)) | ||
| 45 | + (when (= 2 @frames-seen) | ||
| 46 | + (let [t (frames/lookup "peer")] | ||
| 47 | + (check! "a frame becomes a texture" (some? (:tex t))) | ||
| 48 | + (check! "at the size it was given" (and (= 64 (:w t)) (= 32 (:h t)))) | ||
| 49 | + (check! "and is listed as a feed" (contains? (frames/keys*) "peer"))) | ||
| 50 | + ;; A resize must replace the texture rather than write past it. | ||
| 51 | + (frames/put! "peer" 32 64 px)) | ||
| 52 | + (when (= 3 @frames-seen) | ||
| 53 | + (let [t (frames/lookup "peer")] | ||
| 54 | + (check! "a resize replaces it" (and (= 32 (:w t)) (= 64 (:h t))))) | ||
| 55 | + (frames/drop! "peer") | ||
| 56 | + (check! "dropping releases it" (nil? (frames/lookup "peer")))) | ||
| 57 | + [:page {} | ||
| 58 | + [:card {} [:title {:label "frames"}]]]) | ||
| 59 | + {:title "frames" :width 200 :height 120 :frames 4}) | ||
| 60 | + (finally (ffi/free px))) | ||
| 61 | + (doseq [[name ok?] @results] | ||
| 62 | + (println (if ok? "- " "FAIL ") name)) | ||
| 63 | + (let [bad (remove second @results)] | ||
| 64 | + (println (if (seq bad) | ||
| 65 | + (str (count bad) " of " (count @results) " checks FAILED") | ||
| 66 | + (str "all " (count @results) " checks passed"))) | ||
| 67 | + (when (or (seq bad) (< (count @results) 5)) | ||
| 68 | + (println "expected 5 checks, ran" (count @results)) | ||
| 69 | + (System/exit 1))))) | ||
added
jvui/test/jvui/host_check.clj +55 -0 | new file mode 100644 | ||
| @@ -0,0 +1,55 @@ | ||
| 1 | +(ns jvui.host-check | |
| 2 | + "The seven platform calls, against a real window. | |
| 3 | + | |
| 4 | + Under SDL's dummy video driver, which gives a window and a renderer and no | |
| 5 | + display. That limits what can be claimed and the claims are limited to | |
| 6 | + match: `open-url!` is not invoked, because succeeding would mean a browser | |
| 7 | + opening on somebody's desktop and failing would only mean this container | |
| 8 | + has none. Everything else is checked for real." | |
| 9 | + (:require [jvui.app :as app] | |
| 10 | + [jvui.host :as host] | |
| 11 | + [jvui.widgets :as w])) | |
| 12 | + | |
| 13 | +(defn -main [& _] | |
| 14 | + (let [out (atom []) | |
| 15 | + ck! (fn [n ok?] (swap! out conj [n (boolean ok?)])) | |
| 16 | + seen (atom 0)] | |
| 17 | + ;; Before any window: everything must answer harmlessly rather than throw. | |
| 18 | + ;; Numbers and not nil: a client that asks before the window is open | |
| 19 | + ;; does arithmetic on the answer. frq does exactly that on its first | |
| 20 | + ;; tick, and nil there was a NullPointerException in the caller. | |
| 21 | + (ck! "no window: screen-size is [0 0]" (= [0 0] (host/screen-size))) | |
| 22 | + (ck! "no window: window-width is 0" (zero? (host/window-width))) | |
| 23 | + (ck! "no window: set-title! is quiet" (nil? (host/set-title! "x"))) | |
| 24 | + (ck! "no window: no clipboard picture" (false? (host/clipboard-image-png! "/tmp/nope.png"))) | |
| 25 | + (ck! "no chooser on a desktop" (false? (host/pick-image!))) | |
| 26 | + (ck! "and nothing ever chosen" (nil? (host/picked-image! "/tmp/nope.png"))) | |
| 27 | + | |
| 28 | + (app/run! | |
| 29 | + (fn [] | |
| 30 | + (swap! seen inc) | |
| 31 | + (when (= 1 @seen) | |
| 32 | + (ck! "a window is installed" (some? (host/window))) | |
| 33 | + (ck! "window-width is positive" (pos? (host/window-width))) | |
| 34 | + (let [[sw sh] (or (host/screen-size) [0 0])] | |
| 35 | + (ck! "screen-size answers a display" (and (pos? sw) (pos? sh)))) | |
| 36 | + (ck! "set-title! does not throw" (nil? (host/set-title! "retitled"))) | |
| 37 | + ;; No picture on this clipboard, so false — the path that matters | |
| 38 | + ;; is that asking is safe, not that a picture appears. | |
| 39 | + (ck! "clipboard with no picture is false" | |
| 40 | + (false? (host/clipboard-image-png! "/tmp/jvui-clip.png")))) | |
| 41 | + ;; Ask to close on the third frame; the loop is allowed six, so | |
| 42 | + ;; stopping earlier is the evidence. | |
| 43 | + (when (= 3 @seen) (host/quit!)) | |
| 44 | + (w/page* {} (fn [_ _] (w/label "host")))) | |
| 45 | + {:title "host" :width 200 :height 140 :frames 6}) | |
| 46 | + | |
| 47 | + (ck! "quit! stopped the loop early" (< @seen 6)) | |
| 48 | + (ck! "and the window is released" (nil? (host/window))) | |
| 49 | + | |
| 50 | + (doseq [[n ok?] @out] (println (if ok? "- " "FAIL ") n)) | |
| 51 | + (let [bad (remove second @out)] | |
| 52 | + (println (if (seq bad) | |
| 53 | + (str (count bad) " of " (count @out) " checks FAILED") | |
| 54 | + (str "all " (count @out) " checks passed"))) | |
| 55 | + (when (seq bad) (System/exit 1))))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,55 @@ | |||
| 1 | +(ns jvui.host-check | ||
| 2 | + "The seven platform calls, against a real window. | ||
| 3 | + | ||
| 4 | + Under SDL's dummy video driver, which gives a window and a renderer and no | ||
| 5 | + display. That limits what can be claimed and the claims are limited to | ||
| 6 | + match: `open-url!` is not invoked, because succeeding would mean a browser | ||
| 7 | + opening on somebody's desktop and failing would only mean this container | ||
| 8 | + has none. Everything else is checked for real." | ||
| 9 | + (:require [jvui.app :as app] | ||
| 10 | + [jvui.host :as host] | ||
| 11 | + [jvui.widgets :as w])) | ||
| 12 | + | ||
| 13 | +(defn -main [& _] | ||
| 14 | + (let [out (atom []) | ||
| 15 | + ck! (fn [n ok?] (swap! out conj [n (boolean ok?)])) | ||
| 16 | + seen (atom 0)] | ||
| 17 | + ;; Before any window: everything must answer harmlessly rather than throw. | ||
| 18 | + ;; Numbers and not nil: a client that asks before the window is open | ||
| 19 | + ;; does arithmetic on the answer. frq does exactly that on its first | ||
| 20 | + ;; tick, and nil there was a NullPointerException in the caller. | ||
| 21 | + (ck! "no window: screen-size is [0 0]" (= [0 0] (host/screen-size))) | ||
| 22 | + (ck! "no window: window-width is 0" (zero? (host/window-width))) | ||
| 23 | + (ck! "no window: set-title! is quiet" (nil? (host/set-title! "x"))) | ||
| 24 | + (ck! "no window: no clipboard picture" (false? (host/clipboard-image-png! "/tmp/nope.png"))) | ||
| 25 | + (ck! "no chooser on a desktop" (false? (host/pick-image!))) | ||
| 26 | + (ck! "and nothing ever chosen" (nil? (host/picked-image! "/tmp/nope.png"))) | ||
| 27 | + | ||
| 28 | + (app/run! | ||
| 29 | + (fn [] | ||
| 30 | + (swap! seen inc) | ||
| 31 | + (when (= 1 @seen) | ||
| 32 | + (ck! "a window is installed" (some? (host/window))) | ||
| 33 | + (ck! "window-width is positive" (pos? (host/window-width))) | ||
| 34 | + (let [[sw sh] (or (host/screen-size) [0 0])] | ||
| 35 | + (ck! "screen-size answers a display" (and (pos? sw) (pos? sh)))) | ||
| 36 | + (ck! "set-title! does not throw" (nil? (host/set-title! "retitled"))) | ||
| 37 | + ;; No picture on this clipboard, so false — the path that matters | ||
| 38 | + ;; is that asking is safe, not that a picture appears. | ||
| 39 | + (ck! "clipboard with no picture is false" | ||
| 40 | + (false? (host/clipboard-image-png! "/tmp/jvui-clip.png")))) | ||
| 41 | + ;; Ask to close on the third frame; the loop is allowed six, so | ||
| 42 | + ;; stopping earlier is the evidence. | ||
| 43 | + (when (= 3 @seen) (host/quit!)) | ||
| 44 | + (w/page* {} (fn [_ _] (w/label "host")))) | ||
| 45 | + {:title "host" :width 200 :height 140 :frames 6}) | ||
| 46 | + | ||
| 47 | + (ck! "quit! stopped the loop early" (< @seen 6)) | ||
| 48 | + (ck! "and the window is released" (nil? (host/window))) | ||
| 49 | + | ||
| 50 | + (doseq [[n ok?] @out] (println (if ok? "- " "FAIL ") n)) | ||
| 51 | + (let [bad (remove second @out)] | ||
| 52 | + (println (if (seq bad) | ||
| 53 | + (str (count bad) " of " (count @out) " checks FAILED") | ||
| 54 | + (str "all " (count @out) " checks passed"))) | ||
| 55 | + (when (seq bad) (System/exit 1))))) | ||
modified
jvui/test/jvui/tests.clj +58 -0 | @@ -268,12 +268,70 @@ | ||
| 268 | 268 | (check! (= [127 127 127 255] (theme/mix [0 0 0 255] [254 254 254 255] 0.5)) |
| 269 | 269 | "and halfway")) |
| 270 | 270 | |
| 271 | +(defn- check-long-word-breaks! [] | |
| 272 | + ;; An OAuth login URL is ONE word with no spaces in it. Word-wise | |
| 273 | + ;; wrapping leaves it long, it makes its container wider than the | |
| 274 | + ;; window, and the sign-in page then hangs off both edges — which is | |
| 275 | + ;; exactly what frq did. A broken URL is worse than an unbroken one; a | |
| 276 | + ;; window you cannot read is worse than both. | |
| 277 | + (let [url (str "https://freeq.at/auth/login?handle=nandi-test.bsky.social" | |
| 278 | + "&return_to=http%3A%2F%2F127.0.0.1%3A7390%2Fcallback") | |
| 279 | + cx (ctx) seen (atom nil)] | |
| 280 | + (dotimes [_ 2] | |
| 281 | + (frame! cx (fn [] (c/box* {:dir :vertical} | |
| 282 | + (fn [_ _] (reset! seen (w/label url))))))) | |
| 283 | + (let [[_ _ w h] @seen] | |
| 284 | + (check! (<= w 400.5) "an unbreakable word is broken to fit") | |
| 285 | + (check! (> h 16.5) "over as many lines as it takes")))) | |
| 286 | + | |
| 287 | +(defn- check-row-wraps! [] | |
| 288 | + ;; A row of reaction pills has to start a new line rather than run off | |
| 289 | + ;; the end of the message it belongs to. | |
| 290 | + (let [cx (ctx) seen (atom [])] | |
| 291 | + (dotimes [_ 2] | |
| 292 | + (reset! seen []) | |
| 293 | + (frame! cx (fn [] (c/box* {:dir :horizontal :wrap true :spacing 4} | |
| 294 | + (fn [_ _] | |
| 295 | + (dotimes [i 12] | |
| 296 | + (swap! seen conj | |
| 297 | + (w/label (str "pill" i) {:expand :none})))))))) | |
| 298 | + (let [ys (distinct (map second @seen)) | |
| 299 | + xs (map first @seen)] | |
| 300 | + (check! (> (count ys) 1) | |
| 301 | + (str "a wrapped row used more than one line: " (count ys))) | |
| 302 | + (check! (<= (apply max xs) 400.0) | |
| 303 | + (str "and none of it ran off the end: max x=" (apply max xs)))))) | |
| 304 | + | |
| 305 | +(defn- check-label-wraps! [] | |
| 306 | + ;; A sentence longer than its box must come back TALLER and no wider, not | |
| 307 | + ;; wider and on one line. Without this a single long label makes its | |
| 308 | + ;; container wider than the window, and a page that centres its content | |
| 309 | + ;; then hangs off BOTH edges — which is what frq's sign-in screen did the | |
| 310 | + ;; first time jvui painted it. | |
| 311 | + ;; | |
| 312 | + ;; The stub font is eight pixels a character, so the numbers below are | |
| 313 | + ;; the box width and not a property of any real face. | |
| 314 | + (let [text (apply str (repeat 12 "wordy ")) ; 72 chars ≈ 576px unwrapped | |
| 315 | + cx (ctx) ; a 400-wide context | |
| 316 | + seen (atom nil)] | |
| 317 | + (dotimes [_ 2] | |
| 318 | + ;; Twice, because a box's width comes from what its children asked | |
| 319 | + ;; for LAST frame — the first has nothing to wrap against. | |
| 320 | + (frame! cx (fn [] (c/box* {:dir :vertical} | |
| 321 | + (fn [_ _] (reset! seen (w/label text))))))) | |
| 322 | + (let [[_ _ w h] @seen] | |
| 323 | + (check! (<= w 400.5) "a long label wraps inside its box") | |
| 324 | + (check! (> h 16.5) "and takes more than one line to do it")))) | |
| 325 | + | |
| 271 | 326 | ;; --- runner ------------------------------------------------------------------ |
| 272 | 327 | |
| 273 | 328 | (def ^:private checks |
| 274 | 329 | [["a leaf is placed" check-leaf!] |
| 275 | 330 | ["a column stacks" check-vbox-stacks!] |
| 276 | 331 | ["a row runs" check-hbox-runs!] |
| 332 | + ["a long label wraps" check-label-wraps!] | |
| 333 | + ["an unbreakable word breaks" check-long-word-breaks!] | |
| 334 | + ["a row wraps" check-row-wraps!] | |
| 277 | 335 | ["expand shares the slack" check-expand-shares!] |
| 278 | 336 | ["gravity centres" check-gravity-centres!] |
| 279 | 337 | ["layout settles unseen" check-layout-settles!] |
| @@ -268,12 +268,70 @@ | |||
| 268 | (check! (= [127 127 127 255] (theme/mix [0 0 0 255] [254 254 254 255] 0.5)) | 268 | (check! (= [127 127 127 255] (theme/mix [0 0 0 255] [254 254 254 255] 0.5)) |
| 269 | "and halfway")) | 269 | "and halfway")) |
| 270 | 270 | ||
| 271 | +(defn- check-long-word-breaks! [] | ||
| 272 | + ;; An OAuth login URL is ONE word with no spaces in it. Word-wise | ||
| 273 | + ;; wrapping leaves it long, it makes its container wider than the | ||
| 274 | + ;; window, and the sign-in page then hangs off both edges — which is | ||
| 275 | + ;; exactly what frq did. A broken URL is worse than an unbroken one; a | ||
| 276 | + ;; window you cannot read is worse than both. | ||
| 277 | + (let [url (str "https://freeq.at/auth/login?handle=nandi-test.bsky.social" | ||
| 278 | + "&return_to=http%3A%2F%2F127.0.0.1%3A7390%2Fcallback") | ||
| 279 | + cx (ctx) seen (atom nil)] | ||
| 280 | + (dotimes [_ 2] | ||
| 281 | + (frame! cx (fn [] (c/box* {:dir :vertical} | ||
| 282 | + (fn [_ _] (reset! seen (w/label url))))))) | ||
| 283 | + (let [[_ _ w h] @seen] | ||
| 284 | + (check! (<= w 400.5) "an unbreakable word is broken to fit") | ||
| 285 | + (check! (> h 16.5) "over as many lines as it takes")))) | ||
| 286 | + | ||
| 287 | +(defn- check-row-wraps! [] | ||
| 288 | + ;; A row of reaction pills has to start a new line rather than run off | ||
| 289 | + ;; the end of the message it belongs to. | ||
| 290 | + (let [cx (ctx) seen (atom [])] | ||
| 291 | + (dotimes [_ 2] | ||
| 292 | + (reset! seen []) | ||
| 293 | + (frame! cx (fn [] (c/box* {:dir :horizontal :wrap true :spacing 4} | ||
| 294 | + (fn [_ _] | ||
| 295 | + (dotimes [i 12] | ||
| 296 | + (swap! seen conj | ||
| 297 | + (w/label (str "pill" i) {:expand :none})))))))) | ||
| 298 | + (let [ys (distinct (map second @seen)) | ||
| 299 | + xs (map first @seen)] | ||
| 300 | + (check! (> (count ys) 1) | ||
| 301 | + (str "a wrapped row used more than one line: " (count ys))) | ||
| 302 | + (check! (<= (apply max xs) 400.0) | ||
| 303 | + (str "and none of it ran off the end: max x=" (apply max xs)))))) | ||
| 304 | + | ||
| 305 | +(defn- check-label-wraps! [] | ||
| 306 | + ;; A sentence longer than its box must come back TALLER and no wider, not | ||
| 307 | + ;; wider and on one line. Without this a single long label makes its | ||
| 308 | + ;; container wider than the window, and a page that centres its content | ||
| 309 | + ;; then hangs off BOTH edges — which is what frq's sign-in screen did the | ||
| 310 | + ;; first time jvui painted it. | ||
| 311 | + ;; | ||
| 312 | + ;; The stub font is eight pixels a character, so the numbers below are | ||
| 313 | + ;; the box width and not a property of any real face. | ||
| 314 | + (let [text (apply str (repeat 12 "wordy ")) ; 72 chars ≈ 576px unwrapped | ||
| 315 | + cx (ctx) ; a 400-wide context | ||
| 316 | + seen (atom nil)] | ||
| 317 | + (dotimes [_ 2] | ||
| 318 | + ;; Twice, because a box's width comes from what its children asked | ||
| 319 | + ;; for LAST frame — the first has nothing to wrap against. | ||
| 320 | + (frame! cx (fn [] (c/box* {:dir :vertical} | ||
| 321 | + (fn [_ _] (reset! seen (w/label text))))))) | ||
| 322 | + (let [[_ _ w h] @seen] | ||
| 323 | + (check! (<= w 400.5) "a long label wraps inside its box") | ||
| 324 | + (check! (> h 16.5) "and takes more than one line to do it")))) | ||
| 325 | + | ||
| 271 | ;; --- runner ------------------------------------------------------------------ | 326 | ;; --- runner ------------------------------------------------------------------ |
| 272 | 327 | ||
| 273 | (def ^:private checks | 328 | (def ^:private checks |
| 274 | [["a leaf is placed" check-leaf!] | 329 | [["a leaf is placed" check-leaf!] |
| 275 | ["a column stacks" check-vbox-stacks!] | 330 | ["a column stacks" check-vbox-stacks!] |
| 276 | ["a row runs" check-hbox-runs!] | 331 | ["a row runs" check-hbox-runs!] |
| 332 | + ["a long label wraps" check-label-wraps!] | ||
| 333 | + ["an unbreakable word breaks" check-long-word-breaks!] | ||
| 334 | + ["a row wraps" check-row-wraps!] | ||
| 277 | ["expand shares the slack" check-expand-shares!] | 335 | ["expand shares the slack" check-expand-shares!] |
| 278 | ["gravity centres" check-gravity-centres!] | 336 | ["gravity centres" check-gravity-centres!] |
| 279 | ["layout settles unseen" check-layout-settles!] | 337 | ["layout settles unseen" check-layout-settles!] |